Initial commit: prompt template manager
React + Tailwind frontend, FastAPI backend, settings.json storage. Supports preset CRUD, live variable filling, and AI rewrite via OpenAI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import TemplateList from './components/TemplateList.jsx'
|
||||
import TemplateEditor from './components/TemplateEditor.jsx'
|
||||
import PromptRunner from './components/PromptRunner.jsx'
|
||||
import Settings from './components/Settings.jsx'
|
||||
|
||||
async function api(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [presets, setPresets] = useState([])
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [mode, setMode] = useState('empty') // 'empty' | 'run' | 'edit' | 'new'
|
||||
const [apiKeySet, setApiKeySet] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api('GET', '/api/presets').then(d => {
|
||||
setPresets(d.presets)
|
||||
if (d.presets.length > 0) { setSelected(d.presets[0]); setMode('run') }
|
||||
})
|
||||
api('GET', '/api/settings').then(d => setApiKeySet(d.openai_api_key_set))
|
||||
}, [])
|
||||
|
||||
function selectPreset(p) {
|
||||
setSelected(p)
|
||||
setMode('run')
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
setSelected(null)
|
||||
setMode('new')
|
||||
}
|
||||
|
||||
async function handleSave(body) {
|
||||
if (mode === 'new') {
|
||||
const created = await api('POST', '/api/presets', body)
|
||||
setPresets(prev => [...prev, created])
|
||||
setSelected(created)
|
||||
setMode('run')
|
||||
} else {
|
||||
const updated = await api('PUT', `/api/presets/${selected.id}`, body)
|
||||
setPresets(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
setSelected(updated)
|
||||
setMode('run')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selected) return
|
||||
await api('DELETE', `/api/presets/${selected.id}`)
|
||||
const next = presets.filter(p => p.id !== selected.id)
|
||||
setPresets(next)
|
||||
if (next.length > 0) { setSelected(next[0]); setMode('run') }
|
||||
else { setSelected(null); setMode('empty') }
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (selected) setMode('run')
|
||||
else setMode('empty')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 text-zinc-100 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<TemplateList
|
||||
presets={presets}
|
||||
selectedId={selected?.id}
|
||||
onSelect={selectPreset}
|
||||
onNew={startNew}
|
||||
/>
|
||||
|
||||
{/* Main area */}
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
{/* Topbar */}
|
||||
<header className="flex items-center justify-end px-4 py-2 border-b border-zinc-800 bg-zinc-900">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
title="Settings"
|
||||
className="p-1.5 rounded-md text-zinc-400 hover:text-white hover:bg-zinc-700 transition-colors text-lg"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{mode === 'empty' && (
|
||||
<div className="flex-1 flex items-center justify-center text-zinc-500 text-sm">
|
||||
Create a preset to get started.
|
||||
</div>
|
||||
)}
|
||||
{mode === 'run' && selected && (
|
||||
<PromptRunner
|
||||
preset={selected}
|
||||
onEdit={() => setMode('edit')}
|
||||
apiKeySet={apiKeySet}
|
||||
/>
|
||||
)}
|
||||
{(mode === 'edit' || mode === 'new') && (
|
||||
<TemplateEditor
|
||||
preset={mode === 'edit' ? selected : null}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={mode === 'edit' ? handleDelete : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showSettings && (
|
||||
<Settings
|
||||
apiKeySet={apiKeySet}
|
||||
onSaved={() => setApiKeySet(true)}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { parseVars, fillTemplate } from '../lib/parseVars.js'
|
||||
|
||||
export default function PromptRunner({ preset, onEdit, apiKeySet }) {
|
||||
const vars = useMemo(() => parseVars(preset.template), [preset.template])
|
||||
const [values, setValues] = useState({})
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [rewriting, setRewriting] = useState(false)
|
||||
const [rewrittenOutput, setRewrittenOutput] = useState(null)
|
||||
const [rewriteError, setRewriteError] = useState('')
|
||||
|
||||
const baseOutput = fillTemplate(preset.template, values)
|
||||
const output = rewrittenOutput ?? baseOutput
|
||||
|
||||
function setValue(key, val) {
|
||||
setValues(prev => ({ ...prev, [key]: val }))
|
||||
setRewrittenOutput(null)
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(output)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
async function handleRewrite() {
|
||||
setRewriting(true)
|
||||
setRewriteError('')
|
||||
try {
|
||||
const res = await fetch('/api/rewrite', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: baseOutput }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.detail)
|
||||
setRewrittenOutput(data.rewritten)
|
||||
} catch (e) {
|
||||
setRewriteError(e.message)
|
||||
} finally {
|
||||
setRewriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-6 gap-5 overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{preset.name}</h2>
|
||||
{preset.description && <p className="text-sm text-zinc-400 mt-0.5">{preset.description}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{vars.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{vars.map(v => (
|
||||
<div key={v} className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">{v.replace(/_/g, ' ')}</label>
|
||||
<input
|
||||
value={values[v] ?? ''}
|
||||
onChange={e => setValue(v, e.target.value)}
|
||||
placeholder={v}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">
|
||||
Output {rewrittenOutput && <span className="text-indigo-400 normal-case">(AI rewritten)</span>}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{rewrittenOutput && (
|
||||
<button
|
||||
onClick={() => setRewrittenOutput(null)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRewrite}
|
||||
disabled={rewriting || !apiKeySet || !baseOutput.trim()}
|
||||
title={!apiKeySet ? 'Add OpenAI key in Settings ⚙' : 'Rewrite with AI'}
|
||||
className="px-3 py-1 text-xs rounded-md bg-violet-800 hover:bg-violet-700 text-violet-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{rewriting ? 'Rewriting…' : '✦ Rewrite with AI'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-1 text-xs rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{rewriteError && <p className="text-red-400 text-xs">{rewriteError}</p>}
|
||||
<textarea
|
||||
readOnly
|
||||
value={output}
|
||||
className="flex-1 min-h-48 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 resize-none font-mono focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function Settings({ apiKeySet, onSaved, onClose }) {
|
||||
const [key, setKey] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
if (!key.trim()) { setError('Enter an API key'); return }
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ openai_api_key: key.trim() }),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.detail) }
|
||||
setSaved(true)
|
||||
onSaved()
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-white font-semibold text-base">Settings</h2>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 mb-4">
|
||||
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">OpenAI API Key</label>
|
||||
<p className="text-xs text-zinc-500 mb-1.5">
|
||||
{apiKeySet ? 'A key is already saved. Enter a new one to replace it.' : 'Required for AI rewrite feature.'}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={e => setKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-xs mb-3">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saved ? 'Saved!' : saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { parseVars } from '../lib/parseVars.js'
|
||||
|
||||
export default function TemplateEditor({ preset, onSave, onCancel, onDelete }) {
|
||||
const isNew = !preset
|
||||
|
||||
const [name, setName] = useState(preset?.name ?? '')
|
||||
const [description, setDescription] = useState(preset?.description ?? '')
|
||||
const [template, setTemplate] = useState(preset?.template ?? '')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setName(preset?.name ?? '')
|
||||
setDescription(preset?.description ?? '')
|
||||
setTemplate(preset?.template ?? '')
|
||||
setError('')
|
||||
}, [preset])
|
||||
|
||||
const detectedVars = parseVars(template)
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) { setError('Name is required'); return }
|
||||
if (!template.trim()) { setError('Template is required'); return }
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSave({ name: name.trim(), description: description.trim(), template })
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col p-6 gap-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">{isNew ? 'New Preset' : 'Edit Preset'}</h2>
|
||||
<div className="flex gap-2">
|
||||
{!isNew && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-red-900 hover:bg-red-700 text-red-200 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="My prompt template"
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">Description</label>
|
||||
<input
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Optional short description"
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">
|
||||
Template — use <code className="text-indigo-400">{'{{'+'variable'+'}}'}</code> for inputs
|
||||
</label>
|
||||
<textarea
|
||||
value={template}
|
||||
onChange={e => setTemplate(e.target.value)}
|
||||
placeholder="e.g. Resolve {{issue_id}} in {{repo}}. Create the PR as {{author}}."
|
||||
className="flex-1 min-h-48 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none font-mono"
|
||||
/>
|
||||
{detectedVars.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<span className="text-xs text-zinc-500">Detected variables:</span>
|
||||
{detectedVars.map(v => (
|
||||
<span key={v} className="text-xs bg-indigo-900 text-indigo-300 rounded px-2 py-0.5">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function TemplateList({ presets, selectedId, onSelect, onNew }) {
|
||||
return (
|
||||
<aside className="w-64 shrink-0 flex flex-col border-r border-zinc-700 bg-zinc-900 h-full">
|
||||
<div className="p-3 border-b border-zinc-700">
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="w-full py-2 px-3 rounded-md bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
+ New Preset
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{presets.length === 0 && (
|
||||
<p className="p-4 text-zinc-500 text-sm">No presets yet. Create one!</p>
|
||||
)}
|
||||
{presets.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onSelect(p)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-zinc-800 transition-colors ${
|
||||
selectedId === p.id
|
||||
? 'bg-zinc-700 text-white'
|
||||
: 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{p.name}</div>
|
||||
{p.description && (
|
||||
<div className="text-xs text-zinc-500 truncate mt-0.5">{p.description}</div>
|
||||
)}
|
||||
{p.variables.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{p.variables.map(v => (
|
||||
<span key={v} className="text-xs bg-zinc-700 text-indigo-300 rounded px-1.5 py-0.5">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,19 @@
|
||||
const VAR_RE = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g
|
||||
|
||||
export function parseVars(template) {
|
||||
const seen = new Set()
|
||||
const vars = []
|
||||
let m
|
||||
VAR_RE.lastIndex = 0
|
||||
while ((m = VAR_RE.exec(template)) !== null) {
|
||||
if (!seen.has(m[1])) {
|
||||
seen.add(m[1])
|
||||
vars.push(m[1])
|
||||
}
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
export function fillTemplate(template, values) {
|
||||
return template.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (_, k) => values[k] ?? `{{${k}}}`)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />)
|
||||
Reference in New Issue
Block a user