diff --git a/web/src/app/playground/page.tsx b/web/src/app/playground/page.tsx index a5a9828..6f627a6 100644 --- a/web/src/app/playground/page.tsx +++ b/web/src/app/playground/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { Shell } from "@/components/Shell"; import { PlaygroundComposer, + type Message, type PlaygroundSessionOut, type PromptOption, } from "@/components/PlaygroundClient"; @@ -34,7 +35,10 @@ interface PromptRow { interface PromptVersionRow { id: string; version: number; - template: string; + /** Plan B onwards; absent on very old api versions. */ + template_messages?: Message[]; + /** Legacy field; absent eventually. */ + template?: string; } interface PromptVersionList { @@ -85,7 +89,14 @@ export default async function PlaygroundPage() { versions: (versions.data?.versions ?? []).map((v) => ({ id: v.id, version: v.version, - template: v.template, + // Prefer the structured field; fall back to wrapping the legacy + // template (defensive — Plan B's api always sends both). + template_messages: + v.template_messages ?? + (v.template != null + ? [{ role: "human" as const, content: v.template }] + : []), + template: v.template ?? "", })), }; }), diff --git a/web/src/components/PlaygroundClient.tsx b/web/src/components/PlaygroundClient.tsx index 6fa92f5..f7d6994 100644 --- a/web/src/components/PlaygroundClient.tsx +++ b/web/src/components/PlaygroundClient.tsx @@ -3,9 +3,11 @@ import { ExternalLink, Loader2, Play, Sparkles } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMemo, useState, useTransition } from "react"; +import { useEffect, useMemo, useState, useTransition } from "react"; import { ModelPicker } from "@/components/ModelPicker"; +import { MessageEditor } from "@/components/playground/MessageEditor"; +import { SavePromptForm } from "@/components/playground/SavePromptForm"; /** * Interactive Playground canvas. @@ -22,11 +24,24 @@ import { ModelPicker } from "@/components/ModelPicker"; * iteration without changing the storage shape). */ +export interface Message { + role: "system" | "human"; + content: string; +} + export interface PromptOption { id: string; slug: string; name: string; - versions: { id: string; version: number; template: string }[]; + versions: { + id: string; + version: number; + /** Structured form (Plan A+B). Always set on new versions. */ + template_messages: Message[]; + /** Legacy single-string field. Kept for back-compat reads from the + * api during the deprecation window. We don't display this. */ + template: string; + }[]; } export interface PlaygroundSessionOut { @@ -65,6 +80,26 @@ function extractVariables(template: string): string[] { return [...out]; } +/** + * Run the same variable-detection regex across a list of messages and + * return the deduped union (preserving first-seen order). Used by the + * composer to keep the Inputs panel in sync as the user edits System + * and Human bodies. + */ +export function extractVariablesFromMessages(messages: Message[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const m of messages) { + for (const v of extractVariables(m.content)) { + if (!seen.has(v)) { + seen.add(v); + out.push(v); + } + } + } + return out; +} + export function PlaygroundComposer({ projectId, prompts, @@ -75,10 +110,16 @@ export function PlaygroundComposer({ const [mode, setMode] = useState<"single" | "compare">("single"); const [promptId, setPromptId] = useState(""); const [versionId, setVersionId] = useState(""); - const [rawMode, setRawMode] = useState(prompts.length === 0); - const [rawTemplate, setRawTemplate] = useState( - "Summarize the following text in one sentence:\n\n{{ text }}", - ); + // Composer state. messages is the editable list; loadedVersionId is the + // prompt_version we last loaded (null = unsaved free-form composition). + const [messages, setMessages] = useState([ + { role: "system", content: "" }, + { + role: "human", + content: "Summarize the following text in one sentence:\n\n{{ text }}", + }, + ]); + const [loadedVersionId, setLoadedVersionId] = useState(null); const [variables, setVariables] = useState>({ text: "tracebility is a self-hosted LLM observability platform.", }); @@ -100,15 +141,137 @@ export function PlaygroundComposer({ () => selectedPrompt?.versions.find((v) => v.id === versionId) ?? null, [selectedPrompt, versionId], ); - const effectiveTemplate = rawMode - ? rawTemplate - : selectedVersion?.template ?? ""; + + // Loading a saved version replaces the editable messages and pins + // loadedVersionId so Run posts prompt_version_id (not raw_messages). + // Editing afterwards keeps loadedVersionId — Save then sends a new + // version under the same prompt; the api short-circuits identical + // bodies. + useEffect(() => { + if (selectedVersion === null) return; + const next = + selectedVersion.template_messages.length > 0 + ? selectedVersion.template_messages + : [{ role: "human" as const, content: selectedVersion.template }]; + setMessages(next); + setLoadedVersionId(selectedVersion.id); + }, [selectedVersion]); const detectedVars = useMemo( - () => extractVariables(effectiveTemplate), - [effectiveTemplate], + () => extractVariablesFromMessages(messages), + [messages], ); + const isComposerEmpty = !messages.some((m) => m.content.trim().length > 0); + + // Save flow. Two paths: + // 1. loadedVersionId !== null → POST a new version under the same prompt. + // Plan B's api short-circuits identical messages and returns the + // existing row (HTTP 200), so re-saving an unchanged composer is + // a cheap no-op. + // 2. loadedVersionId === null → show the inline name+slug form; on + // submit, create the prompt then post v1 and switch the composer + // to the loaded state so subsequent saves create v2/v3/... + const [showSaveForm, setShowSaveForm] = useState(false); + const [savingBusy, setSavingBusy] = useState(false); + const [saveError, setSaveError] = useState(null); + const canSave = !isComposerEmpty && !savingBusy && !pending; + + async function postNewVersion( + targetPromptId: string, + ): Promise<{ id: string } | null> { + const resp = await fetch(`/api/prompts/${encodeURIComponent(targetPromptId)}/versions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ template_messages: messages }), + }); + if (!resp.ok && resp.status !== 200 && resp.status !== 201) { + let detail = `request failed (${resp.status})`; + try { + const data = await resp.json(); + if (data && typeof data === "object" && "detail" in data) { + detail = String((data as { detail: unknown }).detail); + } + } catch { + /* ignore */ + } + setSaveError(detail); + return null; + } + return (await resp.json()) as { id: string }; + } + + async function saveExisting() { + if (loadedVersionId === null) return; + const owner = prompts.find((p) => + p.versions.some((v) => v.id === loadedVersionId), + ); + if (!owner) { + setSaveError("loaded version not found in prompt catalog"); + return; + } + setSavingBusy(true); + setSaveError(null); + try { + const created = await postNewVersion(owner.id); + if (created) { + setLoadedVersionId(created.id); + router.refresh(); + } + } finally { + setSavingBusy(false); + } + } + + async function saveNew(form: { name: string; slug: string }) { + setSavingBusy(true); + setSaveError(null); + try { + const create = await fetch("/api/prompts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + project_id: projectId, + slug: form.slug, + name: form.name, + }), + }); + if (!create.ok) { + let detail = `prompt create failed (${create.status})`; + try { + const data = await create.json(); + if (data && typeof data === "object" && "detail" in data) { + detail = String((data as { detail: unknown }).detail); + } + } catch { + /* ignore */ + } + setSaveError(detail); + return; + } + const prompt = (await create.json()) as { id: string }; + const created = await postNewVersion(prompt.id); + if (created) { + setShowSaveForm(false); + setPromptId(prompt.id); + setVersionId(created.id); + setLoadedVersionId(created.id); + router.refresh(); + } + } finally { + setSavingBusy(false); + } + } + + function onClickSave() { + setSaveError(null); + if (loadedVersionId !== null) { + void saveExisting(); + } else { + setShowSaveForm(true); + } + } + function setVariable(key: string, value: string) { setVariables((prev) => ({ ...prev, [key]: value })); } @@ -133,14 +296,14 @@ export function PlaygroundComposer({ temperature: tempNum, max_tokens: maxNum, }; - if (rawMode || !selectedVersion) { - if (!effectiveTemplate.trim()) { - setError("template is empty"); + if (loadedVersionId !== null) { + body.prompt_version_id = loadedVersionId; + } else { + if (isComposerEmpty) { + setError("prompt is empty"); return null; } - body.raw_template = effectiveTemplate; - } else { - body.prompt_version_id = selectedVersion.id; + body.raw_messages = messages; } const res = await fetch("/api/playground/runs", { method: "POST", @@ -186,16 +349,22 @@ export function PlaygroundComposer({ setShowSaveForm(false)} + onSubmitSave={saveNew} /> {pending ? : } {pending ? "running…" : mode === "compare" ? "Run both" : "Run"} @@ -269,106 +438,97 @@ function ModeToggle({ function PromptSourceCard({ prompts, - rawMode, - setRawMode, promptId, setPromptId, versionId, setVersionId, - rawTemplate, - setRawTemplate, selectedPrompt, - selectedVersion, + messages, + setMessages, + loadedVersionId, + setLoadedVersionId, + canSave, + savingBusy, + saveError, + showSaveForm, + onClickSave, + onCancelSave, + onSubmitSave, }: { prompts: PromptOption[]; - rawMode: boolean; - setRawMode: (b: boolean) => void; promptId: string; setPromptId: (s: string) => void; versionId: string; setVersionId: (s: string) => void; - rawTemplate: string; - setRawTemplate: (s: string) => void; selectedPrompt: PromptOption | null; - selectedVersion: PromptOption["versions"][number] | null; + messages: Message[]; + setMessages: React.Dispatch>; + loadedVersionId: string | null; + setLoadedVersionId: (id: string | null) => void; + canSave: boolean; + savingBusy: boolean; + saveError: string | null; + showSaveForm: boolean; + onClickSave: () => void; + onCancelSave: () => void; + onSubmitSave: (form: { name: string; slug: string }) => void; }) { + function clearLoaded() { + setPromptId(""); + setVersionId(""); + setLoadedVersionId(null); + } + return ( -
+

Prompt

-
- - -
-
- {rawMode ? ( -