feat(playground): add PresetPicker + ImprovePromptButton

This commit is contained in:
diegosouzapw
2026-05-28 11:20:23 -03:00
parent 6e15911f1e
commit d516cf0506
2 changed files with 343 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/ImprovePromptButton.tsx
import { useState } from "react";
import { useImprovePrompt } from "../hooks/useImprovePrompt";
import type { ConfigState } from "./StudioConfigPane";
interface ImprovePromptButtonProps {
configState: ConfigState;
setConfigState: (s: ConfigState) => void;
}
/**
* ImprovePromptButton — "✨ Improve prompt" button with quota-consumption warning.
*
* Flow: click → confirmation modal → confirm → calls useImprovePrompt.improve()
* → on success, updates configState.systemPrompt and/or configState.params.
*
* D8: uses the model configured in the Config pane (never overrides with cheap model).
*/
export default function ImprovePromptButton({ configState, setConfigState }: ImprovePromptButtonProps) {
const { loading, error, improve } = useImprovePrompt();
const [confirmOpen, setConfirmOpen] = useState(false);
const [improveError, setImproveError] = useState<string | null>(null);
async function handleConfirm() {
setConfirmOpen(false);
setImproveError(null);
const model = configState.model.trim();
if (!model) {
setImproveError("Please set a model in the Config pane first.");
return;
}
const result = await improve({
system: configState.systemPrompt || undefined,
model,
});
if (result == null) {
setImproveError(error ?? "Improve prompt failed.");
return;
}
// Apply improved versions if returned
const next = { ...configState };
if (result.improvedSystem != null) {
next.systemPrompt = result.improvedSystem;
}
setConfigState(next);
}
const isDisabled = loading || !configState.model.trim();
return (
<>
<div className="flex flex-col gap-1">
<button
onClick={() => {
setImproveError(null);
setConfirmOpen(true);
}}
disabled={isDisabled}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed self-start"
aria-label="Improve prompt using AI"
title={!configState.model.trim() ? "Set a model first" : "Improve your prompt with AI"}
>
<span className="text-[13px]"></span>
{loading ? "Improving…" : "Improve prompt"}
</button>
{improveError && (
<p className="text-[11px] text-destructive">{improveError}</p>
)}
</div>
{/* Quota confirmation modal */}
{confirmOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setConfirmOpen(false)}
role="dialog"
aria-modal="true"
aria-label="Confirm improve prompt"
>
<div
className="bg-surface border border-border rounded-xl p-5 w-80 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3 mb-4">
<span className="text-[24px] shrink-0"></span>
<div>
<h3 className="text-sm font-semibold text-text-main mb-1">
Improve prompt
</h3>
<p className="text-xs text-text-muted">
This will send your current system prompt to{" "}
<code className="font-mono text-primary">{configState.model}</code>
{" "}to generate an improved version.
</p>
<p className="text-xs text-text-muted mt-1.5 font-medium">
This action will consume model quota.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setConfirmOpen(false)}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
</button>
<button
onClick={() => void handleConfirm()}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors"
>
<span className="text-[12px]"></span>
Improve
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx
import { useEffect, useState } from "react";
import { usePresets } from "../hooks/usePresets";
import type { ConfigState } from "./StudioConfigPane";
interface PresetPickerProps {
configState: ConfigState;
setConfigState: (s: ConfigState) => void;
}
/**
* PresetPicker — load/save playground presets.
*
* Presets are stored in DB via /api/playground/presets (F3 backend).
* Load applies preset values to configState; Save opens a name-input modal.
*/
export default function PresetPicker({ configState, setConfigState }: PresetPickerProps) {
const { presets, loading, list, create, remove } = usePresets();
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [presetName, setPresetName] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Load presets on mount
useEffect(() => {
void list();
}, [list]);
function handleLoad(presetId: string) {
const preset = presets.find((p) => p.id === presetId);
if (!preset) return;
setConfigState({
...configState,
endpoint: preset.endpoint as ConfigState["endpoint"],
model: preset.model,
systemPrompt: preset.system ?? configState.systemPrompt,
params: {
...configState.params,
...(typeof preset.params === "object" && preset.params != null ? preset.params : {}),
},
});
}
async function handleSave() {
if (!presetName.trim()) {
setSaveError("Name is required");
return;
}
setSaving(true);
setSaveError(null);
const result = await create({
name: presetName.trim(),
endpoint: configState.endpoint,
model: configState.model,
system: configState.systemPrompt || null,
params: configState.params as Record<string, unknown>,
});
setSaving(false);
if (result == null) {
setSaveError("Failed to save preset");
return;
}
setSaveModalOpen(false);
setPresetName("");
}
function openSave() {
setSaveModalOpen(true);
setPresetName("");
setSaveError(null);
}
function closeSave() {
setSaveModalOpen(false);
setPresetName("");
setSaveError(null);
}
return (
<>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Presets
</span>
{/* Load preset select */}
<div className="flex items-center gap-2">
<select
disabled={loading || presets.length === 0}
onChange={(e) => {
if (e.target.value) {
handleLoad(e.target.value);
// Reset select to placeholder after loading
e.target.value = "";
}
}}
defaultValue=""
className="flex-1 text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Load a preset"
>
<option value="" disabled>
{loading ? "Loading…" : presets.length === 0 ? "No presets" : "Load preset…"}
</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
<button
onClick={openSave}
className="text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors shrink-0"
aria-label="Save current config as preset"
>
Save
</button>
</div>
{/* Preset list with delete buttons (compact) */}
{presets.length > 0 && (
<div className="flex flex-col gap-1">
{presets.map((preset) => (
<div
key={preset.id}
className="flex items-center justify-between text-[11px] text-text-muted hover:text-text-main group"
>
<button
onClick={() => handleLoad(preset.id)}
className="truncate text-left flex-1 hover:text-primary transition-colors"
aria-label={`Load preset "${preset.name}"`}
>
{preset.name}
<span className="ml-1.5 opacity-60">{preset.model}</span>
</button>
<button
onClick={() => void remove(preset.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-text-muted hover:text-destructive transition-all"
aria-label={`Delete preset "${preset.name}"`}
>
<span className="material-symbols-outlined text-[12px]">delete</span>
</button>
</div>
))}
</div>
)}
</div>
{/* Save preset modal */}
{saveModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={closeSave}
role="dialog"
aria-modal="true"
aria-label="Save preset"
>
<div
className="bg-surface border border-border rounded-xl p-5 w-80 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold text-text-main mb-4">Save preset</h3>
<div className="flex flex-col gap-3">
<input
type="text"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSave();
if (e.key === "Escape") closeSave();
}}
placeholder="Preset name"
autoFocus
className="text-xs bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
{saveError && (
<p className="text-xs text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<button
onClick={closeSave}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors"
>
Cancel
</button>
<button
onClick={() => void handleSave()}
disabled={saving}
className="text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}