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%
6.5 KB · 130 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { useRouter } from "next/navigation";4import { Copy, MessageSquarePlus } from "lucide-react";5import { ResponsiveDialog } from "@/components/ui/sheet";6import { Button } from "@/components/ui/button";7import { Input, Textarea, Field } from "@/components/ui/input";8import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";9import { toast } from "@/components/ui/toast";10import { api } from "@/lib/client/api";11import { errorMessage } from "@/lib/client/humanize";12import { useCopy } from "@/lib/client/hooks";13import { labelFor, renderTemplate, variablesToAsk, type PromptVariable } from "@/lib/prompts/variables";14import type { PublicPrompt, UsePromptResult } from "@/lib/client/types";15import { dispatchPromptInsert, promptChatHref, toInsertDetail } from "./insert";1617/** Records the use, renders with `variables`, dispatches the insert event and navigates to the chat. */18export async function insertPromptIntoChat(prompt: Pick<PublicPrompt, "id" | "name">, variables: Record<string, string>, router: { push: (href: string) => void }) {19  const res = await api<UsePromptResult>(`/api/prompts/${prompt.id}/use`, { method: "POST", json: { variables } });20  dispatchPromptInsert(toInsertDetail(res, variables));21  router.push(promptChatHref(prompt.id, variables));22  toast.success(res.kind === "system" ? `System prompt “${prompt.name}” applied` : `“${prompt.name}” inserted`, res.missing.length ? `Left empty: ${res.missing.join(", ")}` : undefined);23  return res;24}2526/**27 * Fill-in sheet for `{{variables}}`: one input (or select when `options` exist) per variable, live preview,28 * then "Insert into chat". Prompts without variables to ask should call `insertPromptIntoChat` directly.29 */30export function PromptUseSheet({ prompt, open, onOpenChange }: { prompt: PublicPrompt | null; open: boolean; onOpenChange: (o: boolean) => void }) {31  const router = useRouter();32  const [values, setValues] = React.useState<Record<string, string>>({});33  const [busy, setBusy] = React.useState(false);34  const [copied, copy] = useCopy();35  const firstRef = React.useRef<HTMLInputElement & HTMLTextAreaElement>(null);3637  const vars: PromptVariable[] = React.useMemo(() => prompt?.variables ?? [], [prompt]);38  const ask = React.useMemo(() => variablesToAsk(vars), [vars]);3940  React.useEffect(() => {41    if (!open || !prompt) return;42    // eslint-disable-next-line react-hooks/set-state-in-effect43    setValues(Object.fromEntries(vars.map((v) => [v.name, v.default ?? ""])));44    const t = setTimeout(() => firstRef.current?.focus(), 80);45    return () => clearTimeout(t);46  }, [open, prompt, vars]);4748  const preview = React.useMemo(() => (prompt ? renderTemplate(prompt.content, values, vars) : null), [prompt, values, vars]);49  const missingRequired = ask.filter((v) => v.required && !values[v.name]?.trim()).map((v) => labelFor(v));5051  const submit = async () => {52    if (!prompt || busy) return;53    setBusy(true);54    try {55      await insertPromptIntoChat(prompt, values, router);56      onOpenChange(false);57    } catch (e) {58      toast.error("Could not use prompt", errorMessage(e));59    } finally {60      setBusy(false);61    }62  };6364  return (65    <ResponsiveDialog66      open={open && prompt !== null}67      onOpenChange={onOpenChange}68      title={prompt?.name ?? "Use prompt"}69      description={ask.length ? `Fill in ${ask.length} variable${ask.length === 1 ? "" : "s"} before inserting.` : "Review and insert into a new chat."}70      size="lg"71      snap="full"72      footer={73        <div className="flex gap-2 sm:justify-end">74          <Button variant="ghost" className="sm:flex-none" onClick={() => preview && copy(preview.text)} disabled={!preview}>75            <Copy /> {copied ? "Copied" : "Copy"}76          </Button>77          <div className="flex-1 sm:hidden" />78          <Button className="flex-1 sm:flex-none" loading={busy} disabled={missingRequired.length > 0} onClick={submit}>79            <MessageSquarePlus /> Insert into chat80          </Button>81        </div>82      }83    >84      <form85        className="space-y-4 pt-1"86        onSubmit={(e) => {87          e.preventDefault();88          void submit();89        }}90      >91        {vars.length ? (92          <div className="grid gap-3 sm:grid-cols-2">93            {vars.map((v, i) => {94              const id = `var-${v.name}`;95              const label = labelFor(v);96              const val = values[v.name] ?? "";97              return (98                <Field key={v.name} label={`${label}${v.required ? " *" : ""}`} htmlFor={id} hint={v.default && val === v.default ? "Using the default" : undefined} className={v.options?.length ? "" : val.length > 60 ? "sm:col-span-2" : ""}>99                  {v.options?.length ? (100                    <Select value={val || undefined} onValueChange={(x) => setValues((s) => ({ ...s, [v.name]: x }))}>101                      <SelectTrigger id={id} className="h-11 sm:h-9">102                        <SelectValue placeholder={`Choose ${label.toLowerCase()}`} />103                      </SelectTrigger>104                      <SelectContent>105                        {v.options.map((o) => (106                          <SelectItem key={o} value={o}>107                            {o}108                          </SelectItem>109                        ))}110                      </SelectContent>111                    </Select>112                  ) : val.length > 60 || val.includes("\n") ? (113                    <Textarea ref={i === 0 ? firstRef : undefined} id={id} value={val} onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))} rows={3} placeholder={v.default ? `Default: ${v.default}` : `{{${v.name}}}`} />114                  ) : (115                    <Input ref={i === 0 ? firstRef : undefined} id={id} value={val} onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))} placeholder={v.default ? `Default: ${v.default}` : `{{${v.name}}}`} className="h-11 sm:h-9" />116                  )}117                </Field>118              );119            })}120          </div>121        ) : null}122        {missingRequired.length ? <p className="text-[12px] text-warning">Required: {missingRequired.join(", ")}</p> : null}123        <Field label="Preview" hint={preview?.missing.length ? `Empty: ${preview.missing.join(", ")}` : undefined}>124          <pre className="max-h-[40dvh] overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border bg-bg-subtle p-3 font-mono text-[12.5px] leading-5 text-fg-muted">{preview?.text || " "}</pre>125        </Field>126      </form>127    </ResponsiveDialog>128  );129}130