SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
14.8 KB · 282 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Star, X } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input, Textarea, Field, Label } from "@/components/ui/input";7import { Switch } from "@/components/ui/switch";8import { Segmented } from "@/components/ui/segmented";9import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";10import { toast } from "@/components/ui/toast";11import { ModelSelect } from "@/components/presets/model-select";12import { api, useApi } from "@/lib/client/api";13import { errorMessage } from "@/lib/client/humanize";14import { mergeVariables, labelFor, type PromptVariable } from "@/lib/prompts/variables";15import type { PublicPrompt, PromptKind, PublicProject } from "@/lib/client/types";16import { PROMPT_KIND_META, PROMPT_KIND_ORDER } from "./prompt-kind";17import { cn } from "@/lib/utils";1819interface Draft {20  kind: PromptKind;21  name: string;22  description: string;23  content: string;24  variables: PromptVariable[];25  schemaText: string;26  folder: string;27  tags: string[];28  favorite: boolean;29  defaultModelKey: string | null;30  projectId: string | null;31}3233const NONE = "__none__";34const EMPTY: Draft = { kind: "user", name: "", description: "", content: "", variables: [], schemaText: "", folder: "", tags: [], favorite: false, defaultModelKey: null, projectId: null };3536function toDraft(p: PublicPrompt): Draft {37  return { kind: p.kind, name: p.name, description: p.description ?? "", content: p.content, variables: p.variables, schemaText: p.schema ? JSON.stringify(p.schema, null, 2) : "", folder: p.folder ?? "", tags: p.tags, favorite: p.favorite, defaultModelKey: p.defaultModelKey, projectId: p.projectId };38}3940export interface PromptEditorSheetProps {41  open: boolean;42  onOpenChange: (o: boolean) => void;43  /** Existing prompt to edit; omit for a new one. */44  prompt?: PublicPrompt | null;45  /** Pre-filled values for a new prompt (e.g. `{ projectId }`, or `{ kind: "system", content }` from "Save as prompt"). */46  initial?: Partial<Pick<Draft, "kind" | "name" | "content" | "projectId" | "folder">>;47  folders?: string[];48  onSaved?: (p: PublicPrompt) => void;49}5051/**52 * Prompt editor: kind, name, description, body with live `{{variable}}` detection (label / default / options /53 * required per variable), JSON schema for structured prompts, folder, tags, favourite, default model, project.54 */55export function PromptEditorSheet({ open, onOpenChange, prompt, initial, folders = [], onSaved }: PromptEditorSheetProps) {56  const [d, setD] = React.useState<Draft>(EMPTY);57  const [saving, setSaving] = React.useState(false);58  const [tagInput, setTagInput] = React.useState("");59  const nameRef = React.useRef<HTMLInputElement>(null);60  const { data: projData } = useApi<{ projects: PublicProject[] }>(open ? "/api/projects" : null);61  const projects = React.useMemo(() => (projData?.projects ?? []).filter((p) => !p.archived), [projData]);6263  React.useEffect(() => {64    if (!open) return;65    const base = prompt ? toDraft(prompt) : { ...EMPTY, ...initial, variables: mergeVariables(initial?.content ?? "", []) };66    // eslint-disable-next-line react-hooks/set-state-in-effect67    setD(base);68    setTagInput("");69    const t = setTimeout(() => nameRef.current?.focus(), 80);70    return () => clearTimeout(t);71  }, [open, prompt, initial]);7273  const set = (patch: Partial<Draft>) => setD((s) => ({ ...s, ...patch }));74  const setContent = (content: string) => setD((s) => ({ ...s, content, variables: mergeVariables(content, s.variables) }));75  const setVar = (name: string, patch: Partial<PromptVariable>) => setD((s) => ({ ...s, variables: s.variables.map((v) => (v.name === name ? { ...v, ...patch } : v)) }));7677  const schemaError = React.useMemo(() => {78    if (d.kind !== "structured" || !d.schemaText.trim()) return null;79    try {80      const v = JSON.parse(d.schemaText);81      return v && typeof v === "object" && !Array.isArray(v) ? null : "Schema must be a JSON object";82    } catch (e) {83      return `Invalid JSON: ${(e as Error).message}`;84    }85  }, [d.kind, d.schemaText]);8687  const valid = d.name.trim().length > 0 && d.content.trim().length > 0 && !schemaError;8889  const addTag = (raw: string) => {90    const tags = raw91      .split(/[,\n]/)92      .map((t) => t.trim().toLowerCase())93      .filter(Boolean);94    if (!tags.length) return;95    set({ tags: Array.from(new Set([...d.tags, ...tags])).slice(0, 12) });96    setTagInput("");97  };9899  const save = async () => {100    if (!valid || saving) return;101    setSaving(true);102    const body = {103      kind: d.kind,104      name: d.name.trim(),105      description: d.description.trim() || null,106      content: d.content,107      variables: d.variables.map((v) => ({ name: v.name, label: v.label?.trim() || undefined, default: v.default || undefined, required: v.required || undefined, options: v.options?.length ? v.options : undefined })),108      schema: d.kind === "structured" && d.schemaText.trim() ? (JSON.parse(d.schemaText) as Record<string, unknown>) : null,109      folder: d.folder.trim() || null,110      tags: d.tags,111      favorite: d.favorite,112      defaultModelKey: d.defaultModelKey,113      projectId: d.projectId,114    };115    try {116      const res = prompt ? await api<{ prompt: PublicPrompt }>(`/api/prompts/${prompt.id}`, { method: "PATCH", json: body }) : await api<{ prompt: PublicPrompt }>("/api/prompts", { method: "POST", json: body });117      toast.success(prompt ? "Prompt updated" : "Prompt saved to your library");118      onOpenChange(false);119      onSaved?.(res.prompt);120    } catch (e) {121      toast.error("Could not save prompt", errorMessage(e));122    } finally {123      setSaving(false);124    }125  };126127  const meta = PROMPT_KIND_META[d.kind];128129  return (130    <ResponsiveDialog131      open={open}132      onOpenChange={onOpenChange}133      title={prompt ? "Edit prompt" : "New prompt"}134      description={meta.description}135      size="lg"136      snap="full"137      footer={138        <div className="flex items-center gap-2">139          <button type="button" onClick={() => set({ favorite: !d.favorite })} className={cn("tap inline-flex h-11 items-center gap-1.5 rounded-md px-2 text-[13px] sm:h-9", d.favorite ? "text-warning" : "text-fg-muted hover:text-fg")} aria-pressed={d.favorite} aria-label="Favourite">140            <Star className={cn("size-4", d.favorite && "fill-current")} /> <span className="hidden sm:inline">{d.favorite ? "Favourite" : "Add to favourites"}</span>141          </button>142          <div className="flex-1" />143          <Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>144            Cancel145          </Button>146          <Button loading={saving} disabled={!valid} onClick={save}>147            {prompt ? "Save changes" : "Save prompt"}148          </Button>149        </div>150      }151    >152      <form153        className="space-y-4 pt-1"154        onSubmit={(e) => {155          e.preventDefault();156          void save();157        }}158      >159        <Segmented ariaLabel="Prompt kind" value={d.kind} onChange={(k) => set({ kind: k })} options={PROMPT_KIND_ORDER.map((k) => ({ value: k, label: PROMPT_KIND_META[k].short, icon: PROMPT_KIND_META[k].icon }))} fill className="w-full" />160161        <Field label="Name" htmlFor="prompt-name">162          <Input ref={nameRef} id="prompt-name" value={d.name} onChange={(e) => set({ name: e.target.value })} placeholder={d.kind === "system" ? "Senior code reviewer" : "Summarize an article"} maxLength={120} required className="h-11 sm:h-9" />163        </Field>164        <Field label="Description" htmlFor="prompt-desc" hint="Optional — one line about when to use it.">165          <Input id="prompt-desc" value={d.description} onChange={(e) => set({ description: e.target.value })} placeholder="Reviews diffs for correctness, then style" maxLength={600} className="h-11 sm:h-9" />166        </Field>167        <Field label={d.kind === "system" ? "System prompt" : "Prompt"} htmlFor="prompt-content" hint={`${d.content.length.toLocaleString()} characters · use {{variable}} for fill-in fields`}>168          <Textarea id="prompt-content" value={d.content} onChange={(e) => setContent(e.target.value)} rows={8} maxLength={100_000} required className="min-h-[160px] font-mono text-[13px] leading-5" placeholder={d.kind === "system" ? "You are a senior engineer reviewing pull requests…" : "Summarize {{article}} in {{length}} bullet points for a {{audience}} audience."} spellCheck={false} />169        </Field>170171        {d.variables.length ? (172          <div className="space-y-2 rounded-xl bg-bg-subtle p-3">173            <div className="flex items-center justify-between">174              <Label>Variables ({d.variables.length})</Label>175              <span className="text-[11px] text-fg-subtle">Detected from the prompt</span>176            </div>177            <ul className="space-y-2">178              {d.variables.map((v) => (179                <li key={v.name} className="rounded-lg border border-border bg-bg-elevated p-2.5">180                  <div className="flex items-center gap-2">181                    <code className="rounded bg-bg-muted px-1.5 py-0.5 font-mono text-[12px] text-fg">{`{{${v.name}}}`}</code>182                    <div className="flex-1" />183                    <label className="inline-flex items-center gap-1.5 text-[12px] text-fg-muted">184                      Required <Switch size="sm" checked={Boolean(v.required)} onCheckedChange={(c) => setVar(v.name, { required: c })} aria-label={`${v.name} required`} />185                    </label>186                  </div>187                  <div className="mt-2 grid gap-2 sm:grid-cols-3">188                    <Input value={v.label ?? ""} onChange={(e) => setVar(v.name, { label: e.target.value })} placeholder={`Label (${labelFor({ name: v.name })})`} maxLength={80} className="h-10 sm:h-8 sm:text-[13px]" aria-label={`${v.name} label`} />189                    <Input value={v.default ?? ""} onChange={(e) => setVar(v.name, { default: e.target.value })} placeholder="Default value" maxLength={4000} className="h-10 sm:h-8 sm:text-[13px]" aria-label={`${v.name} default`} />190                    <Input191                      value={(v.options ?? []).join(", ")}192                      onChange={(e) =>193                        setVar(v.name, {194                          options: e.target.value195                            .split(",")196                            .map((o) => o.trim())197                            .filter(Boolean),198                        })199                      }200                      onBlur={(e) => setVar(v.name, { options: Array.from(new Set(e.target.value.split(",").map((o) => o.trim()).filter(Boolean))) })}201                      placeholder="Options (comma-separated)"202                      className="h-10 sm:h-8 sm:text-[13px]"203                      aria-label={`${v.name} options`}204                    />205                  </div>206                </li>207              ))}208            </ul>209          </div>210        ) : null}211212        {d.kind === "structured" ? (213          <Field label="JSON schema" htmlFor="prompt-schema" error={schemaError} hint={schemaError ? undefined : "Requested as response_format json_schema on models that support structured output."}>214            <Textarea id="prompt-schema" value={d.schemaText} onChange={(e) => set({ schemaText: e.target.value })} rows={6} className="min-h-[120px] font-mono text-[12.5px] leading-5" placeholder={'{\n  "type": "object",\n  "properties": { "summary": { "type": "string" } },\n  "required": ["summary"],\n  "additionalProperties": false\n}'} spellCheck={false} />215          </Field>216        ) : null}217218        <div className="grid gap-4 sm:grid-cols-2">219          <Field label="Folder" htmlFor="prompt-folder" hint="Type a new name or pick an existing folder.">220            <Input id="prompt-folder" list="prompt-folders" value={d.folder} onChange={(e) => set({ folder: e.target.value })} placeholder="Writing, Coding, Clients…" maxLength={60} className="h-11 sm:h-9" />221            <datalist id="prompt-folders">222              {folders.map((f) => (223                <option key={f} value={f} />224              ))}225            </datalist>226          </Field>227          <Field label="Tags" htmlFor="prompt-tags" hint="Press Enter or comma to add.">228            <div className="flex min-h-11 flex-wrap items-center gap-1 rounded-md border border-border bg-bg-elevated px-2 py-1 focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/25 sm:min-h-9">229              {d.tags.map((t) => (230                <span key={t} className="inline-flex items-center gap-0.5 rounded-full bg-bg-muted px-2 py-0.5 text-[12px]">231                  #{t}232                  <button type="button" onClick={() => set({ tags: d.tags.filter((x) => x !== t) })} className="tap -mr-1 rounded-full p-0.5 text-fg-subtle hover:text-fg" aria-label={`Remove tag ${t}`}>233                    <X className="size-3" />234                  </button>235                </span>236              ))}237              <input238                id="prompt-tags"239                value={tagInput}240                onChange={(e) => (e.target.value.includes(",") ? addTag(e.target.value) : setTagInput(e.target.value))}241                onKeyDown={(e) => {242                  if (e.key === "Enter") {243                    e.preventDefault();244                    addTag(tagInput);245                  } else if (e.key === "Backspace" && !tagInput && d.tags.length) set({ tags: d.tags.slice(0, -1) });246                }}247                onBlur={() => addTag(tagInput)}248                placeholder={d.tags.length ? "" : "review, email, fr"}249                className="min-w-[80px] flex-1 bg-transparent text-[15px] outline-none placeholder:text-fg-subtle sm:text-sm"250                maxLength={32}251              />252            </div>253          </Field>254        </div>255256        <div className="grid gap-4 sm:grid-cols-2">257          <Field label="Default model" hint="Optional. Only connected providers are listed.">258            <ModelSelect value={d.defaultModelKey} onChange={(k) => set({ defaultModelKey: k })} allowNone noneLabel="Current chat model" className="h-11 sm:h-9" />259          </Field>260          <Field label="Project" hint="Optional. Project prompts show up on the project page.">261            <Select value={d.projectId ?? NONE} onValueChange={(v) => set({ projectId: v === NONE ? null : v })}>262              <SelectTrigger className="h-11 sm:h-9" aria-label="Project">263                <SelectValue />264              </SelectTrigger>265              <SelectContent>266                <SelectItem value={NONE}>No project</SelectItem>267                {projects.map((p) => (268                  <SelectItem key={p.id} value={p.id}>269                    <span className="flex items-center gap-2">270                      <span aria-hidden>{p.icon ?? "◆"}</span> {p.name}271                    </span>272                  </SelectItem>273                ))}274              </SelectContent>275            </Select>276          </Field>277        </div>278      </form>279    </ResponsiveDialog>280  );281}282