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,6 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.tmp
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel
|
||||
|
||||
import backend.storage as storage
|
||||
from backend.models import extract_vars
|
||||
|
||||
DIST = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
# ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PresetBody(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
template: str
|
||||
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
openai_api_key: str
|
||||
|
||||
|
||||
class RewriteBody(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
# ── Presets ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/presets")
|
||||
def get_presets():
|
||||
return {"presets": storage.list_presets()}
|
||||
|
||||
|
||||
@app.post("/api/presets", status_code=201)
|
||||
def create_preset(body: PresetBody):
|
||||
variables = extract_vars(body.template)
|
||||
return storage.create_preset(body.name, body.description, body.template, variables)
|
||||
|
||||
|
||||
@app.put("/api/presets/{preset_id}")
|
||||
def update_preset(preset_id: str, body: PresetBody):
|
||||
variables = extract_vars(body.template)
|
||||
result = storage.update_preset(preset_id, {
|
||||
"name": body.name,
|
||||
"description": body.description,
|
||||
"template": body.template,
|
||||
"variables": variables,
|
||||
})
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/presets/{preset_id}")
|
||||
def delete_preset(preset_id: str):
|
||||
if not storage.delete_preset(preset_id):
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/settings")
|
||||
def get_settings():
|
||||
key = storage.get_openai_key()
|
||||
return {"openai_api_key_set": bool(key)}
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
def update_settings(body: SettingsBody):
|
||||
storage.set_openai_key(body.openai_api_key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── AI Rewrite ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/rewrite")
|
||||
async def rewrite_prompt(body: RewriteBody):
|
||||
key = storage.get_openai_key()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="OpenAI API key not configured")
|
||||
|
||||
client = AsyncOpenAI(api_key=key)
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=OPENAI_MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a prompt engineer. Rewrite the user's prompt to be clearer, "
|
||||
"more specific, and more effective for an AI assistant. "
|
||||
"Return only the rewritten prompt, no explanation."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": body.prompt},
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
return {"rewritten": response.choices[0].message.content}
|
||||
|
||||
|
||||
# ── SPA static serving (must be last) ────────────────────────────────────────
|
||||
|
||||
if DIST.exists():
|
||||
app.mount("/assets", StaticFiles(directory=DIST / "assets"), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
def serve_spa(full_path: str):
|
||||
return FileResponse(DIST / "index.html")
|
||||
@@ -0,0 +1,13 @@
|
||||
import re
|
||||
|
||||
VAR_RE = re.compile(r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}')
|
||||
|
||||
|
||||
def extract_vars(template: str) -> list[str]:
|
||||
seen = set()
|
||||
result = []
|
||||
for m in VAR_RE.finditer(template):
|
||||
if m.group(1) not in seen:
|
||||
seen.add(m.group(1))
|
||||
result.append(m.group(1))
|
||||
return result
|
||||
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SETTINGS_PATH = Path(__file__).parent.parent / "settings.json"
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _read():
|
||||
if not SETTINGS_PATH.exists():
|
||||
return {"openai_api_key": "", "presets": []}
|
||||
return json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write(data):
|
||||
tmp = SETTINGS_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(SETTINGS_PATH)
|
||||
|
||||
|
||||
def list_presets():
|
||||
return _read()["presets"]
|
||||
|
||||
|
||||
def create_preset(name, description, template, variables):
|
||||
data = _read()
|
||||
preset = {
|
||||
"id": secrets.token_hex(4),
|
||||
"name": name,
|
||||
"description": description,
|
||||
"template": template,
|
||||
"variables": variables,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
data["presets"].append(preset)
|
||||
_write(data)
|
||||
return preset
|
||||
|
||||
|
||||
def update_preset(preset_id, fields):
|
||||
data = _read()
|
||||
for p in data["presets"]:
|
||||
if p["id"] == preset_id:
|
||||
p.update(fields)
|
||||
p["updated_at"] = _now()
|
||||
_write(data)
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def delete_preset(preset_id):
|
||||
data = _read()
|
||||
before = len(data["presets"])
|
||||
data["presets"] = [p for p in data["presets"] if p["id"] != preset_id]
|
||||
if len(data["presets"]) == before:
|
||||
return False
|
||||
_write(data)
|
||||
return True
|
||||
|
||||
|
||||
def get_openai_key():
|
||||
return _read().get("openai_api_key", "")
|
||||
|
||||
|
||||
def set_openai_key(key: str):
|
||||
data = _read()
|
||||
data["openai_api_key"] = key
|
||||
_write(data)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Prompt Gen</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2736
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "prompt-gen-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} }
|
||||
}
|
||||
@@ -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 />)
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8001'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
openai>=1.0.0
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"openai_api_key": "",
|
||||
"presets": [
|
||||
{
|
||||
"id": "9b143227",
|
||||
"name": "Test",
|
||||
"description": "demo",
|
||||
"template": "Resolve {{issue_id}} in {{repo}} as {{author}}.",
|
||||
"variables": [
|
||||
"issue_id",
|
||||
"repo",
|
||||
"author"
|
||||
],
|
||||
"created_at": "2026-06-26T12:38:46.654788Z",
|
||||
"updated_at": "2026-06-26T12:38:46.654801Z"
|
||||
},
|
||||
{
|
||||
"id": "ba0eb27f",
|
||||
"name": "Luna Coreprojc betternews",
|
||||
"description": "",
|
||||
"template": "/new resolve COREPROJEC-{{CPC_ID}} in projects/betternews. Remember to always read each AGENTS and CLAUDE.md in all sub-repos ({{SUB_REPOS}}) and respect their rules. Migrations will be needed, do not apply anything to the database yet, only test locally if it compiles, then it should also work live.\n\nRead the description of the work item first please. Respect the Metadata.\n\nMake PRs in all {{SUM_REPOS}} sub-repos once completed, send their links to me, create them as Luna.",
|
||||
"variables": [
|
||||
"CPC_ID",
|
||||
"SUB_REPOS",
|
||||
"SUM_REPOS"
|
||||
],
|
||||
"created_at": "2026-06-26T13:35:20.177361Z",
|
||||
"updated_at": "2026-06-26T13:35:20.177371Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user