SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
8.1 KB · 188 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Braces } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Textarea } from "@/components/ui/input";7import { Segmented } from "@/components/ui/segmented";8import { Switch } from "@/components/ui/switch";9import type { ChatSettings } from "./model-config";10import { cn } from "@/lib/utils";1112type FormatMode = "text" | "json" | "json_schema";1314const DEFAULT_SCHEMA = '{\n  "type": "object",\n  "properties": {\n    "answer": { "type": "string" }\n  },\n  "required": ["answer"],\n  "additionalProperties": false\n}';1516/** Structured output: JSON object / JSON schema (with an editor) — only offered when the model supports it. */17export function StructuredOutputSheet({ open, onOpenChange, settings, onChange }: { open: boolean; onOpenChange: (o: boolean) => void; settings: ChatSettings; onChange: (s: ChatSettings) => void }) {18  const [mode, setMode] = React.useState<FormatMode>(settings.responseFormat?.type ?? "text");19  const [schemaText, setSchemaText] = React.useState(() => (settings.responseFormat?.schema ? JSON.stringify(settings.responseFormat.schema, null, 2) : DEFAULT_SCHEMA));20  const [error, setError] = React.useState<string | null>(null);2122  React.useEffect(() => {23    if (open) {24      // eslint-disable-next-line react-hooks/set-state-in-effect25      setMode(settings.responseFormat?.type ?? "text");26      setSchemaText(settings.responseFormat?.schema ? JSON.stringify(settings.responseFormat.schema, null, 2) : DEFAULT_SCHEMA);27      setError(null);28    }29  }, [open, settings.responseFormat]);3031  const apply = () => {32    const next = { ...settings };33    if (mode === "text") {34      delete next.responseFormat;35    } else if (mode === "json") {36      next.responseFormat = { type: "json" };37    } else {38      try {39        const parsed = JSON.parse(schemaText);40        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Schema must be a JSON object");41        next.responseFormat = { type: "json_schema", schema: parsed, strict: true };42      } catch (e) {43        setError((e as Error).message);44        return;45      }46    }47    onChange(next);48    onOpenChange(false);49  };5051  return (52    <ResponsiveDialog53      open={open}54      onOpenChange={onOpenChange}55      title={56        <span className="inline-flex items-center gap-2">57          <Braces className="size-4" /> Structured output58        </span>59      }60      description="Ask the model to answer with JSON. Strict schema mode validates the shape when the provider supports it."61      size="md"62      footer={63        <div className="flex gap-2 sm:justify-end">64          <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => onOpenChange(false)}>65            Cancel66          </Button>67          <Button className="flex-1 sm:flex-none" onClick={apply}>68            Apply69          </Button>70        </div>71      }72    >73      <div className="space-y-4 pt-1">74        <Segmented<FormatMode> value={mode} onChange={setMode} fill ariaLabel="Response format" options={[{ value: "text", label: "Text" }, { value: "json", label: "JSON object" }, { value: "json_schema", label: "JSON schema" }]} />75        {mode === "json_schema" ? (76          <div className="space-y-1.5">77            <Textarea78              value={schemaText}79              onChange={(e) => {80                setSchemaText(e.target.value);81                try {82                  JSON.parse(e.target.value);83                  setError(null);84                } catch (err) {85                  setError((err as Error).message);86                }87              }}88              className={cn("min-h-[220px] font-mono text-[12.5px] leading-5", error && "border-danger focus:border-danger")}89              spellCheck={false}90              aria-label="JSON schema"91            />92            {error ? <p className="text-xs text-danger">{error}</p> : <p className="text-xs text-fg-subtle">Object root; keep `additionalProperties: false` for strict mode.</p>}93          </div>94        ) : mode === "json" ? (95          <p className="text-[13px] text-fg-muted">The model returns a single JSON object. Mention the keys you expect in your prompt.</p>96        ) : (97          <p className="text-[13px] text-fg-muted">Plain Markdown answers (default).</p>98        )}99      </div>100    </ResponsiveDialog>101  );102}103104/** System prompt editor (conversation-level instructions). */105export function SystemPromptSheet({ open, onOpenChange, value, onChange, projectInstructions }: { open: boolean; onOpenChange: (o: boolean) => void; value: string; onChange: (v: string) => void; projectInstructions?: string | null }) {106  const [draft, setDraft] = React.useState(value);107  React.useEffect(() => {108    // eslint-disable-next-line react-hooks/set-state-in-effect109    if (open) setDraft(value);110  }, [open, value]);111  return (112    <ResponsiveDialog113      open={open}114      onOpenChange={onOpenChange}115      title="System prompt"116      description="Instructions that apply to the whole conversation. They are sent before every message."117      size="md"118      snap="half"119      footer={120        <div className="flex items-center gap-2">121          <span className="text-[12px] tabular-nums text-fg-subtle">{draft.length.toLocaleString()} chars</span>122          <div className="flex-1" />123          <Button variant="ghost" onClick={() => onOpenChange(false)}>124            Cancel125          </Button>126          <Button127            onClick={() => {128              onChange(draft);129              onOpenChange(false);130            }}131          >132            Save133          </Button>134        </div>135      }136    >137      <div className="space-y-3 pt-1">138        {projectInstructions ? (139          <div className="rounded-lg bg-bg-subtle px-3 py-2 text-[12.5px] text-fg-muted">140            <span className="font-medium text-fg">Project instructions</span> are prepended automatically:141            <p className="mt-1 line-clamp-3 whitespace-pre-wrap">{projectInstructions}</p>142          </div>143        ) : null}144        <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="You are a concise assistant that answers in French…" className="min-h-[180px]" autoFocus />145      </div>146    </ResponsiveDialog>147  );148}149150const BUILTIN_TOOLS: { id: string; label: string; description: string }[] = [151  { id: "calculator", label: "Calculator", description: "Exact arithmetic for any non-trivial math." },152  { id: "clock", label: "Clock", description: "Current date and time in any time zone." },153  { id: "random", label: "Random", description: "Cryptographically secure random integers." },154];155156/** Built-in PolyLLM tools (run server-side, side-effect free) — toggles mirror `settings.tools`. */157export function ToolsSheet({ open, onOpenChange, settings, onChange, supported }: { open: boolean; onOpenChange: (o: boolean) => void; settings: ChatSettings; onChange: (s: ChatSettings) => void; supported: boolean }) {158  const on = new Set(settings.tools ?? []);159  const toggle = (id: string) => {160    const next = new Set(on);161    if (next.has(id)) next.delete(id);162    else next.add(id);163    const tools = [...next];164    const out = { ...settings };165    if (tools.length) out.tools = tools;166    else {167      delete out.tools;168      delete out.toolChoice;169    }170    onChange(out);171  };172  return (173    <ResponsiveDialog open={open} onOpenChange={onOpenChange} title="Tools" description={supported ? "Built-in PolyLLM tools the model may call. They run server-side and have no side effects." : "This model does not support tool calling."} size="sm">174      <ul className="divide-y divide-hairline pt-1">175        {BUILTIN_TOOLS.map((t) => (176          <li key={t.id} className="flex min-h-[52px] items-center gap-3 py-2">177            <span className="min-w-0 flex-1">178              <span className="block text-[14px] font-medium">{t.label}</span>179              <span className="block text-[12px] text-fg-muted">{t.description}</span>180            </span>181            <Switch checked={on.has(t.id)} onCheckedChange={() => toggle(t.id)} disabled={!supported} aria-label={`Enable ${t.label}`} />182          </li>183        ))}184      </ul>185    </ResponsiveDialog>186  );187}188