Spaces:
Runtime error
Runtime error
Upload PromptForge v1.0 — Structured prompt generator for Google AI Studio
Browse files- .env.example +10 -12
- README.md +4 -13
- backend/instruction_store.py +154 -0
- backend/main.py +191 -123
- backend/prompt_logic.py +226 -142
- backend/schemas.py +152 -70
- backend/store.py +4 -15
- backend/tests/test_promptforge.py +305 -131
- frontend/client.js +503 -201
- frontend/index.html +404 -183
- frontend/style.css +198 -128
.env.example
CHANGED
|
@@ -1,16 +1,14 @@
|
|
| 1 |
-
# PromptForge — Environment Variables
|
| 2 |
-
# Copy
|
| 3 |
-
# NEVER commit .env to version control.
|
| 4 |
|
| 5 |
-
# ──
|
| 6 |
-
#
|
| 7 |
-
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
HF_API_KEY=your_huggingface_api_key_here
|
| 12 |
|
| 13 |
-
# ── Server
|
| 14 |
HOST=0.0.0.0
|
| 15 |
-
PORT=
|
| 16 |
-
LOG_DIR=./logs
|
|
|
|
| 1 |
+
# PromptForge v3.0 — Environment Variables
|
| 2 |
+
# Copy to .env and fill in your values. NEVER commit .env to version control.
|
|
|
|
| 3 |
|
| 4 |
+
# ── AI Provider Keys ───────────────────────────────────────────────────────
|
| 5 |
+
# Hugging Face: https://huggingface.co/settings/tokens (starts with hf_)
|
| 6 |
+
HF_API_KEY=your_huggingface_token_here
|
| 7 |
|
| 8 |
+
# Google AI Studio: https://aistudio.google.com/app/apikey (starts with AIza)
|
| 9 |
+
GOOGLE_API_KEY=your_google_api_key_here
|
|
|
|
| 10 |
|
| 11 |
+
# ── Server Settings ────────────────────────────────────────────────────────
|
| 12 |
HOST=0.0.0.0
|
| 13 |
+
PORT=7860 # HuggingFace Spaces requires 7860
|
| 14 |
+
LOG_DIR=./logs # Where to persist prompt/settings JSON files
|
README.md
CHANGED
|
@@ -1,13 +1,3 @@
|
|
| 1 |
-
---
|
| 2 |
-
sdk: docker
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom: pink
|
| 5 |
-
colorTo: yellow
|
| 6 |
-
pinned: true
|
| 7 |
-
thumbnail: >-
|
| 8 |
-
https://cdn-uploads.huggingface.co/production/uploads/66367933cc7af105efbcd2dc/iW0OtZd-v78iPSycCQ8AK.png
|
| 9 |
-
short_description: '333'
|
| 10 |
-
---
|
| 11 |
# ⚙️ PromptForge
|
| 12 |
|
| 13 |
**PromptForge** is a cloud-ready FastAPI service that converts raw user instructions into
|
|
@@ -78,7 +68,8 @@ pip install -r backend/requirements.txt
|
|
| 78 |
```bash
|
| 79 |
cp .env.example .env
|
| 80 |
# Open .env and fill in your keys:
|
| 81 |
-
#
|
|
|
|
| 82 |
```
|
| 83 |
|
| 84 |
> **Security note:** API keys entered in the frontend are sent only over HTTPS
|
|
@@ -153,7 +144,7 @@ Expected output: **19 tests passing**.
|
|
| 153 |
"instruction": "Generate a TypeScript React component with TailwindCSS and unit tests.",
|
| 154 |
"output_format": "both",
|
| 155 |
"provider": "google",
|
| 156 |
-
"api_key": "
|
| 157 |
"enhance": true,
|
| 158 |
"extra_context": "Must support dark mode and be accessible."
|
| 159 |
}
|
|
@@ -260,4 +251,4 @@ export const Button = ({ label, onClick }: ButtonProps) => (
|
|
| 260 |
|
| 261 |
## 📝 License
|
| 262 |
|
| 263 |
-
MIT — use freely, modify as needed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# ⚙️ PromptForge
|
| 2 |
|
| 3 |
**PromptForge** is a cloud-ready FastAPI service that converts raw user instructions into
|
|
|
|
| 68 |
```bash
|
| 69 |
cp .env.example .env
|
| 70 |
# Open .env and fill in your keys:
|
| 71 |
+
# GOOGLE_API_KEY=...
|
| 72 |
+
# HF_API_KEY=...
|
| 73 |
```
|
| 74 |
|
| 75 |
> **Security note:** API keys entered in the frontend are sent only over HTTPS
|
|
|
|
| 144 |
"instruction": "Generate a TypeScript React component with TailwindCSS and unit tests.",
|
| 145 |
"output_format": "both",
|
| 146 |
"provider": "google",
|
| 147 |
+
"api_key": "YOUR_KEY_HERE",
|
| 148 |
"enhance": true,
|
| 149 |
"extra_context": "Must support dark mode and be accessible."
|
| 150 |
}
|
|
|
|
| 251 |
|
| 252 |
## 📝 License
|
| 253 |
|
| 254 |
+
MIT — use freely, modify as needed.
|
backend/instruction_store.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PromptForge — Persistent store for InstructionSettings.
|
| 3 |
+
Separate from prompt_store to keep concerns clean.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import logging
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
from schemas import InstructionSettings, InstructionSettingsCreate, InstructionSettingsUpdate
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("promptforge.instruction_store")
|
| 17 |
+
|
| 18 |
+
_DB: Dict[str, InstructionSettings] = {}
|
| 19 |
+
_LOG_DIR = Path(os.getenv("LOG_DIR", "logs"))
|
| 20 |
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
_PERSIST_FILE = _LOG_DIR / "instruction_settings.json"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── CRUD ────────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
def create(data: InstructionSettingsCreate) -> InstructionSettings:
|
| 27 |
+
sid = str(uuid.uuid4())
|
| 28 |
+
now = datetime.utcnow()
|
| 29 |
+
setting = InstructionSettings(
|
| 30 |
+
settings_id=sid,
|
| 31 |
+
created_at=now,
|
| 32 |
+
updated_at=now,
|
| 33 |
+
use_count=0,
|
| 34 |
+
**data.model_dump(),
|
| 35 |
+
)
|
| 36 |
+
_DB[sid] = setting
|
| 37 |
+
_persist()
|
| 38 |
+
logger.info("CREATED instruction setting | id=%s title=%r", sid, setting.title)
|
| 39 |
+
return setting
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get(settings_id: str) -> Optional[InstructionSettings]:
|
| 43 |
+
return _DB.get(settings_id)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def list_all(tag: Optional[str] = None) -> List[InstructionSettings]:
|
| 47 |
+
items = sorted(_DB.values(), key=lambda x: x.updated_at, reverse=True)
|
| 48 |
+
if tag:
|
| 49 |
+
items = [i for i in items if tag in i.tags]
|
| 50 |
+
return items
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def update(settings_id: str, data: InstructionSettingsUpdate) -> Optional[InstructionSettings]:
|
| 54 |
+
existing = _DB.get(settings_id)
|
| 55 |
+
if not existing:
|
| 56 |
+
return None
|
| 57 |
+
patch = {k: v for k, v in data.model_dump().items() if v is not None}
|
| 58 |
+
patch["updated_at"] = datetime.utcnow()
|
| 59 |
+
updated = existing.model_copy(update=patch)
|
| 60 |
+
_DB[settings_id] = updated
|
| 61 |
+
_persist()
|
| 62 |
+
logger.info("UPDATED instruction setting | id=%s", settings_id)
|
| 63 |
+
return updated
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def delete(settings_id: str) -> bool:
|
| 67 |
+
if settings_id in _DB:
|
| 68 |
+
del _DB[settings_id]
|
| 69 |
+
_persist()
|
| 70 |
+
logger.info("DELETED instruction setting | id=%s", settings_id)
|
| 71 |
+
return True
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def increment_use_count(settings_id: str) -> None:
|
| 76 |
+
if settings_id in _DB:
|
| 77 |
+
s = _DB[settings_id]
|
| 78 |
+
_DB[settings_id] = s.model_copy(
|
| 79 |
+
update={"use_count": s.use_count + 1, "updated_at": datetime.utcnow()}
|
| 80 |
+
)
|
| 81 |
+
_persist()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ── Persistence ─────────────────────────────────────────────────────────────
|
| 85 |
+
|
| 86 |
+
def _persist() -> None:
|
| 87 |
+
try:
|
| 88 |
+
data = [s.model_dump(mode="json") for s in _DB.values()]
|
| 89 |
+
_PERSIST_FILE.write_text(json.dumps(data, indent=2, default=str))
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
logger.warning("Could not persist instruction store: %s", exc)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def load_from_disk() -> None:
|
| 95 |
+
if not _PERSIST_FILE.exists():
|
| 96 |
+
_seed_defaults()
|
| 97 |
+
return
|
| 98 |
+
try:
|
| 99 |
+
raw = json.loads(_PERSIST_FILE.read_text())
|
| 100 |
+
for entry in raw:
|
| 101 |
+
s = InstructionSettings.model_validate(entry)
|
| 102 |
+
_DB[s.settings_id] = s
|
| 103 |
+
logger.info("Loaded %d instruction settings from disk.", len(_DB))
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
logger.warning("Could not load instruction settings: %s", exc)
|
| 106 |
+
_seed_defaults()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _seed_defaults() -> None:
|
| 110 |
+
"""Create a few useful starter templates on first run."""
|
| 111 |
+
from schemas import InstructionSettingsCreate, OutputFormat, PersonaType, StyleType, AIProvider
|
| 112 |
+
defaults = [
|
| 113 |
+
InstructionSettingsCreate(
|
| 114 |
+
title="React Component Generator",
|
| 115 |
+
description="Generates a TypeScript React component with TailwindCSS and tests.",
|
| 116 |
+
instruction="Create a reusable TypeScript React component with TailwindCSS styling, proper props interface, and Jest unit tests.",
|
| 117 |
+
extra_context="Follow React best practices: hooks, memo, accessibility.",
|
| 118 |
+
output_format=OutputFormat.both,
|
| 119 |
+
persona=PersonaType.senior_dev,
|
| 120 |
+
style=StyleType.professional,
|
| 121 |
+
constraints=["Use TypeScript strict mode", "WCAG 2.1 AA accessibility", "Include PropTypes documentation"],
|
| 122 |
+
tags=["react", "typescript", "frontend"],
|
| 123 |
+
provider=AIProvider.none,
|
| 124 |
+
enhance=False,
|
| 125 |
+
),
|
| 126 |
+
InstructionSettingsCreate(
|
| 127 |
+
title="Python API Endpoint",
|
| 128 |
+
description="Generates a FastAPI endpoint with validation and error handling.",
|
| 129 |
+
instruction="Create a FastAPI endpoint with Pydantic request/response models, input validation, error handling, and docstring.",
|
| 130 |
+
extra_context="Follow REST best practices. Include OpenAPI tags.",
|
| 131 |
+
output_format=OutputFormat.both,
|
| 132 |
+
persona=PersonaType.senior_dev,
|
| 133 |
+
style=StyleType.detailed,
|
| 134 |
+
constraints=["Python 3.11+", "PEP-8 compliant", "Type hints everywhere", "Include unit tests"],
|
| 135 |
+
tags=["python", "fastapi", "backend"],
|
| 136 |
+
provider=AIProvider.none,
|
| 137 |
+
enhance=False,
|
| 138 |
+
),
|
| 139 |
+
InstructionSettingsCreate(
|
| 140 |
+
title="Technical Blog Post",
|
| 141 |
+
description="Creates a structured technical article with code examples.",
|
| 142 |
+
instruction="Write a technical blog post explaining the concept with clear sections, code examples, and actionable takeaways.",
|
| 143 |
+
output_format=OutputFormat.text,
|
| 144 |
+
persona=PersonaType.tech_writer,
|
| 145 |
+
style=StyleType.detailed,
|
| 146 |
+
constraints=["800-1200 words", "Include at least 2 code examples", "Add a TL;DR section"],
|
| 147 |
+
tags=["writing", "technical", "blog"],
|
| 148 |
+
provider=AIProvider.none,
|
| 149 |
+
enhance=False,
|
| 150 |
+
),
|
| 151 |
+
]
|
| 152 |
+
for d in defaults:
|
| 153 |
+
create(d)
|
| 154 |
+
logger.info("Seeded %d default instruction settings.", len(defaults))
|
backend/main.py
CHANGED
|
@@ -1,59 +1,58 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — FastAPI server
|
| 3 |
Run: uvicorn main:app --reload --host 0.0.0.0 --port 7860
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
-
import logging
|
| 7 |
-
import os
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
-
from fastapi import FastAPI, HTTPException,
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
from fastapi.responses import HTMLResponse, JSONResponse
|
| 13 |
from fastapi.staticfiles import StaticFiles
|
| 14 |
|
| 15 |
import store
|
|
|
|
| 16 |
from ai_client import enhance_prompt
|
| 17 |
-
from prompt_logic import
|
|
|
|
|
|
|
|
|
|
| 18 |
from schemas import (
|
| 19 |
-
ApproveRequest,
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
ExportResponse,
|
| 23 |
-
GenerateRequest,
|
| 24 |
-
GenerateResponse,
|
| 25 |
HistoryResponse,
|
| 26 |
RefineRequest,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
-
#
|
| 30 |
-
# Logging
|
| 31 |
-
# ---------------------------------------------------------------------------
|
| 32 |
logging.basicConfig(
|
| 33 |
level=logging.INFO,
|
| 34 |
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 35 |
)
|
| 36 |
logger = logging.getLogger("promptforge.main")
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
# App bootstrap
|
| 40 |
-
# ---------------------------------------------------------------------------
|
| 41 |
app = FastAPI(
|
| 42 |
title="PromptForge",
|
| 43 |
-
description="
|
| 44 |
-
version="
|
| 45 |
docs_url="/docs",
|
| 46 |
redoc_url="/redoc",
|
| 47 |
)
|
| 48 |
|
| 49 |
app.add_middleware(
|
| 50 |
CORSMiddleware,
|
| 51 |
-
allow_origins=["*"],
|
| 52 |
allow_methods=["*"],
|
| 53 |
allow_headers=["*"],
|
| 54 |
)
|
| 55 |
|
| 56 |
-
# Serve frontend from /frontend directory
|
| 57 |
_FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
| 58 |
if _FRONTEND_DIR.exists():
|
| 59 |
app.mount("/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="static")
|
|
@@ -62,204 +61,273 @@ if _FRONTEND_DIR.exists():
|
|
| 62 |
@app.on_event("startup")
|
| 63 |
async def _startup() -> None:
|
| 64 |
store.load_from_disk()
|
|
|
|
| 65 |
port = os.environ.get("PORT", "7860")
|
| 66 |
-
logger.info("PromptForge started. Visit http://localhost:%s", port)
|
| 67 |
|
| 68 |
|
| 69 |
-
#
|
| 70 |
-
# Root — serve the HTML frontend
|
| 71 |
-
# ---------------------------------------------------------------------------
|
| 72 |
|
| 73 |
@app.get("/", response_class=HTMLResponse, tags=["Frontend"])
|
| 74 |
async def serve_frontend() -> HTMLResponse:
|
| 75 |
index = _FRONTEND_DIR / "index.html"
|
| 76 |
if index.exists():
|
| 77 |
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 78 |
-
return HTMLResponse(
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
)
|
| 82 |
|
| 83 |
|
| 84 |
-
#
|
| 85 |
-
# Step 0 + 1: Accept instruction → return manifest
|
| 86 |
-
# ---------------------------------------------------------------------------
|
| 87 |
|
| 88 |
-
@app.post("/api/
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
"""
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
the prompt is finalized.
|
| 94 |
"""
|
| 95 |
-
|
| 96 |
-
|
|
|
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
if req.enhance and req.provider != "none":
|
| 105 |
enhanced_text, notes = await enhance_prompt(
|
| 106 |
raw_prompt=manifest.structured_prompt.raw_prompt_text,
|
| 107 |
-
provider=
|
| 108 |
-
api_key=
|
| 109 |
)
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
update={"raw_prompt_text": enhanced_text}
|
| 113 |
-
)
|
| 114 |
-
manifest = manifest.model_copy(
|
| 115 |
-
update={"structured_prompt": sp, "enhancement_notes": notes}
|
| 116 |
-
)
|
| 117 |
-
logger.info("ENHANCE | %s", notes)
|
| 118 |
|
|
|
|
| 119 |
store.save(manifest)
|
| 120 |
-
logger.info("
|
| 121 |
|
| 122 |
return GenerateResponse(
|
| 123 |
success=True,
|
| 124 |
prompt_id=manifest.prompt_id,
|
| 125 |
manifest=manifest,
|
|
|
|
| 126 |
)
|
| 127 |
|
| 128 |
|
| 129 |
-
#
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
@app.post("/api/approve", response_model=ApproveResponse, tags=["Prompts"])
|
| 134 |
async def approve_prompt(req: ApproveRequest) -> ApproveResponse:
|
| 135 |
-
"""
|
| 136 |
-
**Step 2/3** — Approve (and optionally edit) a pending manifest.
|
| 137 |
-
Returns the finalized structured prompt ready for Google AI Studio.
|
| 138 |
-
"""
|
| 139 |
manifest = store.get(req.prompt_id)
|
| 140 |
if not manifest:
|
| 141 |
-
raise HTTPException(
|
| 142 |
-
|
| 143 |
-
if req.edits:
|
| 144 |
-
manifest = apply_edits(manifest, req.edits)
|
| 145 |
-
else:
|
| 146 |
-
manifest = manifest.model_copy(update={"status": "approved"})
|
| 147 |
-
|
| 148 |
store.save(manifest)
|
| 149 |
logger.info("APPROVED | prompt_id=%s", manifest.prompt_id)
|
| 150 |
-
|
| 151 |
return ApproveResponse(
|
| 152 |
-
success=True,
|
| 153 |
-
prompt_id=manifest.prompt_id,
|
| 154 |
message="Prompt approved and finalized.",
|
| 155 |
finalized_prompt=manifest.structured_prompt,
|
| 156 |
)
|
| 157 |
|
| 158 |
|
| 159 |
-
#
|
| 160 |
-
# Step 4: Export
|
| 161 |
-
# ---------------------------------------------------------------------------
|
| 162 |
|
| 163 |
@app.post("/api/export", response_model=ExportResponse, tags=["Prompts"])
|
| 164 |
async def export_prompt(req: ExportRequest) -> ExportResponse:
|
| 165 |
-
"""
|
| 166 |
-
**Step 4** — Export a finalized prompt as JSON or plain text.
|
| 167 |
-
"""
|
| 168 |
manifest = store.get(req.prompt_id)
|
| 169 |
if not manifest:
|
| 170 |
-
raise HTTPException(
|
| 171 |
if manifest.status not in ("approved", "exported"):
|
| 172 |
-
raise HTTPException(
|
| 173 |
-
status_code=400,
|
| 174 |
-
detail="Prompt must be approved before exporting. Call /api/approve first.",
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
manifest = manifest.model_copy(update={"status": "exported"})
|
| 178 |
store.save(manifest)
|
| 179 |
-
logger.info("EXPORTED | prompt_id=%s format=%s", manifest.prompt_id, req.export_format)
|
| 180 |
-
|
| 181 |
if req.export_format == "text":
|
| 182 |
data = manifest.structured_prompt.raw_prompt_text
|
| 183 |
elif req.export_format == "json":
|
| 184 |
data = manifest.structured_prompt.model_dump()
|
| 185 |
-
else:
|
| 186 |
-
data = {
|
| 187 |
-
|
| 188 |
-
"text": manifest.structured_prompt.raw_prompt_text,
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
return ExportResponse(success=True, prompt_id=manifest.prompt_id, data=data)
|
| 192 |
|
| 193 |
|
| 194 |
-
#
|
| 195 |
-
# Step 5: Iterative refinement
|
| 196 |
-
# ---------------------------------------------------------------------------
|
| 197 |
|
| 198 |
@app.post("/api/refine", response_model=GenerateResponse, tags=["Prompts"])
|
| 199 |
async def refine_prompt(req: RefineRequest) -> GenerateResponse:
|
| 200 |
-
"""
|
| 201 |
-
**Step 5** — Refine an existing prompt with user feedback.
|
| 202 |
-
Creates a new version (v+1) of the manifest.
|
| 203 |
-
"""
|
| 204 |
manifest = store.get(req.prompt_id)
|
| 205 |
if not manifest:
|
| 206 |
-
raise HTTPException(
|
| 207 |
-
|
| 208 |
refined = refine_with_feedback(manifest, req.feedback)
|
| 209 |
-
|
| 210 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
enhanced_text, notes = await enhance_prompt(
|
| 212 |
raw_prompt=refined.structured_prompt.raw_prompt_text,
|
| 213 |
-
provider=req.provider,
|
| 214 |
-
api_key=req.api_key,
|
| 215 |
)
|
| 216 |
sp = refined.structured_prompt.model_copy(update={"raw_prompt_text": enhanced_text})
|
| 217 |
refined = refined.model_copy(update={"structured_prompt": sp, "enhancement_notes": notes})
|
| 218 |
-
|
| 219 |
store.save(refined)
|
| 220 |
-
logger.info("REFINED
|
| 221 |
-
|
| 222 |
return GenerateResponse(
|
| 223 |
-
success=True,
|
| 224 |
-
prompt_id=refined.prompt_id,
|
| 225 |
-
manifest=refined,
|
| 226 |
message=f"Refined to v{refined.version} — awaiting approval.",
|
| 227 |
)
|
| 228 |
|
| 229 |
|
| 230 |
-
#
|
| 231 |
-
# History
|
| 232 |
-
# ---------------------------------------------------------------------------
|
| 233 |
|
| 234 |
@app.get("/api/history", response_model=HistoryResponse, tags=["History"])
|
| 235 |
async def get_history() -> HistoryResponse:
|
| 236 |
-
"""Return a list of all previously generated prompts."""
|
| 237 |
entries = store.all_entries()
|
| 238 |
return HistoryResponse(total=len(entries), entries=entries)
|
| 239 |
|
| 240 |
|
| 241 |
@app.get("/api/history/{prompt_id}", tags=["History"])
|
| 242 |
async def get_prompt(prompt_id: str) -> dict:
|
| 243 |
-
"""Return the full manifest for a single prompt by ID."""
|
| 244 |
manifest = store.get(prompt_id)
|
| 245 |
if not manifest:
|
| 246 |
-
raise HTTPException(
|
| 247 |
return manifest.model_dump(mode="json")
|
| 248 |
|
| 249 |
|
| 250 |
@app.delete("/api/history/{prompt_id}", tags=["History"])
|
| 251 |
async def delete_prompt(prompt_id: str) -> JSONResponse:
|
| 252 |
-
"""Delete a prompt from history."""
|
| 253 |
deleted = store.delete(prompt_id)
|
| 254 |
if not deleted:
|
| 255 |
-
raise HTTPException(
|
| 256 |
return JSONResponse({"success": True, "message": f"Prompt {prompt_id} deleted."})
|
| 257 |
|
| 258 |
|
| 259 |
-
#
|
| 260 |
-
# Health
|
| 261 |
-
# ---------------------------------------------------------------------------
|
| 262 |
|
| 263 |
@app.get("/health", tags=["System"])
|
| 264 |
async def health() -> dict:
|
| 265 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge — FastAPI server v3.0
|
| 3 |
Run: uvicorn main:app --reload --host 0.0.0.0 --port 7860
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
+
import logging, os
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
+
from fastapi import FastAPI, HTTPException, status
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from fastapi.responses import HTMLResponse, JSONResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
|
| 14 |
import store
|
| 15 |
+
import instruction_store
|
| 16 |
from ai_client import enhance_prompt
|
| 17 |
+
from prompt_logic import (
|
| 18 |
+
build_manifest, build_manifest_from_settings,
|
| 19 |
+
apply_edits, refine_with_feedback, generate_explanation,
|
| 20 |
+
)
|
| 21 |
from schemas import (
|
| 22 |
+
ApproveRequest, ApproveResponse,
|
| 23 |
+
ExportRequest, ExportResponse,
|
| 24 |
+
GenerateRequest, GenerateFromSettingsRequest, GenerateResponse,
|
|
|
|
|
|
|
|
|
|
| 25 |
HistoryResponse,
|
| 26 |
RefineRequest,
|
| 27 |
+
InstructionSettingsCreate, InstructionSettingsUpdate,
|
| 28 |
+
InstructionSettingsList, InstructionSettings,
|
| 29 |
+
ExplainResponse, EnvConfigStatus,
|
| 30 |
+
AIProvider,
|
| 31 |
)
|
| 32 |
|
| 33 |
+
# ── Logging ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 34 |
logging.basicConfig(
|
| 35 |
level=logging.INFO,
|
| 36 |
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 37 |
)
|
| 38 |
logger = logging.getLogger("promptforge.main")
|
| 39 |
|
| 40 |
+
# ── App bootstrap ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 41 |
app = FastAPI(
|
| 42 |
title="PromptForge",
|
| 43 |
+
description="Structured prompt generator for Google AI Studio. v3.0",
|
| 44 |
+
version="3.0.0",
|
| 45 |
docs_url="/docs",
|
| 46 |
redoc_url="/redoc",
|
| 47 |
)
|
| 48 |
|
| 49 |
app.add_middleware(
|
| 50 |
CORSMiddleware,
|
| 51 |
+
allow_origins=["*"],
|
| 52 |
allow_methods=["*"],
|
| 53 |
allow_headers=["*"],
|
| 54 |
)
|
| 55 |
|
|
|
|
| 56 |
_FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
| 57 |
if _FRONTEND_DIR.exists():
|
| 58 |
app.mount("/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="static")
|
|
|
|
| 61 |
@app.on_event("startup")
|
| 62 |
async def _startup() -> None:
|
| 63 |
store.load_from_disk()
|
| 64 |
+
instruction_store.load_from_disk()
|
| 65 |
port = os.environ.get("PORT", "7860")
|
| 66 |
+
logger.info("PromptForge v3.0 started. Visit http://localhost:%s", port)
|
| 67 |
|
| 68 |
|
| 69 |
+
# ── Frontend ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 70 |
|
| 71 |
@app.get("/", response_class=HTMLResponse, tags=["Frontend"])
|
| 72 |
async def serve_frontend() -> HTMLResponse:
|
| 73 |
index = _FRONTEND_DIR / "index.html"
|
| 74 |
if index.exists():
|
| 75 |
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 76 |
+
return HTMLResponse("<h1>PromptForge API is running.</h1><a href='/docs'>API Docs</a>")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ── Environment Config (read-only) ───────────────────────────────────────────
|
| 80 |
+
|
| 81 |
+
@app.get("/api/config", response_model=EnvConfigStatus, tags=["System"])
|
| 82 |
+
async def get_config() -> EnvConfigStatus:
|
| 83 |
+
"""Return which API keys are set in the environment (values never exposed)."""
|
| 84 |
+
return EnvConfigStatus(
|
| 85 |
+
hf_key_set=bool(os.environ.get("HF_API_KEY")),
|
| 86 |
+
google_key_set=bool(os.environ.get("GOOGLE_API_KEY")),
|
| 87 |
+
port=os.environ.get("PORT", "7860"),
|
| 88 |
+
version="3.0.0",
|
| 89 |
)
|
| 90 |
|
| 91 |
|
| 92 |
+
# ── Instruction Settings CRUD ────────────────────────────────────────────────
|
|
|
|
|
|
|
| 93 |
|
| 94 |
+
@app.post("/api/instructions", response_model=InstructionSettings,
|
| 95 |
+
status_code=status.HTTP_201_CREATED, tags=["Settings"])
|
| 96 |
+
async def create_instruction(data: InstructionSettingsCreate) -> InstructionSettings:
|
| 97 |
+
"""Save a new instruction setting template."""
|
| 98 |
+
setting = instruction_store.create(data)
|
| 99 |
+
logger.info("INSTRUCTION CREATED | id=%s title=%r", setting.settings_id, setting.title)
|
| 100 |
+
return setting
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.get("/api/instructions", response_model=InstructionSettingsList, tags=["Settings"])
|
| 104 |
+
async def list_instructions(tag: str | None = None) -> InstructionSettingsList:
|
| 105 |
+
"""List all saved instruction settings (optionally filtered by tag)."""
|
| 106 |
+
items = instruction_store.list_all(tag=tag)
|
| 107 |
+
return InstructionSettingsList(total=len(items), items=items)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.get("/api/instructions/{settings_id}", response_model=InstructionSettings, tags=["Settings"])
|
| 111 |
+
async def get_instruction(settings_id: str) -> InstructionSettings:
|
| 112 |
+
setting = instruction_store.get(settings_id)
|
| 113 |
+
if not setting:
|
| 114 |
+
raise HTTPException(404, f"Instruction setting '{settings_id}' not found.")
|
| 115 |
+
return setting
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@app.patch("/api/instructions/{settings_id}", response_model=InstructionSettings, tags=["Settings"])
|
| 119 |
+
async def update_instruction(settings_id: str, data: InstructionSettingsUpdate) -> InstructionSettings:
|
| 120 |
+
updated = instruction_store.update(settings_id, data)
|
| 121 |
+
if not updated:
|
| 122 |
+
raise HTTPException(404, f"Instruction setting '{settings_id}' not found.")
|
| 123 |
+
return updated
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@app.delete("/api/instructions/{settings_id}", tags=["Settings"])
|
| 127 |
+
async def delete_instruction(settings_id: str) -> JSONResponse:
|
| 128 |
+
deleted = instruction_store.delete(settings_id)
|
| 129 |
+
if not deleted:
|
| 130 |
+
raise HTTPException(404, f"Instruction setting '{settings_id}' not found.")
|
| 131 |
+
return JSONResponse({"success": True, "message": f"Setting {settings_id} deleted."})
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ── Generate from settings ────────────────────────────────────────────────────
|
| 135 |
+
|
| 136 |
+
@app.post("/api/generate/from-settings", response_model=GenerateResponse, tags=["Prompts"])
|
| 137 |
+
async def generate_from_settings(req: GenerateFromSettingsRequest) -> GenerateResponse:
|
| 138 |
"""
|
| 139 |
+
Generate a prompt manifest directly from a saved InstructionSettings.
|
| 140 |
+
API key can be passed in the request OR read from environment variables.
|
|
|
|
| 141 |
"""
|
| 142 |
+
setting = instruction_store.get(req.settings_id)
|
| 143 |
+
if not setting:
|
| 144 |
+
raise HTTPException(404, f"Instruction setting '{req.settings_id}' not found.")
|
| 145 |
|
| 146 |
+
# Resolve API key: request > env var
|
| 147 |
+
api_key = req.api_key
|
| 148 |
+
if not api_key and setting.provider == "huggingface":
|
| 149 |
+
api_key = os.environ.get("HF_API_KEY")
|
| 150 |
+
elif not api_key and setting.provider == "google":
|
| 151 |
+
api_key = os.environ.get("GOOGLE_API_KEY")
|
| 152 |
+
|
| 153 |
+
manifest = build_manifest_from_settings(setting)
|
| 154 |
|
| 155 |
+
if setting.enhance and setting.provider != "none" and api_key:
|
|
|
|
| 156 |
enhanced_text, notes = await enhance_prompt(
|
| 157 |
raw_prompt=manifest.structured_prompt.raw_prompt_text,
|
| 158 |
+
provider=setting.provider.value,
|
| 159 |
+
api_key=api_key,
|
| 160 |
)
|
| 161 |
+
sp = manifest.structured_prompt.model_copy(update={"raw_prompt_text": enhanced_text})
|
| 162 |
+
manifest = manifest.model_copy(update={"structured_prompt": sp, "enhancement_notes": notes})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
+
instruction_store.increment_use_count(req.settings_id)
|
| 165 |
store.save(manifest)
|
| 166 |
+
logger.info("GENERATE_FROM_SETTINGS | settings_id=%s prompt_id=%s", req.settings_id, manifest.prompt_id)
|
| 167 |
|
| 168 |
return GenerateResponse(
|
| 169 |
success=True,
|
| 170 |
prompt_id=manifest.prompt_id,
|
| 171 |
manifest=manifest,
|
| 172 |
+
message=f"Generated from settings '{setting.title}'.",
|
| 173 |
)
|
| 174 |
|
| 175 |
|
| 176 |
+
# ── Standard Generate ─────────────────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
@app.post("/api/generate", response_model=GenerateResponse, tags=["Prompts"])
|
| 179 |
+
async def generate_prompt(req: GenerateRequest) -> GenerateResponse:
|
| 180 |
+
"""Generate a structured prompt from a raw instruction."""
|
| 181 |
+
logger.info("GENERATE | instruction=%r | persona=%s | style=%s | enhance=%s",
|
| 182 |
+
req.instruction[:80], req.persona, req.style, req.enhance)
|
| 183 |
+
|
| 184 |
+
# Resolve API key from env if not provided
|
| 185 |
+
api_key = req.api_key
|
| 186 |
+
if not api_key:
|
| 187 |
+
if req.provider == AIProvider.huggingface:
|
| 188 |
+
api_key = os.environ.get("HF_API_KEY")
|
| 189 |
+
elif req.provider == AIProvider.google:
|
| 190 |
+
api_key = os.environ.get("GOOGLE_API_KEY")
|
| 191 |
+
|
| 192 |
+
manifest = build_manifest(
|
| 193 |
+
instruction=req.instruction,
|
| 194 |
+
extra_context=req.extra_context,
|
| 195 |
+
persona=req.persona,
|
| 196 |
+
custom_persona=req.custom_persona,
|
| 197 |
+
style=req.style,
|
| 198 |
+
user_constraints=req.user_constraints,
|
| 199 |
+
settings_id=req.settings_id,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
if req.enhance and req.provider != AIProvider.none and api_key:
|
| 203 |
+
enhanced_text, notes = await enhance_prompt(
|
| 204 |
+
raw_prompt=manifest.structured_prompt.raw_prompt_text,
|
| 205 |
+
provider=req.provider.value,
|
| 206 |
+
api_key=api_key,
|
| 207 |
+
)
|
| 208 |
+
sp = manifest.structured_prompt.model_copy(update={"raw_prompt_text": enhanced_text})
|
| 209 |
+
manifest = manifest.model_copy(update={"structured_prompt": sp, "enhancement_notes": notes})
|
| 210 |
+
logger.info("ENHANCED | %s", notes)
|
| 211 |
+
|
| 212 |
+
store.save(manifest)
|
| 213 |
+
return GenerateResponse(success=True, prompt_id=manifest.prompt_id, manifest=manifest)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ── Explain ───────────────────────────────────────────────────────────────────
|
| 217 |
+
|
| 218 |
+
@app.get("/api/explain/{prompt_id}", response_model=ExplainResponse, tags=["Prompts"])
|
| 219 |
+
async def explain_prompt(prompt_id: str) -> ExplainResponse:
|
| 220 |
+
"""Return a plain-English explanation for why a prompt was structured the way it was."""
|
| 221 |
+
manifest = store.get(prompt_id)
|
| 222 |
+
if not manifest:
|
| 223 |
+
raise HTTPException(404, f"Prompt '{prompt_id}' not found.")
|
| 224 |
+
explanation, decisions = generate_explanation(manifest)
|
| 225 |
+
return ExplainResponse(
|
| 226 |
+
prompt_id=prompt_id,
|
| 227 |
+
explanation=explanation,
|
| 228 |
+
key_decisions=decisions,
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ── Approve ───────────────────────────────────────────────────────────────────
|
| 233 |
|
| 234 |
@app.post("/api/approve", response_model=ApproveResponse, tags=["Prompts"])
|
| 235 |
async def approve_prompt(req: ApproveRequest) -> ApproveResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
manifest = store.get(req.prompt_id)
|
| 237 |
if not manifest:
|
| 238 |
+
raise HTTPException(404, f"Prompt '{req.prompt_id}' not found.")
|
| 239 |
+
manifest = apply_edits(manifest, req.edits) if req.edits else manifest.model_copy(update={"status": "approved"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
store.save(manifest)
|
| 241 |
logger.info("APPROVED | prompt_id=%s", manifest.prompt_id)
|
|
|
|
| 242 |
return ApproveResponse(
|
| 243 |
+
success=True, prompt_id=manifest.prompt_id,
|
|
|
|
| 244 |
message="Prompt approved and finalized.",
|
| 245 |
finalized_prompt=manifest.structured_prompt,
|
| 246 |
)
|
| 247 |
|
| 248 |
|
| 249 |
+
# ── Export ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 250 |
|
| 251 |
@app.post("/api/export", response_model=ExportResponse, tags=["Prompts"])
|
| 252 |
async def export_prompt(req: ExportRequest) -> ExportResponse:
|
|
|
|
|
|
|
|
|
|
| 253 |
manifest = store.get(req.prompt_id)
|
| 254 |
if not manifest:
|
| 255 |
+
raise HTTPException(404, f"Prompt '{req.prompt_id}' not found.")
|
| 256 |
if manifest.status not in ("approved", "exported"):
|
| 257 |
+
raise HTTPException(400, "Prompt must be approved before exporting.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
manifest = manifest.model_copy(update={"status": "exported"})
|
| 259 |
store.save(manifest)
|
|
|
|
|
|
|
| 260 |
if req.export_format == "text":
|
| 261 |
data = manifest.structured_prompt.raw_prompt_text
|
| 262 |
elif req.export_format == "json":
|
| 263 |
data = manifest.structured_prompt.model_dump()
|
| 264 |
+
else:
|
| 265 |
+
data = {"json": manifest.structured_prompt.model_dump(),
|
| 266 |
+
"text": manifest.structured_prompt.raw_prompt_text}
|
|
|
|
|
|
|
|
|
|
| 267 |
return ExportResponse(success=True, prompt_id=manifest.prompt_id, data=data)
|
| 268 |
|
| 269 |
|
| 270 |
+
# ── Refine ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 271 |
|
| 272 |
@app.post("/api/refine", response_model=GenerateResponse, tags=["Prompts"])
|
| 273 |
async def refine_prompt(req: RefineRequest) -> GenerateResponse:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
manifest = store.get(req.prompt_id)
|
| 275 |
if not manifest:
|
| 276 |
+
raise HTTPException(404, f"Prompt '{req.prompt_id}' not found.")
|
|
|
|
| 277 |
refined = refine_with_feedback(manifest, req.feedback)
|
| 278 |
+
api_key = req.api_key
|
| 279 |
+
if not api_key:
|
| 280 |
+
if req.provider == AIProvider.huggingface:
|
| 281 |
+
api_key = os.environ.get("HF_API_KEY")
|
| 282 |
+
elif req.provider == AIProvider.google:
|
| 283 |
+
api_key = os.environ.get("GOOGLE_API_KEY")
|
| 284 |
+
if req.provider != AIProvider.none and api_key:
|
| 285 |
enhanced_text, notes = await enhance_prompt(
|
| 286 |
raw_prompt=refined.structured_prompt.raw_prompt_text,
|
| 287 |
+
provider=req.provider.value, api_key=api_key,
|
|
|
|
| 288 |
)
|
| 289 |
sp = refined.structured_prompt.model_copy(update={"raw_prompt_text": enhanced_text})
|
| 290 |
refined = refined.model_copy(update={"structured_prompt": sp, "enhancement_notes": notes})
|
|
|
|
| 291 |
store.save(refined)
|
| 292 |
+
logger.info("REFINED | prompt_id=%s version=%d", refined.prompt_id, refined.version)
|
|
|
|
| 293 |
return GenerateResponse(
|
| 294 |
+
success=True, prompt_id=refined.prompt_id, manifest=refined,
|
|
|
|
|
|
|
| 295 |
message=f"Refined to v{refined.version} — awaiting approval.",
|
| 296 |
)
|
| 297 |
|
| 298 |
|
| 299 |
+
# ── History ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 300 |
|
| 301 |
@app.get("/api/history", response_model=HistoryResponse, tags=["History"])
|
| 302 |
async def get_history() -> HistoryResponse:
|
|
|
|
| 303 |
entries = store.all_entries()
|
| 304 |
return HistoryResponse(total=len(entries), entries=entries)
|
| 305 |
|
| 306 |
|
| 307 |
@app.get("/api/history/{prompt_id}", tags=["History"])
|
| 308 |
async def get_prompt(prompt_id: str) -> dict:
|
|
|
|
| 309 |
manifest = store.get(prompt_id)
|
| 310 |
if not manifest:
|
| 311 |
+
raise HTTPException(404, f"Prompt '{prompt_id}' not found.")
|
| 312 |
return manifest.model_dump(mode="json")
|
| 313 |
|
| 314 |
|
| 315 |
@app.delete("/api/history/{prompt_id}", tags=["History"])
|
| 316 |
async def delete_prompt(prompt_id: str) -> JSONResponse:
|
|
|
|
| 317 |
deleted = store.delete(prompt_id)
|
| 318 |
if not deleted:
|
| 319 |
+
raise HTTPException(404, f"Prompt '{prompt_id}' not found.")
|
| 320 |
return JSONResponse({"success": True, "message": f"Prompt {prompt_id} deleted."})
|
| 321 |
|
| 322 |
|
| 323 |
+
# ── Health ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 324 |
|
| 325 |
@app.get("/health", tags=["System"])
|
| 326 |
async def health() -> dict:
|
| 327 |
+
return {
|
| 328 |
+
"status": "ok",
|
| 329 |
+
"service": "PromptForge",
|
| 330 |
+
"version": "3.0.0",
|
| 331 |
+
"prompts_in_memory": len(store._DB),
|
| 332 |
+
"settings_in_memory": len(instruction_store._DB),
|
| 333 |
+
}
|
backend/prompt_logic.py
CHANGED
|
@@ -1,107 +1,125 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — Core
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
import re
|
| 7 |
import uuid
|
| 8 |
import textwrap
|
| 9 |
from datetime import datetime
|
| 10 |
-
from typing import Any, Dict, List, Optional
|
| 11 |
-
|
| 12 |
-
from schemas import
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
]
|
| 32 |
|
| 33 |
-
|
| 34 |
-
(
|
| 35 |
-
(
|
| 36 |
-
(
|
| 37 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
]
|
| 39 |
|
| 40 |
_SAFETY_DEFAULTS: List[str] = [
|
| 41 |
"Do not produce harmful, misleading, or unethical content.",
|
| 42 |
"Respect intellectual property; do not reproduce copyrighted material verbatim.",
|
| 43 |
-
"If the request is ambiguous or potentially harmful, ask for clarification
|
| 44 |
"Adhere to Google AI Studio usage policies and Responsible AI guidelines.",
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
_CONSTRAINT_PATTERNS: List[tuple[str, str]] = [
|
| 48 |
-
(r"\btypescript\b", "Use TypeScript with strict mode enabled."),
|
| 49 |
-
(r"\bpython\b", "Use Python 3.10+; follow PEP-8 style guide."),
|
| 50 |
-
(r"\btailwind\b", "Use TailwindCSS utility classes exclusively; avoid custom CSS unless unavoidable."),
|
| 51 |
-
(r"\bunit test[s]?\b|\bjest\b|\bpytest\b", "Include comprehensive unit tests with >80% coverage."),
|
| 52 |
-
(r"\bjson\b", "All structured data must be valid, parseable JSON."),
|
| 53 |
-
(r"\baccessib\w+\b", "Ensure WCAG 2.1 AA accessibility compliance."),
|
| 54 |
-
(r"\bresponsive\b", "Design must be fully responsive for mobile, tablet, and desktop."),
|
| 55 |
-
(r"\bdocker\b", "Provide a Dockerfile and docker-compose.yml."),
|
| 56 |
-
(r"\bno comment[s]?\b", "Do not include inline code comments."),
|
| 57 |
-
(r"\bcomment[s]?\b", "Include clear, concise inline comments explaining non-obvious logic."),
|
| 58 |
]
|
| 59 |
|
| 60 |
|
| 61 |
-
#
|
| 62 |
-
# Public API
|
| 63 |
-
# ---------------------------------------------------------------------------
|
| 64 |
|
| 65 |
def build_manifest(
|
| 66 |
instruction: str,
|
| 67 |
extra_context: Optional[str] = None,
|
| 68 |
version: int = 1,
|
| 69 |
existing_id: Optional[str] = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
) -> PromptManifest:
|
| 71 |
-
"""Transform a raw instruction
|
| 72 |
prompt_id = existing_id or str(uuid.uuid4())
|
| 73 |
lower = instruction.lower()
|
| 74 |
|
| 75 |
-
role =
|
| 76 |
-
task =
|
| 77 |
-
input_fmt
|
| 78 |
output_fmt = _infer_output_format(lower)
|
| 79 |
-
constraints =
|
| 80 |
-
|
| 81 |
safety = list(_SAFETY_DEFAULTS)
|
| 82 |
examples = _build_examples(lower, role)
|
| 83 |
|
| 84 |
raw_text = _render_raw_prompt(
|
| 85 |
-
role=role,
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
examples=examples,
|
| 93 |
-
extra_context=extra_context,
|
| 94 |
)
|
| 95 |
|
| 96 |
structured = StructuredPrompt(
|
| 97 |
-
role=role,
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
constraints=constraints,
|
| 102 |
-
style=style,
|
| 103 |
-
safety=safety,
|
| 104 |
-
examples=examples,
|
| 105 |
raw_prompt_text=raw_text,
|
| 106 |
)
|
| 107 |
|
|
@@ -112,54 +130,82 @@ def build_manifest(
|
|
| 112 |
instruction=instruction,
|
| 113 |
status="pending",
|
| 114 |
structured_prompt=structured,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
)
|
| 116 |
|
| 117 |
|
| 118 |
-
def
|
| 119 |
-
"""
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
constraints=sp.constraints,
|
| 129 |
-
style=sp.style,
|
| 130 |
-
safety=sp.safety,
|
| 131 |
-
examples=sp.examples,
|
| 132 |
-
)
|
| 133 |
-
}
|
| 134 |
-
)
|
| 135 |
-
return manifest.model_copy(
|
| 136 |
-
update={"structured_prompt": sp, "status": "approved"}
|
| 137 |
)
|
| 138 |
|
| 139 |
|
| 140 |
-
def
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
return build_manifest(
|
| 144 |
instruction=manifest.instruction + " " + feedback,
|
| 145 |
version=manifest.version + 1,
|
| 146 |
existing_id=manifest.prompt_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
)
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
-
#
|
| 151 |
-
# Private helpers
|
| 152 |
-
# ---------------------------------------------------------------------------
|
| 153 |
|
| 154 |
-
def
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
if any(kw in lower for kw in keywords):
|
| 157 |
return role
|
| 158 |
return "General AI Assistant"
|
| 159 |
|
| 160 |
|
| 161 |
-
def
|
| 162 |
-
# Sentence-case the instruction and ensure it ends with a period
|
| 163 |
task = instruction.strip()
|
| 164 |
if not task.endswith((".", "!", "?")):
|
| 165 |
task += "."
|
|
@@ -167,71 +213,114 @@ def _infer_task(instruction: str) -> str:
|
|
| 167 |
|
| 168 |
|
| 169 |
def _infer_input_format(lower: str) -> str:
|
| 170 |
-
if any(k in lower for k in ["json", "object", "dict"]):
|
| 171 |
return "A JSON object containing the relevant fields described in the task."
|
| 172 |
-
if any(k in lower for k in ["file", "upload", "csv", "pdf"]):
|
| 173 |
-
return "A file
|
| 174 |
-
if any(k in lower for k in ["image", "photo", "screenshot", "diagram"]):
|
| 175 |
-
return "An image
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
|
| 178 |
|
| 179 |
def _infer_output_format(lower: str) -> str:
|
| 180 |
-
if any(k in lower for k in ["json", "structured", "object"]):
|
| 181 |
-
return "A well-formatted JSON object with clearly named keys.
|
| 182 |
-
if any(k in lower for k in ["markdown", "md", "readme", "documentation"]):
|
| 183 |
return "A Markdown-formatted document with appropriate headers, code blocks, and lists."
|
| 184 |
-
if any(k in lower for k in ["code", "script", "function", "class", "component"]):
|
| 185 |
-
return "Source code inside a properly labeled fenced code block
|
| 186 |
-
if any(k in lower for k in ["list", "bullet", "steps"]):
|
| 187 |
return "A numbered or bulleted list with concise, actionable items."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
return "A clear, well-structured plain-text response."
|
| 189 |
|
| 190 |
|
| 191 |
-
def
|
| 192 |
found: List[str] = []
|
| 193 |
for pattern, constraint in _CONSTRAINT_PATTERNS:
|
| 194 |
if re.search(pattern, lower):
|
| 195 |
found.append(constraint)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
if not found:
|
| 197 |
found.append("Keep the response concise and directly relevant to the task.")
|
| 198 |
return found
|
| 199 |
|
| 200 |
|
| 201 |
-
def _infer_style(lower: str) -> str:
|
| 202 |
-
for keywords, style in _STYLE_MAP:
|
| 203 |
-
if any(kw in lower for kw in keywords):
|
| 204 |
-
return style
|
| 205 |
-
return "Professional and clear; balance technical accuracy with readability."
|
| 206 |
-
|
| 207 |
-
|
| 208 |
def _build_examples(lower: str, role: str) -> Optional[List[Dict[str, str]]]:
|
| 209 |
-
"""Return 1 few-shot example where applicable."""
|
| 210 |
if "react" in lower or "component" in lower:
|
| 211 |
-
return [
|
| 212 |
-
|
| 213 |
-
"input": "Create a Button component.",
|
| 214 |
-
"output": "```tsx\ninterface ButtonProps { label: string; onClick: () => void; }\nexport const Button = ({ label, onClick }: ButtonProps) => (\n <button onClick={onClick} className='px-4 py-2 bg-blue-600 text-white rounded'>{label}</button>\n);\n```",
|
| 215 |
-
}
|
| 216 |
-
]
|
| 217 |
if "summarize" in lower or "summary" in lower:
|
| 218 |
-
return [
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
| 224 |
return None
|
| 225 |
|
| 226 |
|
| 227 |
-
def
|
| 228 |
role: str,
|
| 229 |
-
|
| 230 |
-
input_fmt: str,
|
| 231 |
-
output_fmt: str,
|
| 232 |
constraints: List[str],
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
examples: Optional[List[Dict[str, str]]] = None,
|
| 236 |
extra_context: Optional[str] = None,
|
| 237 |
) -> str:
|
|
@@ -244,21 +333,16 @@ def _render_raw_prompt(
|
|
| 244 |
]
|
| 245 |
for i, c in enumerate(constraints, 1):
|
| 246 |
lines.append(f"{i}. {c}")
|
| 247 |
-
|
| 248 |
lines.append(f"\n## STYLE & TONE\n{style}")
|
| 249 |
-
|
| 250 |
lines.append("\n## SAFETY GUIDELINES")
|
| 251 |
for i, s in enumerate(safety, 1):
|
| 252 |
lines.append(f"{i}. {s}")
|
| 253 |
-
|
| 254 |
if extra_context:
|
| 255 |
lines.append(f"\n## ADDITIONAL CONTEXT\n{extra_context}")
|
| 256 |
-
|
| 257 |
if examples:
|
| 258 |
lines.append("\n## FEW-SHOT EXAMPLES")
|
| 259 |
for ex in examples:
|
| 260 |
lines.append(f"**Input:** {ex['input']}")
|
| 261 |
lines.append(f"**Output:** {ex['output']}\n")
|
| 262 |
-
|
| 263 |
-
lines.append("\n---\n*Prompt generated by PromptForge — compatible with Google AI Studio.*")
|
| 264 |
return "\n".join(lines)
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge — Core prompt generation logic (v3.0).
|
| 3 |
+
Adds persona-aware generation, style variants, user constraints, and explanation.
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
import re
|
| 7 |
import uuid
|
| 8 |
import textwrap
|
| 9 |
from datetime import datetime
|
| 10 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
from schemas import (
|
| 13 |
+
PromptManifest, StructuredPrompt, PersonaType, StyleType,
|
| 14 |
+
InstructionSettings,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ── Persona definitions ──────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
_PERSONA_ROLES: Dict[PersonaType, str] = {
|
| 21 |
+
PersonaType.default: "General AI Assistant",
|
| 22 |
+
PersonaType.senior_dev: "Senior Software Engineer with 10+ years of experience",
|
| 23 |
+
PersonaType.data_scientist: "Senior Data Scientist specializing in ML/AI pipelines",
|
| 24 |
+
PersonaType.tech_writer: "Technical Writer producing clear, precise developer documentation",
|
| 25 |
+
PersonaType.product_mgr: "Product Manager focused on user-centric, data-driven decisions",
|
| 26 |
+
PersonaType.security_eng: "Security Engineer with expertise in threat modeling and secure coding",
|
| 27 |
+
PersonaType.custom: "", # filled from custom_persona field
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
_STYLE_DESCRIPTIONS: Dict[StyleType, str] = {
|
| 31 |
+
StyleType.professional: "Professional and clear; balance technical accuracy with readability. Use precise language.",
|
| 32 |
+
StyleType.concise: "Ultra-concise; bullet points preferred; zero filler words. Every sentence must add value.",
|
| 33 |
+
StyleType.detailed: "Thoroughly detailed; explain every decision; include rationale, alternatives considered, and trade-offs.",
|
| 34 |
+
StyleType.beginner: "Beginner-friendly; avoid jargon; explain acronyms; use analogies and step-by-step breakdowns.",
|
| 35 |
+
StyleType.formal: "Formal prose; structured with headings; professional tone suitable for reports or specifications.",
|
| 36 |
+
StyleType.creative: "Engaging and vivid; use narrative techniques; make dry content interesting without sacrificing accuracy.",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
_HEURISTIC_ROLES: List[Tuple[List[str], str]] = [
|
| 40 |
+
(["react", "vue", "angular", "component", "frontend", "ui", "tailwind", "svelte"], "Senior Frontend Engineer"),
|
| 41 |
+
(["api", "rest", "fastapi", "flask", "django", "backend", "endpoint", "graphql"], "Senior Backend Engineer"),
|
| 42 |
+
(["sql", "database", "postgres", "mongo", "redis", "query", "schema", "orm"], "Database Architect"),
|
| 43 |
+
(["test", "unittest", "pytest", "jest", "coverage", "tdd", "bdd", "e2e"], "QA / Test Automation Engineer"),
|
| 44 |
+
(["devops", "docker", "kubernetes", "ci/cd", "deploy", "cloud", "terraform", "helm"], "DevOps / Cloud Engineer"),
|
| 45 |
+
(["machine learning", "ml", "model", "training", "dataset", "neural", "pytorch", "tensorflow", "llm"], "Machine Learning Engineer"),
|
| 46 |
+
(["data analysis", "pandas", "numpy", "visualization", "chart", "plot", "etl", "pipeline"], "Data Scientist"),
|
| 47 |
+
(["security", "auth", "oauth", "jwt", "encrypt", "pentest", "vulnerability", "cve"], "Security Engineer"),
|
| 48 |
+
(["write", "blog", "article", "essay", "copy", "content", "documentation", "readme"], "Technical Writer"),
|
| 49 |
+
(["summarize", "summary", "tldr", "abstract", "extract", "distill"], "Technical Summarizer"),
|
| 50 |
+
(["translate", "localize", "i18n", "l10n", "language"], "Multilingual Specialist"),
|
| 51 |
+
(["product", "roadmap", "user story", "backlog", "sprint", "okr", "kpi"], "Product Manager"),
|
| 52 |
+
(["sql", "bi", "dashboard", "report", "analytics", "metrics"], "Business Intelligence Analyst"),
|
| 53 |
]
|
| 54 |
|
| 55 |
+
_CONSTRAINT_PATTERNS: List[Tuple[str, str]] = [
|
| 56 |
+
(r"\btypescript\b", "Use TypeScript with strict mode enabled."),
|
| 57 |
+
(r"\bpython\b", "Use Python 3.10+; follow PEP-8 style guide."),
|
| 58 |
+
(r"\btailwind(?:css)?\b", "Use TailwindCSS utility classes only; avoid custom CSS unless unavoidable."),
|
| 59 |
+
(r"\bunit test[s]?\b|\bjest\b|\bpytest\b", "Include comprehensive unit tests with ≥80% coverage."),
|
| 60 |
+
(r"\bjson\b", "All structured data must be valid, parseable JSON."),
|
| 61 |
+
(r"\baccessib\w+\b", "Ensure WCAG 2.1 AA accessibility compliance."),
|
| 62 |
+
(r"\bresponsive\b", "Design must be fully responsive for mobile, tablet, and desktop."),
|
| 63 |
+
(r"\bdocker\b", "Provide a Dockerfile and docker-compose.yml."),
|
| 64 |
+
(r"\bno comment[s]?\b", "Do not include inline code comments."),
|
| 65 |
+
(r"\bcomment[s]?\b", "Include clear, concise inline comments explaining non-obvious logic."),
|
| 66 |
+
(r"\berror handling\b|\bexception\b","Include comprehensive error/exception handling with meaningful messages."),
|
| 67 |
+
(r"\blogg?ing\b", "Add structured logging at appropriate severity levels."),
|
| 68 |
+
(r"\bpagination\b", "Implement cursor- or offset-based pagination."),
|
| 69 |
+
(r"\bcach(e|ing)\b", "Implement caching with appropriate TTL and invalidation."),
|
| 70 |
+
(r"\bsecurity\b|\bauth\b", "Follow OWASP security guidelines; validate and sanitize all inputs."),
|
| 71 |
]
|
| 72 |
|
| 73 |
_SAFETY_DEFAULTS: List[str] = [
|
| 74 |
"Do not produce harmful, misleading, or unethical content.",
|
| 75 |
"Respect intellectual property; do not reproduce copyrighted material verbatim.",
|
| 76 |
+
"If the request is ambiguous or potentially harmful, ask for clarification.",
|
| 77 |
"Adhere to Google AI Studio usage policies and Responsible AI guidelines.",
|
| 78 |
+
"Do not expose sensitive data, API keys, passwords, or PII in any output.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
]
|
| 80 |
|
| 81 |
|
| 82 |
+
# ── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 83 |
|
| 84 |
def build_manifest(
|
| 85 |
instruction: str,
|
| 86 |
extra_context: Optional[str] = None,
|
| 87 |
version: int = 1,
|
| 88 |
existing_id: Optional[str] = None,
|
| 89 |
+
persona: PersonaType = PersonaType.default,
|
| 90 |
+
custom_persona: Optional[str] = None,
|
| 91 |
+
style: StyleType = StyleType.professional,
|
| 92 |
+
user_constraints: Optional[List[str]] = None,
|
| 93 |
+
settings_id: Optional[str] = None,
|
| 94 |
) -> PromptManifest:
|
| 95 |
+
"""Transform a raw instruction into a full PromptManifest."""
|
| 96 |
prompt_id = existing_id or str(uuid.uuid4())
|
| 97 |
lower = instruction.lower()
|
| 98 |
|
| 99 |
+
role = _resolve_role(persona, custom_persona, lower)
|
| 100 |
+
task = _format_task(instruction)
|
| 101 |
+
input_fmt = _infer_input_format(lower)
|
| 102 |
output_fmt = _infer_output_format(lower)
|
| 103 |
+
constraints = _build_constraints(lower, user_constraints or [])
|
| 104 |
+
style_desc = _STYLE_DESCRIPTIONS.get(style, _STYLE_DESCRIPTIONS[StyleType.professional])
|
| 105 |
safety = list(_SAFETY_DEFAULTS)
|
| 106 |
examples = _build_examples(lower, role)
|
| 107 |
|
| 108 |
raw_text = _render_raw_prompt(
|
| 109 |
+
role=role, task=task, input_fmt=input_fmt, output_fmt=output_fmt,
|
| 110 |
+
constraints=constraints, style=style_desc, safety=safety,
|
| 111 |
+
examples=examples, extra_context=extra_context,
|
| 112 |
+
)
|
| 113 |
+
explanation = _generate_explanation(
|
| 114 |
+
role=role, instruction=instruction, constraints=constraints,
|
| 115 |
+
persona=persona, style=style,
|
|
|
|
|
|
|
| 116 |
)
|
| 117 |
|
| 118 |
structured = StructuredPrompt(
|
| 119 |
+
role=role, task=task,
|
| 120 |
+
input_format=input_fmt, output_format=output_fmt,
|
| 121 |
+
constraints=constraints, style=style_desc,
|
| 122 |
+
safety=safety, examples=examples,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
raw_prompt_text=raw_text,
|
| 124 |
)
|
| 125 |
|
|
|
|
| 130 |
instruction=instruction,
|
| 131 |
status="pending",
|
| 132 |
structured_prompt=structured,
|
| 133 |
+
explanation=explanation,
|
| 134 |
+
settings_id=settings_id,
|
| 135 |
+
persona_used=persona,
|
| 136 |
+
style_used=style,
|
| 137 |
)
|
| 138 |
|
| 139 |
|
| 140 |
+
def build_manifest_from_settings(settings: InstructionSettings) -> PromptManifest:
|
| 141 |
+
"""Convenience: build a manifest from a saved InstructionSettings object."""
|
| 142 |
+
return build_manifest(
|
| 143 |
+
instruction=settings.instruction,
|
| 144 |
+
extra_context=settings.extra_context,
|
| 145 |
+
persona=settings.persona,
|
| 146 |
+
custom_persona=settings.custom_persona,
|
| 147 |
+
style=settings.style,
|
| 148 |
+
user_constraints=settings.constraints,
|
| 149 |
+
settings_id=settings.settings_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
)
|
| 151 |
|
| 152 |
|
| 153 |
+
def apply_edits(manifest: PromptManifest, edits: Dict[str, Any]) -> PromptManifest:
|
| 154 |
+
sp = manifest.structured_prompt.model_copy(update=edits)
|
| 155 |
+
sp = sp.model_copy(update={
|
| 156 |
+
"raw_prompt_text": _render_raw_prompt(
|
| 157 |
+
role=sp.role, task=sp.task,
|
| 158 |
+
input_fmt=sp.input_format, output_fmt=sp.output_format,
|
| 159 |
+
constraints=sp.constraints, style=sp.style,
|
| 160 |
+
safety=sp.safety, examples=sp.examples,
|
| 161 |
+
)
|
| 162 |
+
})
|
| 163 |
+
return manifest.model_copy(update={"structured_prompt": sp, "status": "approved"})
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def refine_with_feedback(
|
| 167 |
+
manifest: PromptManifest,
|
| 168 |
+
feedback: str,
|
| 169 |
+
) -> PromptManifest:
|
| 170 |
+
"""Incorporate textual feedback and bump the version."""
|
| 171 |
return build_manifest(
|
| 172 |
instruction=manifest.instruction + " " + feedback,
|
| 173 |
version=manifest.version + 1,
|
| 174 |
existing_id=manifest.prompt_id,
|
| 175 |
+
persona=manifest.persona_used,
|
| 176 |
+
style=manifest.style_used,
|
| 177 |
+
settings_id=manifest.settings_id,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def generate_explanation(manifest: PromptManifest) -> Tuple[str, List[str]]:
|
| 182 |
+
"""Return (explanation_text, key_decisions[]) for a manifest."""
|
| 183 |
+
explanation = manifest.explanation or _generate_explanation(
|
| 184 |
+
role=manifest.structured_prompt.role,
|
| 185 |
+
instruction=manifest.instruction,
|
| 186 |
+
constraints=manifest.structured_prompt.constraints,
|
| 187 |
+
persona=manifest.persona_used,
|
| 188 |
+
style=manifest.style_used,
|
| 189 |
)
|
| 190 |
+
decisions = _extract_key_decisions(manifest)
|
| 191 |
+
return explanation, decisions
|
| 192 |
|
| 193 |
|
| 194 |
+
# ── Private helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 195 |
|
| 196 |
+
def _resolve_role(persona: PersonaType, custom_persona: Optional[str], lower: str) -> str:
|
| 197 |
+
if persona == PersonaType.custom and custom_persona:
|
| 198 |
+
return custom_persona
|
| 199 |
+
if persona != PersonaType.default:
|
| 200 |
+
return _PERSONA_ROLES.get(persona, "General AI Assistant")
|
| 201 |
+
# Heuristic fallback
|
| 202 |
+
for keywords, role in _HEURISTIC_ROLES:
|
| 203 |
if any(kw in lower for kw in keywords):
|
| 204 |
return role
|
| 205 |
return "General AI Assistant"
|
| 206 |
|
| 207 |
|
| 208 |
+
def _format_task(instruction: str) -> str:
|
|
|
|
| 209 |
task = instruction.strip()
|
| 210 |
if not task.endswith((".", "!", "?")):
|
| 211 |
task += "."
|
|
|
|
| 213 |
|
| 214 |
|
| 215 |
def _infer_input_format(lower: str) -> str:
|
| 216 |
+
if any(k in lower for k in ["json", "object", "dict", "payload"]):
|
| 217 |
return "A JSON object containing the relevant fields described in the task."
|
| 218 |
+
if any(k in lower for k in ["file", "upload", "csv", "pdf", "spreadsheet"]):
|
| 219 |
+
return "A file (provide file path, URL, or base64-encoded content)."
|
| 220 |
+
if any(k in lower for k in ["image", "photo", "screenshot", "diagram", "figure"]):
|
| 221 |
+
return "An image provided as a URL or base64-encoded string."
|
| 222 |
+
if any(k in lower for k in ["url", "link", "website", "webpage"]):
|
| 223 |
+
return "A URL or list of URLs to process."
|
| 224 |
+
return "A plain-text string describing the user's request or content to process."
|
| 225 |
|
| 226 |
|
| 227 |
def _infer_output_format(lower: str) -> str:
|
| 228 |
+
if any(k in lower for k in ["json", "structured", "object", "dict"]):
|
| 229 |
+
return "A well-formatted JSON object with clearly named keys. No extra prose outside the JSON block."
|
| 230 |
+
if any(k in lower for k in ["markdown", "md", "readme", "documentation", "doc"]):
|
| 231 |
return "A Markdown-formatted document with appropriate headers, code blocks, and lists."
|
| 232 |
+
if any(k in lower for k in ["code", "script", "function", "class", "component", "snippet"]):
|
| 233 |
+
return "Source code inside a properly labeled fenced code block. Include a brief explanation before and after."
|
| 234 |
+
if any(k in lower for k in ["list", "bullet", "steps", "enumerat"]):
|
| 235 |
return "A numbered or bulleted list with concise, actionable items."
|
| 236 |
+
if any(k in lower for k in ["report", "analysis", "summary"]):
|
| 237 |
+
return "A structured report with an executive summary, body sections, and key findings."
|
| 238 |
+
if any(k in lower for k in ["table", "comparison", "matrix"]):
|
| 239 |
+
return "A Markdown table with clearly labeled columns and rows."
|
| 240 |
return "A clear, well-structured plain-text response."
|
| 241 |
|
| 242 |
|
| 243 |
+
def _build_constraints(lower: str, user_constraints: List[str]) -> List[str]:
|
| 244 |
found: List[str] = []
|
| 245 |
for pattern, constraint in _CONSTRAINT_PATTERNS:
|
| 246 |
if re.search(pattern, lower):
|
| 247 |
found.append(constraint)
|
| 248 |
+
# Merge with user-provided constraints (dedup)
|
| 249 |
+
for uc in user_constraints:
|
| 250 |
+
if uc.strip() and uc.strip() not in found:
|
| 251 |
+
found.append(uc.strip())
|
| 252 |
if not found:
|
| 253 |
found.append("Keep the response concise and directly relevant to the task.")
|
| 254 |
return found
|
| 255 |
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
def _build_examples(lower: str, role: str) -> Optional[List[Dict[str, str]]]:
|
|
|
|
| 258 |
if "react" in lower or "component" in lower:
|
| 259 |
+
return [{"input": "Create a Button component.",
|
| 260 |
+
"output": "```tsx\ninterface ButtonProps { label: string; onClick: () => void; }\nexport const Button = ({ label, onClick }: ButtonProps) => (\n <button onClick={onClick} className='px-4 py-2 bg-indigo-600 text-white rounded'>{label}</button>\n);\n```"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
if "summarize" in lower or "summary" in lower:
|
| 262 |
+
return [{"input": "Summarize: 'The quick brown fox jumps over the lazy dog.'",
|
| 263 |
+
"output": "A fox jumps over a dog."}]
|
| 264 |
+
if "fastapi" in lower or ("api" in lower and "endpoint" in lower):
|
| 265 |
+
return [{"input": "Create a GET /users endpoint.",
|
| 266 |
+
"output": '```python\n@router.get("/users", response_model=list[UserOut])\nasync def list_users(db: Session = Depends(get_db)):\n return db.query(User).all()\n```'}]
|
| 267 |
+
if "sql" in lower or "query" in lower:
|
| 268 |
+
return [{"input": "Get all users created this month.",
|
| 269 |
+
"output": "```sql\nSELECT * FROM users\nWHERE created_at >= DATE_TRUNC('month', NOW());\n```"}]
|
| 270 |
return None
|
| 271 |
|
| 272 |
|
| 273 |
+
def _generate_explanation(
|
| 274 |
role: str,
|
| 275 |
+
instruction: str,
|
|
|
|
|
|
|
| 276 |
constraints: List[str],
|
| 277 |
+
persona: PersonaType,
|
| 278 |
+
style: StyleType,
|
| 279 |
+
) -> str:
|
| 280 |
+
lines = [
|
| 281 |
+
f"This prompt was engineered to elicit optimal output from a '{role}' persona.",
|
| 282 |
+
"",
|
| 283 |
+
f"**Why this role?** The instruction '{instruction[:80]}...' contains terminology "
|
| 284 |
+
f"most naturally handled by a {role}. Assigning the right role primes the AI's "
|
| 285 |
+
f"response patterns toward domain-specific knowledge and conventions.",
|
| 286 |
+
"",
|
| 287 |
+
f"**Why the '{style.value}' style?** The selected style ({style.value}) ensures the "
|
| 288 |
+
f"output matches the intended audience — adjusting verbosity, formality, and "
|
| 289 |
+
f"technical depth accordingly.",
|
| 290 |
+
"",
|
| 291 |
+
"**Why these constraints?**",
|
| 292 |
+
]
|
| 293 |
+
for c in constraints[:4]:
|
| 294 |
+
lines.append(f"- {c}")
|
| 295 |
+
lines += [
|
| 296 |
+
"",
|
| 297 |
+
"**Safety section:** Every prompt includes guardrails aligned with Google AI Studio "
|
| 298 |
+
"Responsible AI policies. These prevent generation of harmful, misleading, or "
|
| 299 |
+
"policy-violating content.",
|
| 300 |
+
"",
|
| 301 |
+
"**Few-shot examples:** Where applicable, a concrete input→output example is included "
|
| 302 |
+
"to guide the model's output format and quality level.",
|
| 303 |
+
]
|
| 304 |
+
return "\n".join(lines)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _extract_key_decisions(manifest: PromptManifest) -> List[str]:
|
| 308 |
+
sp = manifest.structured_prompt
|
| 309 |
+
decisions = [
|
| 310 |
+
f"Role assigned: {sp.role}",
|
| 311 |
+
f"Style applied: {manifest.style_used.value} — {_STYLE_DESCRIPTIONS[manifest.style_used][:60]}…",
|
| 312 |
+
f"Output format type: {sp.output_format[:60]}…",
|
| 313 |
+
f"{len(sp.constraints)} constraint(s) inferred + user-defined",
|
| 314 |
+
f"{len(sp.safety)} safety guardrail(s) applied",
|
| 315 |
+
]
|
| 316 |
+
if sp.examples:
|
| 317 |
+
decisions.append(f"{len(sp.examples)} few-shot example(s) injected")
|
| 318 |
+
return decisions
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _render_raw_prompt(
|
| 322 |
+
role: str, task: str, input_fmt: str, output_fmt: str,
|
| 323 |
+
constraints: List[str], style: str, safety: List[str],
|
| 324 |
examples: Optional[List[Dict[str, str]]] = None,
|
| 325 |
extra_context: Optional[str] = None,
|
| 326 |
) -> str:
|
|
|
|
| 333 |
]
|
| 334 |
for i, c in enumerate(constraints, 1):
|
| 335 |
lines.append(f"{i}. {c}")
|
|
|
|
| 336 |
lines.append(f"\n## STYLE & TONE\n{style}")
|
|
|
|
| 337 |
lines.append("\n## SAFETY GUIDELINES")
|
| 338 |
for i, s in enumerate(safety, 1):
|
| 339 |
lines.append(f"{i}. {s}")
|
|
|
|
| 340 |
if extra_context:
|
| 341 |
lines.append(f"\n## ADDITIONAL CONTEXT\n{extra_context}")
|
|
|
|
| 342 |
if examples:
|
| 343 |
lines.append("\n## FEW-SHOT EXAMPLES")
|
| 344 |
for ex in examples:
|
| 345 |
lines.append(f"**Input:** {ex['input']}")
|
| 346 |
lines.append(f"**Output:** {ex['output']}\n")
|
| 347 |
+
lines.append("\n---\n*Prompt generated by PromptForge v3.0 — compatible with Google AI Studio.*")
|
|
|
|
| 348 |
return "\n".join(lines)
|
backend/schemas.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — Pydantic schemas
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
from typing import Any, Dict, List, Optional
|
|
@@ -8,111 +9,173 @@ from enum import Enum
|
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
# Enumerations
|
| 13 |
-
# ---------------------------------------------------------------------------
|
| 14 |
|
| 15 |
class OutputFormat(str, Enum):
|
| 16 |
text = "text"
|
| 17 |
json = "json"
|
| 18 |
both = "both"
|
| 19 |
|
| 20 |
-
|
| 21 |
class AIProvider(str, Enum):
|
| 22 |
none = "none"
|
| 23 |
huggingface = "huggingface"
|
| 24 |
google = "google"
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
class GenerateRequest(BaseModel):
|
| 32 |
-
instruction: str = Field(
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
)
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
enhance: bool = Field(
|
| 52 |
-
False,
|
| 53 |
-
description="If True, call the selected AI provider to iteratively refine the prompt.",
|
| 54 |
-
)
|
| 55 |
-
extra_context: Optional[str] = Field(
|
| 56 |
-
None,
|
| 57 |
-
max_length=2000,
|
| 58 |
-
description="Optional additional context or constraints for the prompt.",
|
| 59 |
-
)
|
| 60 |
|
| 61 |
|
| 62 |
class ApproveRequest(BaseModel):
|
| 63 |
-
prompt_id: str
|
| 64 |
-
edits: Optional[Dict[str, Any]] =
|
| 65 |
-
None,
|
| 66 |
-
description="Optional user edits to apply before finalizing.",
|
| 67 |
-
)
|
| 68 |
|
| 69 |
|
| 70 |
class ExportRequest(BaseModel):
|
| 71 |
-
prompt_id: str
|
| 72 |
export_format: OutputFormat = Field(OutputFormat.json)
|
| 73 |
|
| 74 |
|
| 75 |
class RefineRequest(BaseModel):
|
| 76 |
-
prompt_id: str
|
| 77 |
-
feedback: str = Field(
|
| 78 |
-
...,
|
| 79 |
-
min_length=3,
|
| 80 |
-
max_length=2000,
|
| 81 |
-
description="User feedback describing changes needed.",
|
| 82 |
-
)
|
| 83 |
provider: AIProvider = Field(AIProvider.none)
|
| 84 |
api_key: Optional[str] = Field(None)
|
| 85 |
|
| 86 |
|
| 87 |
-
#
|
| 88 |
-
# Inner prompt structure
|
| 89 |
-
# ---------------------------------------------------------------------------
|
| 90 |
|
| 91 |
class StructuredPrompt(BaseModel):
|
| 92 |
-
role: str
|
| 93 |
-
task: str
|
| 94 |
-
input_format: str
|
| 95 |
-
output_format: str
|
| 96 |
-
constraints: List[str] = Field(default_factory=list
|
| 97 |
-
style: str
|
| 98 |
-
safety: List[str] = Field(default_factory=list
|
| 99 |
-
examples: Optional[List[Dict[str, str]]] =
|
| 100 |
-
raw_prompt_text: str
|
| 101 |
|
| 102 |
|
| 103 |
class PromptManifest(BaseModel):
|
| 104 |
-
prompt_id: str
|
| 105 |
-
version: int =
|
| 106 |
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 107 |
-
instruction: str
|
| 108 |
-
status: str =
|
| 109 |
structured_prompt: StructuredPrompt
|
| 110 |
-
enhancement_notes: Optional[str] =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
-
#
|
| 114 |
-
# Response models
|
| 115 |
-
# ---------------------------------------------------------------------------
|
| 116 |
|
| 117 |
class GenerateResponse(BaseModel):
|
| 118 |
success: bool
|
|
@@ -131,7 +194,14 @@ class ApproveResponse(BaseModel):
|
|
| 131 |
class ExportResponse(BaseModel):
|
| 132 |
success: bool
|
| 133 |
prompt_id: str
|
| 134 |
-
data: Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
class HistoryEntry(BaseModel):
|
|
@@ -140,8 +210,20 @@ class HistoryEntry(BaseModel):
|
|
| 140 |
created_at: datetime
|
| 141 |
instruction: str
|
| 142 |
status: str
|
|
|
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
class HistoryResponse(BaseModel):
|
| 146 |
total: int
|
| 147 |
entries: List[HistoryEntry]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge — Pydantic schemas (v3.0)
|
| 3 |
+
Adds InstructionSettings, ExplainResponse, and EnvVarConfig.
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
from typing import Any, Dict, List, Optional
|
|
|
|
| 9 |
from pydantic import BaseModel, Field
|
| 10 |
|
| 11 |
|
| 12 |
+
# ── Enumerations ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class OutputFormat(str, Enum):
|
| 15 |
text = "text"
|
| 16 |
json = "json"
|
| 17 |
both = "both"
|
| 18 |
|
|
|
|
| 19 |
class AIProvider(str, Enum):
|
| 20 |
none = "none"
|
| 21 |
huggingface = "huggingface"
|
| 22 |
google = "google"
|
| 23 |
|
| 24 |
+
class PersonaType(str, Enum):
|
| 25 |
+
default = "default"
|
| 26 |
+
senior_dev = "senior_dev"
|
| 27 |
+
data_scientist= "data_scientist"
|
| 28 |
+
tech_writer = "tech_writer"
|
| 29 |
+
product_mgr = "product_mgr"
|
| 30 |
+
security_eng = "security_eng"
|
| 31 |
+
custom = "custom"
|
| 32 |
+
|
| 33 |
+
class StyleType(str, Enum):
|
| 34 |
+
professional = "professional"
|
| 35 |
+
concise = "concise"
|
| 36 |
+
detailed = "detailed"
|
| 37 |
+
beginner = "beginner"
|
| 38 |
+
formal = "formal"
|
| 39 |
+
creative = "creative"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ── InstructionSettings (the new core model) ───────────────────────────────
|
| 43 |
+
|
| 44 |
+
class InstructionSettings(BaseModel):
|
| 45 |
+
settings_id: str = Field(..., description="Unique ID for this saved instruction setting.")
|
| 46 |
+
title: str = Field(..., min_length=2, max_length=120,
|
| 47 |
+
description="Human-readable name for this instruction template.")
|
| 48 |
+
description: Optional[str] = Field(None, max_length=1000,
|
| 49 |
+
description="Optional context or notes about this setting.")
|
| 50 |
+
instruction: str = Field(..., min_length=5, max_length=8000,
|
| 51 |
+
description="The raw instruction/task text.")
|
| 52 |
+
extra_context: Optional[str] = Field(None, max_length=2000,
|
| 53 |
+
description="Additional constraints or background info.")
|
| 54 |
+
output_format: OutputFormat = Field(OutputFormat.both)
|
| 55 |
+
persona: PersonaType = Field(PersonaType.default,
|
| 56 |
+
description="The AI persona to use for generation.")
|
| 57 |
+
custom_persona: Optional[str] = Field(None, max_length=200,
|
| 58 |
+
description="Custom persona text (when persona=custom).")
|
| 59 |
+
style: StyleType = Field(StyleType.professional)
|
| 60 |
+
constraints: List[str] = Field(default_factory=list,
|
| 61 |
+
description="User-defined constraint strings.")
|
| 62 |
+
tags: List[str] = Field(default_factory=list,
|
| 63 |
+
description="Free-form tags for filtering.")
|
| 64 |
+
provider: AIProvider = Field(AIProvider.none)
|
| 65 |
+
enhance: bool = Field(False)
|
| 66 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 67 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 68 |
+
use_count: int = Field(0, description="How many times this setting was used to generate.")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class InstructionSettingsCreate(BaseModel):
|
| 72 |
+
"""Request model — subset of InstructionSettings without auto fields."""
|
| 73 |
+
title: str = Field(..., min_length=2, max_length=120)
|
| 74 |
+
description: Optional[str] = Field(None, max_length=1000)
|
| 75 |
+
instruction: str = Field(..., min_length=5, max_length=8000)
|
| 76 |
+
extra_context: Optional[str] = Field(None, max_length=2000)
|
| 77 |
+
output_format: OutputFormat = Field(OutputFormat.both)
|
| 78 |
+
persona: PersonaType = Field(PersonaType.default)
|
| 79 |
+
custom_persona: Optional[str] = Field(None, max_length=200)
|
| 80 |
+
style: StyleType = Field(StyleType.professional)
|
| 81 |
+
constraints: List[str] = Field(default_factory=list)
|
| 82 |
+
tags: List[str] = Field(default_factory=list)
|
| 83 |
+
provider: AIProvider = Field(AIProvider.none)
|
| 84 |
+
enhance: bool = Field(False)
|
| 85 |
|
| 86 |
+
|
| 87 |
+
class InstructionSettingsUpdate(BaseModel):
|
| 88 |
+
title: Optional[str] = Field(None, max_length=120)
|
| 89 |
+
description: Optional[str] = None
|
| 90 |
+
instruction: Optional[str] = Field(None, min_length=5, max_length=8000)
|
| 91 |
+
extra_context: Optional[str] = None
|
| 92 |
+
output_format: Optional[OutputFormat] = None
|
| 93 |
+
persona: Optional[PersonaType] = None
|
| 94 |
+
custom_persona: Optional[str] = None
|
| 95 |
+
style: Optional[StyleType] = None
|
| 96 |
+
constraints: Optional[List[str]] = None
|
| 97 |
+
tags: Optional[List[str]] = None
|
| 98 |
+
provider: Optional[AIProvider] = None
|
| 99 |
+
enhance: Optional[bool] = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class InstructionSettingsList(BaseModel):
|
| 103 |
+
total: int
|
| 104 |
+
items: List[InstructionSettings]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── Existing prompt models (unchanged) ─────────────────────────────────────
|
| 108 |
|
| 109 |
class GenerateRequest(BaseModel):
|
| 110 |
+
instruction: str = Field(..., min_length=5, max_length=8000)
|
| 111 |
+
output_format: OutputFormat = Field(OutputFormat.both)
|
| 112 |
+
provider: AIProvider = Field(AIProvider.none)
|
| 113 |
+
api_key: Optional[str] = Field(None)
|
| 114 |
+
enhance: bool = Field(False)
|
| 115 |
+
extra_context: Optional[str] = Field(None, max_length=2000)
|
| 116 |
+
# New fields from settings
|
| 117 |
+
persona: PersonaType = Field(PersonaType.default)
|
| 118 |
+
custom_persona: Optional[str] = Field(None, max_length=200)
|
| 119 |
+
style: StyleType = Field(StyleType.professional)
|
| 120 |
+
user_constraints: List[str] = Field(default_factory=list)
|
| 121 |
+
settings_id: Optional[str] = Field(None,
|
| 122 |
+
description="If provided, links this generation to a saved settings.")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class GenerateFromSettingsRequest(BaseModel):
|
| 126 |
+
settings_id: str
|
| 127 |
+
api_key: Optional[str] = Field(None,
|
| 128 |
+
description="API key (overrides env var if provided).")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
class ApproveRequest(BaseModel):
|
| 132 |
+
prompt_id: str
|
| 133 |
+
edits: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
|
| 136 |
class ExportRequest(BaseModel):
|
| 137 |
+
prompt_id: str
|
| 138 |
export_format: OutputFormat = Field(OutputFormat.json)
|
| 139 |
|
| 140 |
|
| 141 |
class RefineRequest(BaseModel):
|
| 142 |
+
prompt_id: str
|
| 143 |
+
feedback: str = Field(..., min_length=3, max_length=2000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
provider: AIProvider = Field(AIProvider.none)
|
| 145 |
api_key: Optional[str] = Field(None)
|
| 146 |
|
| 147 |
|
| 148 |
+
# ── Structured prompt ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 149 |
|
| 150 |
class StructuredPrompt(BaseModel):
|
| 151 |
+
role: str
|
| 152 |
+
task: str
|
| 153 |
+
input_format: str
|
| 154 |
+
output_format: str
|
| 155 |
+
constraints: List[str] = Field(default_factory=list)
|
| 156 |
+
style: str
|
| 157 |
+
safety: List[str] = Field(default_factory=list)
|
| 158 |
+
examples: Optional[List[Dict[str, str]]] = None
|
| 159 |
+
raw_prompt_text: str
|
| 160 |
|
| 161 |
|
| 162 |
class PromptManifest(BaseModel):
|
| 163 |
+
prompt_id: str
|
| 164 |
+
version: int = 1
|
| 165 |
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 166 |
+
instruction: str
|
| 167 |
+
status: str = "pending"
|
| 168 |
structured_prompt: StructuredPrompt
|
| 169 |
+
enhancement_notes: Optional[str] = None
|
| 170 |
+
explanation: Optional[str] = Field(None,
|
| 171 |
+
description="Plain-English explanation of why this prompt was structured this way.")
|
| 172 |
+
settings_id: Optional[str] = Field(None,
|
| 173 |
+
description="Linked InstructionSettings ID if generated from settings.")
|
| 174 |
+
persona_used: PersonaType = Field(PersonaType.default)
|
| 175 |
+
style_used: StyleType = Field(StyleType.professional)
|
| 176 |
|
| 177 |
|
| 178 |
+
# ── Response models ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 179 |
|
| 180 |
class GenerateResponse(BaseModel):
|
| 181 |
success: bool
|
|
|
|
| 194 |
class ExportResponse(BaseModel):
|
| 195 |
success: bool
|
| 196 |
prompt_id: str
|
| 197 |
+
data: Any
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
class ExplainResponse(BaseModel):
|
| 201 |
+
prompt_id: str
|
| 202 |
+
explanation: str
|
| 203 |
+
key_decisions: List[str] = Field(default_factory=list,
|
| 204 |
+
description="Bullet-point list of key structural decisions made.")
|
| 205 |
|
| 206 |
|
| 207 |
class HistoryEntry(BaseModel):
|
|
|
|
| 210 |
created_at: datetime
|
| 211 |
instruction: str
|
| 212 |
status: str
|
| 213 |
+
settings_id: Optional[str] = None
|
| 214 |
+
explanation: Optional[str] = None
|
| 215 |
|
| 216 |
|
| 217 |
class HistoryResponse(BaseModel):
|
| 218 |
total: int
|
| 219 |
entries: List[HistoryEntry]
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ── Env config (for UI-driven key management) ───────────────────────────────
|
| 223 |
+
|
| 224 |
+
class EnvConfigStatus(BaseModel):
|
| 225 |
+
"""Read-only status of which API keys are configured in the environment."""
|
| 226 |
+
hf_key_set: bool = Field(..., description="True if HF_API_KEY env var is present.")
|
| 227 |
+
google_key_set: bool = Field(..., description="True if GOOGLE_API_KEY env var is present.")
|
| 228 |
+
port: str = "7860"
|
| 229 |
+
version: str = "3.0"
|
backend/store.py
CHANGED
|
@@ -1,12 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — In-memory store with
|
| 3 |
-
In production, swap this for a proper database (PostgreSQL, SQLite, etc.).
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
-
import json
|
| 7 |
-
import os
|
| 8 |
-
import logging
|
| 9 |
-
from copy import deepcopy
|
| 10 |
from datetime import datetime
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Dict, List, Optional
|
|
@@ -21,10 +17,6 @@ _LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
| 21 |
_PERSIST_FILE = _LOG_DIR / "prompt_history.json"
|
| 22 |
|
| 23 |
|
| 24 |
-
# ---------------------------------------------------------------------------
|
| 25 |
-
# CRUD
|
| 26 |
-
# ---------------------------------------------------------------------------
|
| 27 |
-
|
| 28 |
def save(manifest: PromptManifest) -> None:
|
| 29 |
_DB[manifest.prompt_id] = manifest
|
| 30 |
_persist()
|
|
@@ -42,6 +34,8 @@ def all_entries() -> List[HistoryEntry]:
|
|
| 42 |
created_at=m.created_at,
|
| 43 |
instruction=m.instruction,
|
| 44 |
status=m.status,
|
|
|
|
|
|
|
| 45 |
)
|
| 46 |
for m in sorted(_DB.values(), key=lambda x: x.created_at, reverse=True)
|
| 47 |
]
|
|
@@ -55,10 +49,6 @@ def delete(prompt_id: str) -> bool:
|
|
| 55 |
return False
|
| 56 |
|
| 57 |
|
| 58 |
-
# ---------------------------------------------------------------------------
|
| 59 |
-
# Persistence
|
| 60 |
-
# ---------------------------------------------------------------------------
|
| 61 |
-
|
| 62 |
def _persist() -> None:
|
| 63 |
try:
|
| 64 |
data = [m.model_dump(mode="json") for m in _DB.values()]
|
|
@@ -68,7 +58,6 @@ def _persist() -> None:
|
|
| 68 |
|
| 69 |
|
| 70 |
def load_from_disk() -> None:
|
| 71 |
-
"""Call once at startup to reload previous sessions."""
|
| 72 |
if not _PERSIST_FILE.exists():
|
| 73 |
return
|
| 74 |
try:
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge — In-memory store with JSON persistence for PromptManifests.
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
+
import json, os, logging
|
|
|
|
|
|
|
|
|
|
| 6 |
from datetime import datetime
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Dict, List, Optional
|
|
|
|
| 17 |
_PERSIST_FILE = _LOG_DIR / "prompt_history.json"
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def save(manifest: PromptManifest) -> None:
|
| 21 |
_DB[manifest.prompt_id] = manifest
|
| 22 |
_persist()
|
|
|
|
| 34 |
created_at=m.created_at,
|
| 35 |
instruction=m.instruction,
|
| 36 |
status=m.status,
|
| 37 |
+
settings_id=m.settings_id,
|
| 38 |
+
explanation=m.explanation,
|
| 39 |
)
|
| 40 |
for m in sorted(_DB.values(), key=lambda x: x.created_at, reverse=True)
|
| 41 |
]
|
|
|
|
| 49 |
return False
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def _persist() -> None:
|
| 53 |
try:
|
| 54 |
data = [m.model_dump(mode="json") for m in _DB.values()]
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
def load_from_disk() -> None:
|
|
|
|
| 61 |
if not _PERSIST_FILE.exists():
|
| 62 |
return
|
| 63 |
try:
|
backend/tests/test_promptforge.py
CHANGED
|
@@ -1,24 +1,34 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge —
|
| 3 |
-
Run
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
-
import sys
|
| 7 |
-
import os
|
| 8 |
-
|
| 9 |
-
# Make backend importable
|
| 10 |
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 11 |
|
| 12 |
import pytest
|
| 13 |
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
from schemas import PromptManifest, GenerateRequest, OutputFormat, AIProvider
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
# ---------------------------------------------------------------------------
|
| 20 |
-
# Fixtures
|
| 21 |
-
# ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
@pytest.fixture
|
| 24 |
def client():
|
|
@@ -27,178 +37,342 @@ def client():
|
|
| 27 |
|
| 28 |
|
| 29 |
@pytest.fixture
|
| 30 |
-
def
|
| 31 |
return "Generate a TypeScript React component with TailwindCSS and unit tests."
|
| 32 |
|
| 33 |
|
| 34 |
@pytest.fixture
|
| 35 |
-
def
|
| 36 |
-
return build_manifest(
|
| 37 |
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
class TestBuildManifest:
|
| 44 |
-
def test_returns_manifest_type(self,
|
| 45 |
-
assert isinstance(
|
| 46 |
|
| 47 |
-
def
|
| 48 |
-
assert
|
| 49 |
|
| 50 |
-
def test_version_is_1(self,
|
| 51 |
-
assert
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
assert len(
|
| 55 |
|
| 56 |
-
def
|
| 57 |
-
assert "Frontend" in
|
| 58 |
|
| 59 |
-
def
|
| 60 |
-
|
| 61 |
-
assert any("TypeScript" in c for c in constraints)
|
| 62 |
|
| 63 |
-
def
|
| 64 |
-
|
| 65 |
-
assert any("TailwindCSS" in c for c in constraints)
|
| 66 |
|
| 67 |
-
def
|
| 68 |
-
|
| 69 |
-
assert "## ROLE" in raw
|
| 70 |
|
| 71 |
-
def
|
| 72 |
-
|
| 73 |
-
assert "## TASK" in raw
|
| 74 |
|
| 75 |
-
def
|
| 76 |
-
assert
|
| 77 |
|
| 78 |
-
def
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
assert "500 words" in m.structured_prompt.raw_prompt_text
|
| 81 |
|
| 82 |
-
def
|
| 83 |
-
|
| 84 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
def
|
| 87 |
-
m = build_manifest("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
assert m.structured_prompt.role == "General AI Assistant"
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
class TestApplyEdits:
|
| 92 |
-
def
|
| 93 |
-
edited = apply_edits(
|
| 94 |
-
assert edited.structured_prompt.role == "Principal
|
| 95 |
|
| 96 |
-
def test_status_becomes_approved(self,
|
| 97 |
-
edited = apply_edits(
|
| 98 |
assert edited.status == "approved"
|
| 99 |
|
| 100 |
-
def test_raw_text_regenerated(self,
|
| 101 |
-
edited = apply_edits(
|
| 102 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
class TestRefineWithFeedback:
|
| 106 |
-
def test_version_increments(self,
|
| 107 |
-
refined = refine_with_feedback(
|
| 108 |
assert refined.version == 2
|
| 109 |
|
| 110 |
-
def test_same_prompt_id(self,
|
| 111 |
-
refined = refine_with_feedback(
|
| 112 |
-
assert refined.prompt_id ==
|
| 113 |
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
-
# ---------------------------------------------------------------------------
|
| 116 |
-
# API route tests
|
| 117 |
-
# ---------------------------------------------------------------------------
|
| 118 |
|
| 119 |
-
class
|
| 120 |
-
def
|
| 121 |
-
|
| 122 |
-
assert
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
data = resp.json()
|
| 127 |
-
assert "prompt_id" in data
|
| 128 |
-
assert len(data["prompt_id"]) == 36
|
| 129 |
-
|
| 130 |
-
def test_generate_response_has_manifest(self, client, sample_instruction):
|
| 131 |
-
resp = client.post("/api/generate", json={"instruction": sample_instruction})
|
| 132 |
-
data = resp.json()
|
| 133 |
-
assert "manifest" in data
|
| 134 |
-
assert data["manifest"]["status"] == "pending"
|
| 135 |
|
| 136 |
-
def
|
| 137 |
-
|
| 138 |
-
assert
|
| 139 |
|
| 140 |
-
def test_generate_missing_instruction_422(self, client):
|
| 141 |
-
resp = client.post("/api/generate", json={})
|
| 142 |
-
assert resp.status_code == 422
|
| 143 |
|
|
|
|
| 144 |
|
| 145 |
-
class
|
| 146 |
-
def
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
def
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
assert
|
|
|
|
| 154 |
|
| 155 |
-
def
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
| 159 |
|
| 160 |
-
def
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
|
| 171 |
-
def test_export_json_200(self, client):
|
| 172 |
-
pid = self._generate_and_approve(client)
|
| 173 |
-
resp = client.post("/api/export", json={"prompt_id": pid, "export_format": "json"})
|
| 174 |
-
assert resp.status_code == 200
|
| 175 |
-
data = resp.json()
|
| 176 |
-
assert isinstance(data["data"], dict)
|
| 177 |
|
| 178 |
-
|
| 179 |
-
pid = self._generate_and_approve(client)
|
| 180 |
-
resp = client.post("/api/export", json={"prompt_id": pid, "export_format": "text"})
|
| 181 |
-
assert resp.status_code == 200
|
| 182 |
-
assert isinstance(resp.json()["data"], str)
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
class TestHealthRoute:
|
| 201 |
def test_health_ok(self, client):
|
| 202 |
-
|
| 203 |
-
assert
|
| 204 |
-
assert
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge v3.0 — Comprehensive tests.
|
| 3 |
+
Run: cd backend && pytest tests/ -v --tb=short
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
+
import sys, os
|
|
|
|
|
|
|
|
|
|
| 7 |
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 8 |
|
| 9 |
import pytest
|
| 10 |
from fastapi.testclient import TestClient
|
| 11 |
+
from prompt_logic import build_manifest, apply_edits, refine_with_feedback, generate_explanation
|
| 12 |
+
from schemas import (
|
| 13 |
+
PromptManifest, PersonaType, StyleType, OutputFormat,
|
| 14 |
+
InstructionSettingsCreate, AIProvider,
|
| 15 |
+
)
|
| 16 |
+
import instruction_store as istore
|
| 17 |
+
|
| 18 |
|
| 19 |
+
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
| 20 |
|
| 21 |
+
@pytest.fixture(autouse=True)
|
| 22 |
+
def reset_stores():
|
| 23 |
+
"""Reset in-memory stores before each test."""
|
| 24 |
+
from store import _DB as prompt_db
|
| 25 |
+
from instruction_store import _DB as settings_db
|
| 26 |
+
prompt_db.clear()
|
| 27 |
+
settings_db.clear()
|
| 28 |
+
yield
|
| 29 |
+
prompt_db.clear()
|
| 30 |
+
settings_db.clear()
|
| 31 |
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@pytest.fixture
|
| 34 |
def client():
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
@pytest.fixture
|
| 40 |
+
def react_instruction():
|
| 41 |
return "Generate a TypeScript React component with TailwindCSS and unit tests."
|
| 42 |
|
| 43 |
|
| 44 |
@pytest.fixture
|
| 45 |
+
def react_manifest(react_instruction):
|
| 46 |
+
return build_manifest(react_instruction)
|
| 47 |
|
| 48 |
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def sample_settings_data():
|
| 51 |
+
return InstructionSettingsCreate(
|
| 52 |
+
title="Test Setting",
|
| 53 |
+
description="A test instruction setting",
|
| 54 |
+
instruction="Write a Python function that validates email addresses.",
|
| 55 |
+
output_format=OutputFormat.both,
|
| 56 |
+
persona=PersonaType.senior_dev,
|
| 57 |
+
style=StyleType.professional,
|
| 58 |
+
constraints=["Include type hints", "Add docstring"],
|
| 59 |
+
tags=["python", "validation"],
|
| 60 |
+
provider=AIProvider.none,
|
| 61 |
+
enhance=False,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ── prompt_logic: build_manifest ─────────────────────────────────────────────
|
| 66 |
|
| 67 |
class TestBuildManifest:
|
| 68 |
+
def test_returns_manifest_type(self, react_manifest):
|
| 69 |
+
assert isinstance(react_manifest, PromptManifest)
|
| 70 |
|
| 71 |
+
def test_status_pending(self, react_manifest):
|
| 72 |
+
assert react_manifest.status == "pending"
|
| 73 |
|
| 74 |
+
def test_version_is_1(self, react_manifest):
|
| 75 |
+
assert react_manifest.version == 1
|
| 76 |
|
| 77 |
+
def test_uuid_format(self, react_manifest):
|
| 78 |
+
assert len(react_manifest.prompt_id) == 36
|
| 79 |
|
| 80 |
+
def test_role_inferred_frontend(self, react_manifest):
|
| 81 |
+
assert "Frontend" in react_manifest.structured_prompt.role
|
| 82 |
|
| 83 |
+
def test_typescript_constraint(self, react_manifest):
|
| 84 |
+
assert any("TypeScript" in c for c in react_manifest.structured_prompt.constraints)
|
|
|
|
| 85 |
|
| 86 |
+
def test_tailwind_constraint(self, react_manifest):
|
| 87 |
+
assert any("TailwindCSS" in c for c in react_manifest.structured_prompt.constraints)
|
|
|
|
| 88 |
|
| 89 |
+
def test_raw_prompt_has_role_section(self, react_manifest):
|
| 90 |
+
assert "## ROLE" in react_manifest.structured_prompt.raw_prompt_text
|
|
|
|
| 91 |
|
| 92 |
+
def test_raw_prompt_has_task_section(self, react_manifest):
|
| 93 |
+
assert "## TASK" in react_manifest.structured_prompt.raw_prompt_text
|
|
|
|
| 94 |
|
| 95 |
+
def test_raw_prompt_has_constraints_section(self, react_manifest):
|
| 96 |
+
assert "## CONSTRAINTS" in react_manifest.structured_prompt.raw_prompt_text
|
| 97 |
|
| 98 |
+
def test_safety_present(self, react_manifest):
|
| 99 |
+
assert len(react_manifest.structured_prompt.safety) >= 3
|
| 100 |
+
|
| 101 |
+
def test_extra_context_in_prompt(self):
|
| 102 |
+
m = build_manifest("Write a blog post.", extra_context="Keep under 500 words.")
|
| 103 |
assert "500 words" in m.structured_prompt.raw_prompt_text
|
| 104 |
|
| 105 |
+
def test_explanation_generated(self, react_manifest):
|
| 106 |
+
assert react_manifest.explanation is not None
|
| 107 |
+
assert len(react_manifest.explanation) > 50
|
| 108 |
+
|
| 109 |
+
def test_persona_senior_dev(self):
|
| 110 |
+
m = build_manifest("Build an API", persona=PersonaType.senior_dev)
|
| 111 |
+
assert "Senior Software Engineer" in m.structured_prompt.role
|
| 112 |
+
|
| 113 |
+
def test_persona_custom(self):
|
| 114 |
+
m = build_manifest("Do something", persona=PersonaType.custom,
|
| 115 |
+
custom_persona="Expert Blockchain Developer")
|
| 116 |
+
assert "Blockchain" in m.structured_prompt.role
|
| 117 |
|
| 118 |
+
def test_style_concise(self):
|
| 119 |
+
m = build_manifest("Summarize this.", style=StyleType.concise)
|
| 120 |
+
assert "concise" in m.structured_prompt.style.lower()
|
| 121 |
+
|
| 122 |
+
def test_user_constraints_merged(self):
|
| 123 |
+
m = build_manifest("Write code.", user_constraints=["Must use async/await", "No external libs"])
|
| 124 |
+
constraints = m.structured_prompt.constraints
|
| 125 |
+
assert any("async/await" in c for c in constraints)
|
| 126 |
+
|
| 127 |
+
def test_settings_id_stored(self):
|
| 128 |
+
m = build_manifest("Write code.", settings_id="test-123")
|
| 129 |
+
assert m.settings_id == "test-123"
|
| 130 |
+
|
| 131 |
+
def test_unknown_instruction_default_role(self):
|
| 132 |
+
m = build_manifest("Do something very generic.")
|
| 133 |
assert m.structured_prompt.role == "General AI Assistant"
|
| 134 |
|
| 135 |
+
def test_fastapi_role_inferred(self):
|
| 136 |
+
m = build_manifest("Create a FastAPI REST endpoint.")
|
| 137 |
+
assert "Backend" in m.structured_prompt.role or "Engineer" in m.structured_prompt.role
|
| 138 |
+
|
| 139 |
+
def test_security_persona(self):
|
| 140 |
+
m = build_manifest("Review code for security vulnerabilities.", persona=PersonaType.security_eng)
|
| 141 |
+
assert "Security" in m.structured_prompt.role
|
| 142 |
+
|
| 143 |
|
| 144 |
class TestApplyEdits:
|
| 145 |
+
def test_role_edit(self, react_manifest):
|
| 146 |
+
edited = apply_edits(react_manifest, {"role": "Principal Engineer"})
|
| 147 |
+
assert edited.structured_prompt.role == "Principal Engineer"
|
| 148 |
|
| 149 |
+
def test_status_becomes_approved(self, react_manifest):
|
| 150 |
+
edited = apply_edits(react_manifest, {"task": "New task."})
|
| 151 |
assert edited.status == "approved"
|
| 152 |
|
| 153 |
+
def test_raw_text_regenerated(self, react_manifest):
|
| 154 |
+
edited = apply_edits(react_manifest, {"role": "UniqueXYZRole"})
|
| 155 |
+
assert "UniqueXYZRole" in edited.structured_prompt.raw_prompt_text
|
| 156 |
+
|
| 157 |
+
def test_constraints_edit(self, react_manifest):
|
| 158 |
+
edited = apply_edits(react_manifest, {"constraints": ["Custom constraint only"]})
|
| 159 |
+
assert "Custom constraint only" in edited.structured_prompt.constraints
|
| 160 |
|
| 161 |
|
| 162 |
class TestRefineWithFeedback:
|
| 163 |
+
def test_version_increments(self, react_manifest):
|
| 164 |
+
refined = refine_with_feedback(react_manifest, "Add accessibility support.")
|
| 165 |
assert refined.version == 2
|
| 166 |
|
| 167 |
+
def test_same_prompt_id(self, react_manifest):
|
| 168 |
+
refined = refine_with_feedback(react_manifest, "Add dark mode.")
|
| 169 |
+
assert refined.prompt_id == react_manifest.prompt_id
|
| 170 |
|
| 171 |
+
def test_feedback_incorporated(self, react_manifest):
|
| 172 |
+
refined = refine_with_feedback(react_manifest, "Add accessibility support WCAG AA")
|
| 173 |
+
assert any("WCAG" in c for c in refined.structured_prompt.constraints)
|
| 174 |
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
+
class TestGenerateExplanation:
|
| 177 |
+
def test_returns_explanation_and_decisions(self, react_manifest):
|
| 178 |
+
explanation, decisions = generate_explanation(react_manifest)
|
| 179 |
+
assert isinstance(explanation, str)
|
| 180 |
+
assert len(explanation) > 30
|
| 181 |
+
assert isinstance(decisions, list)
|
| 182 |
+
assert len(decisions) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
def test_decisions_contain_role(self, react_manifest):
|
| 185 |
+
_, decisions = generate_explanation(react_manifest)
|
| 186 |
+
assert any("Role" in d for d in decisions)
|
| 187 |
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
# ── instruction_store tests ───────────────────────────────────────────────────
|
| 190 |
|
| 191 |
+
class TestInstructionStore:
|
| 192 |
+
def test_create_returns_setting(self, sample_settings_data):
|
| 193 |
+
s = istore.create(sample_settings_data)
|
| 194 |
+
assert s.settings_id is not None
|
| 195 |
+
assert s.title == "Test Setting"
|
| 196 |
+
assert s.use_count == 0
|
| 197 |
|
| 198 |
+
def test_get_returns_setting(self, sample_settings_data):
|
| 199 |
+
s = istore.create(sample_settings_data)
|
| 200 |
+
fetched = istore.get(s.settings_id)
|
| 201 |
+
assert fetched is not None
|
| 202 |
+
assert fetched.settings_id == s.settings_id
|
| 203 |
|
| 204 |
+
def test_list_returns_all(self, sample_settings_data):
|
| 205 |
+
istore.create(sample_settings_data)
|
| 206 |
+
istore.create(sample_settings_data.model_copy(update={"title": "Second"}))
|
| 207 |
+
items = istore.list_all()
|
| 208 |
+
assert len(items) == 2
|
| 209 |
|
| 210 |
+
def test_list_filter_by_tag(self, sample_settings_data):
|
| 211 |
+
istore.create(sample_settings_data)
|
| 212 |
+
items = istore.list_all(tag="python")
|
| 213 |
+
assert len(items) == 1
|
| 214 |
+
items_no_match = istore.list_all(tag="nonexistent")
|
| 215 |
+
assert len(items_no_match) == 0
|
| 216 |
|
| 217 |
+
def test_delete_removes_setting(self, sample_settings_data):
|
| 218 |
+
s = istore.create(sample_settings_data)
|
| 219 |
+
deleted = istore.delete(s.settings_id)
|
| 220 |
+
assert deleted is True
|
| 221 |
+
assert istore.get(s.settings_id) is None
|
| 222 |
|
| 223 |
+
def test_increment_use_count(self, sample_settings_data):
|
| 224 |
+
s = istore.create(sample_settings_data)
|
| 225 |
+
istore.increment_use_count(s.settings_id)
|
| 226 |
+
updated = istore.get(s.settings_id)
|
| 227 |
+
assert updated.use_count == 1
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
+
# ── API routes ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
class TestGenerateRoute:
|
| 233 |
+
def test_generate_200(self, client, react_instruction):
|
| 234 |
+
r = client.post("/api/generate", json={"instruction": react_instruction})
|
| 235 |
+
assert r.status_code == 200
|
| 236 |
+
|
| 237 |
+
def test_generate_has_prompt_id(self, client, react_instruction):
|
| 238 |
+
r = client.post("/api/generate", json={"instruction": react_instruction})
|
| 239 |
+
assert len(r.json()["prompt_id"]) == 36
|
| 240 |
+
|
| 241 |
+
def test_generate_with_persona(self, client):
|
| 242 |
+
r = client.post("/api/generate", json={
|
| 243 |
+
"instruction": "Write a security audit report.",
|
| 244 |
+
"persona": "security_eng",
|
| 245 |
+
"style": "formal",
|
| 246 |
+
})
|
| 247 |
+
assert r.status_code == 200
|
| 248 |
+
assert "Security" in r.json()["manifest"]["structured_prompt"]["role"]
|
| 249 |
+
|
| 250 |
+
def test_generate_with_user_constraints(self, client):
|
| 251 |
+
r = client.post("/api/generate", json={
|
| 252 |
+
"instruction": "Build a REST API.",
|
| 253 |
+
"user_constraints": ["Must use async/await", "Rate limiting required"],
|
| 254 |
+
})
|
| 255 |
+
assert r.status_code == 200
|
| 256 |
+
constraints = r.json()["manifest"]["structured_prompt"]["constraints"]
|
| 257 |
+
assert any("async/await" in c for c in constraints)
|
| 258 |
|
| 259 |
+
def test_generate_short_instruction_422(self, client):
|
| 260 |
+
r = client.post("/api/generate", json={"instruction": "hi"})
|
| 261 |
+
assert r.status_code == 422
|
| 262 |
|
| 263 |
+
def test_generate_missing_instruction_422(self, client):
|
| 264 |
+
r = client.post("/api/generate", json={})
|
| 265 |
+
assert r.status_code == 422
|
| 266 |
+
|
| 267 |
+
def test_generate_has_explanation(self, client, react_instruction):
|
| 268 |
+
r = client.post("/api/generate", json={"instruction": react_instruction})
|
| 269 |
+
manifest = r.json()["manifest"]
|
| 270 |
+
assert manifest.get("explanation") is not None
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class TestInstructionSettingsRoutes:
|
| 274 |
+
def _create(self, client):
|
| 275 |
+
return client.post("/api/instructions", json={
|
| 276 |
+
"title": "Test Setting",
|
| 277 |
+
"instruction": "Write a Python function.",
|
| 278 |
+
"output_format": "both",
|
| 279 |
+
"persona": "senior_dev",
|
| 280 |
+
"style": "professional",
|
| 281 |
+
"constraints": [],
|
| 282 |
+
"tags": ["python"],
|
| 283 |
+
"provider": "none",
|
| 284 |
+
"enhance": False,
|
| 285 |
+
})
|
| 286 |
+
|
| 287 |
+
def test_create_201(self, client):
|
| 288 |
+
r = self._create(client)
|
| 289 |
+
assert r.status_code == 201
|
| 290 |
+
assert r.json()["settings_id"] is not None
|
| 291 |
+
|
| 292 |
+
def test_list_200(self, client):
|
| 293 |
+
self._create(client)
|
| 294 |
+
r = client.get("/api/instructions")
|
| 295 |
+
assert r.status_code == 200
|
| 296 |
+
assert r.json()["total"] >= 1
|
| 297 |
+
|
| 298 |
+
def test_get_200(self, client):
|
| 299 |
+
sid = self._create(client).json()["settings_id"]
|
| 300 |
+
r = client.get(f"/api/instructions/{sid}")
|
| 301 |
+
assert r.status_code == 200
|
| 302 |
+
|
| 303 |
+
def test_get_404(self, client):
|
| 304 |
+
r = client.get("/api/instructions/nonexistent")
|
| 305 |
+
assert r.status_code == 404
|
| 306 |
+
|
| 307 |
+
def test_delete_200(self, client):
|
| 308 |
+
sid = self._create(client).json()["settings_id"]
|
| 309 |
+
r = client.delete(f"/api/instructions/{sid}")
|
| 310 |
+
assert r.status_code == 200
|
| 311 |
+
|
| 312 |
+
def test_generate_from_settings(self, client):
|
| 313 |
+
sid = self._create(client).json()["settings_id"]
|
| 314 |
+
r = client.post("/api/generate/from-settings", json={"settings_id": sid})
|
| 315 |
+
assert r.status_code == 200
|
| 316 |
+
data = r.json()
|
| 317 |
+
assert data["manifest"]["settings_id"] == sid
|
| 318 |
+
|
| 319 |
+
def test_generate_from_settings_404(self, client):
|
| 320 |
+
r = client.post("/api/generate/from-settings", json={"settings_id": "bad-id"})
|
| 321 |
+
assert r.status_code == 404
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
class TestExplainRoute:
|
| 325 |
+
def test_explain_200(self, client, react_instruction):
|
| 326 |
+
pid = client.post("/api/generate", json={"instruction": react_instruction}).json()["prompt_id"]
|
| 327 |
+
r = client.get(f"/api/explain/{pid}")
|
| 328 |
+
assert r.status_code == 200
|
| 329 |
+
data = r.json()
|
| 330 |
+
assert "explanation" in data
|
| 331 |
+
assert "key_decisions" in data
|
| 332 |
+
assert len(data["key_decisions"]) > 0
|
| 333 |
+
|
| 334 |
+
def test_explain_404(self, client):
|
| 335 |
+
r = client.get("/api/explain/nonexistent")
|
| 336 |
+
assert r.status_code == 404
|
| 337 |
|
| 338 |
+
|
| 339 |
+
class TestApproveRoute:
|
| 340 |
+
def test_approve_200(self, client):
|
| 341 |
+
pid = client.post("/api/generate", json={"instruction": "Build an API endpoint."}).json()["prompt_id"]
|
| 342 |
+
r = client.post("/api/approve", json={"prompt_id": pid})
|
| 343 |
+
assert r.status_code == 200
|
| 344 |
+
|
| 345 |
+
def test_approve_with_edits(self, client):
|
| 346 |
+
pid = client.post("/api/generate", json={"instruction": "Build an API."}).json()["prompt_id"]
|
| 347 |
+
r = client.post("/api/approve", json={"prompt_id": pid, "edits": {"role": "Custom Role XYZ"}})
|
| 348 |
+
assert r.status_code == 200
|
| 349 |
+
assert "Custom Role XYZ" in r.json()["finalized_prompt"]["role"]
|
| 350 |
+
|
| 351 |
+
def test_approve_404(self, client):
|
| 352 |
+
r = client.post("/api/approve", json={"prompt_id": "bad-id"})
|
| 353 |
+
assert r.status_code == 404
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
class TestConfigRoute:
|
| 357 |
+
def test_config_200(self, client):
|
| 358 |
+
r = client.get("/api/config")
|
| 359 |
+
assert r.status_code == 200
|
| 360 |
+
data = r.json()
|
| 361 |
+
assert "hf_key_set" in data
|
| 362 |
+
assert "google_key_set" in data
|
| 363 |
+
|
| 364 |
+
def test_config_no_env_keys_false(self, client, monkeypatch):
|
| 365 |
+
monkeypatch.delenv("HF_API_KEY", raising=False)
|
| 366 |
+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
|
| 367 |
+
r = client.get("/api/config")
|
| 368 |
+
assert r.json()["hf_key_set"] is False
|
| 369 |
+
assert r.json()["google_key_set"] is False
|
| 370 |
|
| 371 |
|
| 372 |
class TestHealthRoute:
|
| 373 |
def test_health_ok(self, client):
|
| 374 |
+
r = client.get("/health")
|
| 375 |
+
assert r.status_code == 200
|
| 376 |
+
assert r.json()["status"] == "ok"
|
| 377 |
+
assert "prompts_in_memory" in r.json()
|
| 378 |
+
assert "settings_in_memory" in r.json()
|
frontend/client.js
CHANGED
|
@@ -1,176 +1,201 @@
|
|
| 1 |
/**
|
| 2 |
-
* PromptForge — client.js
|
| 3 |
-
*
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
const API = "";
|
| 7 |
let currentPromptId = null;
|
|
|
|
| 8 |
const $ = id => document.getElementById(id);
|
| 9 |
const show = el => el?.classList.remove("hidden");
|
| 10 |
const hide = el => el?.classList.add("hidden");
|
| 11 |
-
const esc = s => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
| 12 |
|
| 13 |
-
/* ── API
|
| 14 |
-
|
| 15 |
-
const
|
| 16 |
-
|
| 17 |
-
const
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
const PROVIDER_LABELS = {
|
| 21 |
-
none:
|
| 22 |
-
google:
|
| 23 |
-
huggingface:
|
| 24 |
};
|
| 25 |
|
| 26 |
function updateApiKeyPanel() {
|
| 27 |
-
const p =
|
| 28 |
const info = PROVIDER_LABELS[p] || PROVIDER_LABELS.none;
|
| 29 |
-
|
| 30 |
if (p === "none") {
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
} else {
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
}
|
| 41 |
}
|
| 42 |
|
| 43 |
-
function
|
| 44 |
-
const key =
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
apiStatusDot.classList.remove("set");
|
| 50 |
-
apiStatusText.textContent = "Key too short — check it";
|
| 51 |
-
} else {
|
| 52 |
-
apiStatusDot.classList.add("set");
|
| 53 |
-
apiStatusText.textContent = "Key is set ✓ — AI enhancement enabled";
|
| 54 |
-
}
|
| 55 |
}
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
$("btn-toggle-key").
|
| 63 |
-
const isPassword = apiKeyInput.type === "password";
|
| 64 |
-
apiKeyInput.type = isPassword ? "text" : "password";
|
| 65 |
-
$("btn-toggle-key").textContent = isPassword ? "🙈" : "👁";
|
| 66 |
});
|
|
|
|
| 67 |
|
| 68 |
-
/* ──
|
| 69 |
-
|
| 70 |
-
const
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
t.classList.add("leaving");
|
| 78 |
-
t.addEventListener("animationend", () => t.remove());
|
| 79 |
-
}, 4000);
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
/* ── Step Progress ──────────────────────────────────────────────── */
|
| 83 |
-
function setStep(n) {
|
| 84 |
-
for (let i = 1; i <= 5; i++) {
|
| 85 |
-
const s = $(`pstep-${i}`); if (!s) continue;
|
| 86 |
-
s.classList.remove("active", "done");
|
| 87 |
-
if (i < n) s.classList.add("done");
|
| 88 |
-
if (i === n) s.classList.add("active");
|
| 89 |
-
}
|
| 90 |
-
for (let i = 1; i <= 4; i++) {
|
| 91 |
-
const l = $(`pline-${i}`); if (!l) continue;
|
| 92 |
-
l.classList.toggle("filled", i < n);
|
| 93 |
}
|
| 94 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
/* ──
|
| 97 |
document.querySelectorAll(".tab").forEach(tab => {
|
| 98 |
tab.addEventListener("click", () => {
|
| 99 |
-
const
|
| 100 |
-
|
| 101 |
tab.classList.add("active");
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
});
|
| 105 |
});
|
| 106 |
|
| 107 |
-
/* ── Copy
|
| 108 |
document.addEventListener("click", e => {
|
| 109 |
const btn = e.target.closest(".btn-copy");
|
| 110 |
if (!btn) return;
|
| 111 |
-
const
|
| 112 |
-
if (!
|
| 113 |
-
navigator.clipboard.writeText(
|
| 114 |
btn.textContent = "✅ Copied!";
|
| 115 |
btn.classList.add("copied");
|
| 116 |
-
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋 Copy
|
| 117 |
});
|
| 118 |
});
|
| 119 |
|
| 120 |
-
/* ──
|
| 121 |
-
function
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
const r = await fetch(API + path, opts);
|
| 132 |
-
if (!r.ok) {
|
| 133 |
-
const e = await r.json().catch(() => ({ detail: r.statusText }));
|
| 134 |
-
throw new Error(e.detail || "Request failed");
|
| 135 |
}
|
| 136 |
-
return r.json();
|
| 137 |
}
|
| 138 |
|
| 139 |
-
/* ── STEP 1: Generate ───────────────────────────────────────────── */
|
| 140 |
-
$("btn-generate").addEventListener("click", async () => {
|
| 141 |
const instruction = $("instruction").value.trim();
|
| 142 |
-
if (
|
| 143 |
-
toast("Please enter a meaningful instruction (at least 5 characters).", "error");
|
| 144 |
-
return;
|
| 145 |
-
}
|
| 146 |
const btn = $("btn-generate");
|
| 147 |
setLoading(btn, true);
|
| 148 |
try {
|
| 149 |
-
const provider =
|
| 150 |
-
const apiKey =
|
| 151 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
const data = await apiFetch("/api/generate", "POST", {
|
| 154 |
instruction,
|
| 155 |
output_format: "both",
|
| 156 |
-
provider,
|
| 157 |
-
|
| 158 |
-
enhance,
|
| 159 |
extra_context: $("extra-context").value.trim() || null,
|
|
|
|
|
|
|
| 160 |
});
|
| 161 |
-
|
| 162 |
currentPromptId = data.prompt_id;
|
| 163 |
renderManifest(data.manifest);
|
| 164 |
-
hide($("step-input"));
|
| 165 |
-
|
| 166 |
-
$("step-manifest").scrollIntoView({ behavior: "smooth", block: "start" });
|
| 167 |
setStep(2);
|
| 168 |
-
toast(
|
| 169 |
-
} catch (e) {
|
| 170 |
-
|
| 171 |
-
} finally {
|
| 172 |
-
setLoading(btn, false);
|
| 173 |
-
}
|
| 174 |
});
|
| 175 |
|
| 176 |
function renderManifest(manifest) {
|
|
@@ -178,24 +203,42 @@ function renderManifest(manifest) {
|
|
| 178 |
const grid = $("manifest-grid");
|
| 179 |
grid.innerHTML = "";
|
| 180 |
[
|
| 181 |
-
{ key:"role", label:"Role", value:sp.role,
|
| 182 |
-
{ key:"style", label:"Style & Tone", value:sp.style,
|
| 183 |
-
{ key:"task", label:"Task", value:sp.task,
|
| 184 |
-
{ key:"input_format", label:"Input Format", value:sp.input_format,
|
| 185 |
-
{ key:"output_format",label:"Output Format", value:sp.output_format,
|
| 186 |
-
{ key:"constraints", label:"Constraints", value:sp.constraints.join("\n"),
|
| 187 |
-
{ key:"safety", label:"Safety", value:sp.safety.join("\n"),
|
| 188 |
].forEach(f => {
|
| 189 |
const d = document.createElement("div");
|
| 190 |
-
d.className = `manifest-field${f.full
|
| 191 |
-
d.innerHTML = `<label>${esc(f.label)}</label><textarea id="field-${f.key}" rows="${f.full
|
| 192 |
grid.appendChild(d);
|
| 193 |
});
|
| 194 |
$("manifest-json").textContent = JSON.stringify(manifest, null, 2);
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
-
/* ──
|
| 198 |
-
$("btn-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
if (!currentPromptId) return;
|
| 200 |
const edits = {};
|
| 201 |
["role","style","task","input_format","output_format"].forEach(k => {
|
|
@@ -208,16 +251,12 @@ $("btn-approve").addEventListener("click", async () => {
|
|
| 208 |
try {
|
| 209 |
const data = await apiFetch("/api/approve", "POST", { prompt_id: currentPromptId, edits });
|
| 210 |
renderFinalized(data.finalized_prompt);
|
| 211 |
-
hide($("step-manifest"));
|
| 212 |
-
|
| 213 |
-
$("step-finalized").scrollIntoView({ behavior: "smooth", block: "start" });
|
| 214 |
setStep(3);
|
| 215 |
-
toast("Prompt approved
|
| 216 |
-
} catch (e) {
|
| 217 |
-
|
| 218 |
-
} finally {
|
| 219 |
-
setLoading(btn, false);
|
| 220 |
-
}
|
| 221 |
});
|
| 222 |
|
| 223 |
function renderFinalized(sp) {
|
|
@@ -225,84 +264,344 @@ function renderFinalized(sp) {
|
|
| 225 |
$("finalized-json").textContent = JSON.stringify(sp, null, 2);
|
| 226 |
}
|
| 227 |
|
| 228 |
-
/* ─
|
| 229 |
async function doExport(format) {
|
| 230 |
if (!currentPromptId) return;
|
| 231 |
try {
|
| 232 |
const data = await apiFetch("/api/export", "POST", { prompt_id: currentPromptId, export_format: format });
|
| 233 |
const content = format === "json" ? JSON.stringify(data.data, null, 2) : String(data.data);
|
| 234 |
const blob = new Blob([content], { type: format === "json" ? "application/json" : "text/plain" });
|
| 235 |
-
const
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
| 238 |
setStep(4);
|
| 239 |
toast(`Exported as ${format.toUpperCase()}!`, "success");
|
| 240 |
-
} catch (e) {
|
| 241 |
-
toast(`Export failed: ${e.message}`, "error");
|
| 242 |
-
}
|
| 243 |
}
|
| 244 |
-
$("btn-export-json").addEventListener("click", () => doExport("json"));
|
| 245 |
-
$("btn-export-txt"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
/* ── STEP 5: Refine ─────────────────────────────────────────────── */
|
| 248 |
-
$("btn-refine").addEventListener("click", () => {
|
| 249 |
-
hide($("step-finalized"));
|
| 250 |
-
show($("step-refine"));
|
| 251 |
$("step-refine").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 252 |
setStep(5);
|
| 253 |
});
|
| 254 |
-
$("btn-cancel-refine").addEventListener("click", () => {
|
| 255 |
-
hide($("step-refine"));
|
| 256 |
-
show($("step-finalized"));
|
| 257 |
-
setStep(3);
|
| 258 |
});
|
| 259 |
-
$("btn-submit-refine").addEventListener("click", async () => {
|
| 260 |
const feedback = $("feedback").value.trim();
|
| 261 |
-
if (!feedback) { toast("Please describe what
|
| 262 |
const btn = $("btn-submit-refine");
|
| 263 |
setLoading(btn, true);
|
| 264 |
try {
|
| 265 |
-
const provider = providerSelect.value;
|
| 266 |
-
const apiKey = apiKeyInput.value.trim() || null;
|
| 267 |
const data = await apiFetch("/api/refine", "POST", {
|
| 268 |
-
prompt_id: currentPromptId, feedback,
|
|
|
|
|
|
|
| 269 |
});
|
| 270 |
currentPromptId = data.prompt_id;
|
| 271 |
renderManifest(data.manifest);
|
| 272 |
-
hide($("step-refine"));
|
| 273 |
-
show($("step-manifest"));
|
| 274 |
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 275 |
setStep(2);
|
| 276 |
-
toast(`Refined to ${data.
|
| 277 |
-
} catch (e) {
|
| 278 |
-
|
| 279 |
-
} finally {
|
| 280 |
-
setLoading(btn, false);
|
| 281 |
-
}
|
| 282 |
});
|
| 283 |
|
| 284 |
-
/* ── Reset ─────────────────────────────────────────────────────
|
| 285 |
-
$("btn-reset").addEventListener("click", () => {
|
| 286 |
-
hide($("step-manifest"));
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
$("extra-context").value = "";
|
| 290 |
-
currentPromptId = null;
|
| 291 |
-
setStep(1);
|
| 292 |
-
toast("Reset — start a new prompt.", "info");
|
| 293 |
});
|
| 294 |
$("btn-new")?.addEventListener("click", () => {
|
| 295 |
-
hide($("step-finalized"));
|
| 296 |
-
|
| 297 |
-
$("instruction").value = "";
|
| 298 |
-
$("extra-context").value = "";
|
| 299 |
currentPromptId = null;
|
| 300 |
-
setStep(1);
|
| 301 |
$("step-input").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 302 |
});
|
| 303 |
|
| 304 |
-
/* ──
|
| 305 |
-
$("btn-load-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
async function loadHistory() {
|
| 307 |
const btn = $("btn-load-history");
|
| 308 |
setLoading(btn, true);
|
|
@@ -310,35 +609,38 @@ async function loadHistory() {
|
|
| 310 |
const data = await apiFetch("/api/history");
|
| 311 |
const tbody = $("history-body");
|
| 312 |
if (!data.entries?.length) {
|
| 313 |
-
tbody.innerHTML = `<tr><td class="empty-msg" colspan="
|
| 314 |
return;
|
| 315 |
}
|
| 316 |
tbody.innerHTML = data.entries.map(e => `
|
| 317 |
<tr>
|
| 318 |
-
<td><code style="font-size:.
|
| 319 |
-
<td style="max-width:
|
| 320 |
-
<td>v${e.version
|
| 321 |
-
<td><span class="badge badge-${e.status
|
| 322 |
-
<td style="
|
|
|
|
| 323 |
<td>
|
| 324 |
-
<button class="btn-secondary btn-sm btn-danger" onclick="
|
| 325 |
</td>
|
| 326 |
</tr>`).join("");
|
| 327 |
toast(`Loaded ${data.total} prompt(s).`, "info");
|
| 328 |
-
} catch (e) {
|
| 329 |
-
|
| 330 |
-
} finally {
|
| 331 |
-
setLoading(btn, false);
|
| 332 |
-
}
|
| 333 |
}
|
| 334 |
|
| 335 |
-
async function
|
| 336 |
if (!confirm("Delete this prompt?")) return;
|
| 337 |
try {
|
| 338 |
await apiFetch(`/api/history/${id}`, "DELETE");
|
| 339 |
-
toast("
|
| 340 |
loadHistory();
|
| 341 |
-
} catch (e) {
|
| 342 |
-
toast(`Delete failed: ${e.message}`, "error");
|
| 343 |
-
}
|
| 344 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
+
* PromptForge v3.0 — client.js
|
| 3 |
+
* Tab navigation, Generate flow, Instruction Settings CRUD, History.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
const API = "";
|
| 7 |
let currentPromptId = null;
|
| 8 |
+
let allSettings = [];
|
| 9 |
const $ = id => document.getElementById(id);
|
| 10 |
const show = el => el?.classList.remove("hidden");
|
| 11 |
const hide = el => el?.classList.add("hidden");
|
| 12 |
+
const esc = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
| 13 |
|
| 14 |
+
/* ── API Fetch ─────────────────────────────────────────────────────── */
|
| 15 |
+
async function apiFetch(path, method = "GET", body = null) {
|
| 16 |
+
const opts = { method, headers: { "Content-Type": "application/json" } };
|
| 17 |
+
if (body) opts.body = JSON.stringify(body);
|
| 18 |
+
const r = await fetch(API + path, opts);
|
| 19 |
+
if (!r.ok) {
|
| 20 |
+
const e = await r.json().catch(() => ({ detail: r.statusText }));
|
| 21 |
+
throw new Error(e.detail || "Request failed");
|
| 22 |
+
}
|
| 23 |
+
return r.json();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* ── Toast ─────────────────────────────────────────────────────────── */
|
| 27 |
+
function toast(msg, type = "info") {
|
| 28 |
+
const icons = { success:"✅", error:"❌", info:"💡", warn:"⚠️" };
|
| 29 |
+
const t = document.createElement("div");
|
| 30 |
+
t.className = `toast ${type}`;
|
| 31 |
+
t.innerHTML = `<div class="toast-icon">${icons[type]||"💡"}</div><span>${msg}</span>`;
|
| 32 |
+
$("toast-container").appendChild(t);
|
| 33 |
+
setTimeout(() => { t.classList.add("leaving"); t.addEventListener("animationend", () => t.remove()); }, 4200);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* ── Loading ───────────────────────────────────────────────────────── */
|
| 37 |
+
function setLoading(btn, on) {
|
| 38 |
+
btn.disabled = on;
|
| 39 |
+
btn._orig = btn._orig || btn.innerHTML;
|
| 40 |
+
btn.innerHTML = on ? `<span class="spinner"></span> Working…` : btn._orig;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* ── Main Tab Navigation ───────────────────────────────────────────── */
|
| 44 |
+
document.querySelectorAll(".main-tab").forEach(tab => {
|
| 45 |
+
tab.addEventListener("click", () => {
|
| 46 |
+
document.querySelectorAll(".main-tab").forEach(t => t.classList.remove("active"));
|
| 47 |
+
document.querySelectorAll(".tab-page").forEach(p => p.classList.remove("active").add?.("hidden") || hide(p));
|
| 48 |
+
tab.classList.add("active");
|
| 49 |
+
const name = tab.dataset.mainTab;
|
| 50 |
+
const page = $(`tab-${name}`);
|
| 51 |
+
if (page) { show(page); page.classList.add("active"); }
|
| 52 |
+
if (name === "settings") { loadSettingsList(); }
|
| 53 |
+
if (name === "history") { loadHistory(); }
|
| 54 |
+
});
|
| 55 |
+
});
|
| 56 |
|
| 57 |
+
/* ── Config (env key status) ───────────────────────────────────────── */
|
| 58 |
+
async function loadConfig() {
|
| 59 |
+
try {
|
| 60 |
+
const cfg = await apiFetch("/api/config");
|
| 61 |
+
const hfDot = $("env-hf-dot");
|
| 62 |
+
const ggDot = $("env-google-dot");
|
| 63 |
+
if (cfg.hf_key_set) { hfDot?.classList.add("active"); }
|
| 64 |
+
if (cfg.google_key_set) { ggDot?.classList.add("active"); }
|
| 65 |
+
} catch {}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/* ── API Key Panel ─────────────────────────────────────────────────── */
|
| 69 |
const PROVIDER_LABELS = {
|
| 70 |
+
none: { hint:"(no key needed)", placeholder:"Not required for local engine" },
|
| 71 |
+
google: { hint:"Google Gemini", placeholder:"AIza… (aistudio.google.com/apikey)" },
|
| 72 |
+
huggingface: { hint:"Hugging Face", placeholder:"hf_… (huggingface.co/settings/tokens)" },
|
| 73 |
};
|
| 74 |
|
| 75 |
function updateApiKeyPanel() {
|
| 76 |
+
const p = $("provider").value;
|
| 77 |
const info = PROVIDER_LABELS[p] || PROVIDER_LABELS.none;
|
| 78 |
+
if ($("api-key-hint")) $("api-key-hint").textContent = info.hint;
|
| 79 |
if (p === "none") {
|
| 80 |
+
$("api-key").disabled = true;
|
| 81 |
+
$("api-key").value = "";
|
| 82 |
+
$("api-key").placeholder = info.placeholder;
|
| 83 |
+
$("api-status-dot")?.classList.remove("set");
|
| 84 |
+
if ($("api-status-text")) $("api-status-text").textContent = "Not needed — local engine";
|
| 85 |
} else {
|
| 86 |
+
$("api-key").disabled = false;
|
| 87 |
+
$("api-key").placeholder = info.placeholder;
|
| 88 |
+
updateApiStatus();
|
| 89 |
}
|
| 90 |
}
|
| 91 |
|
| 92 |
+
function updateApiStatus() {
|
| 93 |
+
const key = $("api-key")?.value?.trim() || "";
|
| 94 |
+
const set = key.length >= 10;
|
| 95 |
+
$("api-status-dot")?.classList.toggle("set", set);
|
| 96 |
+
if ($("api-status-text"))
|
| 97 |
+
$("api-status-text").textContent = set ? "Key set ✓ — AI enhancement enabled" : "No key — AI enhancement disabled";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
+
$("provider")?.addEventListener("change", updateApiKeyPanel);
|
| 101 |
+
$("api-key")?.addEventListener("input", updateApiStatus);
|
| 102 |
+
$("btn-toggle-key")?.addEventListener("click", () => {
|
| 103 |
+
const k = $("api-key");
|
| 104 |
+
k.type = k.type === "password" ? "text" : "password";
|
| 105 |
+
$("btn-toggle-key").textContent = k.type === "password" ? "👁" : "🙈";
|
|
|
|
|
|
|
|
|
|
| 106 |
});
|
| 107 |
+
updateApiKeyPanel();
|
| 108 |
|
| 109 |
+
/* ── Advanced options toggle ───────────────────────────────────────── */
|
| 110 |
+
$("btn-toggle-advanced")?.addEventListener("click", () => {
|
| 111 |
+
const panel = $("advanced-options");
|
| 112 |
+
if (panel.classList.contains("hidden")) {
|
| 113 |
+
show(panel);
|
| 114 |
+
$("btn-toggle-advanced").textContent = "⚙️ Hide advanced options";
|
| 115 |
+
} else {
|
| 116 |
+
hide(panel);
|
| 117 |
+
$("btn-toggle-advanced").textContent = "⚙️ Advanced options";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
$("gen-persona")?.addEventListener("change", () => {
|
| 122 |
+
const show = $("gen-persona").value === "custom";
|
| 123 |
+
$("custom-persona-field").style.display = show ? "block" : "none";
|
| 124 |
+
});
|
| 125 |
|
| 126 |
+
/* ── Inner tab switcher ────────────────────────────────────────────── */
|
| 127 |
document.querySelectorAll(".tab").forEach(tab => {
|
| 128 |
tab.addEventListener("click", () => {
|
| 129 |
+
const tabBar = tab.closest(".tab-bar");
|
| 130 |
+
tabBar.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
|
| 131 |
tab.classList.add("active");
|
| 132 |
+
const name = tab.dataset.tab;
|
| 133 |
+
// find sibling panels
|
| 134 |
+
const section = tabBar.closest("section");
|
| 135 |
+
section.querySelectorAll(".tab-panel").forEach(p => hide(p));
|
| 136 |
+
show(section.querySelector(`#tab-${name}`));
|
| 137 |
});
|
| 138 |
});
|
| 139 |
|
| 140 |
+
/* ── Copy buttons ──────────────────────────────────────────────────── */
|
| 141 |
document.addEventListener("click", e => {
|
| 142 |
const btn = e.target.closest(".btn-copy");
|
| 143 |
if (!btn) return;
|
| 144 |
+
const el = $(btn.dataset.target);
|
| 145 |
+
if (!el) return;
|
| 146 |
+
navigator.clipboard.writeText(el.textContent).then(() => {
|
| 147 |
btn.textContent = "✅ Copied!";
|
| 148 |
btn.classList.add("copied");
|
| 149 |
+
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋 Copy"; }, 2000);
|
| 150 |
});
|
| 151 |
});
|
| 152 |
|
| 153 |
+
/* ── Step Progress ─────────────────────────────────────────────────── */
|
| 154 |
+
function setStep(n) {
|
| 155 |
+
for (let i = 1; i <= 5; i++) {
|
| 156 |
+
const s = $(`pstep-${i}`); if (!s) continue;
|
| 157 |
+
s.classList.remove("active","done");
|
| 158 |
+
if (i < n) s.classList.add("done");
|
| 159 |
+
if (i === n) s.classList.add("active");
|
| 160 |
+
}
|
| 161 |
+
for (let i = 1; i <= 4; i++) {
|
| 162 |
+
const l = $(`pline-${i}`); if (!l) continue;
|
| 163 |
+
l.classList.toggle("filled", i < n);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
}
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
+
/* ── STEP 1: Generate ──────────────────────────────────────────────── */
|
| 168 |
+
$("btn-generate")?.addEventListener("click", async () => {
|
| 169 |
const instruction = $("instruction").value.trim();
|
| 170 |
+
if (instruction.length < 5) { toast("Please enter a meaningful instruction (min 5 chars).", "error"); return; }
|
|
|
|
|
|
|
|
|
|
| 171 |
const btn = $("btn-generate");
|
| 172 |
setLoading(btn, true);
|
| 173 |
try {
|
| 174 |
+
const provider = $("provider").value;
|
| 175 |
+
const apiKey = $("api-key").value.trim() || null;
|
| 176 |
+
const persona = $("gen-persona")?.value || "default";
|
| 177 |
+
const style = $("gen-style")?.value || "professional";
|
| 178 |
+
const customPersona = persona === "custom" ? ($("gen-custom-persona")?.value?.trim() || null) : null;
|
| 179 |
+
const constraintsRaw = $("gen-constraints")?.value?.trim() || "";
|
| 180 |
+
const userConstraints = constraintsRaw ? constraintsRaw.split("\n").map(s=>s.trim()).filter(Boolean) : [];
|
| 181 |
|
| 182 |
const data = await apiFetch("/api/generate", "POST", {
|
| 183 |
instruction,
|
| 184 |
output_format: "both",
|
| 185 |
+
provider, api_key: apiKey,
|
| 186 |
+
enhance: provider !== "none" && !!apiKey,
|
|
|
|
| 187 |
extra_context: $("extra-context").value.trim() || null,
|
| 188 |
+
persona, custom_persona: customPersona, style,
|
| 189 |
+
user_constraints: userConstraints,
|
| 190 |
});
|
|
|
|
| 191 |
currentPromptId = data.prompt_id;
|
| 192 |
renderManifest(data.manifest);
|
| 193 |
+
hide($("step-input")); show($("step-manifest"));
|
| 194 |
+
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
|
|
|
| 195 |
setStep(2);
|
| 196 |
+
toast("Manifest generated! Review and approve.", "success");
|
| 197 |
+
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 198 |
+
finally { setLoading(btn, false); }
|
|
|
|
|
|
|
|
|
|
| 199 |
});
|
| 200 |
|
| 201 |
function renderManifest(manifest) {
|
|
|
|
| 203 |
const grid = $("manifest-grid");
|
| 204 |
grid.innerHTML = "";
|
| 205 |
[
|
| 206 |
+
{ key:"role", label:"Role", value:sp.role, full:false },
|
| 207 |
+
{ key:"style", label:"Style & Tone", value:sp.style, full:false },
|
| 208 |
+
{ key:"task", label:"Task", value:sp.task, full:true },
|
| 209 |
+
{ key:"input_format", label:"Input Format", value:sp.input_format, full:false },
|
| 210 |
+
{ key:"output_format",label:"Output Format", value:sp.output_format, full:false },
|
| 211 |
+
{ key:"constraints", label:"Constraints", value:sp.constraints.join("\n"), full:true },
|
| 212 |
+
{ key:"safety", label:"Safety", value:sp.safety.join("\n"), full:true },
|
| 213 |
].forEach(f => {
|
| 214 |
const d = document.createElement("div");
|
| 215 |
+
d.className = `manifest-field${f.full?" full":""}`;
|
| 216 |
+
d.innerHTML = `<label>${esc(f.label)}</label><textarea id="field-${f.key}" rows="${f.full?3:2}">${esc(f.value)}</textarea>`;
|
| 217 |
grid.appendChild(d);
|
| 218 |
});
|
| 219 |
$("manifest-json").textContent = JSON.stringify(manifest, null, 2);
|
| 220 |
+
hide($("explanation-panel"));
|
| 221 |
}
|
| 222 |
|
| 223 |
+
/* ── Explain ───────────────────────────────────────────────────────── */
|
| 224 |
+
$("btn-explain")?.addEventListener("click", async () => {
|
| 225 |
+
if (!currentPromptId) return;
|
| 226 |
+
const btn = $("btn-explain");
|
| 227 |
+
setLoading(btn, true);
|
| 228 |
+
try {
|
| 229 |
+
const data = await apiFetch(`/api/explain/${currentPromptId}`);
|
| 230 |
+
const panel = $("explanation-panel");
|
| 231 |
+
$("explanation-text").textContent = data.explanation;
|
| 232 |
+
const kd = $("key-decisions");
|
| 233 |
+
kd.innerHTML = data.key_decisions.map(d => `<div class="decision-chip">${esc(d)}</div>`).join("");
|
| 234 |
+
show(panel);
|
| 235 |
+
panel.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
| 236 |
+
} catch (e) { toast(`Could not load explanation: ${e.message}`, "warn"); }
|
| 237 |
+
finally { setLoading(btn, false); }
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
/* ── STEP 2: Approve ───────────────────────────────────────────────── */
|
| 241 |
+
$("btn-approve")?.addEventListener("click", async () => {
|
| 242 |
if (!currentPromptId) return;
|
| 243 |
const edits = {};
|
| 244 |
["role","style","task","input_format","output_format"].forEach(k => {
|
|
|
|
| 251 |
try {
|
| 252 |
const data = await apiFetch("/api/approve", "POST", { prompt_id: currentPromptId, edits });
|
| 253 |
renderFinalized(data.finalized_prompt);
|
| 254 |
+
hide($("step-manifest")); show($("step-finalized"));
|
| 255 |
+
$("step-finalized").scrollIntoView({ behavior:"smooth", block:"start" });
|
|
|
|
| 256 |
setStep(3);
|
| 257 |
+
toast("Prompt approved! 🎉", "success");
|
| 258 |
+
} catch (e) { toast(`Approval failed: ${e.message}`, "error"); }
|
| 259 |
+
finally { setLoading(btn, false); }
|
|
|
|
|
|
|
|
|
|
| 260 |
});
|
| 261 |
|
| 262 |
function renderFinalized(sp) {
|
|
|
|
| 264 |
$("finalized-json").textContent = JSON.stringify(sp, null, 2);
|
| 265 |
}
|
| 266 |
|
| 267 |
+
/* ��─ STEP 4: Export ────────────────────────────────────────────────── */
|
| 268 |
async function doExport(format) {
|
| 269 |
if (!currentPromptId) return;
|
| 270 |
try {
|
| 271 |
const data = await apiFetch("/api/export", "POST", { prompt_id: currentPromptId, export_format: format });
|
| 272 |
const content = format === "json" ? JSON.stringify(data.data, null, 2) : String(data.data);
|
| 273 |
const blob = new Blob([content], { type: format === "json" ? "application/json" : "text/plain" });
|
| 274 |
+
const a = Object.assign(document.createElement("a"), {
|
| 275 |
+
href: URL.createObjectURL(blob),
|
| 276 |
+
download: `prompt-${currentPromptId.slice(0,8)}.${format === "json" ? "json" : "txt"}`,
|
| 277 |
+
});
|
| 278 |
+
a.click(); URL.revokeObjectURL(a.href);
|
| 279 |
setStep(4);
|
| 280 |
toast(`Exported as ${format.toUpperCase()}!`, "success");
|
| 281 |
+
} catch (e) { toast(`Export failed: ${e.message}`, "error"); }
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
+
$("btn-export-json")?.addEventListener("click", () => doExport("json"));
|
| 284 |
+
$("btn-export-txt")?.addEventListener("click", () => doExport("text"));
|
| 285 |
+
|
| 286 |
+
/* ── Save current prompt as a Setting ─────────────────────────────── */
|
| 287 |
+
$("btn-save-as-setting")?.addEventListener("click", () => {
|
| 288 |
+
// Switch to settings tab and pre-populate
|
| 289 |
+
const instruction = $("instruction")?.value?.trim() || "";
|
| 290 |
+
const context = $("extra-context")?.value?.trim() || "";
|
| 291 |
+
document.querySelector('[data-main-tab="settings"]')?.click();
|
| 292 |
+
setTimeout(() => {
|
| 293 |
+
clearSettingsForm();
|
| 294 |
+
if ($("s-title")) $("s-title").value = instruction.slice(0, 60) + "…";
|
| 295 |
+
if ($("s-instruction")) $("s-instruction").value = instruction;
|
| 296 |
+
if ($("s-extra-context")) $("s-extra-context").value = context;
|
| 297 |
+
$("s-title")?.focus();
|
| 298 |
+
toast("Pre-filled from current prompt — adjust and save!", "info");
|
| 299 |
+
}, 150);
|
| 300 |
+
});
|
| 301 |
|
| 302 |
+
/* ── STEP 5: Refine ────────────────────────────────────────────────── */
|
| 303 |
+
$("btn-refine")?.addEventListener("click", () => {
|
| 304 |
+
hide($("step-finalized")); show($("step-refine"));
|
|
|
|
| 305 |
$("step-refine").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 306 |
setStep(5);
|
| 307 |
});
|
| 308 |
+
$("btn-cancel-refine")?.addEventListener("click", () => {
|
| 309 |
+
hide($("step-refine")); show($("step-finalized")); setStep(3);
|
|
|
|
|
|
|
| 310 |
});
|
| 311 |
+
$("btn-submit-refine")?.addEventListener("click", async () => {
|
| 312 |
const feedback = $("feedback").value.trim();
|
| 313 |
+
if (!feedback) { toast("Please describe what to change.", "error"); return; }
|
| 314 |
const btn = $("btn-submit-refine");
|
| 315 |
setLoading(btn, true);
|
| 316 |
try {
|
|
|
|
|
|
|
| 317 |
const data = await apiFetch("/api/refine", "POST", {
|
| 318 |
+
prompt_id: currentPromptId, feedback,
|
| 319 |
+
provider: $("provider").value,
|
| 320 |
+
api_key: $("api-key").value.trim() || null,
|
| 321 |
});
|
| 322 |
currentPromptId = data.prompt_id;
|
| 323 |
renderManifest(data.manifest);
|
| 324 |
+
hide($("step-refine")); show($("step-manifest"));
|
|
|
|
| 325 |
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 326 |
setStep(2);
|
| 327 |
+
toast(`Refined to v${data.manifest.version} — review and approve!`, "success");
|
| 328 |
+
} catch (e) { toast(`Refinement failed: ${e.message}`, "error"); }
|
| 329 |
+
finally { setLoading(btn, false); }
|
|
|
|
|
|
|
|
|
|
| 330 |
});
|
| 331 |
|
| 332 |
+
/* ── Reset / New ───────────────────────────────────────────────────── */
|
| 333 |
+
$("btn-reset")?.addEventListener("click", () => {
|
| 334 |
+
hide($("step-manifest")); show($("step-input")); setStep(1);
|
| 335 |
+
$("instruction").value = ""; $("extra-context").value = "";
|
| 336 |
+
currentPromptId = null; toast("Reset.", "info");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
});
|
| 338 |
$("btn-new")?.addEventListener("click", () => {
|
| 339 |
+
hide($("step-finalized")); show($("step-input")); setStep(1);
|
| 340 |
+
$("instruction").value = ""; $("extra-context").value = "";
|
|
|
|
|
|
|
| 341 |
currentPromptId = null;
|
|
|
|
| 342 |
$("step-input").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 343 |
});
|
| 344 |
|
| 345 |
+
/* ── Load from Settings (modal) ────────────────────────────────────── */
|
| 346 |
+
$("btn-load-from-settings")?.addEventListener("click", async () => {
|
| 347 |
+
await loadSettingsForModal();
|
| 348 |
+
show($("modal-overlay"));
|
| 349 |
+
});
|
| 350 |
+
$("btn-modal-close")?.addEventListener("click", () => hide($("modal-overlay")));
|
| 351 |
+
$("modal-overlay")?.addEventListener("click", e => { if (e.target === $("modal-overlay")) hide($("modal-overlay")); });
|
| 352 |
+
|
| 353 |
+
$("modal-search")?.addEventListener("input", () => {
|
| 354 |
+
const q = $("modal-search").value.toLowerCase();
|
| 355 |
+
document.querySelectorAll(".modal-item").forEach(item => {
|
| 356 |
+
item.style.display = item.dataset.search?.includes(q) ? "" : "none";
|
| 357 |
+
});
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
async function loadSettingsForModal() {
|
| 361 |
+
const list = $("modal-settings-list");
|
| 362 |
+
try {
|
| 363 |
+
const data = await apiFetch("/api/instructions");
|
| 364 |
+
if (!data.items?.length) {
|
| 365 |
+
list.innerHTML = `<div class="modal-empty">No saved settings yet. Create one in the Instruction Settings tab.</div>`;
|
| 366 |
+
return;
|
| 367 |
+
}
|
| 368 |
+
list.innerHTML = data.items.map(s => `
|
| 369 |
+
<div class="modal-item" data-id="${esc(s.settings_id)}" data-search="${esc((s.title+s.instruction).toLowerCase())}">
|
| 370 |
+
<div class="modal-item-title">${esc(s.title)}</div>
|
| 371 |
+
<div class="modal-item-desc">${esc(s.instruction.slice(0, 100))}${s.instruction.length > 100 ? "…" : ""}</div>
|
| 372 |
+
</div>`).join("");
|
| 373 |
+
document.querySelectorAll(".modal-item").forEach(item => {
|
| 374 |
+
item.addEventListener("click", async () => {
|
| 375 |
+
const sid = item.dataset.id;
|
| 376 |
+
hide($("modal-overlay"));
|
| 377 |
+
await generateFromSetting(sid);
|
| 378 |
+
});
|
| 379 |
+
});
|
| 380 |
+
} catch (e) {
|
| 381 |
+
list.innerHTML = `<div class="modal-empty">Failed to load settings: ${esc(e.message)}</div>`;
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
async function generateFromSetting(sid) {
|
| 386 |
+
const btn = $("btn-generate");
|
| 387 |
+
setLoading(btn, true);
|
| 388 |
+
// Switch to generate tab
|
| 389 |
+
document.querySelector('[data-main-tab="generate"]')?.click();
|
| 390 |
+
try {
|
| 391 |
+
const apiKey = $("api-key")?.value?.trim() || null;
|
| 392 |
+
const data = await apiFetch("/api/generate/from-settings", "POST", { settings_id: sid, api_key: apiKey });
|
| 393 |
+
currentPromptId = data.prompt_id;
|
| 394 |
+
renderManifest(data.manifest);
|
| 395 |
+
hide($("step-input")); show($("step-manifest"));
|
| 396 |
+
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 397 |
+
setStep(2);
|
| 398 |
+
toast(`Generated from "${data.manifest.settings_id ? "saved setting" : "settings"}"! ✨`, "success");
|
| 399 |
+
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 400 |
+
finally { setLoading(btn, false); }
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
/* ══════════════════════════════════════════════════════════════════
|
| 404 |
+
INSTRUCTION SETTINGS PANEL
|
| 405 |
+
══════════════════════════════════════════════════════════════════ */
|
| 406 |
+
|
| 407 |
+
/* ── Persona custom field ── */
|
| 408 |
+
$("s-persona")?.addEventListener("change", () => {
|
| 409 |
+
$("s-custom-persona-field").style.display = $("s-persona").value === "custom" ? "block" : "none";
|
| 410 |
+
});
|
| 411 |
+
|
| 412 |
+
/* ── Instruction character count ── */
|
| 413 |
+
$("s-instruction")?.addEventListener("input", () => {
|
| 414 |
+
const len = $("s-instruction").value.length;
|
| 415 |
+
const note = $("s-instruction-count");
|
| 416 |
+
if (note) {
|
| 417 |
+
note.textContent = `${len} / 8000 characters`;
|
| 418 |
+
note.style.color = len > 7500 ? "var(--red)" : "var(--text-muted)";
|
| 419 |
+
}
|
| 420 |
+
});
|
| 421 |
+
|
| 422 |
+
/* ── Save setting ── */
|
| 423 |
+
$("btn-settings-save")?.addEventListener("click", async () => {
|
| 424 |
+
const title = $("s-title").value.trim();
|
| 425 |
+
const instruction = $("s-instruction").value.trim();
|
| 426 |
+
if (!title) { toast("Please enter a title.", "error"); $("s-title").focus(); return; }
|
| 427 |
+
if (instruction.length < 5) { toast("Instruction must be at least 5 characters.", "error"); $("s-instruction").focus(); return; }
|
| 428 |
+
|
| 429 |
+
const editId = $("edit-settings-id").value;
|
| 430 |
+
const persona = $("s-persona").value;
|
| 431 |
+
const constraintsRaw = $("s-constraints").value.trim();
|
| 432 |
+
const constraints = constraintsRaw ? constraintsRaw.split("\n").map(s=>s.trim()).filter(Boolean) : [];
|
| 433 |
+
const tagsRaw = $("s-tags").value.trim();
|
| 434 |
+
const tags = tagsRaw ? tagsRaw.split(",").map(s=>s.trim().toLowerCase()).filter(Boolean) : [];
|
| 435 |
+
|
| 436 |
+
const payload = {
|
| 437 |
+
title,
|
| 438 |
+
description: $("s-description").value.trim() || null,
|
| 439 |
+
instruction,
|
| 440 |
+
extra_context: $("s-extra-context").value.trim() || null,
|
| 441 |
+
output_format: $("s-output-format").value,
|
| 442 |
+
persona,
|
| 443 |
+
custom_persona: persona === "custom" ? ($("s-custom-persona").value.trim() || null) : null,
|
| 444 |
+
style: $("s-style").value,
|
| 445 |
+
constraints,
|
| 446 |
+
tags,
|
| 447 |
+
provider: $("s-provider").value,
|
| 448 |
+
enhance: $("s-enhance").checked,
|
| 449 |
+
};
|
| 450 |
+
|
| 451 |
+
const btn = $("btn-settings-save");
|
| 452 |
+
setLoading(btn, true);
|
| 453 |
+
try {
|
| 454 |
+
if (editId) {
|
| 455 |
+
await apiFetch(`/api/instructions/${editId}`, "PATCH", payload);
|
| 456 |
+
toast("Setting updated! ✅", "success");
|
| 457 |
+
} else {
|
| 458 |
+
await apiFetch("/api/instructions", "POST", payload);
|
| 459 |
+
toast("Setting saved! 💾", "success");
|
| 460 |
+
}
|
| 461 |
+
clearSettingsForm();
|
| 462 |
+
await loadSettingsList();
|
| 463 |
+
} catch (e) { toast(`Save failed: ${e.message}`, "error"); }
|
| 464 |
+
finally { setLoading(btn, false); }
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
/* ── Clear form ── */
|
| 468 |
+
$("btn-settings-clear")?.addEventListener("click", clearSettingsForm);
|
| 469 |
+
|
| 470 |
+
function clearSettingsForm() {
|
| 471 |
+
$("edit-settings-id").value = "";
|
| 472 |
+
["s-title","s-description","s-instruction","s-extra-context","s-constraints","s-tags","s-custom-persona"].forEach(id => {
|
| 473 |
+
const el = $(id); if (el) el.value = "";
|
| 474 |
+
});
|
| 475 |
+
if ($("s-persona")) $("s-persona").value = "default";
|
| 476 |
+
if ($("s-style")) $("s-style").value = "professional";
|
| 477 |
+
if ($("s-output-format")) $("s-output-format").value = "both";
|
| 478 |
+
if ($("s-provider")) $("s-provider").value = "none";
|
| 479 |
+
if ($("s-enhance")) $("s-enhance").checked = false;
|
| 480 |
+
if ($("s-custom-persona-field")) $("s-custom-persona-field").style.display = "none";
|
| 481 |
+
if ($("s-instruction-count")) $("s-instruction-count").textContent = "0 / 8000 characters";
|
| 482 |
+
if ($("settings-form-title")) $("settings-form-title").textContent = "➕ New Instruction Setting";
|
| 483 |
+
if ($("btn-settings-generate")) $("btn-settings-generate").style.display = "none";
|
| 484 |
+
document.querySelectorAll(".setting-card").forEach(c => c.classList.remove("active-edit"));
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
/* ── Load settings list ── */
|
| 488 |
+
async function loadSettingsList() {
|
| 489 |
+
try {
|
| 490 |
+
const data = await apiFetch("/api/instructions");
|
| 491 |
+
allSettings = data.items || [];
|
| 492 |
+
renderSettingsList(allSettings);
|
| 493 |
+
updateSettingsCount(data.total);
|
| 494 |
+
} catch (e) { toast(`Failed to load settings: ${e.message}`, "error"); }
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
function updateSettingsCount(count) {
|
| 498 |
+
const badge = $("settings-count");
|
| 499 |
+
if (badge) badge.textContent = count;
|
| 500 |
+
const total = $("settings-total-count");
|
| 501 |
+
if (total) total.textContent = count;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
function renderSettingsList(items) {
|
| 505 |
+
const container = $("settings-list");
|
| 506 |
+
if (!items.length) {
|
| 507 |
+
container.innerHTML = `<div class="settings-empty"><div class="empty-icon">📋</div><p>No settings yet. Create your first one!</p></div>`;
|
| 508 |
+
return;
|
| 509 |
+
}
|
| 510 |
+
container.innerHTML = items.map(s => `
|
| 511 |
+
<div class="setting-card" data-id="${esc(s.settings_id)}">
|
| 512 |
+
<div class="setting-card-title">
|
| 513 |
+
${personaEmoji(s.persona)} ${esc(s.title)}
|
| 514 |
+
</div>
|
| 515 |
+
${s.description ? `<div class="setting-card-desc">${esc(s.description)}</div>` : ""}
|
| 516 |
+
<div class="setting-card-meta">
|
| 517 |
+
${s.tags.slice(0,4).map(t => `<span class="tag-chip">${esc(t)}</span>`).join("")}
|
| 518 |
+
<span class="tag-chip style">${esc(s.style)}</span>
|
| 519 |
+
<span class="use-count">${s.use_count} uses</span>
|
| 520 |
+
</div>
|
| 521 |
+
<div class="setting-card-actions">
|
| 522 |
+
<button class="btn-secondary btn-sm" onclick="editSetting('${esc(s.settings_id)}')">✏️</button>
|
| 523 |
+
<button class="btn-secondary btn-sm btn-danger" onclick="deleteSetting('${esc(s.settings_id)}')">🗑</button>
|
| 524 |
+
<button class="btn-primary btn-sm" onclick="generateFromSetting('${esc(s.settings_id)}')">⚡</button>
|
| 525 |
+
</div>
|
| 526 |
+
</div>`).join("");
|
| 527 |
+
|
| 528 |
+
// Rebuild tag filter
|
| 529 |
+
const allTags = [...new Set(items.flatMap(s => s.tags))].sort();
|
| 530 |
+
const filterEl = $("settings-filter-tag");
|
| 531 |
+
if (filterEl) {
|
| 532 |
+
filterEl.innerHTML = `<option value="">All tags</option>` + allTags.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join("");
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
function personaEmoji(persona) {
|
| 537 |
+
const map = { senior_dev:"👨💻", data_scientist:"📊", tech_writer:"✍️", product_mgr:"📋", security_eng:"🔒", custom:"✏️" };
|
| 538 |
+
return map[persona] || "🤖";
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
/* ── Search & filter ── */
|
| 542 |
+
$("settings-search")?.addEventListener("input", filterSettings);
|
| 543 |
+
$("settings-filter-tag")?.addEventListener("change", filterSettings);
|
| 544 |
+
|
| 545 |
+
function filterSettings() {
|
| 546 |
+
const q = ($("settings-search")?.value || "").toLowerCase();
|
| 547 |
+
const tag = $("settings-filter-tag")?.value || "";
|
| 548 |
+
const filtered = allSettings.filter(s => {
|
| 549 |
+
const matchQ = !q || (s.title + s.instruction + (s.description || "")).toLowerCase().includes(q);
|
| 550 |
+
const matchTag = !tag || s.tags.includes(tag);
|
| 551 |
+
return matchQ && matchTag;
|
| 552 |
+
});
|
| 553 |
+
renderSettingsList(filtered);
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
/* ── Edit setting ── */
|
| 557 |
+
async function editSetting(sid) {
|
| 558 |
+
try {
|
| 559 |
+
const s = await apiFetch(`/api/instructions/${sid}`);
|
| 560 |
+
$("edit-settings-id").value = s.settings_id;
|
| 561 |
+
if ($("s-title")) $("s-title").value = s.title;
|
| 562 |
+
if ($("s-description")) $("s-description").value = s.description || "";
|
| 563 |
+
if ($("s-instruction")) { $("s-instruction").value = s.instruction; $("s-instruction").dispatchEvent(new Event("input")); }
|
| 564 |
+
if ($("s-extra-context")) $("s-extra-context").value = s.extra_context || "";
|
| 565 |
+
if ($("s-output-format")) $("s-output-format").value = s.output_format;
|
| 566 |
+
if ($("s-persona")) { $("s-persona").value = s.persona; $("s-persona").dispatchEvent(new Event("change")); }
|
| 567 |
+
if ($("s-custom-persona")) $("s-custom-persona").value = s.custom_persona || "";
|
| 568 |
+
if ($("s-style")) $("s-style").value = s.style;
|
| 569 |
+
if ($("s-constraints")) $("s-constraints").value = (s.constraints || []).join("\n");
|
| 570 |
+
if ($("s-tags")) $("s-tags").value = (s.tags || []).join(", ");
|
| 571 |
+
if ($("s-provider")) $("s-provider").value = s.provider;
|
| 572 |
+
if ($("s-enhance")) $("s-enhance").checked = s.enhance;
|
| 573 |
+
if ($("settings-form-title")) $("settings-form-title").textContent = `✏️ Edit: ${s.title}`;
|
| 574 |
+
if ($("btn-settings-generate")) $("btn-settings-generate").style.display = "inline-flex";
|
| 575 |
+
document.querySelectorAll(".setting-card").forEach(c => c.classList.toggle("active-edit", c.dataset.id === sid));
|
| 576 |
+
$("settings-form-card").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 577 |
+
} catch (e) { toast(`Failed to load setting: ${e.message}`, "error"); }
|
| 578 |
+
}
|
| 579 |
+
window.editSetting = editSetting;
|
| 580 |
+
|
| 581 |
+
/* ── Delete setting ── */
|
| 582 |
+
async function deleteSetting(sid) {
|
| 583 |
+
if (!confirm("Delete this instruction setting?")) return;
|
| 584 |
+
try {
|
| 585 |
+
await apiFetch(`/api/instructions/${sid}`, "DELETE");
|
| 586 |
+
toast("Setting deleted.", "success");
|
| 587 |
+
if ($("edit-settings-id").value === sid) clearSettingsForm();
|
| 588 |
+
await loadSettingsList();
|
| 589 |
+
} catch (e) { toast(`Delete failed: ${e.message}`, "error"); }
|
| 590 |
+
}
|
| 591 |
+
window.deleteSetting = deleteSetting;
|
| 592 |
+
window.generateFromSetting = generateFromSetting;
|
| 593 |
+
|
| 594 |
+
/* ── Generate Now button (in edit mode) ── */
|
| 595 |
+
$("btn-settings-generate")?.addEventListener("click", async () => {
|
| 596 |
+
const sid = $("edit-settings-id").value;
|
| 597 |
+
if (!sid) return;
|
| 598 |
+
document.querySelector('[data-main-tab="generate"]')?.click();
|
| 599 |
+
await generateFromSetting(sid);
|
| 600 |
+
});
|
| 601 |
+
|
| 602 |
+
/* ── History ────────────────────────────────────────────────────────── */
|
| 603 |
+
$("btn-load-history")?.addEventListener("click", loadHistory);
|
| 604 |
+
|
| 605 |
async function loadHistory() {
|
| 606 |
const btn = $("btn-load-history");
|
| 607 |
setLoading(btn, true);
|
|
|
|
| 609 |
const data = await apiFetch("/api/history");
|
| 610 |
const tbody = $("history-body");
|
| 611 |
if (!data.entries?.length) {
|
| 612 |
+
tbody.innerHTML = `<tr><td class="empty-msg" colspan="7">No prompts yet. Generate your first one!</td></tr>`;
|
| 613 |
return;
|
| 614 |
}
|
| 615 |
tbody.innerHTML = data.entries.map(e => `
|
| 616 |
<tr>
|
| 617 |
+
<td><code style="font-size:.72rem;color:var(--text-muted)">${esc(e.prompt_id?.slice(0,8))}…</code></td>
|
| 618 |
+
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.instruction)}">${esc(e.instruction?.slice(0,55) || "—")}</td>
|
| 619 |
+
<td style="font-family:var(--font-mono)">v${e.version||1}</td>
|
| 620 |
+
<td><span class="badge badge-${e.status||'pending'}">${esc(e.status||"pending")}</span></td>
|
| 621 |
+
<td style="font-size:.75rem;color:var(--text-muted)">${e.settings_id ? `<span class="tag-chip">linked</span>` : "—"}</td>
|
| 622 |
+
<td style="white-space:nowrap;font-size:.75rem">${e.created_at ? new Date(e.created_at).toLocaleDateString() : "—"}</td>
|
| 623 |
<td>
|
| 624 |
+
<button class="btn-secondary btn-sm btn-danger" onclick="deleteHistory('${esc(e.prompt_id)}')">🗑</button>
|
| 625 |
</td>
|
| 626 |
</tr>`).join("");
|
| 627 |
toast(`Loaded ${data.total} prompt(s).`, "info");
|
| 628 |
+
} catch (e) { toast(`History error: ${e.message}`, "error"); }
|
| 629 |
+
finally { setLoading(btn, false); }
|
|
|
|
|
|
|
|
|
|
| 630 |
}
|
| 631 |
|
| 632 |
+
async function deleteHistory(id) {
|
| 633 |
if (!confirm("Delete this prompt?")) return;
|
| 634 |
try {
|
| 635 |
await apiFetch(`/api/history/${id}`, "DELETE");
|
| 636 |
+
toast("Deleted.", "success");
|
| 637 |
loadHistory();
|
| 638 |
+
} catch (e) { toast(`Delete failed: ${e.message}`, "error"); }
|
|
|
|
|
|
|
| 639 |
}
|
| 640 |
+
window.deleteHistory = deleteHistory;
|
| 641 |
+
|
| 642 |
+
/* ── Init ───────────────────────────────────────────────────────────── */
|
| 643 |
+
(async () => {
|
| 644 |
+
await loadConfig();
|
| 645 |
+
await loadSettingsList();
|
| 646 |
+
})();
|
frontend/index.html
CHANGED
|
@@ -4,23 +4,20 @@
|
|
| 4 |
<meta charset="UTF-8"/>
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
<title>PromptForge — Structured Prompt Generator</title>
|
|
|
|
|
|
|
| 7 |
<link rel="stylesheet" href="/static/style.css"/>
|
| 8 |
-
<meta name="description" content="Transform raw instructions into structured, ready-to-use prompts for Google AI Studio."/>
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
|
| 12 |
-
<!--
|
| 13 |
-
<div id="cursor-dot"></div>
|
| 14 |
-
<div
|
| 15 |
-
<div class="
|
| 16 |
-
<div class="bg-grid"></div>
|
| 17 |
-
<div class="orb orb-1"></div>
|
| 18 |
-
<div class="orb orb-2"></div>
|
| 19 |
-
<div class="orb orb-3"></div>
|
| 20 |
|
| 21 |
<div id="app">
|
| 22 |
|
| 23 |
-
<!-- ── Header ─────────────────────────────────────────────────── -->
|
| 24 |
<header>
|
| 25 |
<div class="header-inner">
|
| 26 |
<div class="logo-group">
|
|
@@ -29,221 +26,431 @@
|
|
| 29 |
<span class="logo-tag">v3.0</span>
|
| 30 |
</div>
|
| 31 |
<div class="header-meta">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
<div class="status-pill">
|
| 33 |
<div class="status-dot"></div>
|
| 34 |
<span>ONLINE</span>
|
| 35 |
</div>
|
| 36 |
-
<a class="nav-link" href="/docs" target="_blank">📖
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</div>
|
| 38 |
</div>
|
| 39 |
</header>
|
| 40 |
|
| 41 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
</div>
|
| 52 |
-
<div class="api-row">
|
| 53 |
-
<div class="api-field">
|
| 54 |
-
<label><span class="lbl-dot"></span> AI Provider</label>
|
| 55 |
-
<select id="provider">
|
| 56 |
-
<option value="none">⚡ Local Engine (no API key needed)</option>
|
| 57 |
-
<option value="google">🌐 Google Gemini</option>
|
| 58 |
-
<option value="huggingface">🤗 Hugging Face</option>
|
| 59 |
-
</select>
|
| 60 |
</div>
|
| 61 |
-
<div class="api-
|
| 62 |
-
<
|
| 63 |
-
<span class="lbl-dot"></span>
|
| 64 |
-
<
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
disabled/>
|
| 70 |
-
<button class="api-toggle-btn" id="btn-toggle-key" title="Show/hide key">👁</button>
|
| 71 |
</div>
|
| 72 |
-
<div class="api-field-
|
| 73 |
-
<
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
</div>
|
| 78 |
-
</div>
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
<div class="prog-
|
| 84 |
-
<div class="prog-label">
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
<div class="prog-node">
|
| 89 |
-
<div class="prog-
|
| 90 |
-
|
| 91 |
-
<div class="prog-line" id="pline-2"></div>
|
| 92 |
-
<div class="prog-step" id="pstep-3">
|
| 93 |
-
<div class="prog-node">3</div>
|
| 94 |
-
<div class="prog-label">Finalize</div>
|
| 95 |
</div>
|
| 96 |
-
<div class="prog-line" id="pline-3"></div>
|
| 97 |
-
<div class="prog-step" id="pstep-4">
|
| 98 |
-
<div class="prog-node">4</div>
|
| 99 |
-
<div class="prog-label">Export</div>
|
| 100 |
-
</div>
|
| 101 |
-
<div class="prog-line" id="pline-4"></div>
|
| 102 |
-
<div class="prog-step" id="pstep-5">
|
| 103 |
-
<div class="prog-node">5</div>
|
| 104 |
-
<div class="prog-label">Refine</div>
|
| 105 |
-
</div>
|
| 106 |
-
</div>
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
-
<
|
| 116 |
-
|
| 117 |
-
<
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
<
|
| 121 |
-
|
| 122 |
-
<
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
<
|
| 127 |
-
|
| 128 |
-
<
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
</div>
|
| 135 |
-
</section>
|
| 136 |
|
| 137 |
-
<!-- ── STEP 2: Manifest Review ──────────────────────────────── -->
|
| 138 |
-
<section id="step-manifest" class="card hidden fade-in">
|
| 139 |
-
<div class="card-header">
|
| 140 |
-
<h2>🔍 Review & Edit Manifest</h2>
|
| 141 |
-
<span class="step-badge">STEP 02</span>
|
| 142 |
-
</div>
|
| 143 |
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
<div
|
| 147 |
|
| 148 |
-
|
| 149 |
-
<
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
|
| 154 |
-
<button id="btn-approve" class="btn-primary">✅ Approve & Finalize</button>
|
| 155 |
-
<button id="btn-reset" class="btn-secondary">↩ Start Over</button>
|
| 156 |
-
</div>
|
| 157 |
-
</section>
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
| 165 |
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
<div id="tab-json" class="tab-panel hidden">
|
| 178 |
-
<pre id="finalized-json"></pre>
|
| 179 |
-
<button class="btn-copy" data-target="finalized-json">📋 Copy to Clipboard</button>
|
| 180 |
-
</div>
|
| 181 |
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
<button id="btn-new" class="btn-primary">➕ New Prompt</button>
|
| 189 |
-
</div>
|
| 190 |
-
</section>
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
</section>
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
<
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
</
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
-
|
|
|
|
|
|
|
| 239 |
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
<footer>
|
| 242 |
<div class="footer-inner">
|
| 243 |
-
<span class="footer-copy">© 2025 PromptForge
|
| 244 |
<div class="footer-links">
|
| 245 |
-
<a href="/docs"
|
| 246 |
-
<a href="/redoc"
|
| 247 |
<a href="/health" target="_blank">Health</a>
|
| 248 |
</div>
|
| 249 |
</div>
|
|
@@ -251,6 +458,20 @@
|
|
| 251 |
|
| 252 |
</div><!-- #app -->
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
<div id="toast-container"></div>
|
| 255 |
<script src="/static/client.js"></script>
|
| 256 |
</body>
|
|
|
|
| 4 |
<meta charset="UTF-8"/>
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
<title>PromptForge — Structured Prompt Generator</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
| 9 |
<link rel="stylesheet" href="/static/style.css"/>
|
|
|
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
|
| 13 |
+
<!-- Legacy cursor/bg elements (hidden via CSS) -->
|
| 14 |
+
<div id="cursor-dot"></div><div id="cursor-ring"></div>
|
| 15 |
+
<div class="bg-mesh"></div><div class="bg-grid"></div>
|
| 16 |
+
<div class="orb orb-1"></div><div class="orb orb-2"></div><div class="orb orb-3"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
<div id="app">
|
| 19 |
|
| 20 |
+
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
| 21 |
<header>
|
| 22 |
<div class="header-inner">
|
| 23 |
<div class="logo-group">
|
|
|
|
| 26 |
<span class="logo-tag">v3.0</span>
|
| 27 |
</div>
|
| 28 |
<div class="header-meta">
|
| 29 |
+
<div class="api-env-status" id="env-status-bar">
|
| 30 |
+
<span class="env-dot" id="env-hf-dot" title="Hugging Face API key status">HF</span>
|
| 31 |
+
<span class="env-dot" id="env-google-dot" title="Google API key status">GG</span>
|
| 32 |
+
</div>
|
| 33 |
<div class="status-pill">
|
| 34 |
<div class="status-dot"></div>
|
| 35 |
<span>ONLINE</span>
|
| 36 |
</div>
|
| 37 |
+
<a class="nav-link" href="/docs" target="_blank">📖 Docs</a>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
<!-- ── Main Tab Bar ──────────────────────────────────────────────── -->
|
| 41 |
+
<div class="main-tabs">
|
| 42 |
+
<div class="tabs-inner">
|
| 43 |
+
<button class="main-tab active" data-main-tab="generate">
|
| 44 |
+
<span class="tab-icon">⚡</span> Generate
|
| 45 |
+
</button>
|
| 46 |
+
<button class="main-tab" data-main-tab="settings">
|
| 47 |
+
<span class="tab-icon">⚙️</span> Instruction Settings
|
| 48 |
+
<span class="settings-count-badge" id="settings-count">0</span>
|
| 49 |
+
</button>
|
| 50 |
+
<button class="main-tab" data-main-tab="history">
|
| 51 |
+
<span class="tab-icon">📜</span> History
|
| 52 |
+
</button>
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
</header>
|
| 56 |
|
| 57 |
+
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
| 58 |
+
<!-- TAB 1: GENERATE -->
|
| 59 |
+
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
| 60 |
+
<div id="tab-generate" class="tab-page active">
|
| 61 |
+
<main>
|
| 62 |
|
| 63 |
+
<!-- API Config Banner -->
|
| 64 |
+
<div class="api-config-banner">
|
| 65 |
+
<div class="api-banner-header">
|
| 66 |
+
<div class="api-banner-icon">🔑</div>
|
| 67 |
+
<div class="api-banner-title">
|
| 68 |
+
<h3>AI Enhancement Keys</h3>
|
| 69 |
+
<p>API keys are read from environment variables first. Enter below to override for this session.</p>
|
| 70 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
</div>
|
| 72 |
+
<div class="api-row">
|
| 73 |
+
<div class="api-field">
|
| 74 |
+
<label><span class="lbl-dot"></span> AI Provider</label>
|
| 75 |
+
<select id="provider">
|
| 76 |
+
<option value="none">⚡ Local Engine (no API key needed)</option>
|
| 77 |
+
<option value="google">🌐 Google Gemini</option>
|
| 78 |
+
<option value="huggingface">🤗 Hugging Face</option>
|
| 79 |
+
</select>
|
|
|
|
|
|
|
| 80 |
</div>
|
| 81 |
+
<div class="api-field" id="api-key-group">
|
| 82 |
+
<label>
|
| 83 |
+
<span class="lbl-dot"></span> API Key
|
| 84 |
+
<span class="lbl-opt" id="api-key-hint">Select a provider above</span>
|
| 85 |
+
</label>
|
| 86 |
+
<div class="api-input-wrap">
|
| 87 |
+
<input type="password" id="api-key" placeholder="Not required for local engine" disabled/>
|
| 88 |
+
<button class="api-toggle-btn" id="btn-toggle-key" title="Show/hide">👁</button>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="api-field-note">
|
| 91 |
+
<span class="api-dot" id="api-status-dot"></span>
|
| 92 |
+
<span id="api-status-text">No key entered</span>
|
| 93 |
+
</div>
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
</div>
|
|
|
|
| 97 |
|
| 98 |
+
<!-- Step Progress -->
|
| 99 |
+
<div class="step-progress">
|
| 100 |
+
<div class="prog-step active" id="pstep-1"><div class="prog-node">1</div><div class="prog-label">Input</div></div>
|
| 101 |
+
<div class="prog-line" id="pline-1"></div>
|
| 102 |
+
<div class="prog-step" id="pstep-2"><div class="prog-node">2</div><div class="prog-label">Review</div></div>
|
| 103 |
+
<div class="prog-line" id="pline-2"></div>
|
| 104 |
+
<div class="prog-step" id="pstep-3"><div class="prog-node">3</div><div class="prog-label">Finalize</div></div>
|
| 105 |
+
<div class="prog-line" id="pline-3"></div>
|
| 106 |
+
<div class="prog-step" id="pstep-4"><div class="prog-node">4</div><div class="prog-label">Export</div></div>
|
| 107 |
+
<div class="prog-line" id="pline-4"></div>
|
| 108 |
+
<div class="prog-step" id="pstep-5"><div class="prog-node">5</div><div class="prog-label">Refine</div></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
+
<!-- STEP 1: Input -->
|
| 112 |
+
<section id="step-input" class="card">
|
| 113 |
+
<div class="card-header">
|
| 114 |
+
<h2>✍️ Enter Your Instruction</h2>
|
| 115 |
+
<span class="step-badge">STEP 01</span>
|
| 116 |
+
</div>
|
| 117 |
+
<div class="info-banner">
|
| 118 |
+
<span class="info-icon">💡</span>
|
| 119 |
+
<span>Describe any task. PromptForge transforms it into a fully structured Google AI Studio prompt — with role, constraints, style, safety, and examples.</span>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="field">
|
| 122 |
+
<label><span class="lbl-dot"></span> Instruction</label>
|
| 123 |
+
<textarea id="instruction" rows="5" placeholder="e.g. Generate a TypeScript React component with TailwindCSS and unit tests."></textarea>
|
| 124 |
+
</div>
|
| 125 |
+
<div class="field">
|
| 126 |
+
<label><span class="lbl-dot"></span> Additional Context <span class="lbl-opt">optional</span></label>
|
| 127 |
+
<textarea id="extra-context" rows="2" placeholder="e.g. Support dark mode, WCAG AA accessibility, React hooks only."></textarea>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="advanced-toggle-row">
|
| 130 |
+
<button class="btn-link" id="btn-toggle-advanced">⚙️ Advanced options</button>
|
| 131 |
+
</div>
|
| 132 |
+
<div id="advanced-options" class="advanced-panel hidden">
|
| 133 |
+
<div class="adv-grid">
|
| 134 |
+
<div class="field">
|
| 135 |
+
<label><span class="lbl-dot"></span> Persona
|
| 136 |
+
<span class="tooltip-wrap"><span class="tooltip-icon">?</span><span class="tooltip-text">Sets the AI's role identity, influencing vocabulary, depth, and response style.</span></span>
|
| 137 |
+
</label>
|
| 138 |
+
<select id="gen-persona">
|
| 139 |
+
<option value="default">🤖 Auto-detect from instruction</option>
|
| 140 |
+
<option value="senior_dev">👨💻 Senior Software Engineer</option>
|
| 141 |
+
<option value="data_scientist">📊 Data Scientist</option>
|
| 142 |
+
<option value="tech_writer">✍️ Technical Writer</option>
|
| 143 |
+
<option value="product_mgr">📋 Product Manager</option>
|
| 144 |
+
<option value="security_eng">🔒 Security Engineer</option>
|
| 145 |
+
<option value="custom">✏️ Custom persona…</option>
|
| 146 |
+
</select>
|
| 147 |
+
</div>
|
| 148 |
+
<div class="field">
|
| 149 |
+
<label><span class="lbl-dot"></span> Style
|
| 150 |
+
<span class="tooltip-wrap"><span class="tooltip-icon">?</span><span class="tooltip-text">Controls verbosity, formality, and explanation depth.</span></span>
|
| 151 |
+
</label>
|
| 152 |
+
<select id="gen-style">
|
| 153 |
+
<option value="professional">💼 Professional</option>
|
| 154 |
+
<option value="concise">⚡ Concise</option>
|
| 155 |
+
<option value="detailed">📖 Detailed</option>
|
| 156 |
+
<option value="beginner">🎓 Beginner-friendly</option>
|
| 157 |
+
<option value="formal">📄 Formal</option>
|
| 158 |
+
<option value="creative">🎨 Creative</option>
|
| 159 |
+
</select>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
<div class="field" id="custom-persona-field" style="display:none">
|
| 163 |
+
<label><span class="lbl-dot"></span> Custom Persona Text</label>
|
| 164 |
+
<input type="text" id="gen-custom-persona" placeholder="e.g. Expert Kubernetes architect with CNCF certification"/>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="field">
|
| 167 |
+
<label><span class="lbl-dot"></span> Additional Constraints <span class="lbl-opt">one per line</span></label>
|
| 168 |
+
<textarea id="gen-constraints" rows="3" placeholder="e.g. Must use async/await Rate limiting required No external dependencies"></textarea>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
<div class="action-row">
|
| 172 |
+
<button id="btn-generate" class="btn-primary">⚡ Generate Prompt Manifest</button>
|
| 173 |
+
<button id="btn-load-from-settings" class="btn-secondary">📂 Load from Settings</button>
|
| 174 |
+
</div>
|
| 175 |
+
</section>
|
| 176 |
|
| 177 |
+
<!-- STEP 2: Manifest Review -->
|
| 178 |
+
<section id="step-manifest" class="card hidden fade-in">
|
| 179 |
+
<div class="card-header">
|
| 180 |
+
<h2>🔍 Review & Edit Manifest</h2>
|
| 181 |
+
<span class="step-badge">STEP 02</span>
|
| 182 |
+
</div>
|
| 183 |
+
<p class="muted">Every field is editable. Tweak anything before approving — the final prompt will regenerate from your edits.</p>
|
| 184 |
+
<div id="manifest-grid" class="manifest-grid"></div>
|
| 185 |
+
<details>
|
| 186 |
+
<summary>📋 Raw JSON Manifest</summary>
|
| 187 |
+
<pre id="manifest-json"></pre>
|
| 188 |
+
</details>
|
| 189 |
+
<!-- Explanation panel -->
|
| 190 |
+
<div id="explanation-panel" class="explanation-panel hidden">
|
| 191 |
+
<div class="explanation-header">
|
| 192 |
+
<span class="explanation-icon">🧠</span>
|
| 193 |
+
<strong>Why was this prompt structured this way?</strong>
|
| 194 |
+
</div>
|
| 195 |
+
<div id="explanation-text" class="explanation-body"></div>
|
| 196 |
+
<div id="key-decisions" class="key-decisions"></div>
|
| 197 |
+
</div>
|
| 198 |
+
<div class="action-row">
|
| 199 |
+
<button id="btn-approve" class="btn-primary">✅ Approve & Finalize</button>
|
| 200 |
+
<button id="btn-explain" class="btn-secondary">🧠 Explain Structure</button>
|
| 201 |
+
<button id="btn-reset" class="btn-secondary">↩ Start Over</button>
|
| 202 |
+
</div>
|
| 203 |
+
</section>
|
| 204 |
|
| 205 |
+
<!-- STEP 3: Finalized Prompt -->
|
| 206 |
+
<section id="step-finalized" class="card hidden fade-in">
|
| 207 |
+
<div class="card-header">
|
| 208 |
+
<h2>🎉 Finalized Prompt</h2>
|
| 209 |
+
<span class="step-badge">STEP 03</span>
|
| 210 |
+
</div>
|
| 211 |
+
<p class="muted">Your structured prompt is ready. Copy it directly into Google AI Studio or export it below.</p>
|
| 212 |
+
<div class="tab-bar">
|
| 213 |
+
<button class="tab active" data-tab="text">📄 Plain Text</button>
|
| 214 |
+
<button class="tab" data-tab="json">{ } JSON</button>
|
| 215 |
+
</div>
|
| 216 |
+
<div id="tab-text" class="tab-panel">
|
| 217 |
+
<pre id="finalized-text"></pre>
|
| 218 |
+
<button class="btn-copy" data-target="finalized-text">📋 Copy</button>
|
| 219 |
+
</div>
|
| 220 |
+
<div id="tab-json" class="tab-panel hidden">
|
| 221 |
+
<pre id="finalized-json"></pre>
|
| 222 |
+
<button class="btn-copy" data-target="finalized-json">📋 Copy</button>
|
| 223 |
+
</div>
|
| 224 |
+
<div class="divider"></div>
|
| 225 |
+
<div class="action-row">
|
| 226 |
+
<button id="btn-export-json" class="btn-secondary">⬇ JSON</button>
|
| 227 |
+
<button id="btn-export-txt" class="btn-secondary">⬇ Text</button>
|
| 228 |
+
<button id="btn-refine" class="btn-secondary">🔁 Refine</button>
|
| 229 |
+
<button id="btn-save-as-setting" class="btn-secondary">💾 Save as Setting</button>
|
| 230 |
+
<button id="btn-new" class="btn-primary">➕ New Prompt</button>
|
| 231 |
+
</div>
|
| 232 |
+
</section>
|
| 233 |
|
| 234 |
+
<!-- STEP 5: Refine -->
|
| 235 |
+
<section id="step-refine" class="card hidden fade-in">
|
| 236 |
+
<div class="card-header">
|
| 237 |
+
<h2>🔁 Refine Prompt</h2>
|
| 238 |
+
<span class="step-badge">STEP 05</span>
|
| 239 |
+
</div>
|
| 240 |
+
<p class="muted">Describe what to change. PromptForge creates a new version (v+1) for re-approval.</p>
|
| 241 |
+
<div class="field">
|
| 242 |
+
<label><span class="lbl-dot"></span> Your Feedback</label>
|
| 243 |
+
<textarea id="feedback" rows="3" placeholder="e.g. Add ARIA labels, keyboard navigation, and a dark-mode variant prop."></textarea>
|
| 244 |
+
</div>
|
| 245 |
+
<div class="action-row">
|
| 246 |
+
<button id="btn-submit-refine" class="btn-primary">🔁 Submit Refinement</button>
|
| 247 |
+
<button id="btn-cancel-refine" class="btn-secondary">Cancel</button>
|
| 248 |
+
</div>
|
| 249 |
+
</section>
|
| 250 |
|
| 251 |
+
</main>
|
| 252 |
+
</div><!-- /tab-generate -->
|
|
|
|
|
|
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
+
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
| 256 |
+
<!-- TAB 2: INSTRUCTION SETTINGS -->
|
| 257 |
+
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
| 258 |
+
<div id="tab-settings" class="tab-page hidden">
|
| 259 |
+
<main>
|
| 260 |
|
| 261 |
+
<div class="settings-layout">
|
| 262 |
|
| 263 |
+
<!-- ── Left: Settings Form ────────────────────────────────── -->
|
| 264 |
+
<div class="settings-form-col">
|
| 265 |
+
<div class="card" id="settings-form-card">
|
| 266 |
+
<div class="card-header">
|
| 267 |
+
<h2 id="settings-form-title">➕ New Instruction Setting</h2>
|
| 268 |
+
<div style="display:flex;gap:8px">
|
| 269 |
+
<button class="btn-secondary btn-sm" id="btn-settings-clear">✕ Clear</button>
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
|
| 273 |
+
<input type="hidden" id="edit-settings-id"/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
+
<div class="field">
|
| 276 |
+
<label>
|
| 277 |
+
<span class="lbl-dot"></span> Title *
|
| 278 |
+
<span class="tooltip-wrap"><span class="tooltip-icon">?</span><span class="tooltip-text">A short, descriptive name for this instruction template.</span></span>
|
| 279 |
+
</label>
|
| 280 |
+
<input type="text" id="s-title" placeholder="e.g. React Component Generator" maxlength="120"/>
|
| 281 |
+
</div>
|
| 282 |
|
| 283 |
+
<div class="field">
|
| 284 |
+
<label><span class="lbl-dot"></span> Description <span class="lbl-opt">optional</span></label>
|
| 285 |
+
<textarea id="s-description" rows="2" placeholder="Brief notes about when to use this setting…"></textarea>
|
| 286 |
+
</div>
|
| 287 |
|
| 288 |
+
<div class="field">
|
| 289 |
+
<label><span class="lbl-dot"></span> Instruction *</label>
|
| 290 |
+
<textarea id="s-instruction" rows="5" placeholder="The full instruction or task description that will be used to generate the prompt…"></textarea>
|
| 291 |
+
<div class="field-note" id="s-instruction-count">0 / 8000 characters</div>
|
| 292 |
+
</div>
|
| 293 |
|
| 294 |
+
<div class="field">
|
| 295 |
+
<label><span class="lbl-dot"></span> Extra Context <span class="lbl-opt">optional</span></label>
|
| 296 |
+
<textarea id="s-extra-context" rows="2" placeholder="Additional constraints, background info, or requirements…"></textarea>
|
| 297 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
+
<div class="settings-grid-2">
|
| 300 |
+
<div class="field">
|
| 301 |
+
<label>
|
| 302 |
+
<span class="lbl-dot"></span> Persona
|
| 303 |
+
<span class="tooltip-wrap"><span class="tooltip-icon">?</span><span class="tooltip-text">The AI persona — sets domain expertise and communication style.</span></span>
|
| 304 |
+
</label>
|
| 305 |
+
<select id="s-persona">
|
| 306 |
+
<option value="default">🤖 Auto-detect</option>
|
| 307 |
+
<option value="senior_dev">👨💻 Senior Dev</option>
|
| 308 |
+
<option value="data_scientist">📊 Data Scientist</option>
|
| 309 |
+
<option value="tech_writer">✍️ Tech Writer</option>
|
| 310 |
+
<option value="product_mgr">📋 Product Manager</option>
|
| 311 |
+
<option value="security_eng">🔒 Security Engineer</option>
|
| 312 |
+
<option value="custom">✏️ Custom…</option>
|
| 313 |
+
</select>
|
| 314 |
+
</div>
|
| 315 |
+
<div class="field">
|
| 316 |
+
<label>
|
| 317 |
+
<span class="lbl-dot"></span> Style
|
| 318 |
+
<span class="tooltip-wrap"><span class="tooltip-icon">?</span><span class="tooltip-text">Controls verbosity, tone, and explanation depth.</span></span>
|
| 319 |
+
</label>
|
| 320 |
+
<select id="s-style">
|
| 321 |
+
<option value="professional">💼 Professional</option>
|
| 322 |
+
<option value="concise">⚡ Concise</option>
|
| 323 |
+
<option value="detailed">📖 Detailed</option>
|
| 324 |
+
<option value="beginner">🎓 Beginner</option>
|
| 325 |
+
<option value="formal">📄 Formal</option>
|
| 326 |
+
<option value="creative">🎨 Creative</option>
|
| 327 |
+
</select>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
|
| 331 |
+
<div class="field" id="s-custom-persona-field" style="display:none">
|
| 332 |
+
<label><span class="lbl-dot"></span> Custom Persona Text</label>
|
| 333 |
+
<input type="text" id="s-custom-persona" placeholder="e.g. Expert Rust systems programmer"/>
|
| 334 |
+
</div>
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
+
<div class="settings-grid-2">
|
| 337 |
+
<div class="field">
|
| 338 |
+
<label>
|
| 339 |
+
<span class="lbl-dot"></span> Output Format
|
| 340 |
+
</label>
|
| 341 |
+
<select id="s-output-format">
|
| 342 |
+
<option value="both">📦 Both (Text + JSON)</option>
|
| 343 |
+
<option value="text">📄 Text only</option>
|
| 344 |
+
<option value="json">{ } JSON only</option>
|
| 345 |
+
</select>
|
| 346 |
+
</div>
|
| 347 |
+
<div class="field">
|
| 348 |
+
<label>
|
| 349 |
+
<span class="lbl-dot"></span> AI Enhancement
|
| 350 |
+
</label>
|
| 351 |
+
<select id="s-provider">
|
| 352 |
+
<option value="none">⚡ None (local)</option>
|
| 353 |
+
<option value="huggingface">🤗 Hugging Face</option>
|
| 354 |
+
<option value="google">🌐 Google Gemini</option>
|
| 355 |
+
</select>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
|
| 359 |
+
<div class="field">
|
| 360 |
+
<label>
|
| 361 |
+
<span class="lbl-dot"></span> Constraints
|
| 362 |
+
<span class="tooltip-wrap"><span class="tooltip-icon">?</span><span class="tooltip-text">Hard rules the AI must follow. One constraint per line.</span></span>
|
| 363 |
+
<span class="lbl-opt">one per line</span>
|
| 364 |
+
</label>
|
| 365 |
+
<textarea id="s-constraints" rows="4" placeholder="Use TypeScript strict mode Include unit tests WCAG 2.1 AA accessibility No external dependencies"></textarea>
|
| 366 |
+
</div>
|
| 367 |
|
| 368 |
+
<div class="field">
|
| 369 |
+
<label><span class="lbl-dot"></span> Tags <span class="lbl-opt">comma-separated</span></label>
|
| 370 |
+
<input type="text" id="s-tags" placeholder="react, typescript, frontend"/>
|
| 371 |
+
</div>
|
|
|
|
| 372 |
|
| 373 |
+
<div class="field enhance-toggle-row">
|
| 374 |
+
<label class="toggle-label">
|
| 375 |
+
<input type="checkbox" id="s-enhance" class="toggle-checkbox"/>
|
| 376 |
+
<span class="toggle-track">
|
| 377 |
+
<span class="toggle-thumb"></span>
|
| 378 |
+
</span>
|
| 379 |
+
<span>Enable AI enhancement on generate</span>
|
| 380 |
+
</label>
|
| 381 |
+
</div>
|
| 382 |
|
| 383 |
+
<div class="action-row">
|
| 384 |
+
<button id="btn-settings-save" class="btn-primary">💾 Save Setting</button>
|
| 385 |
+
<button id="btn-settings-generate" class="btn-secondary" style="display:none">⚡ Generate Now</button>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<!-- ── Right: Saved Settings List ─────────────────────────── -->
|
| 391 |
+
<div class="settings-list-col">
|
| 392 |
+
<div class="settings-list-header">
|
| 393 |
+
<h3>Saved Settings <span id="settings-total-count" class="count-badge">0</span></h3>
|
| 394 |
+
<div class="settings-list-actions">
|
| 395 |
+
<input type="text" id="settings-search" placeholder="🔍 Search…" class="search-input"/>
|
| 396 |
+
<select id="settings-filter-tag" class="filter-select">
|
| 397 |
+
<option value="">All tags</option>
|
| 398 |
+
</select>
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
|
| 402 |
+
<div id="settings-list" class="settings-list">
|
| 403 |
+
<div class="settings-empty">
|
| 404 |
+
<div class="empty-icon">📋</div>
|
| 405 |
+
<p>No settings yet. Create your first one!</p>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
|
| 410 |
+
</div><!-- /settings-layout -->
|
| 411 |
+
</main>
|
| 412 |
+
</div><!-- /tab-settings -->
|
| 413 |
|
| 414 |
+
|
| 415 |
+
<!-- ════════════════════════���══════════════════════════════════════════ -->
|
| 416 |
+
<!-- TAB 3: HISTORY -->
|
| 417 |
+
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
| 418 |
+
<div id="tab-history" class="tab-page hidden">
|
| 419 |
+
<main>
|
| 420 |
+
<div class="card">
|
| 421 |
+
<div class="card-header">
|
| 422 |
+
<h2>📜 Prompt History</h2>
|
| 423 |
+
<button id="btn-load-history" class="btn-secondary btn-sm">↺ Refresh</button>
|
| 424 |
+
</div>
|
| 425 |
+
<div class="table-wrap">
|
| 426 |
+
<table id="history-table">
|
| 427 |
+
<thead>
|
| 428 |
+
<tr>
|
| 429 |
+
<th>ID</th>
|
| 430 |
+
<th>Instruction</th>
|
| 431 |
+
<th>Ver</th>
|
| 432 |
+
<th>Status</th>
|
| 433 |
+
<th>Settings</th>
|
| 434 |
+
<th>Date</th>
|
| 435 |
+
<th>Actions</th>
|
| 436 |
+
</tr>
|
| 437 |
+
</thead>
|
| 438 |
+
<tbody id="history-body">
|
| 439 |
+
<tr><td class="empty-msg" colspan="7">Click ↺ Refresh to load history.</td></tr>
|
| 440 |
+
</tbody>
|
| 441 |
+
</table>
|
| 442 |
+
</div>
|
| 443 |
+
</div>
|
| 444 |
+
</main>
|
| 445 |
+
</div><!-- /tab-history -->
|
| 446 |
+
|
| 447 |
+
<!-- ── Footer ─────────────────────────────────────────────────────── -->
|
| 448 |
<footer>
|
| 449 |
<div class="footer-inner">
|
| 450 |
+
<span class="footer-copy">© 2025 PromptForge v3.0 · Port 7860</span>
|
| 451 |
<div class="footer-links">
|
| 452 |
+
<a href="/docs" target="_blank">API Docs</a>
|
| 453 |
+
<a href="/redoc" target="_blank">ReDoc</a>
|
| 454 |
<a href="/health" target="_blank">Health</a>
|
| 455 |
</div>
|
| 456 |
</div>
|
|
|
|
| 458 |
|
| 459 |
</div><!-- #app -->
|
| 460 |
|
| 461 |
+
<!-- Load-from-settings modal -->
|
| 462 |
+
<div id="modal-overlay" class="modal-overlay hidden">
|
| 463 |
+
<div class="modal-box">
|
| 464 |
+
<div class="modal-header">
|
| 465 |
+
<h3>📂 Load from Saved Setting</h3>
|
| 466 |
+
<button class="modal-close" id="btn-modal-close">✕</button>
|
| 467 |
+
</div>
|
| 468 |
+
<div class="modal-body">
|
| 469 |
+
<input type="text" id="modal-search" class="modal-search" placeholder="🔍 Search settings…"/>
|
| 470 |
+
<div id="modal-settings-list" class="modal-list"></div>
|
| 471 |
+
</div>
|
| 472 |
+
</div>
|
| 473 |
+
</div>
|
| 474 |
+
|
| 475 |
<div id="toast-container"></div>
|
| 476 |
<script src="/static/client.js"></script>
|
| 477 |
</body>
|
frontend/style.css
CHANGED
|
@@ -1,208 +1,278 @@
|
|
| 1 |
-
/*
|
| 2 |
-
|
| 3 |
-
Aesthetic: Professional SaaS · Light & Airy · Clear Hierarchy
|
| 4 |
-
═══════════════════════════════════════════════════════════════ */
|
| 5 |
-
|
| 6 |
-
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 7 |
|
| 8 |
:root {
|
| 9 |
-
--bg:
|
| 10 |
-
--
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
--
|
| 18 |
-
--
|
| 19 |
-
--
|
| 20 |
-
--
|
| 21 |
-
--
|
| 22 |
-
--
|
| 23 |
-
--
|
| 24 |
-
--red: #ef4444;
|
| 25 |
-
--red-lt: #fee2e2;
|
| 26 |
-
--text: #111827;
|
| 27 |
-
--text-soft: #374151;
|
| 28 |
-
--text-muted: #6b7280;
|
| 29 |
-
--text-faint: #9ca3af;
|
| 30 |
-
--radius-sm: 8px;
|
| 31 |
-
--radius-md: 12px;
|
| 32 |
-
--radius-lg: 16px;
|
| 33 |
-
--radius-xl: 20px;
|
| 34 |
-
--shadow-sm: 0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.04);
|
| 35 |
-
--shadow-md: 0 4px 16px rgba(0,0,0,.08),0 1px 4px rgba(0,0,0,.05);
|
| 36 |
-
--shadow-lg: 0 12px 40px rgba(0,0,0,.10),0 4px 12px rgba(0,0,0,.06);
|
| 37 |
-
--shadow-btn: 0 2px 8px rgba(99,102,241,.30);
|
| 38 |
-
--font-body: 'Inter',system-ui,sans-serif;
|
| 39 |
-
--font-mono: 'JetBrains Mono','Fira Code',monospace;
|
| 40 |
}
|
| 41 |
-
|
| 42 |
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
| 43 |
html{scroll-behavior:smooth}
|
| 44 |
body{background:var(--bg);color:var(--text);font-family:var(--font-body);font-size:15px;line-height:1.6;min-height:100vh;-webkit-font-smoothing:antialiased}
|
| 45 |
-
::-webkit-scrollbar{width:
|
| 46 |
-
::-webkit-scrollbar-thumb{background:var(--border);border-radius:6px}
|
| 47 |
::-webkit-scrollbar-thumb:hover{background:var(--indigo-mid)}
|
| 48 |
-
|
| 49 |
-
/* Remove dark elements */
|
| 50 |
#cursor-dot,#cursor-ring,.bg-mesh,.bg-grid,.orb{display:none!important}
|
| 51 |
|
| 52 |
-
/* ──
|
| 53 |
#app{min-height:100vh;display:flex;flex-direction:column}
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
|
| 56 |
/* ── Header ── */
|
| 57 |
-
header{position:sticky;top:0;z-index:
|
| 58 |
-
.header-inner{max-width:
|
| 59 |
.logo-group{display:flex;align-items:center;gap:10px}
|
| 60 |
-
.logo-icon{width:36px;height:36px;background:linear-gradient(135deg,var(--indigo),var(--violet));border-radius:10px;display:grid;place-items:center;font-size:17px;box-shadow:0 2px
|
| 61 |
-
.logo-text{font-size:1.1rem;font-weight:
|
| 62 |
-
.logo-tag{font-family:var(--font-mono);font-size:.
|
| 63 |
.header-meta{display:flex;align-items:center;gap:10px}
|
| 64 |
-
.
|
| 65 |
-
.
|
| 66 |
-
|
| 67 |
-
.
|
|
|
|
|
|
|
|
|
|
| 68 |
.nav-link:hover{color:var(--indigo);border-color:var(--indigo-mid);background:var(--indigo-lt)}
|
| 69 |
|
| 70 |
-
/* ──
|
| 71 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
.api-banner-header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
|
| 73 |
-
.api-banner-icon{width:
|
| 74 |
-
.api-banner-title h3{font-size:.
|
| 75 |
-
.api-banner-title p{font-size:.
|
| 76 |
.api-row{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
| 77 |
@media(max-width:600px){.api-row{grid-template-columns:1fr}}
|
| 78 |
.api-field label{display:flex;align-items:center;gap:6px;font-size:.72rem;font-weight:600;color:var(--text-soft);letter-spacing:.4px;text-transform:uppercase;margin-bottom:7px}
|
| 79 |
.api-input-wrap{position:relative}
|
| 80 |
-
.api-input-wrap input{width:100%;padding:10px
|
| 81 |
.api-input-wrap input:focus{border-color:var(--border-focus);background:white;box-shadow:0 0 0 3px rgba(99,102,241,.10)}
|
| 82 |
.api-input-wrap input::placeholder{color:var(--text-faint);font-style:italic;font-family:var(--font-body)}
|
| 83 |
-
.api-toggle-btn{position:absolute;right:
|
| 84 |
.api-toggle-btn:hover{color:var(--indigo)}
|
| 85 |
.api-field-note{font-size:.72rem;color:var(--text-muted);margin-top:5px;display:flex;align-items:center;gap:5px}
|
| 86 |
-
.api-dot{width:6px;height:6px;border-radius:50%;background:var(--text-faint);flex-shrink:0;transition:
|
| 87 |
.api-dot.set{background:var(--green);box-shadow:0 0 6px var(--green)}
|
| 88 |
|
| 89 |
/* ── Step Progress ── */
|
| 90 |
.step-progress{display:flex;align-items:center;justify-content:center;padding:8px 0 4px;gap:0}
|
| 91 |
-
.prog-step{display:flex;flex-direction:column;align-items:center;gap:
|
| 92 |
-
.prog-node{width:
|
| 93 |
-
.prog-step.active .prog-node{border-color:var(--indigo);background:var(--indigo);color:white;box-shadow:0 0 0 4px rgba(99,102,241,.
|
| 94 |
-
.prog-step.done .prog-node{border-color:var(--green);background:var(--green);color:white}
|
| 95 |
-
.prog-
|
| 96 |
-
.prog-
|
|
|
|
| 97 |
.prog-step.done .prog-label{color:var(--green)}
|
| 98 |
-
.prog-line{width:
|
| 99 |
-
.prog-line::after{content:'';display:block;height:100%;width:0
|
| 100 |
.prog-line.filled::after{width:100%}
|
| 101 |
|
| 102 |
/* ── Cards ── */
|
| 103 |
-
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:
|
| 104 |
-
@keyframes cardIn{from{opacity:0;transform:translateY(
|
| 105 |
-
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:
|
| 106 |
-
.card-header h2{font-size:
|
| 107 |
-
.step-badge{font-family:var(--font-mono);font-size:.
|
| 108 |
-
|
| 109 |
-
/* ── Info
|
| 110 |
-
.info-banner{display:flex;gap:10px;align-items:flex-start;background:var(--indigo-lt);border:1px solid var(--indigo-mid);border-radius:var(--radius-md);padding:
|
| 111 |
.info-icon{font-size:1rem;flex-shrink:0;margin-top:1px}
|
| 112 |
|
| 113 |
-
/* ── Form ── */
|
| 114 |
-
.field{margin-bottom:
|
| 115 |
-
label{display:flex;align-items:center;gap:6px;font-size:.
|
| 116 |
.lbl-dot{width:4px;height:4px;border-radius:50%;background:var(--indigo);flex-shrink:0}
|
| 117 |
-
.lbl-opt{color:var(--text-faint);font-size:.
|
| 118 |
-
textarea,input[type="password"],select{width:100%;background:var(--surface-2);border:1.5px solid var(--border);border-radius:var(--radius-md);color:var(--text);font-family:var(--font-body);font-size:.
|
| 119 |
-
textarea:focus,input:focus,select:focus{border-color:var(--border-focus);background:white;box-shadow:0 0 0 3px rgba(99,102,241,.
|
| 120 |
textarea::placeholder,input::placeholder{color:var(--text-faint);font-style:italic}
|
| 121 |
-
textarea{min-height:
|
| 122 |
-
select{appearance:none;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg width='11' height='7' viewBox='0 0 11 7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5.5 6L10 1' stroke='%236366f1' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right
|
| 123 |
-
.
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
/* ── Buttons ── */
|
| 128 |
-
button{cursor:pointer}
|
| 129 |
-
.btn-primary{display:inline-flex;align-items:center;justify-content:center;gap:
|
| 130 |
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(99,102,241,.40)}
|
| 131 |
.btn-primary:active{transform:translateY(0)}
|
| 132 |
-
.btn-primary:disabled{opacity:.
|
| 133 |
-
.btn-secondary{display:inline-flex;align-items:center;justify-content:center;gap:
|
| 134 |
.btn-secondary:hover{border-color:var(--indigo-mid);color:var(--indigo);background:var(--indigo-lt)}
|
| 135 |
.btn-secondary:active{transform:scale(.98)}
|
| 136 |
.btn-danger{color:var(--red)}
|
| 137 |
.btn-danger:hover{border-color:var(--red);color:var(--red);background:var(--red-lt)}
|
| 138 |
-
.btn-sm{font-size:.
|
| 139 |
-
.action-row{display:flex;flex-wrap:wrap;gap:
|
| 140 |
-
.spinner{display:inline-block;width:
|
| 141 |
@keyframes spin{to{transform:rotate(360deg)}}
|
| 142 |
|
| 143 |
-
/* ── Manifest
|
| 144 |
-
.manifest-grid{display:grid;grid-template-columns:1fr 1fr;gap:
|
| 145 |
-
.manifest-field label{
|
| 146 |
-
.manifest-field textarea{min-height:
|
| 147 |
.manifest-field.full{grid-column:1/-1}
|
| 148 |
-
@media(max-width:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
/* ── Tabs ── */
|
| 151 |
-
.tab-bar{display:flex;gap:
|
| 152 |
-
.tab{border:none;background:transparent;border-radius:var(--radius-sm);padding:
|
| 153 |
-
.tab.active{background:white;color:var(--indigo);box-shadow:var(--shadow-sm);font-weight:
|
| 154 |
.tab-panel{display:block}
|
| 155 |
.tab-panel.hidden{display:none}
|
| 156 |
|
| 157 |
-
/* ── Code ── */
|
| 158 |
-
pre{background:#1e1e2e;border-radius:var(--radius-md);padding:
|
| 159 |
-
details{margin-top:
|
| 160 |
-
summary{font-size:.
|
| 161 |
summary:hover{color:var(--indigo)}
|
| 162 |
-
summary::before{content:'▶';font-size:.
|
| 163 |
details[open] summary::before{transform:rotate(90deg)}
|
| 164 |
-
.btn-copy{display:inline-flex;align-items:center;gap:
|
| 165 |
.btn-copy:hover{color:var(--indigo);border-color:var(--indigo-mid);background:var(--indigo-lt)}
|
| 166 |
.btn-copy.copied{color:var(--green);border-color:var(--green);background:var(--green-lt)}
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
/* ──
|
| 169 |
-
.
|
| 170 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
/* ── History Table ── */
|
| 173 |
.table-wrap{overflow-x:auto;border-radius:var(--radius-md);border:1px solid var(--border)}
|
| 174 |
-
table{width:100%;border-collapse:collapse;font-size:.
|
| 175 |
thead{background:var(--surface-2)}
|
| 176 |
-
th{padding:
|
| 177 |
-
td{padding:
|
| 178 |
tr:last-child td{border-bottom:none}
|
| 179 |
tr:hover td{background:var(--surface-2)}
|
| 180 |
-
.empty-msg{color:var(--text-faint);font-style:italic;text-align:center;padding:
|
| 181 |
-
.badge{display:inline-block;padding:2px
|
| 182 |
.badge-pending{background:var(--amber-lt);color:#92400e}
|
| 183 |
.badge-approved{background:var(--green-lt);color:#065f46}
|
| 184 |
.badge-exported{background:var(--indigo-lt);color:var(--indigo-dk)}
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
/* ── Toast ── */
|
| 187 |
-
#toast-container{position:fixed;bottom:24px;right:24px;display:flex;flex-direction:column;gap:
|
| 188 |
-
.toast{display:flex;align-items:center;gap:10px;background:white;border:1px solid var(--border);border-radius:var(--radius-md);padding:
|
| 189 |
.toast.leaving{animation:toastOut .3s ease forwards}
|
| 190 |
-
@keyframes toastIn{from{opacity:0;transform:translateX(
|
| 191 |
-
@keyframes toastOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(
|
| 192 |
.toast.success{border-left:4px solid var(--green)}
|
| 193 |
.toast.error{border-left:4px solid var(--red)}
|
| 194 |
.toast.warn{border-left:4px solid var(--amber)}
|
| 195 |
.toast.info{border-left:4px solid var(--indigo)}
|
| 196 |
-
.toast-icon{font-size:
|
| 197 |
|
| 198 |
/* ── Footer ── */
|
| 199 |
-
footer{border-top:1px solid var(--border);background:var(--surface);padding:
|
| 200 |
-
.footer-inner{max-width:
|
| 201 |
-
.footer-links{display:flex;gap:
|
| 202 |
.footer-links a{color:var(--text-muted);text-decoration:none;transition:color .2s}
|
| 203 |
.footer-links a:hover{color:var(--indigo)}
|
| 204 |
|
| 205 |
/* ── Utils ── */
|
| 206 |
.hidden{display:none!important}
|
| 207 |
-
.fade-in{animation:cardIn .
|
| 208 |
-
@media(max-width:600px){main{padding:16px 14px 60px}.card{padding:
|
|
|
|
| 1 |
+
/* PromptForge v3.0 — Full UI including Instruction Settings Panel */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
:root {
|
| 5 |
+
--bg:#f4f6fb; --surface:#ffffff; --surface-2:#f0f3f9; --surface-3:#e8edf6;
|
| 6 |
+
--border:#dde3ee; --border-focus:#6366f1;
|
| 7 |
+
--indigo:#6366f1; --indigo-dk:#4f46e5; --indigo-lt:#eef0ff; --indigo-mid:#c7d2fe;
|
| 8 |
+
--violet:#8b5cf6; --teal:#0d9488; --teal-lt:#ccfbf1;
|
| 9 |
+
--green:#10b981; --green-lt:#d1fae5;
|
| 10 |
+
--amber:#f59e0b; --amber-lt:#fef3c7;
|
| 11 |
+
--red:#ef4444; --red-lt:#fee2e2;
|
| 12 |
+
--text:#111827; --text-soft:#374151; --text-muted:#6b7280; --text-faint:#9ca3af;
|
| 13 |
+
--radius-sm:8px; --radius-md:12px; --radius-lg:16px; --radius-xl:20px;
|
| 14 |
+
--shadow-sm:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.04);
|
| 15 |
+
--shadow-md:0 4px 16px rgba(0,0,0,.08),0 2px 6px rgba(0,0,0,.04);
|
| 16 |
+
--shadow-lg:0 12px 40px rgba(0,0,0,.10),0 4px 12px rgba(0,0,0,.06);
|
| 17 |
+
--shadow-btn:0 2px 8px rgba(99,102,241,.30);
|
| 18 |
+
--font-body:'Plus Jakarta Sans',system-ui,sans-serif;
|
| 19 |
+
--font-mono:'JetBrains Mono','Fira Code',monospace;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
|
|
|
| 21 |
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
| 22 |
html{scroll-behavior:smooth}
|
| 23 |
body{background:var(--bg);color:var(--text);font-family:var(--font-body);font-size:15px;line-height:1.6;min-height:100vh;-webkit-font-smoothing:antialiased}
|
| 24 |
+
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
|
|
|
| 25 |
::-webkit-scrollbar-thumb:hover{background:var(--indigo-mid)}
|
|
|
|
|
|
|
| 26 |
#cursor-dot,#cursor-ring,.bg-mesh,.bg-grid,.orb{display:none!important}
|
| 27 |
|
| 28 |
+
/* ── App shell ── */
|
| 29 |
#app{min-height:100vh;display:flex;flex-direction:column}
|
| 30 |
+
.tab-page{display:none;flex:1}
|
| 31 |
+
.tab-page.active{display:block}
|
| 32 |
+
main{max-width:940px;width:100%;margin:0 auto;padding:24px 20px 80px;display:flex;flex-direction:column;gap:20px}
|
| 33 |
|
| 34 |
/* ── Header ── */
|
| 35 |
+
header{position:sticky;top:0;z-index:200;background:rgba(255,255,255,.95);backdrop-filter:blur(16px);border-bottom:1px solid var(--border);box-shadow:0 1px 0 rgba(0,0,0,.04)}
|
| 36 |
+
.header-inner{max-width:940px;margin:0 auto;padding:13px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px}
|
| 37 |
.logo-group{display:flex;align-items:center;gap:10px}
|
| 38 |
+
.logo-icon{width:36px;height:36px;background:linear-gradient(135deg,var(--indigo),var(--violet));border-radius:10px;display:grid;place-items:center;font-size:17px;box-shadow:0 2px 10px rgba(99,102,241,.35);flex-shrink:0}
|
| 39 |
+
.logo-text{font-size:1.1rem;font-weight:800;color:var(--text);letter-spacing:-.4px}
|
| 40 |
+
.logo-tag{font-family:var(--font-mono);font-size:.62rem;color:var(--indigo);background:var(--indigo-lt);border:1px solid var(--indigo-mid);padding:2px 8px;border-radius:20px}
|
| 41 |
.header-meta{display:flex;align-items:center;gap:10px}
|
| 42 |
+
.api-env-status{display:flex;gap:5px}
|
| 43 |
+
.env-dot{font-size:.62rem;font-family:var(--font-mono);font-weight:700;padding:3px 7px;border-radius:6px;background:var(--surface-3);color:var(--text-faint);border:1px solid var(--border);transition:all .3s;cursor:default}
|
| 44 |
+
.env-dot.active{background:var(--green-lt);color:#065f46;border-color:#6ee7b7}
|
| 45 |
+
.status-pill{display:flex;align-items:center;gap:6px;font-size:.72rem;font-family:var(--font-mono);color:var(--text-muted);background:var(--surface-2);border:1px solid var(--border);padding:5px 11px;border-radius:20px}
|
| 46 |
+
.status-dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:blink 2.5s ease-in-out infinite}
|
| 47 |
+
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
|
| 48 |
+
.nav-link{font-size:.8rem;font-weight:500;color:var(--text-muted);text-decoration:none;padding:6px 13px;border:1px solid var(--border);border-radius:var(--radius-sm);transition:all .2s;background:var(--surface)}
|
| 49 |
.nav-link:hover{color:var(--indigo);border-color:var(--indigo-mid);background:var(--indigo-lt)}
|
| 50 |
|
| 51 |
+
/* ── Main tab bar ── */
|
| 52 |
+
.main-tabs{border-top:1px solid var(--border);background:var(--surface)}
|
| 53 |
+
.tabs-inner{max-width:940px;margin:0 auto;padding:0 20px;display:flex;gap:0}
|
| 54 |
+
.main-tab{display:flex;align-items:center;gap:7px;padding:11px 20px;border:none;background:transparent;font-family:var(--font-body);font-size:.85rem;font-weight:600;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .2s;position:relative;white-space:nowrap}
|
| 55 |
+
.main-tab:hover{color:var(--text);background:var(--surface-2)}
|
| 56 |
+
.main-tab.active{color:var(--indigo);border-bottom-color:var(--indigo);background:transparent}
|
| 57 |
+
.tab-icon{font-size:.9rem}
|
| 58 |
+
.settings-count-badge{font-size:.6rem;font-weight:700;background:var(--indigo);color:white;border-radius:10px;padding:1px 6px;min-width:18px;text-align:center;line-height:1.5}
|
| 59 |
+
|
| 60 |
+
/* ── API Key Panel ── */
|
| 61 |
+
.api-config-banner{background:var(--surface);border:1.5px solid var(--indigo-mid);border-radius:var(--radius-lg);padding:18px 22px;box-shadow:var(--shadow-sm)}
|
| 62 |
.api-banner-header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
|
| 63 |
+
.api-banner-icon{width:38px;height:38px;flex-shrink:0;background:var(--indigo-lt);border-radius:var(--radius-md);display:grid;place-items:center;font-size:18px}
|
| 64 |
+
.api-banner-title h3{font-size:.93rem;font-weight:700;color:var(--text)}
|
| 65 |
+
.api-banner-title p{font-size:.78rem;color:var(--text-muted);margin-top:2px}
|
| 66 |
.api-row{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
| 67 |
@media(max-width:600px){.api-row{grid-template-columns:1fr}}
|
| 68 |
.api-field label{display:flex;align-items:center;gap:6px;font-size:.72rem;font-weight:600;color:var(--text-soft);letter-spacing:.4px;text-transform:uppercase;margin-bottom:7px}
|
| 69 |
.api-input-wrap{position:relative}
|
| 70 |
+
.api-input-wrap input{width:100%;padding:10px 38px 10px 13px;background:var(--surface-2);border:1.5px solid var(--border);border-radius:var(--radius-md);font-size:.83rem;font-family:var(--font-mono);color:var(--text);outline:none;transition:border-color .2s,box-shadow .2s,background .2s}
|
| 71 |
.api-input-wrap input:focus{border-color:var(--border-focus);background:white;box-shadow:0 0 0 3px rgba(99,102,241,.10)}
|
| 72 |
.api-input-wrap input::placeholder{color:var(--text-faint);font-style:italic;font-family:var(--font-body)}
|
| 73 |
+
.api-toggle-btn{position:absolute;right:9px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:14px;padding:3px;transition:color .2s}
|
| 74 |
.api-toggle-btn:hover{color:var(--indigo)}
|
| 75 |
.api-field-note{font-size:.72rem;color:var(--text-muted);margin-top:5px;display:flex;align-items:center;gap:5px}
|
| 76 |
+
.api-dot{width:6px;height:6px;border-radius:50%;background:var(--text-faint);flex-shrink:0;transition:all .3s}
|
| 77 |
.api-dot.set{background:var(--green);box-shadow:0 0 6px var(--green)}
|
| 78 |
|
| 79 |
/* ── Step Progress ── */
|
| 80 |
.step-progress{display:flex;align-items:center;justify-content:center;padding:8px 0 4px;gap:0}
|
| 81 |
+
.prog-step{display:flex;flex-direction:column;align-items:center;gap:4px}
|
| 82 |
+
.prog-node{width:30px;height:30px;border-radius:50%;border:2px solid var(--border);background:var(--surface);display:grid;place-items:center;font-size:.7rem;font-weight:700;color:var(--text-muted);transition:all .3s cubic-bezier(.34,1.56,.64,1)}
|
| 83 |
+
.prog-step.active .prog-node{border-color:var(--indigo);background:var(--indigo);color:white;box-shadow:0 0 0 4px rgba(99,102,241,.15);transform:scale(1.12)}
|
| 84 |
+
.prog-step.done .prog-node{border-color:var(--green);background:var(--green);color:white;font-size:0}
|
| 85 |
+
.prog-step.done .prog-node::before{content:'✓';font-size:.75rem}
|
| 86 |
+
.prog-label{font-size:.58rem;color:var(--text-faint);font-weight:600;letter-spacing:.4px;text-transform:uppercase}
|
| 87 |
+
.prog-step.active .prog-label{color:var(--indigo)}
|
| 88 |
.prog-step.done .prog-label{color:var(--green)}
|
| 89 |
+
.prog-line{width:56px;height:2px;background:var(--border);margin-bottom:20px;border-radius:2px;overflow:hidden}
|
| 90 |
+
.prog-line::after{content:'';display:block;height:100%;width:0;background:linear-gradient(90deg,var(--indigo),var(--green));transition:width .5s ease;border-radius:2px}
|
| 91 |
.prog-line.filled::after{width:100%}
|
| 92 |
|
| 93 |
/* ── Cards ── */
|
| 94 |
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:26px 28px;box-shadow:var(--shadow-md);animation:cardIn .35s ease both}
|
| 95 |
+
@keyframes cardIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
| 96 |
+
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:12px}
|
| 97 |
+
.card-header h2{font-size:1rem;font-weight:700;color:var(--text);letter-spacing:-.2px}
|
| 98 |
+
.step-badge{font-family:var(--font-mono);font-size:.58rem;font-weight:600;letter-spacing:1.2px;text-transform:uppercase;color:var(--indigo);background:var(--indigo-lt);border:1px solid var(--indigo-mid);padding:3px 9px;border-radius:20px;white-space:nowrap}
|
| 99 |
+
|
| 100 |
+
/* ── Info banner ── */
|
| 101 |
+
.info-banner{display:flex;gap:10px;align-items:flex-start;background:var(--indigo-lt);border:1px solid var(--indigo-mid);border-radius:var(--radius-md);padding:11px 15px;margin-bottom:18px;font-size:.84rem;color:var(--indigo-dk);line-height:1.55}
|
| 102 |
.info-icon{font-size:1rem;flex-shrink:0;margin-top:1px}
|
| 103 |
|
| 104 |
+
/* ── Form elements ── */
|
| 105 |
+
.field{margin-bottom:16px}
|
| 106 |
+
label{display:flex;align-items:center;gap:6px;font-size:.72rem;font-weight:600;letter-spacing:.4px;text-transform:uppercase;color:var(--text-soft);margin-bottom:6px}
|
| 107 |
.lbl-dot{width:4px;height:4px;border-radius:50%;background:var(--indigo);flex-shrink:0}
|
| 108 |
+
.lbl-opt{color:var(--text-faint);font-size:.64rem;margin-left:auto;text-transform:none;letter-spacing:0;font-weight:400}
|
| 109 |
+
textarea,input[type="text"],input[type="password"],select{width:100%;background:var(--surface-2);border:1.5px solid var(--border);border-radius:var(--radius-md);color:var(--text);font-family:var(--font-body);font-size:.88rem;padding:10px 13px;outline:none;transition:border-color .2s,box-shadow .2s,background .2s;resize:vertical}
|
| 110 |
+
textarea:focus,input:focus,select:focus{border-color:var(--border-focus);background:white;box-shadow:0 0 0 3px rgba(99,102,241,.09)}
|
| 111 |
textarea::placeholder,input::placeholder{color:var(--text-faint);font-style:italic}
|
| 112 |
+
textarea{min-height:90px;line-height:1.65}
|
| 113 |
+
select{appearance:none;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg width='11' height='7' viewBox='0 0 11 7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5.5 6L10 1' stroke='%236366f1' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 13px center;padding-right:36px;resize:none}
|
| 114 |
+
.field-note{font-size:.7rem;color:var(--text-muted);margin-top:4px;text-align:right}
|
| 115 |
+
.settings-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
| 116 |
+
@media(max-width:580px){.settings-grid-2{grid-template-columns:1fr}}
|
| 117 |
+
|
| 118 |
+
/* ── Advanced panel ── */
|
| 119 |
+
.advanced-toggle-row{margin-bottom:10px}
|
| 120 |
+
.btn-link{background:none;border:none;color:var(--indigo);font-size:.83rem;font-weight:600;cursor:pointer;padding:0;font-family:var(--font-body)}
|
| 121 |
+
.btn-link:hover{text-decoration:underline}
|
| 122 |
+
.advanced-panel{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px;margin-bottom:16px;animation:cardIn .3s ease}
|
| 123 |
+
.adv-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
| 124 |
+
@media(max-width:580px){.adv-grid{grid-template-columns:1fr}}
|
| 125 |
+
|
| 126 |
+
/* ── Tooltip ── */
|
| 127 |
+
.tooltip-wrap{position:relative;display:inline-flex;margin-left:4px}
|
| 128 |
+
.tooltip-icon{width:14px;height:14px;border-radius:50%;background:var(--surface-3);border:1px solid var(--border);font-size:.65rem;display:grid;place-items:center;cursor:default;color:var(--text-muted);font-weight:700;font-style:normal}
|
| 129 |
+
.tooltip-text{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);width:220px;background:#1e1e2e;color:#cdd6f4;font-size:.72rem;padding:8px 10px;border-radius:var(--radius-sm);font-weight:400;text-transform:none;letter-spacing:0;line-height:1.4;opacity:0;pointer-events:none;transition:opacity .2s;z-index:50;box-shadow:var(--shadow-lg)}
|
| 130 |
+
.tooltip-wrap:hover .tooltip-text{opacity:1}
|
| 131 |
+
|
| 132 |
+
/* ── Toggle switch ── */
|
| 133 |
+
.enhance-toggle-row{margin-top:4px}
|
| 134 |
+
.toggle-label{display:flex;align-items:center;gap:10px;cursor:pointer;font-size:.85rem;font-weight:500;color:var(--text-soft);text-transform:none;letter-spacing:0}
|
| 135 |
+
.toggle-checkbox{display:none}
|
| 136 |
+
.toggle-track{width:40px;height:22px;background:var(--border);border-radius:11px;position:relative;transition:background .25s;flex-shrink:0}
|
| 137 |
+
.toggle-checkbox:checked+.toggle-track{background:var(--indigo)}
|
| 138 |
+
.toggle-thumb{position:absolute;width:16px;height:16px;background:white;border-radius:50%;top:3px;left:3px;transition:left .25s;box-shadow:0 1px 3px rgba(0,0,0,.2)}
|
| 139 |
+
.toggle-checkbox:checked+.toggle-track .toggle-thumb{left:21px}
|
| 140 |
|
| 141 |
/* ── Buttons ── */
|
| 142 |
+
button{cursor:pointer;font-family:var(--font-body)}
|
| 143 |
+
.btn-primary{display:inline-flex;align-items:center;justify-content:center;gap:7px;border:none;border-radius:var(--radius-md);font-size:.88rem;font-weight:700;padding:11px 24px;background:linear-gradient(135deg,var(--indigo) 0%,var(--violet) 100%);color:white;box-shadow:var(--shadow-btn);transition:transform .2s,box-shadow .2s;position:relative;overflow:hidden}
|
| 144 |
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(99,102,241,.40)}
|
| 145 |
.btn-primary:active{transform:translateY(0)}
|
| 146 |
+
.btn-primary:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
| 147 |
+
.btn-secondary{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1.5px solid var(--border);border-radius:var(--radius-md);background:var(--surface);color:var(--text-soft);font-size:.85rem;font-weight:500;padding:10px 18px;transition:all .2s}
|
| 148 |
.btn-secondary:hover{border-color:var(--indigo-mid);color:var(--indigo);background:var(--indigo-lt)}
|
| 149 |
.btn-secondary:active{transform:scale(.98)}
|
| 150 |
.btn-danger{color:var(--red)}
|
| 151 |
.btn-danger:hover{border-color:var(--red);color:var(--red);background:var(--red-lt)}
|
| 152 |
+
.btn-sm{font-size:.76rem;padding:5px 12px;border-radius:var(--radius-sm)}
|
| 153 |
+
.action-row{display:flex;flex-wrap:wrap;gap:9px;margin-top:18px;align-items:center}
|
| 154 |
+
.spinner{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,.3);border-top-color:white;border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle}
|
| 155 |
@keyframes spin{to{transform:rotate(360deg)}}
|
| 156 |
|
| 157 |
+
/* ── Manifest grid ── */
|
| 158 |
+
.manifest-grid{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin-bottom:18px}
|
| 159 |
+
.manifest-field label{font-size:.66rem;color:var(--text-muted);text-transform:uppercase}
|
| 160 |
+
.manifest-field textarea{min-height:66px;font-size:.83rem}
|
| 161 |
.manifest-field.full{grid-column:1/-1}
|
| 162 |
+
@media(max-width:580px){.manifest-grid{grid-template-columns:1fr}.manifest-field.full{grid-column:auto}}
|
| 163 |
+
|
| 164 |
+
/* ── Explanation panel ── */
|
| 165 |
+
.explanation-panel{background:linear-gradient(135deg,var(--indigo-lt),#f5f0ff);border:1px solid var(--indigo-mid);border-radius:var(--radius-md);padding:16px 18px;margin-top:16px;animation:cardIn .35s ease}
|
| 166 |
+
.explanation-header{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
| 167 |
+
.explanation-icon{font-size:1.2rem}
|
| 168 |
+
.explanation-header strong{font-size:.88rem;color:var(--indigo-dk);font-weight:700}
|
| 169 |
+
.explanation-body{font-size:.84rem;color:var(--text-soft);line-height:1.65;white-space:pre-wrap;margin-bottom:12px}
|
| 170 |
+
.key-decisions{display:flex;flex-direction:column;gap:5px}
|
| 171 |
+
.decision-chip{display:inline-flex;align-items:center;gap:6px;background:white;border:1px solid var(--indigo-mid);border-radius:20px;padding:4px 12px;font-size:.75rem;color:var(--indigo-dk);font-weight:500}
|
| 172 |
+
.decision-chip::before{content:'•';color:var(--indigo)}
|
| 173 |
|
| 174 |
+
/* ── Tabs (inner) ── */
|
| 175 |
+
.tab-bar{display:flex;gap:3px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-md);padding:3px;width:fit-content;margin-bottom:14px}
|
| 176 |
+
.tab{border:none;background:transparent;border-radius:var(--radius-sm);padding:6px 16px;font-size:.8rem;font-weight:500;color:var(--text-muted);cursor:pointer;transition:all .2s}
|
| 177 |
+
.tab.active{background:white;color:var(--indigo);box-shadow:var(--shadow-sm);font-weight:700}
|
| 178 |
.tab-panel{display:block}
|
| 179 |
.tab-panel.hidden{display:none}
|
| 180 |
|
| 181 |
+
/* ── Code/Pre ── */
|
| 182 |
+
pre{background:#1e1e2e;border-radius:var(--radius-md);padding:14px 16px;overflow-x:auto;font-family:var(--font-mono);font-size:.8rem;line-height:1.7;color:#cdd6f4;white-space:pre-wrap;word-break:break-word;border:1px solid rgba(205,214,244,.06)}
|
| 183 |
+
details{margin-top:10px}
|
| 184 |
+
summary{font-size:.82rem;font-weight:600;color:var(--text-muted);cursor:pointer;padding:9px 0;user-select:none;display:flex;align-items:center;gap:6px;list-style:none}
|
| 185 |
summary:hover{color:var(--indigo)}
|
| 186 |
+
summary::before{content:'▶';font-size:.68rem;transition:transform .2s}
|
| 187 |
details[open] summary::before{transform:rotate(90deg)}
|
| 188 |
+
.btn-copy{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text-muted);font-family:var(--font-mono);font-size:.72rem;padding:6px 12px;margin-top:8px;cursor:pointer;transition:all .2s}
|
| 189 |
.btn-copy:hover{color:var(--indigo);border-color:var(--indigo-mid);background:var(--indigo-lt)}
|
| 190 |
.btn-copy.copied{color:var(--green);border-color:var(--green);background:var(--green-lt)}
|
| 191 |
+
.muted{font-size:.86rem;color:var(--text-muted);margin-bottom:16px;line-height:1.6}
|
| 192 |
+
.divider{height:1px;background:var(--border);margin:18px 0}
|
| 193 |
|
| 194 |
+
/* ── Settings layout (two-column) ── */
|
| 195 |
+
.settings-layout{display:grid;grid-template-columns:1fr 380px;gap:20px;max-width:940px;margin:0 auto;padding:24px 20px 80px;align-items:start}
|
| 196 |
+
@media(max-width:780px){.settings-layout{grid-template-columns:1fr;padding:16px 14px 60px}}
|
| 197 |
+
.settings-form-col{}
|
| 198 |
+
.settings-list-col{position:sticky;top:120px}
|
| 199 |
+
.settings-list-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;gap:10px;flex-wrap:wrap}
|
| 200 |
+
.settings-list-header h3{font-size:.95rem;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px}
|
| 201 |
+
.count-badge{font-size:.62rem;font-weight:700;background:var(--indigo-lt);color:var(--indigo);border:1px solid var(--indigo-mid);border-radius:10px;padding:1px 7px}
|
| 202 |
+
.settings-list-actions{display:flex;gap:8px;flex-wrap:wrap}
|
| 203 |
+
.search-input{padding:7px 12px;border:1.5px solid var(--border);border-radius:var(--radius-md);font-size:.82rem;background:var(--surface);color:var(--text);outline:none;transition:border-color .2s;width:150px}
|
| 204 |
+
.search-input:focus{border-color:var(--border-focus)}
|
| 205 |
+
.filter-select{padding:7px 30px 7px 10px;border:1.5px solid var(--border);border-radius:var(--radius-md);font-size:.78rem;background:var(--surface);color:var(--text-muted);appearance:none;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 9px center;cursor:pointer;outline:none;resize:none}
|
| 206 |
+
.settings-list{display:flex;flex-direction:column;gap:10px;max-height:calc(100vh - 280px);overflow-y:auto;padding-right:2px}
|
| 207 |
+
|
| 208 |
+
/* ── Setting card ── */
|
| 209 |
+
.setting-card{background:var(--surface);border:1.5px solid var(--border);border-radius:var(--radius-lg);padding:14px 16px;cursor:pointer;transition:all .2s;position:relative}
|
| 210 |
+
.setting-card:hover{border-color:var(--indigo-mid);box-shadow:var(--shadow-md);transform:translateY(-1px)}
|
| 211 |
+
.setting-card.active-edit{border-color:var(--indigo);box-shadow:0 0 0 3px rgba(99,102,241,.12)}
|
| 212 |
+
.setting-card-title{font-size:.88rem;font-weight:700;color:var(--text);margin-bottom:4px;display:flex;align-items:center;gap:7px}
|
| 213 |
+
.setting-card-desc{font-size:.76rem;color:var(--text-muted);margin-bottom:9px;line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
| 214 |
+
.setting-card-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
| 215 |
+
.tag-chip{font-size:.63rem;font-weight:600;padding:2px 7px;border-radius:10px;background:var(--indigo-lt);color:var(--indigo-dk);border:1px solid var(--indigo-mid)}
|
| 216 |
+
.tag-chip.style{background:var(--teal-lt);color:var(--teal);border-color:#99f6e4}
|
| 217 |
+
.use-count{font-size:.68rem;color:var(--text-faint);margin-left:auto;font-family:var(--font-mono)}
|
| 218 |
+
.setting-card-actions{position:absolute;top:10px;right:10px;display:flex;gap:4px;opacity:0;transition:opacity .2s}
|
| 219 |
+
.setting-card:hover .setting-card-actions{opacity:1}
|
| 220 |
+
.settings-empty{text-align:center;padding:40px 20px;color:var(--text-muted)}
|
| 221 |
+
.empty-icon{font-size:2.5rem;margin-bottom:10px;opacity:.4}
|
| 222 |
+
.settings-empty p{font-size:.85rem}
|
| 223 |
|
| 224 |
/* ── History Table ── */
|
| 225 |
.table-wrap{overflow-x:auto;border-radius:var(--radius-md);border:1px solid var(--border)}
|
| 226 |
+
table{width:100%;border-collapse:collapse;font-size:.83rem}
|
| 227 |
thead{background:var(--surface-2)}
|
| 228 |
+
th{padding:10px 13px;text-align:left;font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);border-bottom:1px solid var(--border);white-space:nowrap}
|
| 229 |
+
td{padding:10px 13px;color:var(--text-soft);border-bottom:1px solid var(--surface-2)}
|
| 230 |
tr:last-child td{border-bottom:none}
|
| 231 |
tr:hover td{background:var(--surface-2)}
|
| 232 |
+
.empty-msg{color:var(--text-faint);font-style:italic;text-align:center;padding:24px 13px!important}
|
| 233 |
+
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.3px}
|
| 234 |
.badge-pending{background:var(--amber-lt);color:#92400e}
|
| 235 |
.badge-approved{background:var(--green-lt);color:#065f46}
|
| 236 |
.badge-exported{background:var(--indigo-lt);color:var(--indigo-dk)}
|
| 237 |
|
| 238 |
+
/* ── Modal ── */
|
| 239 |
+
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);z-index:500;display:flex;align-items:center;justify-content:center;padding:20px}
|
| 240 |
+
.modal-overlay.hidden{display:none!important}
|
| 241 |
+
.modal-box{background:var(--surface);border-radius:var(--radius-xl);padding:0;width:100%;max-width:520px;max-height:80vh;display:flex;flex-direction:column;box-shadow:var(--shadow-lg);overflow:hidden;animation:cardIn .25s ease}
|
| 242 |
+
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--border)}
|
| 243 |
+
.modal-header h3{font-size:.95rem;font-weight:700}
|
| 244 |
+
.modal-close{background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1rem;padding:4px;transition:color .2s;border-radius:6px}
|
| 245 |
+
.modal-close:hover{color:var(--text);background:var(--surface-2)}
|
| 246 |
+
.modal-body{padding:16px 22px;overflow-y:auto;flex:1}
|
| 247 |
+
.modal-search{width:100%;padding:10px 13px;border:1.5px solid var(--border);border-radius:var(--radius-md);font-size:.87rem;margin-bottom:14px;outline:none;background:var(--surface-2);color:var(--text)}
|
| 248 |
+
.modal-search:focus{border-color:var(--border-focus)}
|
| 249 |
+
.modal-list{display:flex;flex-direction:column;gap:8px}
|
| 250 |
+
.modal-item{padding:12px 14px;border:1.5px solid var(--border);border-radius:var(--radius-md);cursor:pointer;transition:all .2s}
|
| 251 |
+
.modal-item:hover{border-color:var(--indigo);background:var(--indigo-lt)}
|
| 252 |
+
.modal-item-title{font-size:.88rem;font-weight:600;color:var(--text);margin-bottom:3px}
|
| 253 |
+
.modal-item-desc{font-size:.75rem;color:var(--text-muted)}
|
| 254 |
+
.modal-empty{text-align:center;padding:30px;color:var(--text-faint);font-style:italic}
|
| 255 |
+
|
| 256 |
/* ── Toast ── */
|
| 257 |
+
#toast-container{position:fixed;bottom:24px;right:24px;display:flex;flex-direction:column;gap:9px;z-index:9999;pointer-events:none}
|
| 258 |
+
.toast{display:flex;align-items:center;gap:10px;background:white;border:1px solid var(--border);border-radius:var(--radius-md);padding:11px 16px;box-shadow:var(--shadow-lg);font-size:.84rem;color:var(--text);pointer-events:all;animation:toastIn .3s cubic-bezier(.34,1.56,.64,1) both;min-width:250px;max-width:360px}
|
| 259 |
.toast.leaving{animation:toastOut .3s ease forwards}
|
| 260 |
+
@keyframes toastIn{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:translateX(0)}}
|
| 261 |
+
@keyframes toastOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(16px)}}
|
| 262 |
.toast.success{border-left:4px solid var(--green)}
|
| 263 |
.toast.error{border-left:4px solid var(--red)}
|
| 264 |
.toast.warn{border-left:4px solid var(--amber)}
|
| 265 |
.toast.info{border-left:4px solid var(--indigo)}
|
| 266 |
+
.toast-icon{font-size:1rem;flex-shrink:0}
|
| 267 |
|
| 268 |
/* ── Footer ── */
|
| 269 |
+
footer{border-top:1px solid var(--border);background:var(--surface);padding:13px 20px}
|
| 270 |
+
.footer-inner{max-width:940px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:16px;font-size:.76rem;color:var(--text-faint)}
|
| 271 |
+
.footer-links{display:flex;gap:14px}
|
| 272 |
.footer-links a{color:var(--text-muted);text-decoration:none;transition:color .2s}
|
| 273 |
.footer-links a:hover{color:var(--indigo)}
|
| 274 |
|
| 275 |
/* ── Utils ── */
|
| 276 |
.hidden{display:none!important}
|
| 277 |
+
.fade-in{animation:cardIn .35s ease both}
|
| 278 |
+
@media(max-width:600px){main{padding:16px 14px 60px}.card{padding:18px 16px}.prog-line{width:28px}.tabs-inner{overflow-x:auto;gap:0;white-space:nowrap}.main-tab{padding:10px 14px;font-size:.78rem}}
|