Spaces:
Runtime error
Runtime error
Upload PromptForge v1.0 — Structured prompt generator for Google AI Studio
Browse files- .env.example +14 -11
- README.md +165 -183
- backend/__init__.py +0 -0
- backend/ai_client.py +121 -58
- backend/client.js +926 -0
- backend/instruction_store.py +128 -34
- backend/main.py +241 -134
- backend/prompt_logic.py +251 -158
- backend/requirements.txt +16 -5
- backend/schemas.py +193 -129
- backend/store.py +103 -6
- frontend/client.js +562 -282
- frontend/index.html +332 -282
- frontend/style.css +433 -261
- requirements.txt +19 -0
- tests/__init__.py +0 -0
- tests/test_promptforge.py +567 -0
.env.example
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
-
# PromptForge
|
| 2 |
-
# Copy to .env and fill in your values.
|
|
|
|
| 3 |
|
| 4 |
-
# ──
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
| 8 |
-
#
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
# ──
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 1 |
+
# PromptForge v4.0 — Environment Variables
|
| 2 |
+
# Copy this file to .env and fill in your values.
|
| 3 |
+
# Never commit a filled-in .env to version control.
|
| 4 |
|
| 5 |
+
# ── Server ────────────────────────────────────────────────────────────
|
| 6 |
+
PORT=7860
|
| 7 |
+
LOG_DIR=logs
|
| 8 |
|
| 9 |
+
# ── AI Enhancement (optional) ─────────────────────────────────────────
|
| 10 |
+
# Leave blank to use local engine only.
|
| 11 |
+
HF_API_KEY=
|
| 12 |
+
GOOGLE_API_KEY=
|
| 13 |
|
| 14 |
+
# ── Model overrides (optional) ────────────────────────────────────────
|
| 15 |
+
# Override the default model used for each provider.
|
| 16 |
+
# HF_MODEL=mistralai/Mistral-7B-Instruct-v0.2
|
| 17 |
+
# GOOGLE_MODEL=gemini-1.5-flash
|
README.md
CHANGED
|
@@ -1,263 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
sdk: docker
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: yellow
|
| 6 |
-
pinned: true
|
| 7 |
-
thumbnail: >-
|
| 8 |
-
https://cdn-uploads.huggingface.co/production/uploads/66367933cc7af105efbcd2dc/-oSODSxF1hK7KX-3Rmad3.png
|
| 9 |
-
---
|
| 10 |
-
# ⚙️ PromptForge
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
---
|
| 18 |
|
| 19 |
-
##
|
| 20 |
|
| 21 |
```
|
| 22 |
-
|
| 23 |
├── backend/
|
| 24 |
-
│ ├── main.py
|
| 25 |
-
│ ├── schemas.py
|
| 26 |
-
│ ├── prompt_logic.py
|
| 27 |
-
│ ├── ai_client.py
|
| 28 |
-
│ ├── store.py
|
| 29 |
-
│
|
| 30 |
-
│ └── tests/
|
| 31 |
-
│ └── test_promptforge.py
|
| 32 |
├── frontend/
|
| 33 |
-
│ ├── index.html
|
| 34 |
-
│ ├──
|
| 35 |
-
│ └──
|
| 36 |
-
├──
|
| 37 |
-
|
| 38 |
-
├──
|
| 39 |
-
├── .
|
| 40 |
-
├── .
|
| 41 |
└── README.md
|
| 42 |
```
|
| 43 |
|
| 44 |
-
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
| 49 |
-
|
|
| 50 |
-
|
|
| 51 |
-
|
|
| 52 |
-
|
|
| 53 |
-
|
|
| 54 |
-
|
|
| 55 |
-
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
---
|
| 58 |
|
| 59 |
-
##
|
| 60 |
|
| 61 |
-
### 1.
|
| 62 |
|
| 63 |
```bash
|
| 64 |
-
git clone <your-repo-url>
|
| 65 |
-
cd promptforge
|
| 66 |
-
|
| 67 |
-
# Create virtual environment
|
| 68 |
python -m venv .venv
|
| 69 |
-
source .venv/bin/activate
|
| 70 |
-
|
| 71 |
-
# Install dependencies
|
| 72 |
-
pip install -r backend/requirements.txt
|
| 73 |
```
|
| 74 |
|
| 75 |
-
### 2.
|
| 76 |
|
| 77 |
```bash
|
| 78 |
cp .env.example .env
|
| 79 |
-
#
|
| 80 |
-
# GOOGLE_API_KEY=...
|
| 81 |
-
# HF_API_KEY=...
|
| 82 |
```
|
| 83 |
|
| 84 |
-
|
| 85 |
-
> and are never logged or stored. Keys in `.env` are for server-side defaults;
|
| 86 |
-
> frontend keys override them per-request.
|
| 87 |
-
|
| 88 |
-
### 3. Start the server
|
| 89 |
|
| 90 |
```bash
|
| 91 |
cd backend
|
| 92 |
-
uvicorn main:app --reload --host 0.0.0.0 --port
|
| 93 |
```
|
| 94 |
|
| 95 |
-
Open **http://localhost:
|
| 96 |
-
API docs: **http://localhost:8000/docs**
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
## 🐳 Docker Deployment
|
| 101 |
|
| 102 |
```bash
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
# Or production detached
|
| 107 |
-
docker-compose up -d
|
| 108 |
```
|
| 109 |
|
| 110 |
---
|
| 111 |
|
| 112 |
-
##
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
--image gcr.io/YOUR_PROJECT/promptforge \
|
| 121 |
-
--platform managed \
|
| 122 |
-
--region us-central1 \
|
| 123 |
-
--allow-unauthenticated \
|
| 124 |
-
--set-env-vars "LOG_DIR=/tmp/logs"
|
| 125 |
-
```
|
| 126 |
-
|
| 127 |
-
For Cloud Run, set secrets via Secret Manager:
|
| 128 |
-
|
| 129 |
-
```bash
|
| 130 |
-
gcloud secrets create GOOGLE_API_KEY --data-file=- <<< "your-key"
|
| 131 |
-
gcloud secrets create HF_API_KEY --data-file=- <<< "your-key"
|
| 132 |
-
```
|
| 133 |
|
| 134 |
---
|
| 135 |
|
| 136 |
-
##
|
| 137 |
|
| 138 |
-
```bash
|
| 139 |
-
cd backend
|
| 140 |
-
pytest tests/ -v
|
| 141 |
```
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
---
|
| 146 |
|
| 147 |
-
##
|
| 148 |
|
| 149 |
-
###
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
"enhance": true,
|
| 158 |
-
"extra_context": "Must support dark mode and be accessible."
|
| 159 |
-
}
|
| 160 |
-
```
|
| 161 |
|
| 162 |
-
|
| 163 |
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
###
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
``
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
``
|
| 183 |
-
{
|
| 184 |
-
``
|
|
|
|
| 185 |
|
| 186 |
---
|
| 187 |
|
| 188 |
-
##
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
``
|
|
|
|
| 197 |
|
| 198 |
---
|
| 199 |
|
| 200 |
-
##
|
| 201 |
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
---
|
| 205 |
|
| 206 |
-
##
|
| 207 |
|
| 208 |
-
|
| 209 |
|
| 210 |
-
```
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
## OUTPUT FORMAT
|
| 221 |
-
Source code inside a properly labeled fenced code block. Include a brief
|
| 222 |
-
explanation before and after the block.
|
| 223 |
-
|
| 224 |
-
## CONSTRAINTS
|
| 225 |
-
1. Use TypeScript with strict mode enabled.
|
| 226 |
-
2. Use TailwindCSS utility classes exclusively; avoid custom CSS unless unavoidable.
|
| 227 |
-
3. Include comprehensive unit tests with >80% coverage.
|
| 228 |
-
|
| 229 |
-
## STYLE & TONE
|
| 230 |
-
Professional and clear; balance technical accuracy with readability.
|
| 231 |
-
|
| 232 |
-
## SAFETY GUIDELINES
|
| 233 |
-
1. Do not produce harmful, misleading, or unethical content.
|
| 234 |
-
2. Respect intellectual property; do not reproduce copyrighted material verbatim.
|
| 235 |
-
3. If the request is ambiguous or potentially harmful, ask for clarification.
|
| 236 |
-
4. Adhere to Google AI Studio usage policies and Responsible AI guidelines.
|
| 237 |
-
|
| 238 |
-
## FEW-SHOT EXAMPLES
|
| 239 |
-
**Input:** Create a Button component.
|
| 240 |
-
**Output:** ```tsx
|
| 241 |
-
interface ButtonProps { label: string; onClick: () => void; }
|
| 242 |
-
export const Button = ({ label, onClick }: ButtonProps) => (
|
| 243 |
-
<button onClick={onClick} className='px-4 py-2 bg-blue-600 text-white rounded'>{label}</button>
|
| 244 |
-
);
|
| 245 |
```
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
| 249 |
```
|
| 250 |
|
|
|
|
|
|
|
| 251 |
---
|
| 252 |
|
| 253 |
-
##
|
| 254 |
|
| 255 |
-
- API keys
|
| 256 |
-
-
|
| 257 |
-
-
|
|
|
|
| 258 |
|
| 259 |
---
|
| 260 |
|
| 261 |
-
##
|
| 262 |
|
| 263 |
-
MIT — use freely,
|
|
|
|
| 1 |
+
# PromptForge v4.0
|
| 2 |
+
|
| 3 |
+
> **Structured prompt generator for Google AI Studio.**
|
| 4 |
+
> Transform any instruction into a production-ready prompt — with role, constraints, style, safety guardrails, and few-shot examples — via a polished dark-first web UI.
|
| 5 |
+
|
| 6 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
## What's New in v4.0
|
| 9 |
+
|
| 10 |
+
| Feature | Detail |
|
| 11 |
+
|---|---|
|
| 12 |
+
| **2 new personas** | `devops_eng` and `ml_engineer` |
|
| 13 |
+
| **Stats dashboard** | Real-time bar charts, status breakdown, refinement count |
|
| 14 |
+
| **Batch generate** | Up to 10 prompts in a single API call |
|
| 15 |
+
| **Full-text search** | `GET /api/search?q=…` across all stored prompts |
|
| 16 |
+
| **Duplicate setting** | One-click copy of any saved instruction setting |
|
| 17 |
+
| **Favourite / archive** | Star prompts and settings; archive completed work |
|
| 18 |
+
| **API key validation** | Live ✓ key-check endpoint for HF and Google |
|
| 19 |
+
| **Model override** | Use any HF or Gemini model, not just the defaults |
|
| 20 |
+
| **Dark/light theme** | Persistent theme, toggled with `Alt+T` |
|
| 21 |
+
| **Keyboard shortcuts** | Full keyboard navigation (press `?` to see all) |
|
| 22 |
+
| **Retry + backoff** | AI calls now retry up to 3× with exponential backoff |
|
| 23 |
+
| **Word count** | Every generated prompt shows its word count |
|
| 24 |
+
| **Auto-tagging** | Tags are inferred from instruction text automatically |
|
| 25 |
+
| **6 seeded settings** | React, FastAPI, Blog, SQL, Docker/CI, LLM Fine-tuning |
|
| 26 |
|
| 27 |
---
|
| 28 |
|
| 29 |
+
## Project Structure
|
| 30 |
|
| 31 |
```
|
| 32 |
+
promptforge_v4/
|
| 33 |
├── backend/
|
| 34 |
+
│ ├── main.py # FastAPI app — all routes
|
| 35 |
+
│ ├── schemas.py # Pydantic v2 models
|
| 36 |
+
│ ├── prompt_logic.py # Core generation engine
|
| 37 |
+
│ ├── ai_client.py # HuggingFace + Google Gemini client
|
| 38 |
+
│ ├── store.py # Prompt manifest persistence
|
| 39 |
+
│ └── instruction_store.py # Instruction settings persistence
|
|
|
|
|
|
|
| 40 |
├── frontend/
|
| 41 |
+
│ ├── index.html # Single-page app (4 pages)
|
| 42 |
+
│ ├── style.css # Dark-first design system
|
| 43 |
+
│ └── client.js # Vanilla JS — all UI logic
|
| 44 |
+
├── tests/
|
| 45 |
+
│ └── test_promptforge.py # 60+ pytest tests
|
| 46 |
+
├── logs/ # Auto-created; holds .json stores
|
| 47 |
+
├── requirements.txt
|
| 48 |
+
├── .env.example
|
| 49 |
└── README.md
|
| 50 |
```
|
| 51 |
|
| 52 |
+
### File rename table (original → v4.0)
|
| 53 |
|
| 54 |
+
| Uploaded filename | Correct path |
|
| 55 |
+
|---|---|
|
| 56 |
+
| `deepseek_python_20260217_9f4e5a.py` | `backend/main.py` |
|
| 57 |
+
| `deepseek_python_20260217_2030de.py` | `backend/schemas.py` |
|
| 58 |
+
| `deepseek_python_20260217_5bfa10.py` | `backend/prompt_logic.py` |
|
| 59 |
+
| `deepseek_python_20260217_6a4c4c.py` | `backend/ai_client.py` |
|
| 60 |
+
| `deepseek_python_20260217_eb3ef8.py` | `backend/store.py` |
|
| 61 |
+
| `deepseek_python_20260217_c1c990.py` | `backend/instruction_store.py` |
|
| 62 |
+
| `deepseek_html_20260217_cdc933.html` | `frontend/index.html` |
|
| 63 |
+
| `deepseek_css_20260217_6dc1e7.css` | `frontend/style.css` |
|
| 64 |
+
| `deepseek_javascript_20260217_99ef58.js` | `frontend/client.js` |
|
| 65 |
+
| `deepseek_txt_20260217_77ef1d.txt` | `requirements.txt` |
|
| 66 |
|
| 67 |
---
|
| 68 |
|
| 69 |
+
## Quick Start
|
| 70 |
|
| 71 |
+
### 1. Create virtual environment
|
| 72 |
|
| 73 |
```bash
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
python -m venv .venv
|
| 75 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 76 |
+
pip install -r requirements.txt
|
|
|
|
|
|
|
| 77 |
```
|
| 78 |
|
| 79 |
+
### 2. Set environment variables (optional)
|
| 80 |
|
| 81 |
```bash
|
| 82 |
cp .env.example .env
|
| 83 |
+
# Edit .env — all variables are optional for local use
|
|
|
|
|
|
|
| 84 |
```
|
| 85 |
|
| 86 |
+
### 3. Run the server
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
```bash
|
| 89 |
cd backend
|
| 90 |
+
uvicorn main:app --reload --host 0.0.0.0 --port 7860
|
| 91 |
```
|
| 92 |
|
| 93 |
+
Open **http://localhost:7860** in your browser.
|
|
|
|
| 94 |
|
| 95 |
+
### 4. Run tests
|
|
|
|
|
|
|
| 96 |
|
| 97 |
```bash
|
| 98 |
+
cd backend
|
| 99 |
+
pytest ../tests/test_promptforge.py -v --asyncio-mode=auto
|
|
|
|
|
|
|
|
|
|
| 100 |
```
|
| 101 |
|
| 102 |
---
|
| 103 |
|
| 104 |
+
## Environment Variables
|
| 105 |
|
| 106 |
+
| Variable | Default | Description |
|
| 107 |
+
|---|---|---|
|
| 108 |
+
| `PORT` | `7860` | Server port |
|
| 109 |
+
| `LOG_DIR` | `logs` | Directory for JSON persistence files |
|
| 110 |
+
| `HF_API_KEY` | _(empty)_ | Hugging Face API key for AI enhancement |
|
| 111 |
+
| `GOOGLE_API_KEY` | _(empty)_ | Google AI Studio API key |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
---
|
| 114 |
|
| 115 |
+
## 5-Step Workflow
|
| 116 |
|
|
|
|
|
|
|
|
|
|
| 117 |
```
|
| 118 |
+
1. INPUT → Enter your instruction (+ persona, style, constraints)
|
| 119 |
+
2. REVIEW → Edit any generated field in the manifest
|
| 120 |
+
3. FINALISE → Approve → ready to copy into Google AI Studio
|
| 121 |
+
4. EXPORT → Download as JSON or plain text
|
| 122 |
+
5. REFINE → Describe changes → new version is created
|
| 123 |
+
```
|
| 124 |
|
| 125 |
---
|
| 126 |
|
| 127 |
+
## API Reference
|
| 128 |
|
| 129 |
+
### System
|
| 130 |
|
| 131 |
+
| Method | Endpoint | Description |
|
| 132 |
+
|---|---|---|
|
| 133 |
+
| GET | `/health` | Liveness check |
|
| 134 |
+
| GET | `/api/config` | Env key status + model defaults |
|
| 135 |
+
| GET | `/api/stats` | Dashboard metrics |
|
| 136 |
+
| POST | `/api/check-key` | Validate HF or Google API key |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
### Instruction Settings
|
| 139 |
|
| 140 |
+
| Method | Endpoint | Description |
|
| 141 |
+
|---|---|---|
|
| 142 |
+
| POST | `/api/instructions` | Create setting |
|
| 143 |
+
| GET | `/api/instructions` | List (supports `?tag=`, `?q=`, `?favorites_only=true`) |
|
| 144 |
+
| GET | `/api/instructions/tags` | All tag values |
|
| 145 |
+
| GET | `/api/instructions/export` | Export all as JSON |
|
| 146 |
+
| GET | `/api/instructions/{id}` | Get single setting |
|
| 147 |
+
| PATCH | `/api/instructions/{id}` | Update setting |
|
| 148 |
+
| DELETE | `/api/instructions/{id}` | Delete setting |
|
| 149 |
+
| POST | `/api/instructions/{id}/duplicate` | Clone setting |
|
| 150 |
+
| POST | `/api/instructions/{id}/favorite` | Toggle favourite |
|
| 151 |
+
| POST | `/api/instructions/bulk-delete` | Bulk delete |
|
| 152 |
|
| 153 |
+
### Prompts
|
| 154 |
|
| 155 |
+
| Method | Endpoint | Description |
|
| 156 |
+
|---|---|---|
|
| 157 |
+
| POST | `/api/generate` | Generate prompt from instruction |
|
| 158 |
+
| POST | `/api/generate/from-settings` | Generate from saved setting |
|
| 159 |
+
| POST | `/api/generate/batch` | Generate up to 10 at once |
|
| 160 |
+
| GET | `/api/explain/{id}` | Explain prompt structure decisions |
|
| 161 |
+
| POST | `/api/approve` | Approve (+ optional field edits) |
|
| 162 |
+
| POST | `/api/export` | Export approved prompt |
|
| 163 |
+
| POST | `/api/refine` | Refine with feedback → new version |
|
| 164 |
+
| POST | `/api/prompts/{id}/favorite` | Toggle prompt favourite |
|
| 165 |
+
| POST | `/api/prompts/{id}/archive` | Archive prompt |
|
| 166 |
+
| GET | `/api/search?q=…` | Full-text search across prompts |
|
| 167 |
|
| 168 |
+
### History
|
| 169 |
|
| 170 |
+
| Method | Endpoint | Description |
|
| 171 |
+
|---|---|---|
|
| 172 |
+
| GET | `/api/history` | List all prompts (supports filters) |
|
| 173 |
+
| GET | `/api/history/{id}` | Get full prompt manifest |
|
| 174 |
+
| DELETE | `/api/history/{id}` | Delete prompt |
|
| 175 |
+
| POST | `/api/history/bulk-delete` | Bulk delete |
|
| 176 |
|
| 177 |
---
|
| 178 |
|
| 179 |
+
## Keyboard Shortcuts
|
| 180 |
|
| 181 |
+
| Shortcut | Action |
|
| 182 |
+
|---|---|
|
| 183 |
+
| `⌘↵` / `Ctrl+↵` | Generate prompt |
|
| 184 |
+
| `Alt+B` | Toggle sidebar |
|
| 185 |
+
| `Alt+T` | Toggle light/dark theme |
|
| 186 |
+
| `Alt+1–4` | Navigate between pages |
|
| 187 |
+
| `?` | Show shortcuts panel |
|
| 188 |
+
| `Esc` | Close modal / cancel |
|
| 189 |
|
| 190 |
---
|
| 191 |
|
| 192 |
+
## Available Personas
|
| 193 |
|
| 194 |
+
| Value | Description |
|
| 195 |
+
|---|---|
|
| 196 |
+
| `default` | Auto-detects from instruction keywords |
|
| 197 |
+
| `senior_dev` | Senior full-stack Software Engineer |
|
| 198 |
+
| `data_scientist` | ML / statistics / data pipelines |
|
| 199 |
+
| `tech_writer` | Technical documentation specialist |
|
| 200 |
+
| `product_mgr` | User-centric product management |
|
| 201 |
+
| `security_eng` | OWASP, threat modelling, secure-by-design |
|
| 202 |
+
| `devops_eng` | CI/CD, Kubernetes, cloud-native (**new**) |
|
| 203 |
+
| `ml_engineer` | LLM fine-tuning, MLOps, PEFT (**new**) |
|
| 204 |
+
| `custom` | Any free-text persona |
|
| 205 |
|
| 206 |
---
|
| 207 |
|
| 208 |
+
## Cloud Deployment
|
| 209 |
|
| 210 |
+
### Hugging Face Spaces (Dockerfile)
|
| 211 |
|
| 212 |
+
```dockerfile
|
| 213 |
+
FROM python:3.11-slim
|
| 214 |
+
WORKDIR /app
|
| 215 |
+
COPY requirements.txt .
|
| 216 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 217 |
+
COPY backend/ ./backend/
|
| 218 |
+
COPY frontend/ ./frontend/
|
| 219 |
+
EXPOSE 7860
|
| 220 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
```
|
| 222 |
|
| 223 |
+
### Railway / Render / Fly.io
|
| 224 |
+
|
| 225 |
+
```bash
|
| 226 |
+
# Set start command:
|
| 227 |
+
uvicorn backend.main:app --host 0.0.0.0 --port $PORT
|
| 228 |
```
|
| 229 |
|
| 230 |
+
Set `HF_API_KEY` and/or `GOOGLE_API_KEY` as environment variables in your dashboard.
|
| 231 |
+
|
| 232 |
---
|
| 233 |
|
| 234 |
+
## Security Notes
|
| 235 |
|
| 236 |
+
- API keys entered in the UI are **never logged or persisted** server-side.
|
| 237 |
+
- CORS is open (`*`) by default — restrict `allow_origins` for production.
|
| 238 |
+
- All environment variables are read at startup; no secrets in source code.
|
| 239 |
+
- Input lengths are validated via Pydantic; all outputs are HTML-escaped in the UI.
|
| 240 |
|
| 241 |
---
|
| 242 |
|
| 243 |
+
## License
|
| 244 |
|
| 245 |
+
MIT — use freely, contribute back.
|
backend/__init__.py
ADDED
|
File without changes
|
backend/ai_client.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — AI
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
|
|
|
| 8 |
import logging
|
| 9 |
from typing import Optional
|
| 10 |
|
|
@@ -12,126 +13,188 @@ import httpx
|
|
| 12 |
|
| 13 |
logger = logging.getLogger("promptforge.ai_client")
|
| 14 |
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
HF_DEFAULT_MODEL = "mistralai/Mistral-7B-Instruct-v0.2"
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
async def enhance_with_huggingface(
|
| 24 |
raw_prompt: str,
|
| 25 |
api_key: str,
|
| 26 |
model: str = HF_DEFAULT_MODEL,
|
| 27 |
-
max_new_tokens: int =
|
| 28 |
) -> str:
|
| 29 |
-
"""
|
| 30 |
-
Call the Hugging Face Inference API to enhance a raw prompt text.
|
| 31 |
-
Returns the enhanced prompt string, or the original on failure.
|
| 32 |
-
"""
|
| 33 |
-
system_instruction = (
|
| 34 |
-
"You are an expert prompt engineer specializing in Google AI Studio prompts. "
|
| 35 |
-
"Given a draft prompt, improve its clarity, specificity, and structure. "
|
| 36 |
-
"Return ONLY the improved prompt text — no commentary."
|
| 37 |
-
)
|
| 38 |
payload = {
|
| 39 |
-
"inputs":
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
headers = {"Authorization": f"Bearer {api_key}"}
|
| 43 |
-
url
|
| 44 |
|
| 45 |
try:
|
| 46 |
-
async with httpx.AsyncClient(timeout=
|
| 47 |
-
resp = await
|
| 48 |
resp.raise_for_status()
|
| 49 |
data = resp.json()
|
| 50 |
-
# HF returns a list of dicts with "generated_text"
|
| 51 |
if isinstance(data, list) and data:
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
return raw_prompt
|
| 54 |
except Exception as exc:
|
| 55 |
logger.warning("HuggingFace enhancement failed: %s", exc)
|
| 56 |
return raw_prompt
|
| 57 |
|
| 58 |
|
| 59 |
-
#
|
| 60 |
-
# Google AI Studio / Gemini
|
| 61 |
-
# ---------------------------------------------------------------------------
|
| 62 |
-
|
| 63 |
-
GOOGLE_AI_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
| 64 |
-
GOOGLE_DEFAULT_MODEL = "gemini-1.5-flash"
|
| 65 |
-
|
| 66 |
|
| 67 |
async def enhance_with_google(
|
| 68 |
raw_prompt: str,
|
| 69 |
api_key: str,
|
| 70 |
model: str = GOOGLE_DEFAULT_MODEL,
|
| 71 |
) -> str:
|
| 72 |
-
"""
|
| 73 |
-
Call the Google Generative Language API (Gemini) to enhance a prompt.
|
| 74 |
-
Returns the enhanced prompt string, or the original on failure.
|
| 75 |
-
"""
|
| 76 |
url = f"{GOOGLE_AI_BASE}/models/{model}:generateContent?key={api_key}"
|
| 77 |
-
system_part = (
|
| 78 |
-
"You are an expert prompt engineer. "
|
| 79 |
-
"Improve the following draft prompt for use in Google AI Studio. "
|
| 80 |
-
"Return ONLY the improved prompt — no preamble or commentary."
|
| 81 |
-
)
|
| 82 |
payload = {
|
| 83 |
"contents": [
|
| 84 |
{
|
| 85 |
"parts": [
|
| 86 |
-
{"text": f"{
|
| 87 |
]
|
| 88 |
}
|
| 89 |
],
|
| 90 |
-
"generationConfig": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
try:
|
| 94 |
-
async with httpx.AsyncClient(timeout=
|
| 95 |
-
resp = await
|
| 96 |
resp.raise_for_status()
|
| 97 |
data = resp.json()
|
| 98 |
candidates = data.get("candidates", [])
|
| 99 |
if candidates:
|
| 100 |
parts = candidates[0].get("content", {}).get("parts", [])
|
| 101 |
if parts:
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
| 103 |
return raw_prompt
|
| 104 |
except Exception as exc:
|
| 105 |
logger.warning("Google AI enhancement failed: %s", exc)
|
| 106 |
return raw_prompt
|
| 107 |
|
| 108 |
|
| 109 |
-
#
|
| 110 |
-
# Dispatcher
|
| 111 |
-
# ---------------------------------------------------------------------------
|
| 112 |
|
| 113 |
async def enhance_prompt(
|
| 114 |
raw_prompt: str,
|
| 115 |
provider: str,
|
| 116 |
api_key: Optional[str],
|
|
|
|
| 117 |
) -> tuple[str, str]:
|
| 118 |
"""
|
| 119 |
-
Dispatch
|
| 120 |
Returns (enhanced_text, notes_string).
|
| 121 |
"""
|
| 122 |
if not api_key:
|
| 123 |
return raw_prompt, "No API key provided — skipping AI enhancement."
|
| 124 |
|
| 125 |
if provider == "huggingface":
|
| 126 |
-
|
| 127 |
-
|
|
|
|
| 128 |
elif provider == "google":
|
| 129 |
-
|
| 130 |
-
|
|
|
|
| 131 |
else:
|
| 132 |
-
return raw_prompt, "Provider 'none'
|
| 133 |
|
| 134 |
if enhanced == raw_prompt:
|
| 135 |
-
notes += " (Enhancement returned
|
| 136 |
|
| 137 |
return enhanced, notes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge v4.0 — AI enhancement client.
|
| 3 |
+
Upgrades: exponential-backoff retry, configurable model override,
|
| 4 |
+
token-budget awareness, structured error types, and a
|
| 5 |
+
health-check helper for each provider.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
+
import asyncio
|
| 9 |
import logging
|
| 10 |
from typing import Optional
|
| 11 |
|
|
|
|
| 13 |
|
| 14 |
logger = logging.getLogger("promptforge.ai_client")
|
| 15 |
|
| 16 |
+
# ── Defaults ──────────────────────────────────────────────────────────────────
|
| 17 |
+
HF_API_BASE = "https://api-inference.huggingface.co/models"
|
| 18 |
+
HF_DEFAULT_MODEL = "mistralai/Mistral-7B-Instruct-v0.2"
|
| 19 |
+
GOOGLE_AI_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
| 20 |
+
GOOGLE_DEFAULT_MODEL = "gemini-1.5-flash"
|
|
|
|
| 21 |
|
| 22 |
+
_SYSTEM_PROMPT = (
|
| 23 |
+
"You are an expert prompt engineer specialising in Google AI Studio system prompts. "
|
| 24 |
+
"Given a draft prompt, improve its clarity, specificity, structure, and effectiveness. "
|
| 25 |
+
"Preserve all section headers (## ROLE, ## TASK, etc.). "
|
| 26 |
+
"Return ONLY the improved prompt — no commentary, no preamble, no markdown fences."
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── Retry helper ──────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
async def _post_with_retry(
|
| 33 |
+
client: httpx.AsyncClient,
|
| 34 |
+
url: str,
|
| 35 |
+
payload: dict,
|
| 36 |
+
headers: Optional[dict] = None,
|
| 37 |
+
retries: int = 3,
|
| 38 |
+
base_delay: float = 1.0,
|
| 39 |
+
) -> httpx.Response:
|
| 40 |
+
"""POST with exponential backoff on 429 / 503 responses."""
|
| 41 |
+
last_exc: Exception = RuntimeError("No attempts made")
|
| 42 |
+
for attempt in range(retries):
|
| 43 |
+
try:
|
| 44 |
+
resp = await client.post(url, json=payload, headers=headers or {})
|
| 45 |
+
if resp.status_code in (429, 503):
|
| 46 |
+
wait = base_delay * (2 ** attempt)
|
| 47 |
+
logger.warning("Rate-limited (%d) — retrying in %.1fs", resp.status_code, wait)
|
| 48 |
+
await asyncio.sleep(wait)
|
| 49 |
+
continue
|
| 50 |
+
return resp
|
| 51 |
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
| 52 |
+
last_exc = exc
|
| 53 |
+
wait = base_delay * (2 ** attempt)
|
| 54 |
+
logger.warning("Network error on attempt %d: %s — retrying in %.1fs", attempt + 1, exc, wait)
|
| 55 |
+
await asyncio.sleep(wait)
|
| 56 |
+
raise last_exc
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── Hugging Face ──────────────────────────────────────────────────────────────
|
| 60 |
|
| 61 |
async def enhance_with_huggingface(
|
| 62 |
raw_prompt: str,
|
| 63 |
api_key: str,
|
| 64 |
model: str = HF_DEFAULT_MODEL,
|
| 65 |
+
max_new_tokens: int = 1024,
|
| 66 |
) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
payload = {
|
| 68 |
+
"inputs": (
|
| 69 |
+
f"<s>[INST] {_SYSTEM_PROMPT}\n\n"
|
| 70 |
+
f"DRAFT PROMPT:\n{raw_prompt}\n\n"
|
| 71 |
+
f"IMPROVED PROMPT: [/INST]"
|
| 72 |
+
),
|
| 73 |
+
"parameters": {
|
| 74 |
+
"max_new_tokens": max_new_tokens,
|
| 75 |
+
"return_full_text": False,
|
| 76 |
+
"temperature": 0.3,
|
| 77 |
+
"repetition_penalty": 1.1,
|
| 78 |
+
},
|
| 79 |
}
|
| 80 |
headers = {"Authorization": f"Bearer {api_key}"}
|
| 81 |
+
url = f"{HF_API_BASE}/{model}"
|
| 82 |
|
| 83 |
try:
|
| 84 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 85 |
+
resp = await _post_with_retry(client, url, payload, headers)
|
| 86 |
resp.raise_for_status()
|
| 87 |
data = resp.json()
|
|
|
|
| 88 |
if isinstance(data, list) and data:
|
| 89 |
+
enhanced = data[0].get("generated_text", "").strip()
|
| 90 |
+
if enhanced and len(enhanced) > 100:
|
| 91 |
+
return enhanced
|
| 92 |
+
logger.warning("HF returned empty or too-short text; using original.")
|
| 93 |
return raw_prompt
|
| 94 |
except Exception as exc:
|
| 95 |
logger.warning("HuggingFace enhancement failed: %s", exc)
|
| 96 |
return raw_prompt
|
| 97 |
|
| 98 |
|
| 99 |
+
# ── Google Gemini ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
async def enhance_with_google(
|
| 102 |
raw_prompt: str,
|
| 103 |
api_key: str,
|
| 104 |
model: str = GOOGLE_DEFAULT_MODEL,
|
| 105 |
) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
url = f"{GOOGLE_AI_BASE}/models/{model}:generateContent?key={api_key}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
payload = {
|
| 108 |
"contents": [
|
| 109 |
{
|
| 110 |
"parts": [
|
| 111 |
+
{"text": f"{_SYSTEM_PROMPT}\n\nDRAFT PROMPT:\n{raw_prompt}"}
|
| 112 |
]
|
| 113 |
}
|
| 114 |
],
|
| 115 |
+
"generationConfig": {
|
| 116 |
+
"maxOutputTokens": 2048,
|
| 117 |
+
"temperature": 0.3,
|
| 118 |
+
"topP": 0.9,
|
| 119 |
+
},
|
| 120 |
+
"safetySettings": [
|
| 121 |
+
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_ONLY_HIGH"},
|
| 122 |
+
],
|
| 123 |
}
|
| 124 |
|
| 125 |
try:
|
| 126 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 127 |
+
resp = await _post_with_retry(client, url, payload)
|
| 128 |
resp.raise_for_status()
|
| 129 |
data = resp.json()
|
| 130 |
candidates = data.get("candidates", [])
|
| 131 |
if candidates:
|
| 132 |
parts = candidates[0].get("content", {}).get("parts", [])
|
| 133 |
if parts:
|
| 134 |
+
enhanced = parts[0].get("text", "").strip()
|
| 135 |
+
if enhanced and len(enhanced) > 100:
|
| 136 |
+
return enhanced
|
| 137 |
+
logger.warning("Google returned empty or too-short text; using original.")
|
| 138 |
return raw_prompt
|
| 139 |
except Exception as exc:
|
| 140 |
logger.warning("Google AI enhancement failed: %s", exc)
|
| 141 |
return raw_prompt
|
| 142 |
|
| 143 |
|
| 144 |
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 145 |
|
| 146 |
async def enhance_prompt(
|
| 147 |
raw_prompt: str,
|
| 148 |
provider: str,
|
| 149 |
api_key: Optional[str],
|
| 150 |
+
model_override: Optional[str] = None,
|
| 151 |
) -> tuple[str, str]:
|
| 152 |
"""
|
| 153 |
+
Dispatch to the correct provider.
|
| 154 |
Returns (enhanced_text, notes_string).
|
| 155 |
"""
|
| 156 |
if not api_key:
|
| 157 |
return raw_prompt, "No API key provided — skipping AI enhancement."
|
| 158 |
|
| 159 |
if provider == "huggingface":
|
| 160 |
+
model = model_override or HF_DEFAULT_MODEL
|
| 161 |
+
enhanced = await enhance_with_huggingface(raw_prompt, api_key, model=model)
|
| 162 |
+
notes = f"Enhanced via Hugging Face ({model})."
|
| 163 |
elif provider == "google":
|
| 164 |
+
model = model_override or GOOGLE_DEFAULT_MODEL
|
| 165 |
+
enhanced = await enhance_with_google(raw_prompt, api_key, model=model)
|
| 166 |
+
notes = f"Enhanced via Google Gemini ({model})."
|
| 167 |
else:
|
| 168 |
+
return raw_prompt, "Provider 'none' — no AI enhancement applied."
|
| 169 |
|
| 170 |
if enhanced == raw_prompt:
|
| 171 |
+
notes += " (Enhancement returned identical text — possible model or quota issue.)"
|
| 172 |
|
| 173 |
return enhanced, notes
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ── Provider health-check ─────────────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
async def check_hf_key(api_key: str) -> bool:
|
| 179 |
+
"""Return True if the HF key appears valid (quick whoami probe)."""
|
| 180 |
+
try:
|
| 181 |
+
async with httpx.AsyncClient(timeout=8.0) as client:
|
| 182 |
+
r = await client.get(
|
| 183 |
+
"https://huggingface.co/api/whoami",
|
| 184 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 185 |
+
)
|
| 186 |
+
return r.status_code == 200
|
| 187 |
+
except Exception:
|
| 188 |
+
return False
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
async def check_google_key(api_key: str) -> bool:
|
| 192 |
+
"""Return True if the Google key appears valid (list models probe)."""
|
| 193 |
+
try:
|
| 194 |
+
async with httpx.AsyncClient(timeout=8.0) as client:
|
| 195 |
+
r = await client.get(
|
| 196 |
+
f"{GOOGLE_AI_BASE}/models?key={api_key}"
|
| 197 |
+
)
|
| 198 |
+
return r.status_code == 200
|
| 199 |
+
except Exception:
|
| 200 |
+
return False
|
backend/client.js
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* PromptForge v4.0 — client.js
|
| 3 |
+
* Upgrades: sidebar navigation, stats dashboard, full-text search,
|
| 4 |
+
* favorites/archive, setting duplicate, keyboard shortcuts, theme toggle,
|
| 5 |
+
* API key validation, char counters, tag suggestions, smooth transitions.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
const API = "";
|
| 9 |
+
let currentPromptId = null;
|
| 10 |
+
let allSettings = [];
|
| 11 |
+
let favoritesFilter = false;
|
| 12 |
+
|
| 13 |
+
const $ = id => document.getElementById(id);
|
| 14 |
+
const esc = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
| 15 |
+
const show = el => el?.classList.remove("hidden");
|
| 16 |
+
const hide = el => el?.classList.add("hidden");
|
| 17 |
+
|
| 18 |
+
/* ── API fetch ──────────────────────────────────────────────────────── */
|
| 19 |
+
async function apiFetch(path, method = "GET", body = null) {
|
| 20 |
+
const opts = { method, headers: { "Content-Type": "application/json" } };
|
| 21 |
+
if (body) opts.body = JSON.stringify(body);
|
| 22 |
+
const r = await fetch(API + path, opts);
|
| 23 |
+
if (!r.ok) {
|
| 24 |
+
const e = await r.json().catch(() => ({ detail: r.statusText }));
|
| 25 |
+
throw new Error(e.detail || "Request failed");
|
| 26 |
+
}
|
| 27 |
+
return r.json();
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* ── Toast ──────────────────────────────────────────────────────────── */
|
| 31 |
+
function toast(msg, type = "info", duration = 4200) {
|
| 32 |
+
const icons = { success:"✅", error:"❌", info:"💡", warn:"⚠️" };
|
| 33 |
+
const t = document.createElement("div");
|
| 34 |
+
t.className = `toast ${type}`;
|
| 35 |
+
t.innerHTML = `<span class="toast-icon">${icons[type]||"💡"}</span><span>${msg}</span>`;
|
| 36 |
+
$("toast-container").appendChild(t);
|
| 37 |
+
const remove = () => { t.classList.add("leaving"); t.addEventListener("animationend", () => t.remove(), {once:true}); };
|
| 38 |
+
const timer = setTimeout(remove, duration);
|
| 39 |
+
t.addEventListener("click", () => { clearTimeout(timer); remove(); });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/* ── Loading state ──────────────────────────────────────────────────── */
|
| 43 |
+
function setLoading(btn, on) {
|
| 44 |
+
if (!btn) return;
|
| 45 |
+
btn.disabled = on;
|
| 46 |
+
if (!btn._orig) btn._orig = btn.innerHTML;
|
| 47 |
+
btn.innerHTML = on ? `<span class="spinner"></span> Working…` : btn._orig;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ── Sidebar navigation ─────────────────────────────────────────────── */
|
| 51 |
+
document.querySelectorAll(".nav-item").forEach(item => {
|
| 52 |
+
item.addEventListener("click", () => {
|
| 53 |
+
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
|
| 54 |
+
document.querySelectorAll(".page").forEach(p => { p.classList.remove("active"); hide(p); });
|
| 55 |
+
item.classList.add("active");
|
| 56 |
+
const page = $(`page-${item.dataset.page}`);
|
| 57 |
+
if (page) { show(page); page.classList.add("active"); }
|
| 58 |
+
// lazy-load data
|
| 59 |
+
if (item.dataset.page === "settings") loadSettingsList();
|
| 60 |
+
if (item.dataset.page === "history") loadHistory();
|
| 61 |
+
if (item.dataset.page === "stats") loadStats();
|
| 62 |
+
});
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
/* ── Sidebar collapse ───────────────────────────────────────────────── */
|
| 66 |
+
$("btn-sidebar-toggle")?.addEventListener("click", () => {
|
| 67 |
+
const sb = $("sidebar");
|
| 68 |
+
sb.classList.toggle("collapsed");
|
| 69 |
+
localStorage.setItem("pf_sidebar", sb.classList.contains("collapsed") ? "1" : "0");
|
| 70 |
+
});
|
| 71 |
+
if (localStorage.getItem("pf_sidebar") === "1") $("sidebar")?.classList.add("collapsed");
|
| 72 |
+
|
| 73 |
+
/* ── Theme toggle ───────────────────────────────────────────────────── */
|
| 74 |
+
function applyTheme(theme) {
|
| 75 |
+
document.documentElement.dataset.theme = theme;
|
| 76 |
+
$("btn-theme").textContent = theme === "dark" ? "🌙" : "☀️";
|
| 77 |
+
localStorage.setItem("pf_theme", theme);
|
| 78 |
+
}
|
| 79 |
+
$("btn-theme")?.addEventListener("click", () => {
|
| 80 |
+
const cur = document.documentElement.dataset.theme || "dark";
|
| 81 |
+
applyTheme(cur === "dark" ? "light" : "dark");
|
| 82 |
+
});
|
| 83 |
+
applyTheme(localStorage.getItem("pf_theme") || "dark");
|
| 84 |
+
|
| 85 |
+
/* ── Keyboard shortcuts ─────────────────────────────────────────────── */
|
| 86 |
+
$("btn-shortcuts")?.addEventListener("click", () => show($("shortcuts-modal")));
|
| 87 |
+
$("btn-shortcuts-close")?.addEventListener("click", () => hide($("shortcuts-modal")));
|
| 88 |
+
$("shortcuts-modal")?.addEventListener("click", e => { if (e.target === $("shortcuts-modal")) hide($("shortcuts-modal")); });
|
| 89 |
+
|
| 90 |
+
document.addEventListener("keydown", e => {
|
| 91 |
+
if (e.altKey) {
|
| 92 |
+
if (e.key === "b" || e.key === "B") { $("sidebar")?.classList.toggle("collapsed"); return; }
|
| 93 |
+
if (e.key === "t" || e.key === "T") { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); return; }
|
| 94 |
+
if (e.key === "1") { document.querySelector('[data-page="generate"]')?.click(); return; }
|
| 95 |
+
if (e.key === "2") { document.querySelector('[data-page="settings"]')?.click(); return; }
|
| 96 |
+
if (e.key === "3") { document.querySelector('[data-page="history"]')?.click(); return; }
|
| 97 |
+
if (e.key === "4") { document.querySelector('[data-page="stats"]')?.click(); return; }
|
| 98 |
+
}
|
| 99 |
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
| 100 |
+
if ($("page-generate")?.classList.contains("active")) $("btn-generate")?.click();
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
if (e.key === "?" && !["INPUT","TEXTAREA","SELECT"].includes(e.target.tagName)) {
|
| 104 |
+
show($("shortcuts-modal")); return;
|
| 105 |
+
}
|
| 106 |
+
if (e.key === "Escape") {
|
| 107 |
+
hide($("modal-overlay")); hide($("shortcuts-modal"));
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
/* ── Config / env ───────────────────────────────────────────────────── */
|
| 112 |
+
async function loadConfig() {
|
| 113 |
+
try {
|
| 114 |
+
const cfg = await apiFetch("/api/config");
|
| 115 |
+
const dotHF = $("dot-hf");
|
| 116 |
+
const dotGG = $("dot-google");
|
| 117 |
+
if (cfg.hf_key_set) dotHF?.classList.add("active");
|
| 118 |
+
if (cfg.google_key_set) dotGG?.classList.add("active");
|
| 119 |
+
} catch {}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* ── API key panel ──────────────────────────────────────────────────── */
|
| 123 |
+
const PROVIDER_INFO = {
|
| 124 |
+
none: { hint:"No key needed — local engine only", placeholder:"Not required" },
|
| 125 |
+
google: { hint:"google.com/aistudio → Get API key", placeholder:"AIzaSy…" },
|
| 126 |
+
huggingface: { hint:"huggingface.co/settings/tokens", placeholder:"hf_…" },
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
$("btn-api-panel-toggle")?.addEventListener("click", () => {
|
| 130 |
+
const body = $("api-panel-body");
|
| 131 |
+
const chevron = $("api-chevron");
|
| 132 |
+
body.classList.toggle("open");
|
| 133 |
+
chevron.classList.toggle("open");
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
$("provider")?.addEventListener("change", () => {
|
| 137 |
+
const p = $("provider").value;
|
| 138 |
+
const info = PROVIDER_INFO[p] || PROVIDER_INFO.none;
|
| 139 |
+
$("key-hint").textContent = info.hint;
|
| 140 |
+
const needsKey = p !== "none";
|
| 141 |
+
$("api-key").disabled = !needsKey;
|
| 142 |
+
$("api-key").placeholder = info.placeholder;
|
| 143 |
+
$("btn-check-key").disabled = !needsKey;
|
| 144 |
+
$("model-field").style.display = needsKey ? "block" : "none";
|
| 145 |
+
$("api-panel-status").textContent = needsKey ? "AI Enhancement Available" : "Local Engine Active";
|
| 146 |
+
if (!needsKey) {
|
| 147 |
+
$("api-key").value = "";
|
| 148 |
+
$("key-dot").className = "status-dot";
|
| 149 |
+
$("key-status-text").textContent = "No key needed";
|
| 150 |
+
}
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
$("api-key")?.addEventListener("input", () => {
|
| 154 |
+
const has = $("api-key").value.trim().length >= 10;
|
| 155 |
+
$("key-dot").className = "status-dot" + (has ? " ok" : "");
|
| 156 |
+
$("key-status-text").textContent = has ? "Key entered — click ✓ to validate" : "Enter your API key";
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
$("btn-toggle-key")?.addEventListener("click", () => {
|
| 160 |
+
const k = $("api-key");
|
| 161 |
+
k.type = k.type === "password" ? "text" : "password";
|
| 162 |
+
$("btn-toggle-key").textContent = k.type === "password" ? "👁" : "🙈";
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
$("btn-check-key")?.addEventListener("click", async () => {
|
| 166 |
+
const provider = $("provider").value;
|
| 167 |
+
const key = $("api-key").value.trim();
|
| 168 |
+
if (!key) { toast("Enter a key first.", "warn"); return; }
|
| 169 |
+
const btn = $("btn-check-key");
|
| 170 |
+
setLoading(btn, true);
|
| 171 |
+
try {
|
| 172 |
+
const res = await apiFetch("/api/check-key", "POST", { provider, api_key: key });
|
| 173 |
+
$("key-dot").className = "status-dot " + (res.valid ? "ok" : "err");
|
| 174 |
+
$("key-status-text").textContent = res.message;
|
| 175 |
+
toast(res.message, res.valid ? "success" : "error");
|
| 176 |
+
if (res.valid) { $("dot-" + (provider === "google" ? "google" : "hf"))?.classList.add("active"); }
|
| 177 |
+
} catch (e) { toast(`Key check failed: ${e.message}`, "error"); }
|
| 178 |
+
finally { setLoading(btn, false); }
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
/* ── Character counters ─────────────────────────────────────────────── */
|
| 182 |
+
function bindCharCounter(textareaId, counterId, max) {
|
| 183 |
+
const ta = $(textareaId), ct = $(counterId);
|
| 184 |
+
if (!ta || !ct) return;
|
| 185 |
+
ta.addEventListener("input", () => {
|
| 186 |
+
const len = ta.value.length;
|
| 187 |
+
ct.textContent = len;
|
| 188 |
+
ct.parentElement.className = "char-counter" + (len > max * .95 ? " over" : len > max * .85 ? " warn" : "");
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
+
bindCharCounter("instruction", "instr-count", 8000);
|
| 192 |
+
bindCharCounter("s-instruction", "s-instr-count", 8000);
|
| 193 |
+
|
| 194 |
+
/* ── Persona / custom persona ───────────────────────────────────────── */
|
| 195 |
+
$("gen-persona")?.addEventListener("change", () => {
|
| 196 |
+
$("custom-persona-field").style.display = $("gen-persona").value === "custom" ? "block" : "none";
|
| 197 |
+
});
|
| 198 |
+
$("s-persona")?.addEventListener("change", () => {
|
| 199 |
+
$("s-custom-persona-field").style.display = $("s-persona").value === "custom" ? "block" : "none";
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
/* ── Tag autocomplete for settings form ──────��──────────────────────── */
|
| 203 |
+
const COMMON_TAGS = ["react","typescript","python","frontend","backend","devops","ml","security","testing","database","mobile","writing","docker","api","fastapi","tailwind"];
|
| 204 |
+
function renderTagSugs(inputEl, containerEl) {
|
| 205 |
+
const cur = inputEl.value.split(",").map(t=>t.trim()).filter(Boolean);
|
| 206 |
+
const avail = COMMON_TAGS.filter(t => !cur.includes(t));
|
| 207 |
+
containerEl.innerHTML = avail.slice(0,8).map(t =>
|
| 208 |
+
`<span class="tag-sug" data-tag="${esc(t)}">${esc(t)}</span>`
|
| 209 |
+
).join("");
|
| 210 |
+
containerEl.querySelectorAll(".tag-sug").forEach(el => {
|
| 211 |
+
el.addEventListener("click", () => {
|
| 212 |
+
const existing = inputEl.value.trim();
|
| 213 |
+
inputEl.value = existing ? `${existing}, ${el.dataset.tag}` : el.dataset.tag;
|
| 214 |
+
renderTagSugs(inputEl, containerEl);
|
| 215 |
+
});
|
| 216 |
+
});
|
| 217 |
+
}
|
| 218 |
+
const tagsInput = $("s-tags"), tagSugs = $("tag-suggestions");
|
| 219 |
+
if (tagsInput && tagSugs) {
|
| 220 |
+
tagsInput.addEventListener("input", () => renderTagSugs(tagsInput, tagSugs));
|
| 221 |
+
renderTagSugs(tagsInput, tagSugs);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* ── Step progress ──────────────────────────────────────────────────── */
|
| 225 |
+
function setStep(n) {
|
| 226 |
+
document.querySelectorAll(".step").forEach((s, i) => {
|
| 227 |
+
const idx = parseInt(s.dataset.step);
|
| 228 |
+
s.classList.remove("active","done");
|
| 229 |
+
if (idx < n) s.classList.add("done");
|
| 230 |
+
if (idx === n) s.classList.add("active");
|
| 231 |
+
});
|
| 232 |
+
document.querySelectorAll(".step-line").forEach((l, i) => {
|
| 233 |
+
l.classList.toggle("filled", i + 1 < n);
|
| 234 |
+
});
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* ── Copy buttons ───────────────────────────────────────────────────── */
|
| 238 |
+
document.addEventListener("click", e => {
|
| 239 |
+
const btn = e.target.closest(".copy-btn");
|
| 240 |
+
if (!btn) return;
|
| 241 |
+
const el = $(btn.dataset.target);
|
| 242 |
+
if (!el) return;
|
| 243 |
+
navigator.clipboard.writeText(el.textContent).then(() => {
|
| 244 |
+
const orig = btn.textContent;
|
| 245 |
+
btn.textContent = "✅ Copied!";
|
| 246 |
+
btn.classList.add("copied");
|
| 247 |
+
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = orig; }, 2000);
|
| 248 |
+
});
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 252 |
+
GENERATE PAGE
|
| 253 |
+
───────────────────────────────────────────────────────────────────── */
|
| 254 |
+
|
| 255 |
+
/* ── STEP 1: Generate ─── */
|
| 256 |
+
$("btn-generate")?.addEventListener("click", doGenerate);
|
| 257 |
+
|
| 258 |
+
async function doGenerate() {
|
| 259 |
+
const instruction = $("instruction").value.trim();
|
| 260 |
+
if (instruction.length < 5) { toast("Enter a meaningful instruction (min 5 chars).", "error"); return; }
|
| 261 |
+
const btn = $("btn-generate");
|
| 262 |
+
setLoading(btn, true);
|
| 263 |
+
try {
|
| 264 |
+
const provider = $("provider").value;
|
| 265 |
+
const apiKey = $("api-key")?.value.trim() || null;
|
| 266 |
+
const persona = $("gen-persona")?.value || "default";
|
| 267 |
+
const style = $("gen-style")?.value || "professional";
|
| 268 |
+
const customPerso = persona === "custom" ? ($("gen-custom-persona")?.value.trim() || null) : null;
|
| 269 |
+
const constrRaw = $("gen-constraints")?.value.trim() || "";
|
| 270 |
+
const constraints = constrRaw ? constrRaw.split("\n").map(s=>s.trim()).filter(Boolean) : [];
|
| 271 |
+
const modelOvr = $("provider-model")?.value.trim() || null;
|
| 272 |
+
|
| 273 |
+
const data = await apiFetch("/api/generate", "POST", {
|
| 274 |
+
instruction,
|
| 275 |
+
output_format: "both",
|
| 276 |
+
provider, api_key: apiKey,
|
| 277 |
+
enhance: provider !== "none" && !!apiKey,
|
| 278 |
+
extra_context: $("extra-context").value.trim() || null,
|
| 279 |
+
persona, custom_persona: customPerso, style,
|
| 280 |
+
user_constraints: constraints,
|
| 281 |
+
provider_model: modelOvr || undefined,
|
| 282 |
+
});
|
| 283 |
+
currentPromptId = data.prompt_id;
|
| 284 |
+
renderManifest(data.manifest);
|
| 285 |
+
hide($("step-input")); show($("step-manifest"));
|
| 286 |
+
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 287 |
+
setStep(2);
|
| 288 |
+
updateBadge("history-badge", null, +1);
|
| 289 |
+
toast("Manifest generated — review and approve.", "success");
|
| 290 |
+
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 291 |
+
finally { setLoading(btn, false); }
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/* ── Render manifest fields ─── */
|
| 295 |
+
function renderManifest(manifest) {
|
| 296 |
+
const sp = manifest.structured_prompt;
|
| 297 |
+
const grid = $("manifest-grid");
|
| 298 |
+
grid.innerHTML = "";
|
| 299 |
+
const fields = [
|
| 300 |
+
{ key:"role", label:"Role", value:sp.role, full:false },
|
| 301 |
+
{ key:"style", label:"Style & Tone", value:sp.style, full:false },
|
| 302 |
+
{ key:"task", label:"Task", value:sp.task, full:true },
|
| 303 |
+
{ key:"input_format", label:"Input Format", value:sp.input_format, full:false },
|
| 304 |
+
{ key:"output_format", label:"Output Format", value:sp.output_format, full:false },
|
| 305 |
+
{ key:"constraints", label:"Constraints", value:sp.constraints.join("\n"), full:true },
|
| 306 |
+
{ key:"safety", label:"Safety", value:sp.safety.join("\n"), full:true },
|
| 307 |
+
];
|
| 308 |
+
fields.forEach(f => {
|
| 309 |
+
const d = document.createElement("div");
|
| 310 |
+
d.className = `manifest-field${f.full?" full":""}`;
|
| 311 |
+
d.innerHTML = `<label>${esc(f.label)}</label>
|
| 312 |
+
<textarea id="field-${f.key}" rows="${f.full?3:2}">${esc(f.value)}</textarea>`;
|
| 313 |
+
grid.appendChild(d);
|
| 314 |
+
});
|
| 315 |
+
$("manifest-json").textContent = JSON.stringify(manifest, null, 2);
|
| 316 |
+
hide($("explanation-panel"));
|
| 317 |
+
|
| 318 |
+
// Word count badge
|
| 319 |
+
const wc = sp.word_count || sp.raw_prompt_text?.split(/\s+/).length || 0;
|
| 320 |
+
if (!$("wc-badge")) {
|
| 321 |
+
const b = document.createElement("span");
|
| 322 |
+
b.id = "wc-badge"; b.className = "step-tag";
|
| 323 |
+
b.style.marginLeft = "auto";
|
| 324 |
+
$("step-manifest")?.querySelector(".panel-header")?.appendChild(b);
|
| 325 |
+
}
|
| 326 |
+
$("wc-badge").textContent = `~${wc} words`;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/* ── Explain ─── */
|
| 330 |
+
$("btn-explain")?.addEventListener("click", async () => {
|
| 331 |
+
if (!currentPromptId) return;
|
| 332 |
+
const btn = $("btn-explain");
|
| 333 |
+
setLoading(btn, true);
|
| 334 |
+
try {
|
| 335 |
+
const data = await apiFetch(`/api/explain/${currentPromptId}`);
|
| 336 |
+
$("explanation-text").textContent = data.explanation;
|
| 337 |
+
$("key-decisions").innerHTML = data.key_decisions
|
| 338 |
+
.map(d => `<div class="decision-chip">${esc(d)}</div>`).join("");
|
| 339 |
+
const panel = $("explanation-panel");
|
| 340 |
+
show(panel);
|
| 341 |
+
panel.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
| 342 |
+
} catch (e) { toast(`Explanation error: ${e.message}`, "warn"); }
|
| 343 |
+
finally { setLoading(btn, false); }
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
/* ── Approve ─── */
|
| 347 |
+
$("btn-approve")?.addEventListener("click", async () => {
|
| 348 |
+
if (!currentPromptId) return;
|
| 349 |
+
const edits = {};
|
| 350 |
+
["role","style","task","input_format","output_format"].forEach(k => {
|
| 351 |
+
const el = $(`field-${k}`); if (el) edits[k] = el.value.trim();
|
| 352 |
+
});
|
| 353 |
+
const cEl = $("field-constraints"); if (cEl) edits.constraints = cEl.value.trim().split("\n").filter(Boolean);
|
| 354 |
+
const sEl = $("field-safety"); if (sEl) edits.safety = sEl.value.trim().split("\n").filter(Boolean);
|
| 355 |
+
const btn = $("btn-approve");
|
| 356 |
+
setLoading(btn, true);
|
| 357 |
+
try {
|
| 358 |
+
const data = await apiFetch("/api/approve", "POST", { prompt_id: currentPromptId, edits });
|
| 359 |
+
renderFinalized(data.finalized_prompt);
|
| 360 |
+
hide($("step-manifest")); show($("step-finalized"));
|
| 361 |
+
$("step-finalized").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 362 |
+
setStep(3);
|
| 363 |
+
toast("Prompt approved! 🎉", "success");
|
| 364 |
+
} catch (e) { toast(`Approval failed: ${e.message}`, "error"); }
|
| 365 |
+
finally { setLoading(btn, false); }
|
| 366 |
+
});
|
| 367 |
+
|
| 368 |
+
function renderFinalized(sp) {
|
| 369 |
+
$("finalized-text").textContent = sp.raw_prompt_text;
|
| 370 |
+
$("finalized-json").textContent = JSON.stringify(sp, null, 2);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
/* ── Inner tabs (text/json) ─── */
|
| 374 |
+
document.querySelectorAll(".inner-tab").forEach(tab => {
|
| 375 |
+
tab.addEventListener("click", () => {
|
| 376 |
+
const group = tab.closest(".panel");
|
| 377 |
+
group.querySelectorAll(".inner-tab").forEach(t => t.classList.remove("active"));
|
| 378 |
+
group.querySelectorAll(".inner-panel").forEach(p => hide(p));
|
| 379 |
+
tab.classList.add("active");
|
| 380 |
+
const panel = group.querySelector(`#itab-${tab.dataset.tab}`);
|
| 381 |
+
if (panel) show(panel);
|
| 382 |
+
});
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
/* ── Export ─── */
|
| 386 |
+
async function doExport(format) {
|
| 387 |
+
if (!currentPromptId) return;
|
| 388 |
+
try {
|
| 389 |
+
const data = await apiFetch("/api/export", "POST", { prompt_id: currentPromptId, export_format: format });
|
| 390 |
+
const content = format === "json" ? JSON.stringify(data.data, null, 2) : String(data.data);
|
| 391 |
+
const blob = new Blob([content], { type: format === "json" ? "application/json" : "text/plain" });
|
| 392 |
+
const a = Object.assign(document.createElement("a"), {
|
| 393 |
+
href: URL.createObjectURL(blob),
|
| 394 |
+
download: `prompt-${currentPromptId.slice(0,8)}.${format === "json" ? "json" : "txt"}`,
|
| 395 |
+
});
|
| 396 |
+
a.click(); URL.revokeObjectURL(a.href);
|
| 397 |
+
setStep(4);
|
| 398 |
+
toast(`Exported as ${format.toUpperCase()}!`, "success");
|
| 399 |
+
} catch (e) { toast(`Export failed: ${e.message}`, "error"); }
|
| 400 |
+
}
|
| 401 |
+
$("btn-export-json")?.addEventListener("click", () => doExport("json"));
|
| 402 |
+
$("btn-export-txt")?.addEventListener("click", () => doExport("text"));
|
| 403 |
+
|
| 404 |
+
/* ── Favourite prompt ─── */
|
| 405 |
+
$("btn-favorite-prompt")?.addEventListener("click", async () => {
|
| 406 |
+
if (!currentPromptId) return;
|
| 407 |
+
try {
|
| 408 |
+
const r = await apiFetch(`/api/prompts/${currentPromptId}/favorite`, "POST");
|
| 409 |
+
$("btn-favorite-prompt").textContent = r.is_favorite ? "★ Favourited" : "☆ Favourite";
|
| 410 |
+
toast(r.is_favorite ? "Added to favourites ★" : "Removed from favourites", "info");
|
| 411 |
+
} catch (e) { toast("Failed: " + e.message, "error"); }
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
/* ── Save as Setting ─── */
|
| 415 |
+
$("btn-save-as-setting")?.addEventListener("click", () => {
|
| 416 |
+
const instruction = $("instruction")?.value.trim() || "";
|
| 417 |
+
const context = $("extra-context")?.value.trim() || "";
|
| 418 |
+
document.querySelector('[data-page="settings"]')?.click();
|
| 419 |
+
setTimeout(() => {
|
| 420 |
+
clearSettingsForm();
|
| 421 |
+
if ($("s-title")) $("s-title").value = instruction.slice(0,60) + (instruction.length > 60 ? "…" : "");
|
| 422 |
+
if ($("s-instruction")) $("s-instruction").value = instruction;
|
| 423 |
+
if ($("s-extra-context")) $("s-extra-context").value = context;
|
| 424 |
+
$("s-title")?.focus();
|
| 425 |
+
toast("Pre-filled from prompt — adjust title and save!", "info");
|
| 426 |
+
}, 150);
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
/* ── Refine ─── */
|
| 430 |
+
$("btn-refine")?.addEventListener("click", () => {
|
| 431 |
+
hide($("step-finalized")); show($("step-refine"));
|
| 432 |
+
$("step-refine").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 433 |
+
setStep(5);
|
| 434 |
+
});
|
| 435 |
+
$("btn-cancel-refine")?.addEventListener("click", () => {
|
| 436 |
+
hide($("step-refine")); show($("step-finalized")); setStep(3);
|
| 437 |
+
});
|
| 438 |
+
$("btn-submit-refine")?.addEventListener("click", async () => {
|
| 439 |
+
const feedback = $("feedback").value.trim();
|
| 440 |
+
if (!feedback) { toast("Describe what to change.", "error"); return; }
|
| 441 |
+
const btn = $("btn-submit-refine");
|
| 442 |
+
setLoading(btn, true);
|
| 443 |
+
try {
|
| 444 |
+
const data = await apiFetch("/api/refine", "POST", {
|
| 445 |
+
prompt_id: currentPromptId, feedback,
|
| 446 |
+
provider: $("provider").value,
|
| 447 |
+
api_key: $("api-key")?.value.trim() || null,
|
| 448 |
+
});
|
| 449 |
+
currentPromptId = data.prompt_id;
|
| 450 |
+
renderManifest(data.manifest);
|
| 451 |
+
hide($("step-refine")); show($("step-manifest"));
|
| 452 |
+
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 453 |
+
setStep(2);
|
| 454 |
+
toast(`Refined to v${data.manifest.version} — review and approve!`, "success");
|
| 455 |
+
} catch (e) { toast(`Refinement failed: ${e.message}`, "error"); }
|
| 456 |
+
finally { setLoading(btn, false); }
|
| 457 |
+
});
|
| 458 |
+
|
| 459 |
+
/* ── Reset / New ─── */
|
| 460 |
+
$("btn-reset")?.addEventListener("click", () => {
|
| 461 |
+
hide($("step-manifest")); show($("step-input")); setStep(1);
|
| 462 |
+
$("instruction").value = ""; $("extra-context").value = "";
|
| 463 |
+
currentPromptId = null; toast("Reset.", "info");
|
| 464 |
+
});
|
| 465 |
+
$("btn-new")?.addEventListener("click", () => {
|
| 466 |
+
hide($("step-finalized")); show($("step-input")); setStep(1);
|
| 467 |
+
$("instruction").value = ""; $("extra-context").value = "";
|
| 468 |
+
$("instr-count").textContent = "0";
|
| 469 |
+
currentPromptId = null;
|
| 470 |
+
$("step-input").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 471 |
+
});
|
| 472 |
+
|
| 473 |
+
/* ── Load-from-settings modal ─── */
|
| 474 |
+
$("btn-load-from-settings")?.addEventListener("click", async () => {
|
| 475 |
+
await loadSettingsForModal();
|
| 476 |
+
show($("modal-overlay"));
|
| 477 |
+
});
|
| 478 |
+
$("btn-modal-close")?.addEventListener("click", () => hide($("modal-overlay")));
|
| 479 |
+
$("modal-overlay")?.addEventListener("click", e => { if (e.target === $("modal-overlay")) hide($("modal-overlay")); });
|
| 480 |
+
$("modal-search")?.addEventListener("input", () => {
|
| 481 |
+
const q = $("modal-search").value.toLowerCase();
|
| 482 |
+
document.querySelectorAll(".modal-item").forEach(item => {
|
| 483 |
+
item.style.display = item.dataset.search?.includes(q) ? "" : "none";
|
| 484 |
+
});
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
async function loadSettingsForModal() {
|
| 488 |
+
const list = $("modal-list");
|
| 489 |
+
try {
|
| 490 |
+
const data = await apiFetch("/api/instructions");
|
| 491 |
+
if (!data.items?.length) {
|
| 492 |
+
list.innerHTML = `<div class="modal-empty">No saved settings. Create some in the Settings page.</div>`;
|
| 493 |
+
return;
|
| 494 |
+
}
|
| 495 |
+
list.innerHTML = data.items.map(s => `
|
| 496 |
+
<div class="modal-item" data-id="${esc(s.settings_id)}" data-search="${esc((s.title+s.instruction).toLowerCase())}">
|
| 497 |
+
<div class="modal-item-title">${personaEmoji(s.persona)} ${esc(s.title)}</div>
|
| 498 |
+
<div class="modal-item-desc">${esc(s.instruction.slice(0,110))}${s.instruction.length > 110 ? "…" : ""}</div>
|
| 499 |
+
</div>`).join("");
|
| 500 |
+
document.querySelectorAll(".modal-item").forEach(item => {
|
| 501 |
+
item.addEventListener("click", async () => {
|
| 502 |
+
hide($("modal-overlay"));
|
| 503 |
+
await generateFromSetting(item.dataset.id);
|
| 504 |
+
});
|
| 505 |
+
});
|
| 506 |
+
} catch (e) {
|
| 507 |
+
list.innerHTML = `<div class="modal-empty">Failed: ${esc(e.message)}</div>`;
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
async function generateFromSetting(sid) {
|
| 512 |
+
const btn = $("btn-generate");
|
| 513 |
+
setLoading(btn, true);
|
| 514 |
+
document.querySelector('[data-page="generate"]')?.click();
|
| 515 |
+
try {
|
| 516 |
+
const apiKey = $("api-key")?.value.trim() || null;
|
| 517 |
+
const data = await apiFetch("/api/generate/from-settings", "POST", { settings_id: sid, api_key: apiKey });
|
| 518 |
+
currentPromptId = data.prompt_id;
|
| 519 |
+
renderManifest(data.manifest);
|
| 520 |
+
hide($("step-input")); show($("step-manifest"));
|
| 521 |
+
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 522 |
+
setStep(2);
|
| 523 |
+
updateBadge("history-badge", null, +1);
|
| 524 |
+
toast(`Generated from saved setting! ✨`, "success");
|
| 525 |
+
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 526 |
+
finally { setLoading(btn, false); }
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 530 |
+
SETTINGS PAGE
|
| 531 |
+
────────────────────────���──────────────────────────────────────────── */
|
| 532 |
+
|
| 533 |
+
$("btn-settings-save")?.addEventListener("click", async () => {
|
| 534 |
+
const title = $("s-title").value.trim();
|
| 535 |
+
const instruction = $("s-instruction").value.trim();
|
| 536 |
+
if (!title) { toast("Title is required.", "error"); $("s-title").focus(); return; }
|
| 537 |
+
if (instruction.length < 5) { toast("Instruction too short.", "error"); $("s-instruction").focus(); return; }
|
| 538 |
+
|
| 539 |
+
const editId = $("edit-settings-id").value;
|
| 540 |
+
const persona = $("s-persona").value;
|
| 541 |
+
const constraints = ($("s-constraints").value.trim() || "").split("\n").map(s=>s.trim()).filter(Boolean);
|
| 542 |
+
const tags = ($("s-tags").value.trim() || "").split(",").map(s=>s.trim().toLowerCase()).filter(Boolean);
|
| 543 |
+
|
| 544 |
+
const payload = {
|
| 545 |
+
title,
|
| 546 |
+
description: $("s-description").value.trim() || null,
|
| 547 |
+
instruction,
|
| 548 |
+
extra_context: $("s-extra-context")?.value.trim() || null,
|
| 549 |
+
output_format: $("s-output-format").value,
|
| 550 |
+
persona,
|
| 551 |
+
custom_persona: persona === "custom" ? ($("s-custom-persona")?.value.trim() || null) : null,
|
| 552 |
+
style: $("s-style").value,
|
| 553 |
+
constraints, tags,
|
| 554 |
+
provider: $("s-provider").value,
|
| 555 |
+
enhance: $("s-enhance")?.checked || false,
|
| 556 |
+
is_favorite: $("s-favorite")?.checked || false,
|
| 557 |
+
};
|
| 558 |
+
|
| 559 |
+
const btn = $("btn-settings-save");
|
| 560 |
+
setLoading(btn, true);
|
| 561 |
+
try {
|
| 562 |
+
if (editId) {
|
| 563 |
+
await apiFetch(`/api/instructions/${editId}`, "PATCH", payload);
|
| 564 |
+
toast("Setting updated! ✅", "success");
|
| 565 |
+
} else {
|
| 566 |
+
await apiFetch("/api/instructions", "POST", payload);
|
| 567 |
+
toast("Setting saved! 💾", "success");
|
| 568 |
+
}
|
| 569 |
+
clearSettingsForm();
|
| 570 |
+
await loadSettingsList();
|
| 571 |
+
} catch (e) { toast(`Save failed: ${e.message}`, "error"); }
|
| 572 |
+
finally { setLoading(btn, false); }
|
| 573 |
+
});
|
| 574 |
+
|
| 575 |
+
$("btn-settings-clear")?.addEventListener("click", clearSettingsForm);
|
| 576 |
+
|
| 577 |
+
$("btn-export-all-settings")?.addEventListener("click", async () => {
|
| 578 |
+
try {
|
| 579 |
+
const data = await apiFetch("/api/instructions/export");
|
| 580 |
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type:"application/json" });
|
| 581 |
+
const a = Object.assign(document.createElement("a"), {
|
| 582 |
+
href: URL.createObjectURL(blob), download: "promptforge-settings.json"
|
| 583 |
+
});
|
| 584 |
+
a.click(); URL.revokeObjectURL(a.href);
|
| 585 |
+
toast(`Exported ${data.total} settings.`, "success");
|
| 586 |
+
} catch (e) { toast("Export failed: " + e.message, "error"); }
|
| 587 |
+
});
|
| 588 |
+
|
| 589 |
+
function clearSettingsForm() {
|
| 590 |
+
$("edit-settings-id").value = "";
|
| 591 |
+
["s-title","s-description","s-instruction","s-extra-context","s-constraints","s-tags","s-custom-persona"].forEach(id => {
|
| 592 |
+
const el = $(id); if (el) el.value = "";
|
| 593 |
+
});
|
| 594 |
+
$("s-persona").value = "default";
|
| 595 |
+
$("s-style").value = "professional";
|
| 596 |
+
$("s-output-format").value = "both";
|
| 597 |
+
$("s-provider").value = "none";
|
| 598 |
+
if ($("s-enhance")) $("s-enhance").checked = false;
|
| 599 |
+
if ($("s-favorite")) $("s-favorite").checked = false;
|
| 600 |
+
if ($("s-custom-persona-field")) $("s-custom-persona-field").style.display = "none";
|
| 601 |
+
if ($("s-instr-count")) $("s-instr-count").textContent = "0";
|
| 602 |
+
if ($("settings-form-title")) $("settings-form-title").textContent = "➕ New Setting";
|
| 603 |
+
const genBtn = $("btn-settings-generate");
|
| 604 |
+
if (genBtn) { genBtn.classList.add("hidden"); genBtn._orig = null; }
|
| 605 |
+
document.querySelectorAll(".setting-card").forEach(c => c.classList.remove("editing"));
|
| 606 |
+
// reset tag suggestions
|
| 607 |
+
if (tagsInput && tagSugs) renderTagSugs(tagsInput, tagSugs);
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
/* ── Load settings list ─── */
|
| 611 |
+
async function loadSettingsList() {
|
| 612 |
+
try {
|
| 613 |
+
const q = $("settings-search")?.value.trim() || "";
|
| 614 |
+
const tag = $("settings-filter-tag")?.value || "";
|
| 615 |
+
let url = "/api/instructions?";
|
| 616 |
+
if (q) url += `q=${encodeURIComponent(q)}&`;
|
| 617 |
+
if (tag) url += `tag=${encodeURIComponent(tag)}&`;
|
| 618 |
+
if (favoritesFilter) url += "favorites_only=true&";
|
| 619 |
+
|
| 620 |
+
const data = await apiFetch(url);
|
| 621 |
+
allSettings = data.items || [];
|
| 622 |
+
renderSettingsList(allSettings);
|
| 623 |
+
const n = data.total || 0;
|
| 624 |
+
if ($("settings-total-count")) $("settings-total-count").textContent = n;
|
| 625 |
+
if ($("settings-badge")) $("settings-badge").textContent = n;
|
| 626 |
+
|
| 627 |
+
// refresh tag filter
|
| 628 |
+
const tagData = await apiFetch("/api/instructions/tags");
|
| 629 |
+
const filterEl = $("settings-filter-tag");
|
| 630 |
+
if (filterEl) {
|
| 631 |
+
const cur = filterEl.value;
|
| 632 |
+
filterEl.innerHTML = `<option value="">All tags</option>` +
|
| 633 |
+
tagData.tags.map(t => `<option value="${esc(t)}" ${t===cur?"selected":""}>${esc(t)}</option>`).join("");
|
| 634 |
+
}
|
| 635 |
+
} catch (e) { toast(`Failed to load settings: ${e.message}`, "error"); }
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
function renderSettingsList(items) {
|
| 639 |
+
const container = $("settings-list");
|
| 640 |
+
if (!items.length) {
|
| 641 |
+
container.innerHTML = `<div class="empty-state"><div class="empty-icon">📋</div><p>No settings yet.</p></div>`;
|
| 642 |
+
return;
|
| 643 |
+
}
|
| 644 |
+
container.innerHTML = items.map(s => `
|
| 645 |
+
<div class="setting-card${s.is_favorite?" favorite":""}${$("edit-settings-id").value===s.settings_id?" editing":""}"
|
| 646 |
+
data-id="${esc(s.settings_id)}">
|
| 647 |
+
<div class="setting-card-top">
|
| 648 |
+
<div class="setting-card-title">${personaEmoji(s.persona)} ${esc(s.title)}</div>
|
| 649 |
+
<span class="s-star">${s.is_favorite?"★":"☆"}</span>
|
| 650 |
+
</div>
|
| 651 |
+
${s.description ? `<div class="setting-card-desc">${esc(s.description)}</div>` : ""}
|
| 652 |
+
<div class="setting-card-meta">
|
| 653 |
+
${(s.tags||[]).slice(0,4).map(t=>`<span class="tag-chip">${esc(t)}</span>`).join("")}
|
| 654 |
+
<span class="tag-chip style-tag">${esc(s.style)}</span>
|
| 655 |
+
<span class="use-count">× ${s.use_count||0}</span>
|
| 656 |
+
</div>
|
| 657 |
+
<div class="setting-card-actions">
|
| 658 |
+
<button class="icon-btn" title="Edit" onclick="editSetting('${esc(s.settings_id)}')">✏️</button>
|
| 659 |
+
<button class="icon-btn" title="Duplicate" onclick="duplicateSetting('${esc(s.settings_id)}')">⧉</button>
|
| 660 |
+
<button class="icon-btn" title="Favourite" onclick="toggleSettingFav('${esc(s.settings_id)}')">☆</button>
|
| 661 |
+
<button class="icon-btn btn-danger" title="Delete" onclick="deleteSetting('${esc(s.settings_id)}')">🗑</button>
|
| 662 |
+
<button class="btn-primary btn-sm" onclick="generateFromSetting('${esc(s.settings_id)}')">⚡</button>
|
| 663 |
+
</div>
|
| 664 |
+
</div>`).join("");
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
/* ── Search / filter ─── */
|
| 668 |
+
let settingsSearchTimer;
|
| 669 |
+
$("settings-search")?.addEventListener("input", () => {
|
| 670 |
+
clearTimeout(settingsSearchTimer);
|
| 671 |
+
settingsSearchTimer = setTimeout(loadSettingsList, 250);
|
| 672 |
+
});
|
| 673 |
+
$("settings-filter-tag")?.addEventListener("change", loadSettingsList);
|
| 674 |
+
$("btn-filter-favorites")?.addEventListener("click", () => {
|
| 675 |
+
favoritesFilter = !favoritesFilter;
|
| 676 |
+
$("btn-filter-favorites").textContent = favoritesFilter ? "★" : "☆";
|
| 677 |
+
$("btn-filter-favorites").style.color = favoritesFilter ? "var(--amber)" : "";
|
| 678 |
+
loadSettingsList();
|
| 679 |
+
});
|
| 680 |
+
|
| 681 |
+
/* ── Edit setting ─── */
|
| 682 |
+
async function editSetting(sid) {
|
| 683 |
+
try {
|
| 684 |
+
const s = await apiFetch(`/api/instructions/${sid}`);
|
| 685 |
+
$("edit-settings-id").value = s.settings_id;
|
| 686 |
+
$("s-title").value = s.title;
|
| 687 |
+
$("s-description").value = s.description || "";
|
| 688 |
+
$("s-instruction").value = s.instruction;
|
| 689 |
+
$("s-instruction").dispatchEvent(new Event("input"));
|
| 690 |
+
$("s-extra-context").value = s.extra_context || "";
|
| 691 |
+
$("s-output-format").value = s.output_format;
|
| 692 |
+
$("s-persona").value = s.persona;
|
| 693 |
+
$("s-persona").dispatchEvent(new Event("change"));
|
| 694 |
+
$("s-custom-persona").value = s.custom_persona || "";
|
| 695 |
+
$("s-style").value = s.style;
|
| 696 |
+
$("s-constraints").value = (s.constraints || []).join("\n");
|
| 697 |
+
$("s-tags").value = (s.tags || []).join(", ");
|
| 698 |
+
$("s-provider").value = s.provider;
|
| 699 |
+
if ($("s-enhance")) $("s-enhance").checked = s.enhance;
|
| 700 |
+
if ($("s-favorite")) $("s-favorite").checked = s.is_favorite;
|
| 701 |
+
if ($("settings-form-title")) $("settings-form-title").textContent = `✏️ Edit: ${s.title}`;
|
| 702 |
+
const genBtn = $("btn-settings-generate");
|
| 703 |
+
if (genBtn) { genBtn.classList.remove("hidden"); genBtn._orig = null; }
|
| 704 |
+
document.querySelectorAll(".setting-card").forEach(c => c.classList.toggle("editing", c.dataset.id === sid));
|
| 705 |
+
// scroll form into view
|
| 706 |
+
document.querySelector(".settings-form-col")?.scrollIntoView({ behavior:"smooth", block:"start" });
|
| 707 |
+
renderTagSugs($("s-tags"), $("tag-suggestions"));
|
| 708 |
+
} catch (e) { toast(`Failed to load setting: ${e.message}`, "error"); }
|
| 709 |
+
}
|
| 710 |
+
window.editSetting = editSetting;
|
| 711 |
+
|
| 712 |
+
/* ── Duplicate setting ─── */
|
| 713 |
+
async function duplicateSetting(sid) {
|
| 714 |
+
try {
|
| 715 |
+
const s = await apiFetch(`/api/instructions/${sid}/duplicate`, "POST");
|
| 716 |
+
toast(`Duplicated → "${s.title}"`, "success");
|
| 717 |
+
await loadSettingsList();
|
| 718 |
+
} catch (e) { toast("Duplicate failed: " + e.message, "error"); }
|
| 719 |
+
}
|
| 720 |
+
window.duplicateSetting = duplicateSetting;
|
| 721 |
+
|
| 722 |
+
/* ── Toggle favourite ─── */
|
| 723 |
+
async function toggleSettingFav(sid) {
|
| 724 |
+
try {
|
| 725 |
+
const r = await apiFetch(`/api/instructions/${sid}/favorite`, "POST");
|
| 726 |
+
toast(r.is_favorite ? "Added to favourites ★" : "Removed from favourites", "info");
|
| 727 |
+
await loadSettingsList();
|
| 728 |
+
} catch (e) { toast("Failed: " + e.message, "error"); }
|
| 729 |
+
}
|
| 730 |
+
window.toggleSettingFav = toggleSettingFav;
|
| 731 |
+
|
| 732 |
+
/* ── Delete setting ─── */
|
| 733 |
+
async function deleteSetting(sid) {
|
| 734 |
+
if (!confirm("Delete this instruction setting? This cannot be undone.")) return;
|
| 735 |
+
try {
|
| 736 |
+
await apiFetch(`/api/instructions/${sid}`, "DELETE");
|
| 737 |
+
toast("Setting deleted.", "success");
|
| 738 |
+
if ($("edit-settings-id").value === sid) clearSettingsForm();
|
| 739 |
+
await loadSettingsList();
|
| 740 |
+
} catch (e) { toast(`Delete failed: ${e.message}`, "error"); }
|
| 741 |
+
}
|
| 742 |
+
window.deleteSetting = deleteSetting;
|
| 743 |
+
|
| 744 |
+
/* ── Generate now (from edit form) ─── */
|
| 745 |
+
$("btn-settings-generate")?.addEventListener("click", async () => {
|
| 746 |
+
const sid = $("edit-settings-id").value;
|
| 747 |
+
if (!sid) return;
|
| 748 |
+
document.querySelector('[data-page="generate"]')?.click();
|
| 749 |
+
await generateFromSetting(sid);
|
| 750 |
+
});
|
| 751 |
+
|
| 752 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 753 |
+
HISTORY PAGE
|
| 754 |
+
───────────────────────────────────────────────────────────────────── */
|
| 755 |
+
$("btn-history-refresh")?.addEventListener("click", loadHistory);
|
| 756 |
+
|
| 757 |
+
let histSearchTimer;
|
| 758 |
+
$("history-search")?.addEventListener("input", () => {
|
| 759 |
+
clearTimeout(histSearchTimer);
|
| 760 |
+
histSearchTimer = setTimeout(loadHistory, 300);
|
| 761 |
+
});
|
| 762 |
+
$("history-status-filter")?.addEventListener("change", loadHistory);
|
| 763 |
+
|
| 764 |
+
async function loadHistory() {
|
| 765 |
+
const status = $("history-status-filter")?.value || "";
|
| 766 |
+
const q = $("history-search")?.value.trim() || "";
|
| 767 |
+
let url = "/api/history?";
|
| 768 |
+
if (status) url += `status_filter=${status}&`;
|
| 769 |
+
|
| 770 |
+
try {
|
| 771 |
+
const data = await apiFetch(url);
|
| 772 |
+
let entries = data.entries || [];
|
| 773 |
+
|
| 774 |
+
// client-side search if q provided
|
| 775 |
+
if (q) {
|
| 776 |
+
const ql = q.toLowerCase();
|
| 777 |
+
entries = entries.filter(e => e.instruction?.toLowerCase().includes(ql) ||
|
| 778 |
+
e.prompt_id?.toLowerCase().includes(ql) ||
|
| 779 |
+
(e.tags||[]).join(",").includes(ql));
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
const tbody = $("history-body");
|
| 783 |
+
const total = entries.length;
|
| 784 |
+
if ($("history-badge")) $("history-badge").textContent = data.total || 0;
|
| 785 |
+
|
| 786 |
+
if (!total) {
|
| 787 |
+
tbody.innerHTML = `<tr><td class="empty-msg" colspan="8">No prompts found.</td></tr>`;
|
| 788 |
+
return;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
tbody.innerHTML = entries.map(e => `
|
| 792 |
+
<tr>
|
| 793 |
+
<td><span class="fav-star${e.is_favorite?' active':''}"
|
| 794 |
+
onclick="toggleHistFav('${esc(e.prompt_id)}')">${e.is_favorite?"★":"☆"}</span></td>
|
| 795 |
+
<td><code style="font-size:.7rem;color:var(--text-muted)">${esc(e.prompt_id?.slice(0,8))}…</code></td>
|
| 796 |
+
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
| 797 |
+
title="${esc(e.instruction)}">${esc(e.instruction?.slice(0,55)||"—")}</td>
|
| 798 |
+
<td style="font-family:var(--font-mono);font-size:.75rem">v${e.version||1}</td>
|
| 799 |
+
<td><span class="badge badge-${e.status||'pending'}">${esc(e.status||"pending")}</span></td>
|
| 800 |
+
<td>${(e.tags||[]).slice(0,3).map(t=>`<span class="tag-chip">${esc(t)}</span>`).join(" ")}</td>
|
| 801 |
+
<td style="white-space:nowrap;font-size:.74rem;color:var(--text-muted)">${e.created_at?new Date(e.created_at).toLocaleDateString():"—"}</td>
|
| 802 |
+
<td style="white-space:nowrap;display:flex;gap:4px">
|
| 803 |
+
<button class="icon-btn btn-sm" title="Archive" onclick="archivePrompt('${esc(e.prompt_id)}')">📦</button>
|
| 804 |
+
<button class="icon-btn btn-danger btn-sm" title="Delete" onclick="deleteHistory('${esc(e.prompt_id)}')">🗑</button>
|
| 805 |
+
</td>
|
| 806 |
+
</tr>`).join("");
|
| 807 |
+
|
| 808 |
+
toast(`${total} prompt(s) loaded.`, "info", 2000);
|
| 809 |
+
} catch (e) { toast(`History error: ${e.message}`, "error"); }
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
async function toggleHistFav(pid) {
|
| 813 |
+
try {
|
| 814 |
+
const r = await apiFetch(`/api/prompts/${pid}/favorite`, "POST");
|
| 815 |
+
document.querySelectorAll(".fav-star").forEach(el => {
|
| 816 |
+
if (el.getAttribute("onclick")?.includes(pid)) {
|
| 817 |
+
el.textContent = r.is_favorite ? "★" : "☆";
|
| 818 |
+
el.classList.toggle("active", r.is_favorite);
|
| 819 |
+
}
|
| 820 |
+
});
|
| 821 |
+
} catch (e) { toast("Failed: " + e.message, "error"); }
|
| 822 |
+
}
|
| 823 |
+
window.toggleHistFav = toggleHistFav;
|
| 824 |
+
|
| 825 |
+
async function archivePrompt(pid) {
|
| 826 |
+
try {
|
| 827 |
+
await apiFetch(`/api/prompts/${pid}/archive`, "POST");
|
| 828 |
+
toast("Prompt archived.", "success");
|
| 829 |
+
loadHistory();
|
| 830 |
+
} catch (e) { toast("Archive failed: " + e.message, "error"); }
|
| 831 |
+
}
|
| 832 |
+
window.archivePrompt = archivePrompt;
|
| 833 |
+
|
| 834 |
+
async function deleteHistory(id) {
|
| 835 |
+
if (!confirm("Delete this prompt permanently?")) return;
|
| 836 |
+
try {
|
| 837 |
+
await apiFetch(`/api/history/${id}`, "DELETE");
|
| 838 |
+
toast("Deleted.", "success");
|
| 839 |
+
loadHistory();
|
| 840 |
+
} catch (e) { toast(`Delete failed: ${e.message}`, "error"); }
|
| 841 |
+
}
|
| 842 |
+
window.deleteHistory = deleteHistory;
|
| 843 |
+
|
| 844 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 845 |
+
STATS PAGE
|
| 846 |
+
───────────────────────────────────────────────────────────────────── */
|
| 847 |
+
$("btn-stats-refresh")?.addEventListener("click", loadStats);
|
| 848 |
+
|
| 849 |
+
async function loadStats() {
|
| 850 |
+
const grid = $("stats-grid");
|
| 851 |
+
try {
|
| 852 |
+
const s = await apiFetch("/api/stats");
|
| 853 |
+
|
| 854 |
+
grid.innerHTML = [
|
| 855 |
+
{ val:s.total_prompts, lbl:"Total Prompts", sub:"all time", hi:false },
|
| 856 |
+
{ val:s.total_settings, lbl:"Saved Settings", sub:`${s.favorite_settings} favourited`, hi:false },
|
| 857 |
+
{ val:s.total_refinements,lbl:"Refinements", sub:"iterative improvements", hi:true },
|
| 858 |
+
{ val:s.avg_constraints, lbl:"Avg Constraints", sub:"per prompt", hi:false },
|
| 859 |
+
].map(c => `
|
| 860 |
+
<div class="stat-card${c.hi?" highlight":""}">
|
| 861 |
+
<div class="stat-value">${c.val}</div>
|
| 862 |
+
<div class="stat-label">${c.lbl}</div>
|
| 863 |
+
<div class="stat-sub">${c.sub}</div>
|
| 864 |
+
</div>`).join("");
|
| 865 |
+
|
| 866 |
+
// Persona bars
|
| 867 |
+
const maxP = Math.max(1, ...Object.values(s.top_personas));
|
| 868 |
+
$("stats-personas").innerHTML = Object.entries(s.top_personas).length
|
| 869 |
+
? Object.entries(s.top_personas).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `
|
| 870 |
+
<div class="bar-row">
|
| 871 |
+
<div class="bar-row-label">${esc(k)}</div>
|
| 872 |
+
<div class="bar-track"><div class="bar-fill" style="width:${Math.round(v/maxP*100)}%"></div></div>
|
| 873 |
+
<div class="bar-count">${v}</div>
|
| 874 |
+
</div>`).join("")
|
| 875 |
+
: `<p class="muted" style="padding:4px 0">No data yet.</p>`;
|
| 876 |
+
|
| 877 |
+
// Style bars
|
| 878 |
+
const maxS = Math.max(1, ...Object.values(s.top_styles));
|
| 879 |
+
$("stats-styles").innerHTML = Object.entries(s.top_styles).length
|
| 880 |
+
? Object.entries(s.top_styles).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `
|
| 881 |
+
<div class="bar-row">
|
| 882 |
+
<div class="bar-row-label">${esc(k)}</div>
|
| 883 |
+
<div class="bar-track"><div class="bar-fill" style="width:${Math.round(v/maxS*100)}%"></div></div>
|
| 884 |
+
<div class="bar-count">${v}</div>
|
| 885 |
+
</div>`).join("")
|
| 886 |
+
: `<p class="muted" style="padding:4px 0">No data yet.</p>`;
|
| 887 |
+
|
| 888 |
+
// Status breakdown
|
| 889 |
+
$("stats-statuses").innerHTML = [
|
| 890 |
+
{ status:"pending", count:s.pending_count, color:"var(--amber)" },
|
| 891 |
+
{ status:"approved", count:s.approved_count, color:"var(--green)" },
|
| 892 |
+
{ status:"exported", count:s.exported_count, color:"var(--accent)" },
|
| 893 |
+
{ status:"archived", count:s.archived_count, color:"var(--text-faint)" },
|
| 894 |
+
].map(x => `
|
| 895 |
+
<div class="status-item">
|
| 896 |
+
<div class="status-item-count" style="color:${x.color}">${x.count}</div>
|
| 897 |
+
<div class="status-item-label">${x.status}</div>
|
| 898 |
+
</div>`).join("");
|
| 899 |
+
|
| 900 |
+
toast("Stats refreshed.", "info", 2000);
|
| 901 |
+
} catch (e) {
|
| 902 |
+
grid.innerHTML = `<div class="stat-card" style="grid-column:1/-1;text-align:center;color:var(--text-muted)">Could not load stats — is the server running?</div>`;
|
| 903 |
+
toast("Stats error: " + e.message, "error");
|
| 904 |
+
}
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
/* ── Utilities ─── */
|
| 908 |
+
function personaEmoji(persona) {
|
| 909 |
+
const m = { senior_dev:"👨💻", data_scientist:"📊", tech_writer:"✍️", product_mgr:"📋",
|
| 910 |
+
security_eng:"🔒", devops_eng:"🚀", ml_engineer:"🧠", custom:"✏️" };
|
| 911 |
+
return m[persona] || "🤖";
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
function updateBadge(id, total, delta) {
|
| 915 |
+
const el = $(id);
|
| 916 |
+
if (!el) return;
|
| 917 |
+
if (total !== null) { el.textContent = total; }
|
| 918 |
+
else { el.textContent = parseInt(el.textContent || "0") + delta; }
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
/* ── Init ─── */
|
| 922 |
+
(async () => {
|
| 923 |
+
await loadConfig();
|
| 924 |
+
await loadSettingsList();
|
| 925 |
+
setStep(1);
|
| 926 |
+
})();
|
backend/instruction_store.py
CHANGED
|
@@ -1,27 +1,28 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge —
|
| 3 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger("promptforge.instruction_store")
|
| 17 |
|
| 18 |
-
_DB:
|
| 19 |
-
_LOG_DIR
|
| 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())
|
|
@@ -31,11 +32,12 @@ def create(data: InstructionSettingsCreate) -> InstructionSettings:
|
|
| 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
|
| 39 |
return setting
|
| 40 |
|
| 41 |
|
|
@@ -43,10 +45,19 @@ def get(settings_id: str) -> Optional[InstructionSettings]:
|
|
| 43 |
return _DB.get(settings_id)
|
| 44 |
|
| 45 |
|
| 46 |
-
def list_all(
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
if tag:
|
| 49 |
items = [i for i in items if tag in i.tags]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
return items
|
| 51 |
|
| 52 |
|
|
@@ -56,10 +67,11 @@ def update(settings_id: str, data: InstructionSettingsUpdate) -> Optional[Instru
|
|
| 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
|
| 63 |
return updated
|
| 64 |
|
| 65 |
|
|
@@ -67,11 +79,45 @@ def delete(settings_id: str) -> bool:
|
|
| 67 |
if settings_id in _DB:
|
| 68 |
del _DB[settings_id]
|
| 69 |
_persist()
|
| 70 |
-
logger.info("DELETED
|
| 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]
|
|
@@ -81,7 +127,17 @@ def increment_use_count(settings_id: str) -> None:
|
|
| 81 |
_persist()
|
| 82 |
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
def _persist() -> None:
|
| 87 |
try:
|
|
@@ -100,54 +156,92 @@ def load_from_disk() -> None:
|
|
| 100 |
for entry in raw:
|
| 101 |
s = InstructionSettings.model_validate(entry)
|
| 102 |
_DB[s.settings_id] = s
|
| 103 |
-
logger.info("Loaded %d
|
| 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
|
| 115 |
-
description="Generates a TypeScript React component with TailwindCSS and tests.",
|
| 116 |
-
instruction="Create a reusable TypeScript React component with TailwindCSS styling,
|
| 117 |
-
extra_context="Follow React best practices: hooks, memo,
|
| 118 |
output_format=OutputFormat.both,
|
| 119 |
persona=PersonaType.senior_dev,
|
| 120 |
style=StyleType.professional,
|
| 121 |
-
constraints=["
|
| 122 |
-
tags=["react", "typescript", "frontend"],
|
| 123 |
provider=AIProvider.none,
|
| 124 |
enhance=False,
|
|
|
|
| 125 |
),
|
| 126 |
InstructionSettingsCreate(
|
| 127 |
-
title="
|
| 128 |
-
description="Generates a FastAPI endpoint with validation
|
| 129 |
-
instruction="Create a FastAPI endpoint with Pydantic request/response models, input validation, error handling, and
|
| 130 |
-
extra_context="Follow REST best practices. Include
|
| 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
|
| 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
|
| 147 |
tags=["writing", "technical", "blog"],
|
| 148 |
provider=AIProvider.none,
|
| 149 |
enhance=False,
|
| 150 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
]
|
| 152 |
for d in defaults:
|
| 153 |
create(d)
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge v4.0 — Instruction Settings store.
|
| 3 |
+
Upgrades: duplicate setting, export-all-as-JSON, bulk-delete,
|
| 4 |
+
favorite toggle, and richer seeded defaults.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
+
import json, os, logging, uuid
|
|
|
|
|
|
|
|
|
|
| 8 |
from datetime import datetime
|
| 9 |
from pathlib import Path
|
| 10 |
from typing import Dict, List, Optional
|
| 11 |
|
| 12 |
+
from schemas import (
|
| 13 |
+
InstructionSettings, InstructionSettingsCreate, InstructionSettingsUpdate,
|
| 14 |
+
AIProvider, OutputFormat, PersonaType, StyleType,
|
| 15 |
+
)
|
| 16 |
|
| 17 |
logger = logging.getLogger("promptforge.instruction_store")
|
| 18 |
|
| 19 |
+
_DB: Dict[str, InstructionSettings] = {}
|
| 20 |
+
_LOG_DIR = Path(os.getenv("LOG_DIR", "logs"))
|
| 21 |
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 22 |
_PERSIST_FILE = _LOG_DIR / "instruction_settings.json"
|
| 23 |
|
| 24 |
|
| 25 |
+
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
| 26 |
|
| 27 |
def create(data: InstructionSettingsCreate) -> InstructionSettings:
|
| 28 |
sid = str(uuid.uuid4())
|
|
|
|
| 32 |
created_at=now,
|
| 33 |
updated_at=now,
|
| 34 |
use_count=0,
|
| 35 |
+
version=1,
|
| 36 |
**data.model_dump(),
|
| 37 |
)
|
| 38 |
_DB[sid] = setting
|
| 39 |
_persist()
|
| 40 |
+
logger.info("CREATED setting | id=%s title=%r", sid, setting.title)
|
| 41 |
return setting
|
| 42 |
|
| 43 |
|
|
|
|
| 45 |
return _DB.get(settings_id)
|
| 46 |
|
| 47 |
|
| 48 |
+
def list_all(
|
| 49 |
+
tag: Optional[str] = None,
|
| 50 |
+
favorites_only: bool = False,
|
| 51 |
+
search_q: Optional[str] = None,
|
| 52 |
+
) -> List[InstructionSettings]:
|
| 53 |
+
items = sorted(_DB.values(), key=lambda x: (not x.is_favorite, x.updated_at.timestamp() * -1))
|
| 54 |
if tag:
|
| 55 |
items = [i for i in items if tag in i.tags]
|
| 56 |
+
if favorites_only:
|
| 57 |
+
items = [i for i in items if i.is_favorite]
|
| 58 |
+
if search_q:
|
| 59 |
+
q = search_q.lower()
|
| 60 |
+
items = [i for i in items if q in (i.title + i.instruction + (i.description or "")).lower()]
|
| 61 |
return items
|
| 62 |
|
| 63 |
|
|
|
|
| 67 |
return None
|
| 68 |
patch = {k: v for k, v in data.model_dump().items() if v is not None}
|
| 69 |
patch["updated_at"] = datetime.utcnow()
|
| 70 |
+
patch["version"] = existing.version + 1
|
| 71 |
updated = existing.model_copy(update=patch)
|
| 72 |
_DB[settings_id] = updated
|
| 73 |
_persist()
|
| 74 |
+
logger.info("UPDATED setting | id=%s v%d", settings_id, updated.version)
|
| 75 |
return updated
|
| 76 |
|
| 77 |
|
|
|
|
| 79 |
if settings_id in _DB:
|
| 80 |
del _DB[settings_id]
|
| 81 |
_persist()
|
| 82 |
+
logger.info("DELETED setting | id=%s", settings_id)
|
| 83 |
return True
|
| 84 |
return False
|
| 85 |
|
| 86 |
|
| 87 |
+
def bulk_delete(settings_ids: List[str]) -> int:
|
| 88 |
+
deleted = 0
|
| 89 |
+
for sid in settings_ids:
|
| 90 |
+
if sid in _DB:
|
| 91 |
+
del _DB[sid]
|
| 92 |
+
deleted += 1
|
| 93 |
+
if deleted:
|
| 94 |
+
_persist()
|
| 95 |
+
return deleted
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def duplicate(settings_id: str) -> Optional[InstructionSettings]:
|
| 99 |
+
"""Create a copy of an existing setting with '(copy)' suffix."""
|
| 100 |
+
original = _DB.get(settings_id)
|
| 101 |
+
if not original:
|
| 102 |
+
return None
|
| 103 |
+
data = InstructionSettingsCreate(**{
|
| 104 |
+
**original.model_dump(),
|
| 105 |
+
"title": f"{original.title} (copy)",
|
| 106 |
+
"is_favorite": False,
|
| 107 |
+
})
|
| 108 |
+
return create(data)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def toggle_favorite(settings_id: str) -> Optional[bool]:
|
| 112 |
+
s = _DB.get(settings_id)
|
| 113 |
+
if not s:
|
| 114 |
+
return None
|
| 115 |
+
new_val = not s.is_favorite
|
| 116 |
+
_DB[settings_id] = s.model_copy(update={"is_favorite": new_val, "updated_at": datetime.utcnow()})
|
| 117 |
+
_persist()
|
| 118 |
+
return new_val
|
| 119 |
+
|
| 120 |
+
|
| 121 |
def increment_use_count(settings_id: str) -> None:
|
| 122 |
if settings_id in _DB:
|
| 123 |
s = _DB[settings_id]
|
|
|
|
| 127 |
_persist()
|
| 128 |
|
| 129 |
|
| 130 |
+
def export_all() -> List[dict]:
|
| 131 |
+
"""Return all settings as a list of dicts (for bulk JSON export)."""
|
| 132 |
+
return [s.model_dump(mode="json") for s in sorted(_DB.values(), key=lambda x: x.created_at)]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def get_all_tags() -> List[str]:
|
| 136 |
+
tags = sorted({tag for s in _DB.values() for tag in s.tags})
|
| 137 |
+
return tags
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ── Persistence ───────────────────────────────────────────────────────────────
|
| 141 |
|
| 142 |
def _persist() -> None:
|
| 143 |
try:
|
|
|
|
| 156 |
for entry in raw:
|
| 157 |
s = InstructionSettings.model_validate(entry)
|
| 158 |
_DB[s.settings_id] = s
|
| 159 |
+
logger.info("Loaded %d settings from disk.", len(_DB))
|
| 160 |
except Exception as exc:
|
| 161 |
logger.warning("Could not load instruction settings: %s", exc)
|
| 162 |
_seed_defaults()
|
| 163 |
|
| 164 |
|
| 165 |
def _seed_defaults() -> None:
|
|
|
|
|
|
|
| 166 |
defaults = [
|
| 167 |
InstructionSettingsCreate(
|
| 168 |
+
title="React TypeScript Component",
|
| 169 |
+
description="Generates a reusable TypeScript React component with TailwindCSS styling and Jest tests.",
|
| 170 |
+
instruction="Create a reusable TypeScript React component with TailwindCSS styling, a full props interface with JSDoc comments, and Jest + Testing Library unit tests. Include a Storybook story.",
|
| 171 |
+
extra_context="Follow React 18 best practices: hooks, memo, forwardRef where needed. Support dark mode via Tailwind dark: prefix.",
|
| 172 |
output_format=OutputFormat.both,
|
| 173 |
persona=PersonaType.senior_dev,
|
| 174 |
style=StyleType.professional,
|
| 175 |
+
constraints=["TypeScript strict mode", "WCAG 2.1 AA accessibility", "Include PropTypes documentation", "Responsive design"],
|
| 176 |
+
tags=["react", "typescript", "frontend", "tailwind"],
|
| 177 |
provider=AIProvider.none,
|
| 178 |
enhance=False,
|
| 179 |
+
is_favorite=True,
|
| 180 |
),
|
| 181 |
InstructionSettingsCreate(
|
| 182 |
+
title="FastAPI Endpoint",
|
| 183 |
+
description="Generates a FastAPI endpoint with Pydantic validation, error handling, and tests.",
|
| 184 |
+
instruction="Create a FastAPI endpoint with Pydantic v2 request/response models, input validation, structured error handling, OpenAPI metadata (tags, summary, description), and pytest async tests.",
|
| 185 |
+
extra_context="Follow REST best practices. Use dependency injection. Include an integration test against a test database.",
|
| 186 |
output_format=OutputFormat.both,
|
| 187 |
persona=PersonaType.senior_dev,
|
| 188 |
style=StyleType.detailed,
|
| 189 |
+
constraints=["Python 3.11+", "PEP-8 compliant", "Type hints everywhere", "Include async tests", "Structured logging"],
|
| 190 |
+
tags=["python", "fastapi", "backend", "testing"],
|
| 191 |
provider=AIProvider.none,
|
| 192 |
enhance=False,
|
| 193 |
+
is_favorite=True,
|
| 194 |
),
|
| 195 |
InstructionSettingsCreate(
|
| 196 |
title="Technical Blog Post",
|
| 197 |
+
description="Creates a structured technical article with code examples and actionable takeaways.",
|
| 198 |
+
instruction="Write a technical blog post explaining the concept with clear H2/H3 sections, at least two runnable code examples, a TL;DR opener, and concrete actionable takeaways at the end.",
|
| 199 |
output_format=OutputFormat.text,
|
| 200 |
persona=PersonaType.tech_writer,
|
| 201 |
style=StyleType.detailed,
|
| 202 |
+
constraints=["800–1200 words", "Include ≥2 code examples", "Add a TL;DR section", "Link to further reading"],
|
| 203 |
tags=["writing", "technical", "blog"],
|
| 204 |
provider=AIProvider.none,
|
| 205 |
enhance=False,
|
| 206 |
),
|
| 207 |
+
InstructionSettingsCreate(
|
| 208 |
+
title="SQL Query Optimiser",
|
| 209 |
+
description="Analyses and rewrites slow SQL queries with explanations.",
|
| 210 |
+
instruction="Analyse the provided SQL query for performance issues, rewrite it with optimisations (indexes, joins, CTEs), and explain every change with Big-O reasoning.",
|
| 211 |
+
extra_context="Target PostgreSQL 15+. Use EXPLAIN ANALYZE format for before/after comparison.",
|
| 212 |
+
output_format=OutputFormat.both,
|
| 213 |
+
persona=PersonaType.data_scientist,
|
| 214 |
+
style=StyleType.detailed,
|
| 215 |
+
constraints=["PostgreSQL 15+", "Include EXPLAIN ANALYZE output", "Document index strategy"],
|
| 216 |
+
tags=["sql", "database", "performance"],
|
| 217 |
+
provider=AIProvider.none,
|
| 218 |
+
enhance=False,
|
| 219 |
+
),
|
| 220 |
+
InstructionSettingsCreate(
|
| 221 |
+
title="Docker + CI/CD Setup",
|
| 222 |
+
description="Generates multi-stage Dockerfile and GitHub Actions workflow.",
|
| 223 |
+
instruction="Create a production-ready multi-stage Dockerfile and a GitHub Actions CI/CD pipeline with lint, test, build, and deploy stages. Include security scanning with Trivy.",
|
| 224 |
+
output_format=OutputFormat.both,
|
| 225 |
+
persona=PersonaType.devops_eng,
|
| 226 |
+
style=StyleType.professional,
|
| 227 |
+
constraints=["Multi-stage build", "Non-root user", "Health checks", "Cache layer optimisation", "Secrets via GitHub Secrets"],
|
| 228 |
+
tags=["docker", "devops", "ci/cd", "security"],
|
| 229 |
+
provider=AIProvider.none,
|
| 230 |
+
enhance=False,
|
| 231 |
+
),
|
| 232 |
+
InstructionSettingsCreate(
|
| 233 |
+
title="LLM Fine-Tuning Guide",
|
| 234 |
+
description="Step-by-step guide for fine-tuning an open-source LLM.",
|
| 235 |
+
instruction="Write a step-by-step guide for fine-tuning an open-source LLM using LoRA/QLoRA on a custom dataset. Cover data preparation, training config, evaluation metrics, and deployment.",
|
| 236 |
+
extra_context="Target Hugging Face + PEFT + bitsandbytes stack. Assume 1× A100 or equivalent GPU.",
|
| 237 |
+
output_format=OutputFormat.both,
|
| 238 |
+
persona=PersonaType.ml_engineer,
|
| 239 |
+
style=StyleType.detailed,
|
| 240 |
+
constraints=["Hugging Face ecosystem", "LoRA/QLoRA", "Include evaluation with BLEU/ROUGE", "Deployment section"],
|
| 241 |
+
tags=["ml", "llm", "fine-tuning", "huggingface"],
|
| 242 |
+
provider=AIProvider.none,
|
| 243 |
+
enhance=False,
|
| 244 |
+
),
|
| 245 |
]
|
| 246 |
for d in defaults:
|
| 247 |
create(d)
|
backend/main.py
CHANGED
|
@@ -1,19 +1,34 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — FastAPI
|
| 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,
|
|
@@ -22,26 +37,27 @@ 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
|
| 41 |
app = FastAPI(
|
| 42 |
title="PromptForge",
|
| 43 |
-
description="Structured prompt generator for Google AI Studio
|
| 44 |
-
version="
|
| 45 |
docs_url="/docs",
|
| 46 |
redoc_url="/redoc",
|
| 47 |
)
|
|
@@ -62,171 +78,229 @@ if _FRONTEND_DIR.exists():
|
|
| 62 |
async def _startup() -> None:
|
| 63 |
store.load_from_disk()
|
| 64 |
instruction_store.load_from_disk()
|
| 65 |
-
port
|
| 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
|
| 77 |
|
| 78 |
|
| 79 |
-
# ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
|
|
|
|
|
|
| 89 |
)
|
| 90 |
|
| 91 |
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
return setting
|
| 101 |
|
| 102 |
|
| 103 |
@app.get("/api/instructions", response_model=InstructionSettingsList, tags=["Settings"])
|
| 104 |
-
async def list_instructions(
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
@app.get("/api/instructions/{settings_id}", response_model=InstructionSettings, tags=["Settings"])
|
| 111 |
async def get_instruction(settings_id: str) -> InstructionSettings:
|
| 112 |
-
|
| 113 |
-
if not
|
| 114 |
-
raise HTTPException(404, f"
|
| 115 |
-
return
|
| 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"
|
| 123 |
return updated
|
| 124 |
|
| 125 |
|
| 126 |
@app.delete("/api/instructions/{settings_id}", tags=["Settings"])
|
| 127 |
async def delete_instruction(settings_id: str) -> JSONResponse:
|
| 128 |
-
|
| 129 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 165 |
-
|
| 166 |
-
logger.info("GENERATE_FROM_SETTINGS | settings_id=%s prompt_id=%s", req.settings_id, manifest.prompt_id)
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
message=f"Generated from settings '{setting.title}'.",
|
| 173 |
-
)
|
| 174 |
|
| 175 |
|
| 176 |
-
# ──
|
| 177 |
|
| 178 |
@app.post("/api/generate", response_model=GenerateResponse, tags=["Prompts"])
|
| 179 |
async def generate_prompt(req: GenerateRequest) -> GenerateResponse:
|
| 180 |
-
"
|
| 181 |
-
|
| 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 |
-
|
| 195 |
-
|
| 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 |
-
|
| 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 ───────────────────────────────────────────────────────────────────
|
|
@@ -238,10 +312,10 @@ async def approve_prompt(req: ApproveRequest) -> ApproveResponse:
|
|
| 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 |
|
| 242 |
return ApproveResponse(
|
| 243 |
success=True, prompt_id=manifest.prompt_id,
|
| 244 |
-
message="Prompt approved and
|
| 245 |
finalized_prompt=manifest.structured_prompt,
|
| 246 |
)
|
| 247 |
|
|
@@ -262,8 +336,10 @@ async def export_prompt(req: ExportRequest) -> ExportResponse:
|
|
| 262 |
elif req.export_format == "json":
|
| 263 |
data = manifest.structured_prompt.model_dump()
|
| 264 |
else:
|
| 265 |
-
data = {
|
| 266 |
-
|
|
|
|
|
|
|
| 267 |
return ExportResponse(success=True, prompt_id=manifest.prompt_id, data=data)
|
| 268 |
|
| 269 |
|
|
@@ -274,33 +350,54 @@ 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
|
| 278 |
-
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 |
-
|
| 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 |
|
| 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(
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
return HistoryResponse(total=len(entries), entries=entries)
|
| 305 |
|
| 306 |
|
|
@@ -314,20 +411,30 @@ async def get_prompt(prompt_id: str) -> dict:
|
|
| 314 |
|
| 315 |
@app.delete("/api/history/{prompt_id}", tags=["History"])
|
| 316 |
async def delete_prompt(prompt_id: str) -> JSONResponse:
|
| 317 |
-
|
| 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 |
-
# ──
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge v4.0 — FastAPI application
|
| 3 |
Run: uvicorn main:app --reload --host 0.0.0.0 --port 7860
|
| 4 |
+
|
| 5 |
+
New endpoints vs v3.0:
|
| 6 |
+
POST /api/generate/batch
|
| 7 |
+
GET /api/search?q=…
|
| 8 |
+
GET /api/stats
|
| 9 |
+
POST /api/prompts/{id}/favorite
|
| 10 |
+
POST /api/prompts/{id}/archive
|
| 11 |
+
POST /api/history/bulk-delete
|
| 12 |
+
POST /api/instructions/{id}/duplicate
|
| 13 |
+
POST /api/instructions/{id}/favorite
|
| 14 |
+
GET /api/instructions/export
|
| 15 |
+
GET /api/instructions/tags
|
| 16 |
+
POST /api/check-key
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
import logging, os
|
| 20 |
from pathlib import Path
|
| 21 |
+
from typing import List, Optional
|
| 22 |
|
| 23 |
+
from fastapi import FastAPI, HTTPException, Query, status
|
| 24 |
from fastapi.middleware.cors import CORSMiddleware
|
| 25 |
from fastapi.responses import HTMLResponse, JSONResponse
|
| 26 |
from fastapi.staticfiles import StaticFiles
|
| 27 |
+
from pydantic import BaseModel
|
| 28 |
|
| 29 |
import store
|
| 30 |
import instruction_store
|
| 31 |
+
from ai_client import enhance_prompt, check_hf_key, check_google_key, HF_DEFAULT_MODEL, GOOGLE_DEFAULT_MODEL
|
| 32 |
from prompt_logic import (
|
| 33 |
build_manifest, build_manifest_from_settings,
|
| 34 |
apply_edits, refine_with_feedback, generate_explanation,
|
|
|
|
| 37 |
ApproveRequest, ApproveResponse,
|
| 38 |
ExportRequest, ExportResponse,
|
| 39 |
GenerateRequest, GenerateFromSettingsRequest, GenerateResponse,
|
| 40 |
+
BatchGenerateRequest, BatchGenerateResponse,
|
| 41 |
HistoryResponse,
|
| 42 |
RefineRequest,
|
| 43 |
InstructionSettingsCreate, InstructionSettingsUpdate,
|
| 44 |
InstructionSettingsList, InstructionSettings,
|
| 45 |
+
ExplainResponse, EnvConfigStatus, StatsResponse,
|
| 46 |
AIProvider,
|
| 47 |
)
|
| 48 |
|
| 49 |
+
# ── Logging ───────────────────────────────────────────────────────────────────
|
| 50 |
logging.basicConfig(
|
| 51 |
level=logging.INFO,
|
| 52 |
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 53 |
)
|
| 54 |
logger = logging.getLogger("promptforge.main")
|
| 55 |
|
| 56 |
+
# ── App ───────────────────────────────────────────────────────────────────────
|
| 57 |
app = FastAPI(
|
| 58 |
title="PromptForge",
|
| 59 |
+
description="Structured prompt generator for Google AI Studio — v4.0",
|
| 60 |
+
version="4.0.0",
|
| 61 |
docs_url="/docs",
|
| 62 |
redoc_url="/redoc",
|
| 63 |
)
|
|
|
|
| 78 |
async def _startup() -> None:
|
| 79 |
store.load_from_disk()
|
| 80 |
instruction_store.load_from_disk()
|
| 81 |
+
logger.info("PromptForge v4.0 ready on port %s", os.environ.get("PORT", "7860"))
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
+
# ── Frontend ──────────────────────────────────────────────────────────────────
|
| 85 |
|
| 86 |
@app.get("/", response_class=HTMLResponse, tags=["Frontend"])
|
| 87 |
async def serve_frontend() -> HTMLResponse:
|
| 88 |
index = _FRONTEND_DIR / "index.html"
|
| 89 |
if index.exists():
|
| 90 |
return HTMLResponse(content=index.read_text(), status_code=200)
|
| 91 |
+
return HTMLResponse("<h1>PromptForge v4.0 API</h1><a href='/docs'>Swagger Docs →</a>")
|
| 92 |
|
| 93 |
|
| 94 |
+
# ── System ────────────────────────────────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
@app.get("/health", tags=["System"])
|
| 97 |
+
async def health() -> dict:
|
| 98 |
+
return {
|
| 99 |
+
"status": "ok", "service": "PromptForge", "version": "4.0.0",
|
| 100 |
+
"prompts_in_memory": len(store._DB),
|
| 101 |
+
"settings_in_memory": len(instruction_store._DB),
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
|
| 105 |
@app.get("/api/config", response_model=EnvConfigStatus, tags=["System"])
|
| 106 |
async def get_config() -> EnvConfigStatus:
|
|
|
|
| 107 |
return EnvConfigStatus(
|
| 108 |
hf_key_set=bool(os.environ.get("HF_API_KEY")),
|
| 109 |
google_key_set=bool(os.environ.get("GOOGLE_API_KEY")),
|
| 110 |
port=os.environ.get("PORT", "7860"),
|
| 111 |
+
version="4.0.0",
|
| 112 |
+
hf_model=HF_DEFAULT_MODEL,
|
| 113 |
+
google_model=GOOGLE_DEFAULT_MODEL,
|
| 114 |
)
|
| 115 |
|
| 116 |
|
| 117 |
+
@app.get("/api/stats", response_model=StatsResponse, tags=["System"])
|
| 118 |
+
async def get_stats() -> StatsResponse:
|
| 119 |
+
s = store.get_stats()
|
| 120 |
+
i = instruction_store._DB
|
| 121 |
+
return StatsResponse(
|
| 122 |
+
total_prompts=s["total_prompts"],
|
| 123 |
+
total_settings=len(i),
|
| 124 |
+
pending_count=s["pending_count"],
|
| 125 |
+
approved_count=s["approved_count"],
|
| 126 |
+
exported_count=s["exported_count"],
|
| 127 |
+
archived_count=s["archived_count"],
|
| 128 |
+
favorite_prompts=s["favorite_prompts"],
|
| 129 |
+
favorite_settings=sum(1 for x in i.values() if x.is_favorite),
|
| 130 |
+
top_personas=s["top_personas"],
|
| 131 |
+
top_styles=s["top_styles"],
|
| 132 |
+
total_refinements=s["total_refinements"],
|
| 133 |
+
avg_constraints=s["avg_constraints"],
|
| 134 |
+
uptime_since=s["uptime_since"],
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class KeyCheckRequest(BaseModel):
|
| 139 |
+
provider: str
|
| 140 |
+
api_key: str
|
| 141 |
+
|
| 142 |
+
@app.post("/api/check-key", tags=["System"])
|
| 143 |
+
async def check_api_key(req: KeyCheckRequest) -> dict:
|
| 144 |
+
"""Validate an API key against the provider (no storage, no logging of the key)."""
|
| 145 |
+
if req.provider == "huggingface":
|
| 146 |
+
valid = await check_hf_key(req.api_key)
|
| 147 |
+
elif req.provider == "google":
|
| 148 |
+
valid = await check_google_key(req.api_key)
|
| 149 |
+
else:
|
| 150 |
+
return {"valid": False, "message": "Unknown provider."}
|
| 151 |
+
return {"valid": valid, "message": "Key is valid." if valid else "Key appears invalid or quota exhausted."}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ── Search ────────────────────────────────────────────────────────────────────
|
| 155 |
+
|
| 156 |
+
@app.get("/api/search", tags=["Prompts"])
|
| 157 |
+
async def search_prompts(
|
| 158 |
+
q: str = Query(..., min_length=1, description="Search query"),
|
| 159 |
+
status_filter: Optional[str] = Query(None),
|
| 160 |
+
) -> dict:
|
| 161 |
+
results = store.search(q, status_filter=status_filter)
|
| 162 |
+
return {"total": len(results), "results": results}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ── Instruction Settings ──────────────────────────────────────────────────────
|
| 166 |
|
| 167 |
@app.post("/api/instructions", response_model=InstructionSettings,
|
| 168 |
status_code=status.HTTP_201_CREATED, tags=["Settings"])
|
| 169 |
async def create_instruction(data: InstructionSettingsCreate) -> InstructionSettings:
|
| 170 |
+
s = instruction_store.create(data)
|
| 171 |
+
logger.info("SETTING CREATED | id=%s title=%r", s.settings_id, s.title)
|
| 172 |
+
return s
|
|
|
|
| 173 |
|
| 174 |
|
| 175 |
@app.get("/api/instructions", response_model=InstructionSettingsList, tags=["Settings"])
|
| 176 |
+
async def list_instructions(
|
| 177 |
+
tag: Optional[str] = None,
|
| 178 |
+
favorites_only: bool = False,
|
| 179 |
+
q: Optional[str] = Query(None, description="Search in title/instruction/description"),
|
| 180 |
+
) -> InstructionSettingsList:
|
| 181 |
+
items = instruction_store.list_all(tag=tag, favorites_only=favorites_only, search_q=q)
|
| 182 |
+
favs = sum(1 for i in items if i.is_favorite)
|
| 183 |
+
return InstructionSettingsList(total=len(items), items=items, favorites=favs)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@app.get("/api/instructions/tags", tags=["Settings"])
|
| 187 |
+
async def get_all_tags() -> dict:
|
| 188 |
+
return {"tags": instruction_store.get_all_tags()}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@app.get("/api/instructions/export", tags=["Settings"])
|
| 192 |
+
async def export_all_settings() -> dict:
|
| 193 |
+
return {"total": len(instruction_store._DB), "settings": instruction_store.export_all()}
|
| 194 |
|
| 195 |
|
| 196 |
@app.get("/api/instructions/{settings_id}", response_model=InstructionSettings, tags=["Settings"])
|
| 197 |
async def get_instruction(settings_id: str) -> InstructionSettings:
|
| 198 |
+
s = instruction_store.get(settings_id)
|
| 199 |
+
if not s:
|
| 200 |
+
raise HTTPException(404, f"Setting '{settings_id}' not found.")
|
| 201 |
+
return s
|
| 202 |
|
| 203 |
|
| 204 |
@app.patch("/api/instructions/{settings_id}", response_model=InstructionSettings, tags=["Settings"])
|
| 205 |
async def update_instruction(settings_id: str, data: InstructionSettingsUpdate) -> InstructionSettings:
|
| 206 |
updated = instruction_store.update(settings_id, data)
|
| 207 |
if not updated:
|
| 208 |
+
raise HTTPException(404, f"Setting '{settings_id}' not found.")
|
| 209 |
return updated
|
| 210 |
|
| 211 |
|
| 212 |
@app.delete("/api/instructions/{settings_id}", tags=["Settings"])
|
| 213 |
async def delete_instruction(settings_id: str) -> JSONResponse:
|
| 214 |
+
if not instruction_store.delete(settings_id):
|
| 215 |
+
raise HTTPException(404, f"Setting '{settings_id}' not found.")
|
|
|
|
| 216 |
return JSONResponse({"success": True, "message": f"Setting {settings_id} deleted."})
|
| 217 |
|
| 218 |
|
| 219 |
+
@app.post("/api/instructions/{settings_id}/duplicate", response_model=InstructionSettings, tags=["Settings"])
|
| 220 |
+
async def duplicate_instruction(settings_id: str) -> InstructionSettings:
|
| 221 |
+
copy = instruction_store.duplicate(settings_id)
|
| 222 |
+
if not copy:
|
| 223 |
+
raise HTTPException(404, f"Setting '{settings_id}' not found.")
|
| 224 |
+
logger.info("SETTING DUPLICATED | original=%s copy=%s", settings_id, copy.settings_id)
|
| 225 |
+
return copy
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
+
@app.post("/api/instructions/{settings_id}/favorite", tags=["Settings"])
|
| 229 |
+
async def toggle_setting_favorite(settings_id: str) -> dict:
|
| 230 |
+
new_val = instruction_store.toggle_favorite(settings_id)
|
| 231 |
+
if new_val is None:
|
| 232 |
+
raise HTTPException(404, f"Setting '{settings_id}' not found.")
|
| 233 |
+
return {"settings_id": settings_id, "is_favorite": new_val}
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
+
class BulkDeleteRequest(BaseModel):
|
| 237 |
+
ids: List[str]
|
|
|
|
| 238 |
|
| 239 |
+
@app.post("/api/instructions/bulk-delete", tags=["Settings"])
|
| 240 |
+
async def bulk_delete_settings(req: BulkDeleteRequest) -> dict:
|
| 241 |
+
deleted = instruction_store.bulk_delete(req.ids)
|
| 242 |
+
return {"deleted": deleted}
|
|
|
|
|
|
|
| 243 |
|
| 244 |
|
| 245 |
+
# ── Generate ──────────────────────────────────────────────────────────────────
|
| 246 |
|
| 247 |
@app.post("/api/generate", response_model=GenerateResponse, tags=["Prompts"])
|
| 248 |
async def generate_prompt(req: GenerateRequest) -> GenerateResponse:
|
| 249 |
+
logger.info("GENERATE | persona=%s style=%s enhance=%s", req.persona, req.style, req.enhance)
|
| 250 |
+
api_key = _resolve_key(req.api_key, req.provider)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
manifest = build_manifest(
|
| 252 |
+
instruction=req.instruction, extra_context=req.extra_context,
|
| 253 |
+
persona=req.persona, custom_persona=req.custom_persona,
|
| 254 |
+
style=req.style, user_constraints=req.user_constraints,
|
|
|
|
|
|
|
|
|
|
| 255 |
settings_id=req.settings_id,
|
| 256 |
)
|
|
|
|
| 257 |
if req.enhance and req.provider != AIProvider.none and api_key:
|
| 258 |
+
manifest = await _apply_enhancement(manifest, req.provider.value, api_key, req.provider_model)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
store.save(manifest)
|
| 260 |
return GenerateResponse(success=True, prompt_id=manifest.prompt_id, manifest=manifest)
|
| 261 |
|
| 262 |
|
| 263 |
+
@app.post("/api/generate/from-settings", response_model=GenerateResponse, tags=["Prompts"])
|
| 264 |
+
async def generate_from_settings(req: GenerateFromSettingsRequest) -> GenerateResponse:
|
| 265 |
+
setting = instruction_store.get(req.settings_id)
|
| 266 |
+
if not setting:
|
| 267 |
+
raise HTTPException(404, f"Setting '{req.settings_id}' not found.")
|
| 268 |
+
api_key = _resolve_key(req.api_key, setting.provider)
|
| 269 |
+
manifest = build_manifest_from_settings(setting)
|
| 270 |
+
if setting.enhance and setting.provider != AIProvider.none and api_key:
|
| 271 |
+
manifest = await _apply_enhancement(manifest, setting.provider.value, api_key, setting.provider_model)
|
| 272 |
+
instruction_store.increment_use_count(req.settings_id)
|
| 273 |
+
store.save(manifest)
|
| 274 |
+
logger.info("GEN_FROM_SETTINGS | sid=%s pid=%s", req.settings_id, manifest.prompt_id)
|
| 275 |
+
return GenerateResponse(
|
| 276 |
+
success=True, prompt_id=manifest.prompt_id, manifest=manifest,
|
| 277 |
+
message=f"Generated from '{setting.title}'.",
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@app.post("/api/generate/batch", response_model=BatchGenerateResponse, tags=["Prompts"])
|
| 282 |
+
async def batch_generate(req: BatchGenerateRequest) -> BatchGenerateResponse:
|
| 283 |
+
"""Generate up to 10 prompts in a single call."""
|
| 284 |
+
results, failed = [], 0
|
| 285 |
+
for gen_req in req.requests:
|
| 286 |
+
try:
|
| 287 |
+
r = await generate_prompt(gen_req)
|
| 288 |
+
results.append(r)
|
| 289 |
+
except Exception as exc:
|
| 290 |
+
logger.warning("Batch item failed: %s", exc)
|
| 291 |
+
failed += 1
|
| 292 |
+
return BatchGenerateResponse(success=True, total=len(results), results=results, failed=failed)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
# ── Explain ───────────────────────────────────────────────────────────────────
|
| 296 |
|
| 297 |
@app.get("/api/explain/{prompt_id}", response_model=ExplainResponse, tags=["Prompts"])
|
| 298 |
async def explain_prompt(prompt_id: str) -> ExplainResponse:
|
|
|
|
| 299 |
manifest = store.get(prompt_id)
|
| 300 |
if not manifest:
|
| 301 |
raise HTTPException(404, f"Prompt '{prompt_id}' not found.")
|
| 302 |
explanation, decisions = generate_explanation(manifest)
|
| 303 |
+
return ExplainResponse(prompt_id=prompt_id, explanation=explanation, key_decisions=decisions)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
|
| 306 |
# ── Approve ───────────────────────────────────────────────────────────────────
|
|
|
|
| 312 |
raise HTTPException(404, f"Prompt '{req.prompt_id}' not found.")
|
| 313 |
manifest = apply_edits(manifest, req.edits) if req.edits else manifest.model_copy(update={"status": "approved"})
|
| 314 |
store.save(manifest)
|
| 315 |
+
logger.info("APPROVED | pid=%s", manifest.prompt_id)
|
| 316 |
return ApproveResponse(
|
| 317 |
success=True, prompt_id=manifest.prompt_id,
|
| 318 |
+
message="Prompt approved and finalised.",
|
| 319 |
finalized_prompt=manifest.structured_prompt,
|
| 320 |
)
|
| 321 |
|
|
|
|
| 336 |
elif req.export_format == "json":
|
| 337 |
data = manifest.structured_prompt.model_dump()
|
| 338 |
else:
|
| 339 |
+
data = {
|
| 340 |
+
"json": manifest.structured_prompt.model_dump(),
|
| 341 |
+
"text": manifest.structured_prompt.raw_prompt_text,
|
| 342 |
+
}
|
| 343 |
return ExportResponse(success=True, prompt_id=manifest.prompt_id, data=data)
|
| 344 |
|
| 345 |
|
|
|
|
| 350 |
manifest = store.get(req.prompt_id)
|
| 351 |
if not manifest:
|
| 352 |
raise HTTPException(404, f"Prompt '{req.prompt_id}' not found.")
|
| 353 |
+
refined = refine_with_feedback(manifest, req.feedback)
|
| 354 |
+
api_key = _resolve_key(req.api_key, req.provider)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
if req.provider != AIProvider.none and api_key:
|
| 356 |
+
refined = await _apply_enhancement(refined, req.provider.value, api_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
store.save(refined)
|
| 358 |
+
logger.info("REFINED | pid=%s v%d", refined.prompt_id, refined.version)
|
| 359 |
return GenerateResponse(
|
| 360 |
success=True, prompt_id=refined.prompt_id, manifest=refined,
|
| 361 |
message=f"Refined to v{refined.version} — awaiting approval.",
|
| 362 |
)
|
| 363 |
|
| 364 |
|
| 365 |
+
# ── Favorite / Archive ────────────────────────────────────────────────────────
|
| 366 |
+
|
| 367 |
+
@app.post("/api/prompts/{prompt_id}/favorite", tags=["Prompts"])
|
| 368 |
+
async def toggle_prompt_favorite(prompt_id: str) -> dict:
|
| 369 |
+
new_val = store.toggle_favorite(prompt_id)
|
| 370 |
+
if new_val is None:
|
| 371 |
+
raise HTTPException(404, f"Prompt '{prompt_id}' not found.")
|
| 372 |
+
return {"prompt_id": prompt_id, "is_favorite": new_val}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@app.post("/api/prompts/{prompt_id}/archive", tags=["Prompts"])
|
| 376 |
+
async def archive_prompt(prompt_id: str) -> dict:
|
| 377 |
+
if not store.archive(prompt_id):
|
| 378 |
+
raise HTTPException(404, f"Prompt '{prompt_id}' not found.")
|
| 379 |
+
return {"prompt_id": prompt_id, "status": "archived"}
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
@app.post("/api/history/bulk-delete", tags=["History"])
|
| 383 |
+
async def bulk_delete_history(req: BulkDeleteRequest) -> dict:
|
| 384 |
+
deleted = store.bulk_delete(req.ids)
|
| 385 |
+
return {"deleted": deleted}
|
| 386 |
+
|
| 387 |
+
|
| 388 |
# ── History ───────────────────────────────────────────────────────────────────
|
| 389 |
|
| 390 |
@app.get("/api/history", response_model=HistoryResponse, tags=["History"])
|
| 391 |
+
async def get_history(
|
| 392 |
+
status_filter: Optional[str] = Query(None),
|
| 393 |
+
tag_filter: Optional[str] = Query(None),
|
| 394 |
+
favorites_only: bool = Query(False),
|
| 395 |
+
) -> HistoryResponse:
|
| 396 |
+
entries = store.all_entries(
|
| 397 |
+
status_filter=status_filter,
|
| 398 |
+
tag_filter=tag_filter,
|
| 399 |
+
favorites_only=favorites_only,
|
| 400 |
+
)
|
| 401 |
return HistoryResponse(total=len(entries), entries=entries)
|
| 402 |
|
| 403 |
|
|
|
|
| 411 |
|
| 412 |
@app.delete("/api/history/{prompt_id}", tags=["History"])
|
| 413 |
async def delete_prompt(prompt_id: str) -> JSONResponse:
|
| 414 |
+
if not store.delete(prompt_id):
|
|
|
|
| 415 |
raise HTTPException(404, f"Prompt '{prompt_id}' not found.")
|
| 416 |
return JSONResponse({"success": True, "message": f"Prompt {prompt_id} deleted."})
|
| 417 |
|
| 418 |
|
| 419 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 420 |
|
| 421 |
+
def _resolve_key(api_key: Optional[str], provider) -> Optional[str]:
|
| 422 |
+
if api_key:
|
| 423 |
+
return api_key
|
| 424 |
+
provider_val = provider.value if hasattr(provider, "value") else str(provider)
|
| 425 |
+
if provider_val == "huggingface":
|
| 426 |
+
return os.environ.get("HF_API_KEY")
|
| 427 |
+
if provider_val == "google":
|
| 428 |
+
return os.environ.get("GOOGLE_API_KEY")
|
| 429 |
+
return None
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
async def _apply_enhancement(manifest, provider: str, api_key: str, model_override=None):
|
| 433 |
+
enhanced_text, notes = await enhance_prompt(
|
| 434 |
+
raw_prompt=manifest.structured_prompt.raw_prompt_text,
|
| 435 |
+
provider=provider,
|
| 436 |
+
api_key=api_key,
|
| 437 |
+
model_override=model_override,
|
| 438 |
+
)
|
| 439 |
+
sp = manifest.structured_prompt.model_copy(update={"raw_prompt_text": enhanced_text})
|
| 440 |
+
return manifest.model_copy(update={"structured_prompt": sp, "enhancement_notes": notes})
|
backend/prompt_logic.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — Core prompt generation
|
| 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, Tuple
|
| 11 |
|
|
@@ -15,71 +15,89 @@ from schemas import (
|
|
| 15 |
)
|
| 16 |
|
| 17 |
|
| 18 |
-
# ── Persona
|
| 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
|
| 24 |
-
PersonaType.tech_writer: "Technical Writer producing clear, precise developer documentation",
|
| 25 |
-
PersonaType.product_mgr: "Product Manager focused on user-centric, data-driven
|
| 26 |
-
PersonaType.security_eng: "Security Engineer with expertise in threat
|
| 27 |
-
PersonaType.
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
_STYLE_DESCRIPTIONS: Dict[StyleType, str] = {
|
| 31 |
-
StyleType.professional: "Professional and
|
| 32 |
-
StyleType.concise: "Ultra-concise
|
| 33 |
-
StyleType.detailed: "Thoroughly detailed
|
| 34 |
-
StyleType.beginner: "Beginner-friendly
|
| 35 |
-
StyleType.formal: "Formal prose
|
| 36 |
-
StyleType.creative: "Engaging and vivid
|
| 37 |
}
|
| 38 |
|
| 39 |
_HEURISTIC_ROLES: List[Tuple[List[str], str]] = [
|
| 40 |
-
(["react", "vue", "angular", "component", "frontend", "ui", "tailwind", "
|
| 41 |
-
(["api", "rest", "fastapi", "flask", "django", "backend", "endpoint", "
|
| 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 |
-
(["
|
| 45 |
-
(["machine learning", "ml", "
|
| 46 |
-
(["data analysis", "pandas", "numpy", "visualization", "chart", "plot", "etl", "pipeline"], "Data Scientist"),
|
| 47 |
-
(["security", "auth", "oauth", "jwt", "encrypt", "
|
| 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",
|
| 57 |
-
(r"\bpython\b",
|
| 58 |
-
(r"\btailwind(?:css)?\b",
|
| 59 |
-
(r"\bunit test[s]?\b|\bjest\b|\bpytest\b",
|
| 60 |
-
|
| 61 |
-
(r"\
|
| 62 |
-
(r"\
|
| 63 |
-
(r"\
|
| 64 |
-
(r"\
|
| 65 |
-
(r"\
|
| 66 |
-
(r"\
|
| 67 |
-
(r"\
|
| 68 |
-
(r"\
|
| 69 |
-
(r"\
|
| 70 |
-
(r"\
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
]
|
| 72 |
|
| 73 |
_SAFETY_DEFAULTS: List[str] = [
|
| 74 |
"Do not produce harmful, misleading, or unethical content.",
|
| 75 |
-
"Respect intellectual property;
|
| 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,
|
|
@@ -92,18 +110,19 @@ def build_manifest(
|
|
| 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
|
|
|
|
| 98 |
|
| 99 |
-
role
|
| 100 |
-
task
|
| 101 |
-
input_fmt
|
| 102 |
-
output_fmt
|
| 103 |
constraints = _build_constraints(lower, user_constraints or [])
|
| 104 |
-
style_desc
|
| 105 |
-
safety
|
| 106 |
-
examples
|
|
|
|
| 107 |
|
| 108 |
raw_text = _render_raw_prompt(
|
| 109 |
role=role, task=task, input_fmt=input_fmt, output_fmt=output_fmt,
|
|
@@ -114,6 +133,7 @@ def build_manifest(
|
|
| 114 |
role=role, instruction=instruction, constraints=constraints,
|
| 115 |
persona=persona, style=style,
|
| 116 |
)
|
|
|
|
| 117 |
|
| 118 |
structured = StructuredPrompt(
|
| 119 |
role=role, task=task,
|
|
@@ -121,12 +141,14 @@ def build_manifest(
|
|
| 121 |
constraints=constraints, style=style_desc,
|
| 122 |
safety=safety, examples=examples,
|
| 123 |
raw_prompt_text=raw_text,
|
|
|
|
| 124 |
)
|
| 125 |
|
| 126 |
return PromptManifest(
|
| 127 |
prompt_id=prompt_id,
|
| 128 |
version=version,
|
| 129 |
-
created_at=
|
|
|
|
| 130 |
instruction=instruction,
|
| 131 |
status="pending",
|
| 132 |
structured_prompt=structured,
|
|
@@ -134,11 +156,11 @@ def build_manifest(
|
|
| 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,
|
|
@@ -152,24 +174,26 @@ def build_manifest_from_settings(settings: InstructionSettings) -> PromptManifes
|
|
| 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":
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 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,
|
|
@@ -179,7 +203,6 @@ def refine_with_feedback(
|
|
| 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,
|
|
@@ -191,14 +214,13 @@ def generate_explanation(manifest: PromptManifest) -> Tuple[str, List[str]]:
|
|
| 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
|
|
@@ -213,108 +235,163 @@ def _format_task(instruction: str) -> str:
|
|
| 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
|
| 220 |
-
if any(k in lower for k in ["image", "photo", "screenshot", "diagram", "figure"]):
|
| 221 |
-
return "An image
|
| 222 |
-
if any(k in lower for k in ["url", "link", "website", "webpage"]):
|
| 223 |
-
return "A URL or list of URLs
|
| 224 |
-
|
|
|
|
|
|
|
| 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
|
| 230 |
if any(k in lower for k in ["markdown", "md", "readme", "documentation", "doc"]):
|
| 231 |
-
return "A Markdown-formatted document with
|
| 232 |
if any(k in lower for k in ["code", "script", "function", "class", "component", "snippet"]):
|
| 233 |
-
return "Source code
|
| 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
|
| 238 |
-
if any(k in lower for k in ["table", "comparison", "matrix"]):
|
| 239 |
-
return "A Markdown table with
|
| 240 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 249 |
for uc in user_constraints:
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
if "summarize" in lower or "summary" in lower:
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
| 264 |
if "fastapi" in lower or ("api" in lower and "endpoint" in lower):
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
def _generate_explanation(
|
| 274 |
-
role: str,
|
| 275 |
-
|
| 276 |
-
constraints: List[str],
|
| 277 |
-
persona: PersonaType,
|
| 278 |
-
style: StyleType,
|
| 279 |
) -> str:
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
f"
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
|
| 307 |
def _extract_key_decisions(manifest: PromptManifest) -> List[str]:
|
| 308 |
sp = manifest.structured_prompt
|
| 309 |
decisions = [
|
| 310 |
-
f"Role
|
| 311 |
-
f"Style
|
| 312 |
-
f"Output
|
| 313 |
-
f"{len(sp.constraints)} constraint(s)
|
| 314 |
-
f"{len(sp.safety)} safety guardrail(s)
|
|
|
|
| 315 |
]
|
| 316 |
if sp.examples:
|
| 317 |
-
decisions.append(f"{len(sp.examples)} few-shot example(s)
|
|
|
|
|
|
|
| 318 |
return decisions
|
| 319 |
|
| 320 |
|
|
@@ -325,24 +402,40 @@ def _render_raw_prompt(
|
|
| 325 |
extra_context: Optional[str] = None,
|
| 326 |
) -> str:
|
| 327 |
lines = [
|
| 328 |
-
f"## ROLE
|
| 329 |
-
f"
|
| 330 |
-
|
| 331 |
-
f"
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
]
|
| 334 |
for i, c in enumerate(constraints, 1):
|
| 335 |
lines.append(f"{i}. {c}")
|
| 336 |
-
lines
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
for i, s in enumerate(safety, 1):
|
| 339 |
lines.append(f"{i}. {s}")
|
| 340 |
if extra_context:
|
| 341 |
-
lines
|
| 342 |
if examples:
|
| 343 |
-
lines
|
| 344 |
for ex in examples:
|
| 345 |
-
lines
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
| 348 |
return "\n".join(lines)
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge v4.0 — Core prompt generation engine.
|
| 3 |
+
Upgrades: 2 new personas (devops_eng, ml_engineer), expanded heuristics,
|
| 4 |
+
richer constraint patterns, word-count tracking, auto-tagging,
|
| 5 |
+
multi-example support, and improved explanation quality.
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
+
import re, uuid, textwrap
|
|
|
|
|
|
|
| 9 |
from datetime import datetime
|
| 10 |
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
|
|
|
|
| 15 |
)
|
| 16 |
|
| 17 |
|
| 18 |
+
# ── Persona role strings ──────────────────────────────────────────────────────
|
| 19 |
|
| 20 |
_PERSONA_ROLES: Dict[PersonaType, str] = {
|
| 21 |
PersonaType.default: "General AI Assistant",
|
| 22 |
+
PersonaType.senior_dev: "Senior Software Engineer with 10+ years of full-stack experience",
|
| 23 |
+
PersonaType.data_scientist: "Senior Data Scientist specialising in ML/AI pipelines and statistical analysis",
|
| 24 |
+
PersonaType.tech_writer: "Technical Writer producing clear, precise developer documentation and tutorials",
|
| 25 |
+
PersonaType.product_mgr: "Product Manager focused on user-centric, data-driven decision making",
|
| 26 |
+
PersonaType.security_eng: "Security Engineer with expertise in threat modelling, OWASP, and secure-by-design systems",
|
| 27 |
+
PersonaType.devops_eng: "DevOps / Platform Engineer specialising in CI/CD, Kubernetes, and cloud-native infrastructure",
|
| 28 |
+
PersonaType.ml_engineer: "Machine Learning Engineer experienced in LLM fine-tuning, model deployment, and MLOps",
|
| 29 |
+
PersonaType.custom: "",
|
| 30 |
}
|
| 31 |
|
| 32 |
_STYLE_DESCRIPTIONS: Dict[StyleType, str] = {
|
| 33 |
+
StyleType.professional: "Professional and precise — balance technical accuracy with readability. Use exact terminology.",
|
| 34 |
+
StyleType.concise: "Ultra-concise — bullet points preferred, zero filler. Every word must earn its place.",
|
| 35 |
+
StyleType.detailed: "Thoroughly detailed — explain every decision, include rationale, alternatives, and trade-offs.",
|
| 36 |
+
StyleType.beginner: "Beginner-friendly — avoid jargon, explain every acronym, use analogies and step-by-step breakdowns.",
|
| 37 |
+
StyleType.formal: "Formal prose — structured with headings, professional tone suitable for specifications or reports.",
|
| 38 |
+
StyleType.creative: "Engaging and vivid — use narrative techniques to make even dry content memorable without sacrificing accuracy.",
|
| 39 |
}
|
| 40 |
|
| 41 |
_HEURISTIC_ROLES: List[Tuple[List[str], str]] = [
|
| 42 |
+
(["react", "vue", "angular", "svelte", "nextjs", "remix", "component", "frontend", "ui", "ux", "tailwind", "css", "html"], "Senior Frontend Engineer"),
|
| 43 |
+
(["api", "rest", "restful", "fastapi", "flask", "django", "express", "graphql", "grpc", "backend", "server", "endpoint", "microservice"], "Senior Backend Engineer"),
|
| 44 |
+
(["sql", "database", "postgres", "postgresql", "mysql", "mongo", "mongodb", "redis", "dynamodb", "query", "schema", "orm", "migration"], "Database Architect"),
|
| 45 |
+
(["test", "unittest", "pytest", "jest", "cypress", "playwright", "coverage", "tdd", "bdd", "e2e", "integration test", "mock"], "QA / Test Automation Engineer"),
|
| 46 |
+
(["docker", "kubernetes", "k8s", "helm", "terraform", "ansible", "ci/cd", "github actions", "jenkins", "deploy", "cloud", "aws", "gcp", "azure", "infra"], "DevOps / Cloud Engineer"),
|
| 47 |
+
(["machine learning", "ml", "deep learning", "neural", "train", "fine-tune", "dataset", "pytorch", "tensorflow", "hugging face", "llm", "transformer", "embedding"], "Machine Learning Engineer"),
|
| 48 |
+
(["data analysis", "pandas", "numpy", "visualization", "matplotlib", "seaborn", "plotly", "chart", "plot", "etl", "pipeline", "spark", "dbt"], "Data Scientist"),
|
| 49 |
+
(["security", "pentest", "vulnerability", "cve", "owasp", "auth", "oauth", "jwt", "encrypt", "decrypt", "ssl", "tls", "xss", "csrf"], "Security Engineer"),
|
| 50 |
+
(["write", "blog", "article", "essay", "copy", "content", "documentation", "readme", "wiki", "tutorial", "guide", "how-to"], "Technical Writer"),
|
| 51 |
+
(["summarize", "summary", "tldr", "abstract", "extract", "distill", "recap"], "Technical Summarizer"),
|
| 52 |
+
(["translate", "localize", "i18n", "l10n", "language", "multilingual"], "Multilingual Specialist"),
|
| 53 |
+
(["product", "roadmap", "user story", "backlog", "sprint", "okr", "kpi", "stakeholder", "feature", "discovery"], "Product Manager"),
|
| 54 |
+
(["sql", "bi", "dashboard", "report", "analytics", "metrics", "tableau", "looker", "powerbi"], "Business Intelligence Analyst"),
|
| 55 |
+
(["mobile", "ios", "android", "swift", "kotlin", "react native", "flutter", "expo"], "Mobile Engineer"),
|
| 56 |
+
(["blockchain", "smart contract", "solidity", "ethereum", "web3", "defi", "nft", "dao"], "Blockchain / Web3 Engineer"),
|
| 57 |
+
(["game", "unity", "unreal", "godot", "shader", "physics", "rendering", "game design"], "Game Developer"),
|
| 58 |
]
|
| 59 |
|
| 60 |
_CONSTRAINT_PATTERNS: List[Tuple[str, str]] = [
|
| 61 |
+
(r"\btypescript\b", "Use TypeScript with strict mode enabled (`strict: true` in tsconfig)."),
|
| 62 |
+
(r"\bpython\b", "Use Python 3.11+; follow PEP-8 style guide; include type hints everywhere."),
|
| 63 |
+
(r"\btailwind(?:css)?\b", "Use TailwindCSS utility classes exclusively; avoid custom CSS unless unavoidable."),
|
| 64 |
+
(r"\bunit test[s]?\b|\bjest\b|\bpytest\b|\bvitest\b",
|
| 65 |
+
"Include comprehensive unit tests with ≥80% line coverage."),
|
| 66 |
+
(r"\bjson\b", "All structured data must be valid, parseable JSON; validate with a schema."),
|
| 67 |
+
(r"\baccessib\w+\b|\bwcag\b|\ba11y\b", "Ensure WCAG 2.1 AA accessibility compliance (ARIA labels, keyboard nav, contrast ≥4.5:1)."),
|
| 68 |
+
(r"\bresponsive\b", "Design must be fully responsive across mobile (320px+), tablet, and desktop."),
|
| 69 |
+
(r"\bdocker\b", "Provide a multi-stage `Dockerfile` and a `docker-compose.yml`."),
|
| 70 |
+
(r"\bno comment[s]?\b", "Do not include inline code comments."),
|
| 71 |
+
(r"\bcomment[s]?\b", "Include clear, concise inline comments explaining every non-obvious logic block."),
|
| 72 |
+
(r"\berror handling\b|\bexception\b", "Include comprehensive error/exception handling with user-friendly messages and structured logging."),
|
| 73 |
+
(r"\blogg?ing\b", "Add structured logging (JSON format preferred) at appropriate severity levels."),
|
| 74 |
+
(r"\bpagination\b", "Implement cursor- or offset-based pagination with configurable page size."),
|
| 75 |
+
(r"\bcach(e|ing)\b", "Implement caching with appropriate TTL, cache-key strategy, and invalidation logic."),
|
| 76 |
+
(r"\bsecurity\b|\bauth(?:entication|orization)?\b",
|
| 77 |
+
"Follow OWASP Top-10 guidelines; validate and sanitize all inputs; never trust client data."),
|
| 78 |
+
(r"\bdark ?mode\b", "Support both light and dark colour schemes via CSS custom properties or Tailwind dark:."),
|
| 79 |
+
(r"\bi18n\b|\binternat\w+\b", "Internationalise all user-facing strings; use i18n library (e.g., react-i18next, Fluent)."),
|
| 80 |
+
(r"\bperformance\b|\boptimiz\w+\b", "Profile and optimise for performance; include Big-O analysis where relevant."),
|
| 81 |
+
(r"\bgit\b|\bversion control\b", "Include `.gitignore`, conventional commit messages, and branch-naming guidance."),
|
| 82 |
+
(r"\bwebsocket\b|\breal.?time\b|\bsse\b",
|
| 83 |
+
"Use WebSockets or Server-Sent Events for real-time communication; handle reconnection."),
|
| 84 |
+
(r"\bci/?cd\b|\bgithub actions\b|\bpipeline\b",
|
| 85 |
+
"Define a CI/CD pipeline (GitHub Actions preferred); include lint, test, and build stages."),
|
| 86 |
+
(r"\bmigration\b|\bschema change\b", "Provide reversible database migrations with rollback scripts."),
|
| 87 |
+
(r"\benv(?:ironment)? var\b|\.env\b", "Document all environment variables in `.env.example`; never hard-code secrets."),
|
| 88 |
]
|
| 89 |
|
| 90 |
_SAFETY_DEFAULTS: List[str] = [
|
| 91 |
"Do not produce harmful, misleading, or unethical content.",
|
| 92 |
+
"Respect intellectual property; never reproduce copyrighted material verbatim.",
|
| 93 |
+
"If the request is ambiguous or potentially harmful, ask for clarification before proceeding.",
|
| 94 |
"Adhere to Google AI Studio usage policies and Responsible AI guidelines.",
|
| 95 |
"Do not expose sensitive data, API keys, passwords, or PII in any output.",
|
| 96 |
+
"Prefer established, well-maintained libraries over custom implementations for security-critical code.",
|
| 97 |
]
|
| 98 |
|
| 99 |
|
| 100 |
+
# ── Public API ────────────────────────────────────────────────────────────────
|
| 101 |
|
| 102 |
def build_manifest(
|
| 103 |
instruction: str,
|
|
|
|
| 110 |
user_constraints: Optional[List[str]] = None,
|
| 111 |
settings_id: Optional[str] = None,
|
| 112 |
) -> PromptManifest:
|
|
|
|
| 113 |
prompt_id = existing_id or str(uuid.uuid4())
|
| 114 |
+
lower = instruction.lower()
|
| 115 |
+
now = datetime.utcnow()
|
| 116 |
|
| 117 |
+
role = _resolve_role(persona, custom_persona, lower)
|
| 118 |
+
task = _format_task(instruction)
|
| 119 |
+
input_fmt = _infer_input_format(lower)
|
| 120 |
+
output_fmt = _infer_output_format(lower)
|
| 121 |
constraints = _build_constraints(lower, user_constraints or [])
|
| 122 |
+
style_desc = _STYLE_DESCRIPTIONS.get(style, _STYLE_DESCRIPTIONS[StyleType.professional])
|
| 123 |
+
safety = list(_SAFETY_DEFAULTS)
|
| 124 |
+
examples = _build_examples(lower, role)
|
| 125 |
+
auto_tags = _auto_tag(lower)
|
| 126 |
|
| 127 |
raw_text = _render_raw_prompt(
|
| 128 |
role=role, task=task, input_fmt=input_fmt, output_fmt=output_fmt,
|
|
|
|
| 133 |
role=role, instruction=instruction, constraints=constraints,
|
| 134 |
persona=persona, style=style,
|
| 135 |
)
|
| 136 |
+
word_count = len(raw_text.split())
|
| 137 |
|
| 138 |
structured = StructuredPrompt(
|
| 139 |
role=role, task=task,
|
|
|
|
| 141 |
constraints=constraints, style=style_desc,
|
| 142 |
safety=safety, examples=examples,
|
| 143 |
raw_prompt_text=raw_text,
|
| 144 |
+
word_count=word_count,
|
| 145 |
)
|
| 146 |
|
| 147 |
return PromptManifest(
|
| 148 |
prompt_id=prompt_id,
|
| 149 |
version=version,
|
| 150 |
+
created_at=now,
|
| 151 |
+
updated_at=now,
|
| 152 |
instruction=instruction,
|
| 153 |
status="pending",
|
| 154 |
structured_prompt=structured,
|
|
|
|
| 156 |
settings_id=settings_id,
|
| 157 |
persona_used=persona,
|
| 158 |
style_used=style,
|
| 159 |
+
tags=auto_tags,
|
| 160 |
)
|
| 161 |
|
| 162 |
|
| 163 |
def build_manifest_from_settings(settings: InstructionSettings) -> PromptManifest:
|
|
|
|
| 164 |
return build_manifest(
|
| 165 |
instruction=settings.instruction,
|
| 166 |
extra_context=settings.extra_context,
|
|
|
|
| 174 |
|
| 175 |
def apply_edits(manifest: PromptManifest, edits: Dict[str, Any]) -> PromptManifest:
|
| 176 |
sp = manifest.structured_prompt.model_copy(update=edits)
|
| 177 |
+
new_raw = _render_raw_prompt(
|
| 178 |
+
role=sp.role, task=sp.task,
|
| 179 |
+
input_fmt=sp.input_format, output_fmt=sp.output_format,
|
| 180 |
+
constraints=sp.constraints, style=sp.style,
|
| 181 |
+
safety=sp.safety, examples=sp.examples,
|
| 182 |
+
)
|
| 183 |
sp = sp.model_copy(update={
|
| 184 |
+
"raw_prompt_text": new_raw,
|
| 185 |
+
"word_count": len(new_raw.split()),
|
| 186 |
+
})
|
| 187 |
+
return manifest.model_copy(update={
|
| 188 |
+
"structured_prompt": sp,
|
| 189 |
+
"status": "approved",
|
| 190 |
+
"updated_at": datetime.utcnow(),
|
| 191 |
})
|
|
|
|
| 192 |
|
| 193 |
|
| 194 |
+
def refine_with_feedback(manifest: PromptManifest, feedback: str) -> PromptManifest:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return build_manifest(
|
| 196 |
+
instruction=manifest.instruction + "\n\nREFINEMENT REQUEST: " + feedback,
|
| 197 |
version=manifest.version + 1,
|
| 198 |
existing_id=manifest.prompt_id,
|
| 199 |
persona=manifest.persona_used,
|
|
|
|
| 203 |
|
| 204 |
|
| 205 |
def generate_explanation(manifest: PromptManifest) -> Tuple[str, List[str]]:
|
|
|
|
| 206 |
explanation = manifest.explanation or _generate_explanation(
|
| 207 |
role=manifest.structured_prompt.role,
|
| 208 |
instruction=manifest.instruction,
|
|
|
|
| 214 |
return explanation, decisions
|
| 215 |
|
| 216 |
|
| 217 |
+
# ── Private helpers ───────────────────────────────────────────────────────────
|
| 218 |
|
| 219 |
def _resolve_role(persona: PersonaType, custom_persona: Optional[str], lower: str) -> str:
|
| 220 |
if persona == PersonaType.custom and custom_persona:
|
| 221 |
return custom_persona
|
| 222 |
if persona != PersonaType.default:
|
| 223 |
return _PERSONA_ROLES.get(persona, "General AI Assistant")
|
|
|
|
| 224 |
for keywords, role in _HEURISTIC_ROLES:
|
| 225 |
if any(kw in lower for kw in keywords):
|
| 226 |
return role
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
def _infer_input_format(lower: str) -> str:
|
| 238 |
+
if any(k in lower for k in ["json", "object", "dict", "payload", "request body"]):
|
| 239 |
+
return "A JSON object containing the relevant fields described in the task. Validate the schema before processing."
|
| 240 |
+
if any(k in lower for k in ["file", "upload", "csv", "pdf", "spreadsheet", "xlsx"]):
|
| 241 |
+
return "A file provided as a path, URL, or base64-encoded string. Include MIME type where relevant."
|
| 242 |
+
if any(k in lower for k in ["image", "photo", "screenshot", "diagram", "figure", "svg"]):
|
| 243 |
+
return "An image as a URL or base64-encoded string. Specify width, height, and format metadata."
|
| 244 |
+
if any(k in lower for k in ["url", "link", "website", "webpage", "endpoint"]):
|
| 245 |
+
return "A URL or list of URLs. Validate reachability and parse with appropriate scraping/HTTP tools."
|
| 246 |
+
if any(k in lower for k in ["sql", "query", "database"]):
|
| 247 |
+
return "A database schema definition plus a natural-language query or set of requirements."
|
| 248 |
+
return "A plain-text string describing the user's request, requirements, or content to process."
|
| 249 |
|
| 250 |
|
| 251 |
def _infer_output_format(lower: str) -> str:
|
| 252 |
if any(k in lower for k in ["json", "structured", "object", "dict"]):
|
| 253 |
+
return "A well-formatted JSON object with clearly named, camelCase keys. No prose outside the JSON block. Include a JSON schema definition."
|
| 254 |
if any(k in lower for k in ["markdown", "md", "readme", "documentation", "doc"]):
|
| 255 |
+
return "A Markdown-formatted document with H1/H2/H3 hierarchy, fenced code blocks, and a table of contents."
|
| 256 |
if any(k in lower for k in ["code", "script", "function", "class", "component", "snippet"]):
|
| 257 |
+
return "Source code in a properly labelled fenced code block. Add a brief explanation before AND a usage example after."
|
| 258 |
+
if any(k in lower for k in ["list", "bullet", "steps", "enumerat", "checklist"]):
|
| 259 |
+
return "A numbered or bulleted list with concise, actionable items. Group related items under subheadings."
|
| 260 |
+
if any(k in lower for k in ["report", "analysis", "summary", "audit"]):
|
| 261 |
+
return "A structured report: Executive Summary → Findings → Recommendations → Appendix."
|
| 262 |
+
if any(k in lower for k in ["table", "comparison", "matrix", "grid"]):
|
| 263 |
+
return "A Markdown table with descriptive column headers, aligned cells, and a summary row."
|
| 264 |
+
if any(k in lower for k in ["email", "letter", "message", "memo"]):
|
| 265 |
+
return "Formatted email/letter with Subject, Greeting, Body, Sign-off. Formal register unless instructed otherwise."
|
| 266 |
+
return "A clear, well-structured plain-text response with logical section breaks."
|
| 267 |
|
| 268 |
|
| 269 |
def _build_constraints(lower: str, user_constraints: List[str]) -> List[str]:
|
| 270 |
found: List[str] = []
|
| 271 |
for pattern, constraint in _CONSTRAINT_PATTERNS:
|
| 272 |
+
if re.search(pattern, lower, re.IGNORECASE):
|
| 273 |
found.append(constraint)
|
| 274 |
+
seen = set(found)
|
| 275 |
for uc in user_constraints:
|
| 276 |
+
clean = uc.strip()
|
| 277 |
+
if clean and clean not in seen:
|
| 278 |
+
found.append(clean)
|
| 279 |
+
seen.add(clean)
|
| 280 |
if not found:
|
| 281 |
+
found.append("Keep the response concise and directly relevant to the stated task.")
|
| 282 |
return found
|
| 283 |
|
| 284 |
|
| 285 |
+
def _auto_tag(lower: str) -> List[str]:
|
| 286 |
+
"""Infer tags from instruction text for display in history."""
|
| 287 |
+
candidates = {
|
| 288 |
+
"react": ["react", "jsx", "tsx"],
|
| 289 |
+
"typescript": ["typescript", "tsx"],
|
| 290 |
+
"python": ["python", "fastapi", "flask", "django"],
|
| 291 |
+
"testing": ["test", "pytest", "jest", "coverage"],
|
| 292 |
+
"devops": ["docker", "kubernetes", "ci/cd", "terraform"],
|
| 293 |
+
"security": ["security", "auth", "jwt", "owasp"],
|
| 294 |
+
"ml": ["machine learning", "llm", "pytorch", "tensorflow"],
|
| 295 |
+
"frontend": ["css", "html", "tailwind", "ui", "component"],
|
| 296 |
+
"backend": ["api", "backend", "server", "endpoint"],
|
| 297 |
+
"database": ["sql", "database", "postgres", "mongo"],
|
| 298 |
+
"mobile": ["ios", "android", "react native", "flutter"],
|
| 299 |
+
"writing": ["blog", "article", "documentation", "readme"],
|
| 300 |
+
}
|
| 301 |
+
found = []
|
| 302 |
+
for tag, keywords in candidates.items():
|
| 303 |
+
if any(kw in lower for kw in keywords):
|
| 304 |
+
found.append(tag)
|
| 305 |
+
return found[:6]
|
| 306 |
+
|
| 307 |
+
|
| 308 |
def _build_examples(lower: str, role: str) -> Optional[List[Dict[str, str]]]:
|
| 309 |
+
examples = []
|
| 310 |
if "react" in lower or "component" in lower:
|
| 311 |
+
examples.append({
|
| 312 |
+
"input": "Create a reusable `<Button>` component.",
|
| 313 |
+
"output": (
|
| 314 |
+
"```tsx\ninterface ButtonProps {\n label: string;\n variant?: 'primary' | 'secondary' | 'danger';\n"
|
| 315 |
+
" onClick: () => void;\n disabled?: boolean;\n}\n\nexport const Button = ({\n label, variant = 'primary', onClick, disabled = false\n}: ButtonProps) => (\n"
|
| 316 |
+
" <button\n onClick={onClick}\n disabled={disabled}\n aria-disabled={disabled}\n"
|
| 317 |
+
" className={`px-4 py-2 rounded font-semibold transition ${{\n primary: 'bg-indigo-600 text-white hover:bg-indigo-700',\n"
|
| 318 |
+
" secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200',\n danger: 'bg-red-600 text-white hover:bg-red-700',\n }}[variant]}`}\n >\n {label}\n </button>\n);\n```"
|
| 319 |
+
),
|
| 320 |
+
})
|
| 321 |
if "summarize" in lower or "summary" in lower:
|
| 322 |
+
examples.append({
|
| 323 |
+
"input": "Summarise the following paragraph in one sentence.",
|
| 324 |
+
"output": "**Original**: 'The Apollo 11 mission…' → **Summary**: 'Apollo 11 was the first crewed mission to land on the Moon, on 20 July 1969.'",
|
| 325 |
+
})
|
| 326 |
if "fastapi" in lower or ("api" in lower and "endpoint" in lower):
|
| 327 |
+
examples.append({
|
| 328 |
+
"input": "Create a `GET /users` endpoint.",
|
| 329 |
+
"output": (
|
| 330 |
+
"```python\nfrom fastapi import APIRouter, Depends, HTTPException, status\n"
|
| 331 |
+
"from sqlalchemy.orm import Session\nfrom .schemas import UserOut\nfrom .models import User\nfrom .database import get_db\n\n"
|
| 332 |
+
"router = APIRouter(prefix='/users', tags=['Users'])\n\n"
|
| 333 |
+
"@router.get('/', response_model=list[UserOut], summary='List all users')\nasync def list_users(\n"
|
| 334 |
+
" skip: int = 0, limit: int = 100,\n db: Session = Depends(get_db),\n) -> list[User]:\n"
|
| 335 |
+
" return db.query(User).offset(skip).limit(limit).all()\n```"
|
| 336 |
+
),
|
| 337 |
+
})
|
| 338 |
+
if ("sql" in lower or "query" in lower) and not examples:
|
| 339 |
+
examples.append({
|
| 340 |
+
"input": "Get all users registered in the last 30 days.",
|
| 341 |
+
"output": "```sql\nSELECT id, email, created_at\nFROM users\nWHERE created_at >= NOW() - INTERVAL '30 days'\nORDER BY created_at DESC;\n```",
|
| 342 |
+
})
|
| 343 |
+
return examples if examples else None
|
| 344 |
|
| 345 |
|
| 346 |
def _generate_explanation(
|
| 347 |
+
role: str, instruction: str, constraints: List[str],
|
| 348 |
+
persona: PersonaType, style: StyleType,
|
|
|
|
|
|
|
|
|
|
| 349 |
) -> str:
|
| 350 |
+
instr_preview = instruction[:90].rstrip() + ("…" if len(instruction) > 90 else "")
|
| 351 |
+
style_desc = _STYLE_DESCRIPTIONS.get(style, "")[:80]
|
| 352 |
+
constraint_bullets = "\n".join(f" • {c}" for c in constraints[:5])
|
| 353 |
+
if len(constraints) > 5:
|
| 354 |
+
constraint_bullets += f"\n • … and {len(constraints) - 5} more"
|
| 355 |
+
|
| 356 |
+
return textwrap.dedent(f"""\
|
| 357 |
+
**Role Assignment — why "{role}"?**
|
| 358 |
+
The instruction "{instr_preview}" contains domain signals that map most precisely to a {role}. \
|
| 359 |
+
Assigning the correct expert role primes the model to adopt the right vocabulary, \
|
| 360 |
+
depth conventions, and problem-solving heuristics for this task.
|
| 361 |
+
|
| 362 |
+
**Style — "{style.value}"**
|
| 363 |
+
{style_desc}
|
| 364 |
+
This style was applied to calibrate verbosity, formality, and technical depth to \
|
| 365 |
+
the apparent audience and intent of the instruction.
|
| 366 |
+
|
| 367 |
+
**Constraints applied ({len(constraints)} total)**
|
| 368 |
+
These were inferred from keywords in the instruction and merged with any user-defined rules:
|
| 369 |
+
{constraint_bullets}
|
| 370 |
+
|
| 371 |
+
**Safety guardrails**
|
| 372 |
+
Six default safety rules are always injected, aligned with Google AI Studio's Responsible AI \
|
| 373 |
+
policies. They prevent harmful content, IP violations, and accidental data exposure.
|
| 374 |
+
|
| 375 |
+
**Few-shot examples**
|
| 376 |
+
Where domain patterns were detected (React, FastAPI, SQL, etc.), concrete input→output \
|
| 377 |
+
examples are injected to anchor the model's output format and quality expectations.
|
| 378 |
+
""").strip()
|
| 379 |
|
| 380 |
|
| 381 |
def _extract_key_decisions(manifest: PromptManifest) -> List[str]:
|
| 382 |
sp = manifest.structured_prompt
|
| 383 |
decisions = [
|
| 384 |
+
f"Role: {sp.role}",
|
| 385 |
+
f"Style: {manifest.style_used.value} — {_STYLE_DESCRIPTIONS[manifest.style_used][:55]}…",
|
| 386 |
+
f"Output type: {sp.output_format[:60]}…",
|
| 387 |
+
f"{len(sp.constraints)} constraint(s) applied",
|
| 388 |
+
f"{len(sp.safety)} safety guardrail(s) injected",
|
| 389 |
+
f"~{sp.word_count} words in generated prompt",
|
| 390 |
]
|
| 391 |
if sp.examples:
|
| 392 |
+
decisions.append(f"{len(sp.examples)} few-shot example(s) included")
|
| 393 |
+
if manifest.tags:
|
| 394 |
+
decisions.append(f"Auto-tagged: {', '.join(manifest.tags)}")
|
| 395 |
return decisions
|
| 396 |
|
| 397 |
|
|
|
|
| 402 |
extra_context: Optional[str] = None,
|
| 403 |
) -> str:
|
| 404 |
lines = [
|
| 405 |
+
f"## ROLE",
|
| 406 |
+
f"You are a {role}.",
|
| 407 |
+
"",
|
| 408 |
+
f"## TASK",
|
| 409 |
+
task,
|
| 410 |
+
"",
|
| 411 |
+
f"## INPUT FORMAT",
|
| 412 |
+
input_fmt,
|
| 413 |
+
"",
|
| 414 |
+
f"## OUTPUT FORMAT",
|
| 415 |
+
output_fmt,
|
| 416 |
+
"",
|
| 417 |
+
"## CONSTRAINTS",
|
| 418 |
]
|
| 419 |
for i, c in enumerate(constraints, 1):
|
| 420 |
lines.append(f"{i}. {c}")
|
| 421 |
+
lines += [
|
| 422 |
+
"",
|
| 423 |
+
"## STYLE & TONE",
|
| 424 |
+
style,
|
| 425 |
+
"",
|
| 426 |
+
"## SAFETY GUIDELINES",
|
| 427 |
+
]
|
| 428 |
for i, s in enumerate(safety, 1):
|
| 429 |
lines.append(f"{i}. {s}")
|
| 430 |
if extra_context:
|
| 431 |
+
lines += ["", "## ADDITIONAL CONTEXT", extra_context]
|
| 432 |
if examples:
|
| 433 |
+
lines += ["", "## FEW-SHOT EXAMPLES"]
|
| 434 |
for ex in examples:
|
| 435 |
+
lines += [f"**Input:** {ex['input']}", f"**Expected Output:**\n{ex['output']}", ""]
|
| 436 |
+
lines += [
|
| 437 |
+
"",
|
| 438 |
+
"---",
|
| 439 |
+
"*Generated by PromptForge v4.0 — optimised for Google AI Studio.*",
|
| 440 |
+
]
|
| 441 |
return "\n".join(lines)
|
backend/requirements.txt
CHANGED
|
@@ -1,8 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
python-multipart>=0.0.9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
pytest>=8.2.0
|
| 7 |
pytest-asyncio>=0.23.0
|
| 8 |
-
httpx>=0.27.0
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PromptForge v4.0 — Python dependencies
|
| 2 |
+
# Install: pip install -r requirements.txt
|
| 3 |
+
|
| 4 |
+
# ── Core framework ────────────────────────────────────────────────────
|
| 5 |
+
fastapi>=0.111.0,<0.120.0
|
| 6 |
+
uvicorn[standard]>=0.29.0,<0.35.0
|
| 7 |
+
pydantic>=2.7.0,<3.0.0
|
| 8 |
python-multipart>=0.0.9
|
| 9 |
+
|
| 10 |
+
# ── HTTP client (AI providers) ────────────────────────────────────────
|
| 11 |
+
httpx>=0.27.0,<0.30.0
|
| 12 |
+
|
| 13 |
+
# ── Dev / testing ─────────────────────────────────────────────────────
|
| 14 |
pytest>=8.2.0
|
| 15 |
pytest-asyncio>=0.23.0
|
| 16 |
+
httpx>=0.27.0 # also needed by TestClient
|
| 17 |
+
|
| 18 |
+
# ── Optional: static file serving ────────────────────────────────────
|
| 19 |
+
aiofiles>=23.2.1
|
backend/schemas.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge — Pydantic schemas
|
| 3 |
-
|
|
|
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
from datetime import datetime
|
| 8 |
from enum import Enum
|
| 9 |
-
from pydantic import BaseModel, Field
|
| 10 |
|
| 11 |
|
| 12 |
-
# ── Enumerations ────────────────────────────────────────────────────────────
|
| 13 |
|
| 14 |
class OutputFormat(str, Enum):
|
| 15 |
text = "text"
|
|
@@ -17,18 +18,20 @@ class OutputFormat(str, Enum):
|
|
| 17 |
both = "both"
|
| 18 |
|
| 19 |
class AIProvider(str, Enum):
|
| 20 |
-
none
|
| 21 |
huggingface = "huggingface"
|
| 22 |
-
google
|
| 23 |
|
| 24 |
class PersonaType(str, Enum):
|
| 25 |
-
default
|
| 26 |
-
senior_dev
|
| 27 |
-
data_scientist= "data_scientist"
|
| 28 |
-
tech_writer
|
| 29 |
-
product_mgr
|
| 30 |
-
security_eng
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
|
| 33 |
class StyleType(str, Enum):
|
| 34 |
professional = "professional"
|
|
@@ -38,192 +41,253 @@ class StyleType(str, Enum):
|
|
| 38 |
formal = "formal"
|
| 39 |
creative = "creative"
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
|
|
|
| 43 |
|
| 44 |
class InstructionSettings(BaseModel):
|
| 45 |
-
settings_id:
|
| 46 |
-
title:
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
description="
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
class InstructionSettingsCreate(BaseModel):
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
persona: PersonaType = Field(PersonaType.default)
|
| 79 |
custom_persona: Optional[str] = Field(None, max_length=200)
|
| 80 |
-
style:
|
| 81 |
-
constraints:
|
| 82 |
-
tags:
|
| 83 |
-
provider:
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
class InstructionSettingsUpdate(BaseModel):
|
| 88 |
-
title:
|
| 89 |
-
description:
|
| 90 |
-
instruction:
|
| 91 |
-
extra_context:
|
| 92 |
-
output_format:
|
| 93 |
-
persona:
|
| 94 |
-
custom_persona: Optional[str]
|
| 95 |
-
style:
|
| 96 |
-
constraints:
|
| 97 |
-
tags:
|
| 98 |
-
provider:
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
class InstructionSettingsList(BaseModel):
|
| 103 |
-
total:
|
| 104 |
-
items:
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
-
# ──
|
| 108 |
|
| 109 |
class GenerateRequest(BaseModel):
|
| 110 |
-
instruction:
|
| 111 |
-
output_format:
|
| 112 |
-
provider:
|
| 113 |
-
api_key:
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
persona:
|
| 118 |
-
custom_persona:
|
| 119 |
-
style:
|
| 120 |
-
user_constraints: List[str]
|
| 121 |
-
settings_id:
|
| 122 |
-
description="If provided, links this generation to a saved settings.")
|
| 123 |
|
| 124 |
|
| 125 |
class GenerateFromSettingsRequest(BaseModel):
|
| 126 |
settings_id: str
|
| 127 |
-
api_key:
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
class ApproveRequest(BaseModel):
|
| 132 |
prompt_id: str
|
| 133 |
-
edits:
|
| 134 |
|
| 135 |
|
| 136 |
class ExportRequest(BaseModel):
|
| 137 |
-
prompt_id:
|
| 138 |
export_format: OutputFormat = Field(OutputFormat.json)
|
| 139 |
|
| 140 |
|
| 141 |
class RefineRequest(BaseModel):
|
| 142 |
-
prompt_id:
|
| 143 |
-
feedback:
|
| 144 |
-
provider:
|
| 145 |
-
api_key:
|
| 146 |
|
| 147 |
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
class StructuredPrompt(BaseModel):
|
| 151 |
-
role:
|
| 152 |
-
task:
|
| 153 |
-
input_format:
|
| 154 |
output_format: str
|
| 155 |
-
constraints:
|
| 156 |
-
style:
|
| 157 |
-
safety:
|
| 158 |
-
examples:
|
| 159 |
raw_prompt_text: str
|
|
|
|
| 160 |
|
| 161 |
|
| 162 |
class PromptManifest(BaseModel):
|
| 163 |
-
prompt_id:
|
| 164 |
-
version:
|
| 165 |
-
created_at:
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 168 |
structured_prompt: StructuredPrompt
|
| 169 |
enhancement_notes: Optional[str] = None
|
| 170 |
-
explanation:
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
|
| 177 |
|
| 178 |
-
# ── Response models ─────────────────────────────────────────────────────────
|
| 179 |
|
| 180 |
class GenerateResponse(BaseModel):
|
| 181 |
-
success:
|
| 182 |
prompt_id: str
|
| 183 |
-
manifest:
|
| 184 |
-
message:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
class ApproveResponse(BaseModel):
|
| 188 |
-
success:
|
| 189 |
-
prompt_id:
|
| 190 |
-
message:
|
| 191 |
finalized_prompt: StructuredPrompt
|
| 192 |
|
| 193 |
|
| 194 |
class ExportResponse(BaseModel):
|
| 195 |
-
success:
|
| 196 |
prompt_id: str
|
| 197 |
-
data:
|
| 198 |
|
| 199 |
|
| 200 |
class ExplainResponse(BaseModel):
|
| 201 |
-
prompt_id:
|
| 202 |
-
explanation:
|
| 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):
|
| 208 |
-
prompt_id:
|
| 209 |
-
version:
|
| 210 |
-
created_at:
|
|
|
|
| 211 |
instruction: str
|
| 212 |
-
status:
|
| 213 |
settings_id: Optional[str] = None
|
| 214 |
explanation: Optional[str] = None
|
|
|
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
class HistoryResponse(BaseModel):
|
| 218 |
-
total:
|
| 219 |
entries: List[HistoryEntry]
|
| 220 |
|
| 221 |
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
class EnvConfigStatus(BaseModel):
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge v4.0 — Pydantic schemas
|
| 3 |
+
Upgrades: batch generation, stats endpoint, search/filter, duplicate,
|
| 4 |
+
tag management, provider model selection, and theme preference.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
from typing import Any, Dict, List, Optional
|
| 8 |
from datetime import datetime
|
| 9 |
from enum import Enum
|
| 10 |
+
from pydantic import BaseModel, Field, field_validator
|
| 11 |
|
| 12 |
|
| 13 |
+
# ── Enumerations ─────────────────────────────────────────────────────────────
|
| 14 |
|
| 15 |
class OutputFormat(str, Enum):
|
| 16 |
text = "text"
|
|
|
|
| 18 |
both = "both"
|
| 19 |
|
| 20 |
class AIProvider(str, Enum):
|
| 21 |
+
none = "none"
|
| 22 |
huggingface = "huggingface"
|
| 23 |
+
google = "google"
|
| 24 |
|
| 25 |
class PersonaType(str, Enum):
|
| 26 |
+
default = "default"
|
| 27 |
+
senior_dev = "senior_dev"
|
| 28 |
+
data_scientist = "data_scientist"
|
| 29 |
+
tech_writer = "tech_writer"
|
| 30 |
+
product_mgr = "product_mgr"
|
| 31 |
+
security_eng = "security_eng"
|
| 32 |
+
devops_eng = "devops_eng"
|
| 33 |
+
ml_engineer = "ml_engineer"
|
| 34 |
+
custom = "custom"
|
| 35 |
|
| 36 |
class StyleType(str, Enum):
|
| 37 |
professional = "professional"
|
|
|
|
| 41 |
formal = "formal"
|
| 42 |
creative = "creative"
|
| 43 |
|
| 44 |
+
class PromptStatus(str, Enum):
|
| 45 |
+
pending = "pending"
|
| 46 |
+
approved = "approved"
|
| 47 |
+
exported = "exported"
|
| 48 |
+
archived = "archived"
|
| 49 |
|
| 50 |
+
|
| 51 |
+
# ── InstructionSettings ───────────────────────────────────────────────────────
|
| 52 |
|
| 53 |
class InstructionSettings(BaseModel):
|
| 54 |
+
settings_id: str = Field(..., description="Unique UUID for this setting.")
|
| 55 |
+
title: str = Field(..., min_length=2, max_length=120)
|
| 56 |
+
description: Optional[str] = Field(None, max_length=1000)
|
| 57 |
+
instruction: str = Field(..., min_length=5, max_length=8000)
|
| 58 |
+
extra_context: Optional[str] = Field(None, max_length=2000)
|
| 59 |
+
output_format: OutputFormat = Field(OutputFormat.both)
|
| 60 |
+
persona: PersonaType = Field(PersonaType.default)
|
| 61 |
+
custom_persona: Optional[str] = Field(None, max_length=200)
|
| 62 |
+
style: StyleType = Field(StyleType.professional)
|
| 63 |
+
constraints: List[str] = Field(default_factory=list)
|
| 64 |
+
tags: List[str] = Field(default_factory=list)
|
| 65 |
+
provider: AIProvider = Field(AIProvider.none)
|
| 66 |
+
provider_model: Optional[str] = Field(None, max_length=120,
|
| 67 |
+
description="Override the default model for the selected provider.")
|
| 68 |
+
enhance: bool = Field(False)
|
| 69 |
+
is_favorite: bool = Field(False, description="Starred/pinned setting.")
|
| 70 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 71 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 72 |
+
use_count: int = Field(0)
|
| 73 |
+
version: int = Field(1, description="Edit version number.")
|
| 74 |
+
|
| 75 |
+
@field_validator("tags", mode="before")
|
| 76 |
+
@classmethod
|
| 77 |
+
def normalize_tags(cls, v: List[str]) -> List[str]:
|
| 78 |
+
return [t.strip().lower() for t in v if t.strip()]
|
| 79 |
|
| 80 |
|
| 81 |
class InstructionSettingsCreate(BaseModel):
|
| 82 |
+
title: str = Field(..., min_length=2, max_length=120)
|
| 83 |
+
description: Optional[str] = Field(None, max_length=1000)
|
| 84 |
+
instruction: str = Field(..., min_length=5, max_length=8000)
|
| 85 |
+
extra_context: Optional[str] = Field(None, max_length=2000)
|
| 86 |
+
output_format: OutputFormat = Field(OutputFormat.both)
|
| 87 |
+
persona: PersonaType = Field(PersonaType.default)
|
|
|
|
| 88 |
custom_persona: Optional[str] = Field(None, max_length=200)
|
| 89 |
+
style: StyleType = Field(StyleType.professional)
|
| 90 |
+
constraints: List[str] = Field(default_factory=list)
|
| 91 |
+
tags: List[str] = Field(default_factory=list)
|
| 92 |
+
provider: AIProvider = Field(AIProvider.none)
|
| 93 |
+
provider_model: Optional[str] = Field(None, max_length=120)
|
| 94 |
+
enhance: bool = Field(False)
|
| 95 |
+
is_favorite: bool = Field(False)
|
| 96 |
|
| 97 |
|
| 98 |
class InstructionSettingsUpdate(BaseModel):
|
| 99 |
+
title: Optional[str] = Field(None, max_length=120)
|
| 100 |
+
description: Optional[str] = None
|
| 101 |
+
instruction: Optional[str] = Field(None, min_length=5, max_length=8000)
|
| 102 |
+
extra_context: Optional[str] = None
|
| 103 |
+
output_format: Optional[OutputFormat] = None
|
| 104 |
+
persona: Optional[PersonaType] = None
|
| 105 |
+
custom_persona: Optional[str] = None
|
| 106 |
+
style: Optional[StyleType] = None
|
| 107 |
+
constraints: Optional[List[str]] = None
|
| 108 |
+
tags: Optional[List[str]] = None
|
| 109 |
+
provider: Optional[AIProvider] = None
|
| 110 |
+
provider_model: Optional[str] = None
|
| 111 |
+
enhance: Optional[bool] = None
|
| 112 |
+
is_favorite: Optional[bool] = None
|
| 113 |
|
| 114 |
|
| 115 |
class InstructionSettingsList(BaseModel):
|
| 116 |
+
total: int
|
| 117 |
+
items: List[InstructionSettings]
|
| 118 |
+
favorites: int = Field(0, description="Count of starred settings.")
|
| 119 |
|
| 120 |
|
| 121 |
+
# ── Prompt models ─────────────────────────────────────────────────────────────
|
| 122 |
|
| 123 |
class GenerateRequest(BaseModel):
|
| 124 |
+
instruction: str = Field(..., min_length=5, max_length=8000)
|
| 125 |
+
output_format: OutputFormat = Field(OutputFormat.both)
|
| 126 |
+
provider: AIProvider = Field(AIProvider.none)
|
| 127 |
+
api_key: Optional[str] = Field(None)
|
| 128 |
+
provider_model: Optional[str] = Field(None)
|
| 129 |
+
enhance: bool = Field(False)
|
| 130 |
+
extra_context: Optional[str] = Field(None, max_length=2000)
|
| 131 |
+
persona: PersonaType = Field(PersonaType.default)
|
| 132 |
+
custom_persona: Optional[str] = Field(None, max_length=200)
|
| 133 |
+
style: StyleType = Field(StyleType.professional)
|
| 134 |
+
user_constraints: List[str] = Field(default_factory=list)
|
| 135 |
+
settings_id: Optional[str] = Field(None)
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
class GenerateFromSettingsRequest(BaseModel):
|
| 139 |
settings_id: str
|
| 140 |
+
api_key: Optional[str] = Field(None)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class BatchGenerateRequest(BaseModel):
|
| 144 |
+
"""Generate multiple prompts in one call."""
|
| 145 |
+
requests: List[GenerateRequest] = Field(..., min_length=1, max_length=10)
|
| 146 |
|
| 147 |
|
| 148 |
class ApproveRequest(BaseModel):
|
| 149 |
prompt_id: str
|
| 150 |
+
edits: Optional[Dict[str, Any]] = None
|
| 151 |
|
| 152 |
|
| 153 |
class ExportRequest(BaseModel):
|
| 154 |
+
prompt_id: str
|
| 155 |
export_format: OutputFormat = Field(OutputFormat.json)
|
| 156 |
|
| 157 |
|
| 158 |
class RefineRequest(BaseModel):
|
| 159 |
+
prompt_id: str
|
| 160 |
+
feedback: str = Field(..., min_length=3, max_length=2000)
|
| 161 |
+
provider: AIProvider = Field(AIProvider.none)
|
| 162 |
+
api_key: Optional[str] = Field(None)
|
| 163 |
|
| 164 |
|
| 165 |
+
class SearchRequest(BaseModel):
|
| 166 |
+
query: str = Field(..., min_length=1, max_length=500)
|
| 167 |
+
search_in: List[str] = Field(default_factory=lambda: ["instruction", "role", "task"])
|
| 168 |
+
status_filter: Optional[PromptStatus] = None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ── Structured prompt ─────────────────────────────────────────────────────────
|
| 172 |
|
| 173 |
class StructuredPrompt(BaseModel):
|
| 174 |
+
role: str
|
| 175 |
+
task: str
|
| 176 |
+
input_format: str
|
| 177 |
output_format: str
|
| 178 |
+
constraints: List[str] = Field(default_factory=list)
|
| 179 |
+
style: str
|
| 180 |
+
safety: List[str] = Field(default_factory=list)
|
| 181 |
+
examples: Optional[List[Dict[str, str]]] = None
|
| 182 |
raw_prompt_text: str
|
| 183 |
+
word_count: int = Field(0, description="Approximate word count of raw_prompt_text.")
|
| 184 |
|
| 185 |
|
| 186 |
class PromptManifest(BaseModel):
|
| 187 |
+
prompt_id: str
|
| 188 |
+
version: int = 1
|
| 189 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 190 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 191 |
+
instruction: str
|
| 192 |
+
status: str = "pending"
|
| 193 |
structured_prompt: StructuredPrompt
|
| 194 |
enhancement_notes: Optional[str] = None
|
| 195 |
+
explanation: Optional[str] = None
|
| 196 |
+
settings_id: Optional[str] = None
|
| 197 |
+
persona_used: PersonaType = Field(PersonaType.default)
|
| 198 |
+
style_used: StyleType = Field(StyleType.professional)
|
| 199 |
+
tags: List[str] = Field(default_factory=list)
|
| 200 |
+
is_favorite: bool = Field(False)
|
| 201 |
|
| 202 |
|
| 203 |
+
# ── Response models ───────────────────────────────────────────────────────────
|
| 204 |
|
| 205 |
class GenerateResponse(BaseModel):
|
| 206 |
+
success: bool
|
| 207 |
prompt_id: str
|
| 208 |
+
manifest: PromptManifest
|
| 209 |
+
message: str = "Manifest generated — awaiting approval."
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class BatchGenerateResponse(BaseModel):
|
| 213 |
+
success: bool
|
| 214 |
+
total: int
|
| 215 |
+
results: List[GenerateResponse]
|
| 216 |
+
failed: int = 0
|
| 217 |
|
| 218 |
|
| 219 |
class ApproveResponse(BaseModel):
|
| 220 |
+
success: bool
|
| 221 |
+
prompt_id: str
|
| 222 |
+
message: str
|
| 223 |
finalized_prompt: StructuredPrompt
|
| 224 |
|
| 225 |
|
| 226 |
class ExportResponse(BaseModel):
|
| 227 |
+
success: bool
|
| 228 |
prompt_id: str
|
| 229 |
+
data: Any
|
| 230 |
|
| 231 |
|
| 232 |
class ExplainResponse(BaseModel):
|
| 233 |
+
prompt_id: str
|
| 234 |
+
explanation: str
|
| 235 |
+
key_decisions: List[str] = Field(default_factory=list)
|
|
|
|
| 236 |
|
| 237 |
|
| 238 |
class HistoryEntry(BaseModel):
|
| 239 |
+
prompt_id: str
|
| 240 |
+
version: int
|
| 241 |
+
created_at: datetime
|
| 242 |
+
updated_at: datetime
|
| 243 |
instruction: str
|
| 244 |
+
status: str
|
| 245 |
settings_id: Optional[str] = None
|
| 246 |
explanation: Optional[str] = None
|
| 247 |
+
tags: List[str] = Field(default_factory=list)
|
| 248 |
+
is_favorite: bool = False
|
| 249 |
|
| 250 |
|
| 251 |
class HistoryResponse(BaseModel):
|
| 252 |
+
total: int
|
| 253 |
entries: List[HistoryEntry]
|
| 254 |
|
| 255 |
|
| 256 |
+
class SearchResult(BaseModel):
|
| 257 |
+
prompt_id: str
|
| 258 |
+
instruction: str
|
| 259 |
+
status: str
|
| 260 |
+
score: float
|
| 261 |
+
snippet: str
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
class SearchResponse(BaseModel):
|
| 265 |
+
total: int
|
| 266 |
+
results: List[SearchResult]
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class StatsResponse(BaseModel):
|
| 270 |
+
total_prompts: int
|
| 271 |
+
total_settings: int
|
| 272 |
+
pending_count: int
|
| 273 |
+
approved_count: int
|
| 274 |
+
exported_count: int
|
| 275 |
+
archived_count: int
|
| 276 |
+
favorite_prompts: int
|
| 277 |
+
favorite_settings: int
|
| 278 |
+
top_personas: Dict[str, int]
|
| 279 |
+
top_styles: Dict[str, int]
|
| 280 |
+
total_refinements: int
|
| 281 |
+
avg_constraints: float
|
| 282 |
+
uptime_since: datetime
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# ── Env config ────────────────────────────────────────────────────────────────
|
| 286 |
|
| 287 |
class EnvConfigStatus(BaseModel):
|
| 288 |
+
hf_key_set: bool = Field(..., description="True if HF_API_KEY is set.")
|
| 289 |
+
google_key_set: bool = Field(..., description="True if GOOGLE_API_KEY is set.")
|
| 290 |
+
port: str = "7860"
|
| 291 |
+
version: str = "4.0"
|
| 292 |
+
hf_model: str = "mistralai/Mistral-7B-Instruct-v0.2"
|
| 293 |
+
google_model: str = "gemini-1.5-flash"
|
backend/store.py
CHANGED
|
@@ -1,22 +1,28 @@
|
|
| 1 |
"""
|
| 2 |
-
PromptForge —
|
|
|
|
|
|
|
| 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
|
| 9 |
|
| 10 |
-
from schemas import PromptManifest, HistoryEntry
|
| 11 |
|
| 12 |
logger = logging.getLogger("promptforge.store")
|
| 13 |
|
| 14 |
-
_DB:
|
| 15 |
-
_LOG_DIR
|
| 16 |
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
_PERSIST_FILE = _LOG_DIR / "prompt_history.json"
|
|
|
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
| 20 |
def save(manifest: PromptManifest) -> None:
|
| 21 |
_DB[manifest.prompt_id] = manifest
|
| 22 |
_persist()
|
|
@@ -26,18 +32,32 @@ def get(prompt_id: str) -> Optional[PromptManifest]:
|
|
| 26 |
return _DB.get(prompt_id)
|
| 27 |
|
| 28 |
|
| 29 |
-
def all_entries(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return [
|
| 31 |
HistoryEntry(
|
| 32 |
prompt_id=m.prompt_id,
|
| 33 |
version=m.version,
|
| 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
|
| 41 |
]
|
| 42 |
|
| 43 |
|
|
@@ -49,6 +69,83 @@ def delete(prompt_id: str) -> bool:
|
|
| 49 |
return False
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def _persist() -> None:
|
| 53 |
try:
|
| 54 |
data = [m.model_dump(mode="json") for m in _DB.values()]
|
|
|
|
| 1 |
"""
|
| 2 |
+
PromptForge v4.0 — Prompt manifest store.
|
| 3 |
+
Upgrades: full-text search, stats aggregation, archive/unarchive,
|
| 4 |
+
favorite toggle, bulk-delete, and improved persistence.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
import json, os, logging
|
| 8 |
+
from collections import Counter
|
| 9 |
from datetime import datetime
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Dict, List, Optional
|
| 12 |
|
| 13 |
+
from schemas import PromptManifest, HistoryEntry, PromptStatus
|
| 14 |
|
| 15 |
logger = logging.getLogger("promptforge.store")
|
| 16 |
|
| 17 |
+
_DB: Dict[str, PromptManifest] = {}
|
| 18 |
+
_LOG_DIR = Path(os.getenv("LOG_DIR", "logs"))
|
| 19 |
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 20 |
_PERSIST_FILE = _LOG_DIR / "prompt_history.json"
|
| 21 |
+
_STARTED_AT = datetime.utcnow()
|
| 22 |
|
| 23 |
|
| 24 |
+
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
def save(manifest: PromptManifest) -> None:
|
| 27 |
_DB[manifest.prompt_id] = manifest
|
| 28 |
_persist()
|
|
|
|
| 32 |
return _DB.get(prompt_id)
|
| 33 |
|
| 34 |
|
| 35 |
+
def all_entries(
|
| 36 |
+
status_filter: Optional[str] = None,
|
| 37 |
+
tag_filter: Optional[str] = None,
|
| 38 |
+
favorites_only: bool = False,
|
| 39 |
+
) -> List[HistoryEntry]:
|
| 40 |
+
items = sorted(_DB.values(), key=lambda x: x.created_at, reverse=True)
|
| 41 |
+
if status_filter:
|
| 42 |
+
items = [m for m in items if m.status == status_filter]
|
| 43 |
+
if tag_filter:
|
| 44 |
+
items = [m for m in items if tag_filter in m.tags]
|
| 45 |
+
if favorites_only:
|
| 46 |
+
items = [m for m in items if m.is_favorite]
|
| 47 |
return [
|
| 48 |
HistoryEntry(
|
| 49 |
prompt_id=m.prompt_id,
|
| 50 |
version=m.version,
|
| 51 |
created_at=m.created_at,
|
| 52 |
+
updated_at=m.updated_at,
|
| 53 |
instruction=m.instruction,
|
| 54 |
status=m.status,
|
| 55 |
settings_id=m.settings_id,
|
| 56 |
explanation=m.explanation,
|
| 57 |
+
tags=m.tags,
|
| 58 |
+
is_favorite=m.is_favorite,
|
| 59 |
)
|
| 60 |
+
for m in items
|
| 61 |
]
|
| 62 |
|
| 63 |
|
|
|
|
| 69 |
return False
|
| 70 |
|
| 71 |
|
| 72 |
+
def bulk_delete(prompt_ids: List[str]) -> int:
|
| 73 |
+
deleted = 0
|
| 74 |
+
for pid in prompt_ids:
|
| 75 |
+
if pid in _DB:
|
| 76 |
+
del _DB[pid]
|
| 77 |
+
deleted += 1
|
| 78 |
+
if deleted:
|
| 79 |
+
_persist()
|
| 80 |
+
return deleted
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def toggle_favorite(prompt_id: str) -> Optional[bool]:
|
| 84 |
+
m = _DB.get(prompt_id)
|
| 85 |
+
if not m:
|
| 86 |
+
return None
|
| 87 |
+
new_val = not m.is_favorite
|
| 88 |
+
_DB[prompt_id] = m.model_copy(update={"is_favorite": new_val, "updated_at": datetime.utcnow()})
|
| 89 |
+
_persist()
|
| 90 |
+
return new_val
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def archive(prompt_id: str) -> bool:
|
| 94 |
+
m = _DB.get(prompt_id)
|
| 95 |
+
if not m:
|
| 96 |
+
return False
|
| 97 |
+
_DB[prompt_id] = m.model_copy(update={"status": "archived", "updated_at": datetime.utcnow()})
|
| 98 |
+
_persist()
|
| 99 |
+
return True
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def search(query: str, status_filter: Optional[str] = None) -> List[dict]:
|
| 103 |
+
"""Simple full-text search across instruction + role + task fields."""
|
| 104 |
+
q_lower = query.lower()
|
| 105 |
+
results = []
|
| 106 |
+
for m in _DB.values():
|
| 107 |
+
if status_filter and m.status != status_filter:
|
| 108 |
+
continue
|
| 109 |
+
sp = m.structured_prompt
|
| 110 |
+
corpus = f"{m.instruction} {sp.role} {sp.task} {' '.join(m.tags)}".lower()
|
| 111 |
+
if q_lower in corpus:
|
| 112 |
+
idx = corpus.find(q_lower)
|
| 113 |
+
snippet = corpus[max(0, idx - 40): idx + 80].strip()
|
| 114 |
+
results.append({
|
| 115 |
+
"prompt_id": m.prompt_id,
|
| 116 |
+
"instruction": m.instruction[:120],
|
| 117 |
+
"status": m.status,
|
| 118 |
+
"score": corpus.count(q_lower) / max(1, len(corpus.split())),
|
| 119 |
+
"snippet": f"…{snippet}…",
|
| 120 |
+
})
|
| 121 |
+
results.sort(key=lambda x: x["score"], reverse=True)
|
| 122 |
+
return results
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def get_stats() -> dict:
|
| 126 |
+
statuses = Counter(m.status for m in _DB.values())
|
| 127 |
+
personas = Counter(m.persona_used.value for m in _DB.values())
|
| 128 |
+
styles = Counter(m.style_used.value for m in _DB.values())
|
| 129 |
+
refinements = sum(m.version - 1 for m in _DB.values())
|
| 130 |
+
all_constraints = [c for m in _DB.values() for c in m.structured_prompt.constraints]
|
| 131 |
+
avg_c = len(all_constraints) / max(1, len(_DB))
|
| 132 |
+
return {
|
| 133 |
+
"total_prompts": len(_DB),
|
| 134 |
+
"pending_count": statuses.get("pending", 0),
|
| 135 |
+
"approved_count": statuses.get("approved", 0),
|
| 136 |
+
"exported_count": statuses.get("exported", 0),
|
| 137 |
+
"archived_count": statuses.get("archived", 0),
|
| 138 |
+
"favorite_prompts": sum(1 for m in _DB.values() if m.is_favorite),
|
| 139 |
+
"top_personas": dict(personas.most_common(5)),
|
| 140 |
+
"top_styles": dict(styles.most_common(5)),
|
| 141 |
+
"total_refinements": refinements,
|
| 142 |
+
"avg_constraints": round(avg_c, 1),
|
| 143 |
+
"uptime_since": _STARTED_AT,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ── Persistence ───────────────────────────────────────────────────────────────
|
| 148 |
+
|
| 149 |
def _persist() -> None:
|
| 150 |
try:
|
| 151 |
data = [m.model_dump(mode="json") for m in _DB.values()]
|
frontend/client.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
| 1 |
/**
|
| 2 |
-
* PromptForge
|
| 3 |
-
*
|
|
|
|
|
|
|
| 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
|
| 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);
|
|
@@ -23,161 +27,248 @@ async function apiFetch(path, method = "GET", body = null) {
|
|
| 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 = `<
|
| 32 |
$("toast-container").appendChild(t);
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
-
/* ── Loading ────────────────────────────────────────────────────
|
| 37 |
function setLoading(btn, on) {
|
|
|
|
| 38 |
btn.disabled = on;
|
| 39 |
-
btn._orig
|
| 40 |
btn.innerHTML = on ? `<span class="spinner"></span> Working…` : btn._orig;
|
| 41 |
}
|
| 42 |
|
| 43 |
-
/* ──
|
| 44 |
-
document.querySelectorAll(".
|
| 45 |
-
|
| 46 |
-
document.querySelectorAll(".
|
| 47 |
-
document.querySelectorAll(".
|
| 48 |
-
|
| 49 |
-
const
|
| 50 |
-
const page = $(`tab-${name}`);
|
| 51 |
if (page) { show(page); page.classList.add("active"); }
|
| 52 |
-
|
| 53 |
-
if (
|
|
|
|
|
|
|
| 54 |
});
|
| 55 |
});
|
| 56 |
|
| 57 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
async function loadConfig() {
|
| 59 |
try {
|
| 60 |
const cfg = await apiFetch("/api/config");
|
| 61 |
-
const
|
| 62 |
-
const
|
| 63 |
-
if (cfg.hf_key_set)
|
| 64 |
-
if (cfg.google_key_set)
|
| 65 |
} catch {}
|
| 66 |
}
|
| 67 |
|
| 68 |
-
/* ── API
|
| 69 |
-
const
|
| 70 |
-
none: { hint:"
|
| 71 |
-
google: { hint:"
|
| 72 |
-
huggingface: { hint:"
|
| 73 |
};
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
const p = $("provider").value;
|
| 77 |
-
const info =
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
$("api-key").
|
| 87 |
-
$("
|
| 88 |
-
|
| 89 |
}
|
| 90 |
-
}
|
| 91 |
|
| 92 |
-
|
| 93 |
-
const
|
| 94 |
-
|
| 95 |
-
$("
|
| 96 |
-
|
| 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 |
-
|
| 110 |
-
$("
|
| 111 |
-
const
|
| 112 |
-
if (
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
$("
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
});
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
$("gen-persona")?.addEventListener("change", () => {
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
});
|
| 125 |
|
| 126 |
-
/* ──
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
| 137 |
});
|
| 138 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
document.addEventListener("click", e => {
|
| 142 |
-
const btn = e.target.closest(".
|
| 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 =
|
| 150 |
});
|
| 151 |
});
|
| 152 |
|
| 153 |
-
/* ──
|
| 154 |
-
|
| 155 |
-
|
| 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",
|
|
|
|
|
|
|
| 169 |
const instruction = $("instruction").value.trim();
|
| 170 |
-
if (instruction.length < 5) { toast("
|
| 171 |
const btn = $("btn-generate");
|
| 172 |
setLoading(btn, true);
|
| 173 |
try {
|
| 174 |
-
const provider
|
| 175 |
-
const apiKey
|
| 176 |
-
const persona
|
| 177 |
-
const style
|
| 178 |
-
const
|
| 179 |
-
const
|
| 180 |
-
const
|
|
|
|
| 181 |
|
| 182 |
const data = await apiFetch("/api/generate", "POST", {
|
| 183 |
instruction,
|
|
@@ -185,59 +276,74 @@ $("btn-generate")?.addEventListener("click", async () => {
|
|
| 185 |
provider, api_key: apiKey,
|
| 186 |
enhance: provider !== "none" && !!apiKey,
|
| 187 |
extra_context: $("extra-context").value.trim() || null,
|
| 188 |
-
persona, custom_persona:
|
| 189 |
-
user_constraints:
|
|
|
|
| 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 |
-
|
|
|
|
| 197 |
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 198 |
finally { setLoading(btn, false); }
|
| 199 |
-
}
|
| 200 |
|
|
|
|
| 201 |
function renderManifest(manifest) {
|
| 202 |
-
const sp
|
| 203 |
const grid = $("manifest-grid");
|
| 204 |
grid.innerHTML = "";
|
| 205 |
-
[
|
| 206 |
-
{ key:"role",
|
| 207 |
-
{ key:"style",
|
| 208 |
-
{ key:"task",
|
| 209 |
-
{ key:"input_format",
|
| 210 |
-
{ key:"output_format",label:"Output Format", value:sp.output_format,
|
| 211 |
-
{ key:"constraints",
|
| 212 |
-
{ key:"safety",
|
| 213 |
-
]
|
|
|
|
| 214 |
const d = document.createElement("div");
|
| 215 |
d.className = `manifest-field${f.full?" full":""}`;
|
| 216 |
-
d.innerHTML = `<label>${esc(f.label)}</label>
|
|
|
|
| 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 |
-
|
| 233 |
-
|
|
|
|
| 234 |
show(panel);
|
| 235 |
panel.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
| 236 |
-
} catch (e) { toast(`
|
| 237 |
finally { setLoading(btn, false); }
|
| 238 |
});
|
| 239 |
|
| 240 |
-
/* ──
|
| 241 |
$("btn-approve")?.addEventListener("click", async () => {
|
| 242 |
if (!currentPromptId) return;
|
| 243 |
const edits = {};
|
|
@@ -264,7 +370,19 @@ function renderFinalized(sp) {
|
|
| 264 |
$("finalized-json").textContent = JSON.stringify(sp, null, 2);
|
| 265 |
}
|
| 266 |
|
| 267 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
async function doExport(format) {
|
| 269 |
if (!currentPromptId) return;
|
| 270 |
try {
|
|
@@ -283,23 +401,32 @@ async function doExport(format) {
|
|
| 283 |
$("btn-export-json")?.addEventListener("click", () => doExport("json"));
|
| 284 |
$("btn-export-txt")?.addEventListener("click", () => doExport("text"));
|
| 285 |
|
| 286 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
$("btn-save-as-setting")?.addEventListener("click", () => {
|
| 288 |
-
|
| 289 |
-
const
|
| 290 |
-
|
| 291 |
-
document.querySelector('[data-main-tab="settings"]')?.click();
|
| 292 |
setTimeout(() => {
|
| 293 |
clearSettingsForm();
|
| 294 |
-
if ($("s-title"))
|
| 295 |
-
if ($("s-instruction"))
|
| 296 |
if ($("s-extra-context")) $("s-extra-context").value = context;
|
| 297 |
$("s-title")?.focus();
|
| 298 |
-
toast("Pre-filled from
|
| 299 |
}, 150);
|
| 300 |
});
|
| 301 |
|
| 302 |
-
/* ──
|
| 303 |
$("btn-refine")?.addEventListener("click", () => {
|
| 304 |
hide($("step-finalized")); show($("step-refine"));
|
| 305 |
$("step-refine").scrollIntoView({ behavior:"smooth", block:"start" });
|
|
@@ -310,14 +437,14 @@ $("btn-cancel-refine")?.addEventListener("click", () => {
|
|
| 310 |
});
|
| 311 |
$("btn-submit-refine")?.addEventListener("click", async () => {
|
| 312 |
const feedback = $("feedback").value.trim();
|
| 313 |
-
if (!feedback) { toast("
|
| 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:
|
| 321 |
});
|
| 322 |
currentPromptId = data.prompt_id;
|
| 323 |
renderManifest(data.manifest);
|
|
@@ -329,7 +456,7 @@ $("btn-submit-refine")?.addEventListener("click", async () => {
|
|
| 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 = "";
|
|
@@ -338,18 +465,18 @@ $("btn-reset")?.addEventListener("click", () => {
|
|
| 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
|
| 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 => {
|
|
@@ -358,94 +485,75 @@ $("modal-search")?.addEventListener("input", () => {
|
|
| 358 |
});
|
| 359 |
|
| 360 |
async function loadSettingsForModal() {
|
| 361 |
-
const list = $("modal-
|
| 362 |
try {
|
| 363 |
const data = await apiFetch("/api/instructions");
|
| 364 |
if (!data.items?.length) {
|
| 365 |
-
list.innerHTML = `<div class="modal-empty">No saved settings
|
| 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,
|
| 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(
|
| 378 |
});
|
| 379 |
});
|
| 380 |
} catch (e) {
|
| 381 |
-
list.innerHTML = `<div class="modal-empty">Failed
|
| 382 |
}
|
| 383 |
}
|
| 384 |
|
| 385 |
async function generateFromSetting(sid) {
|
| 386 |
const btn = $("btn-generate");
|
| 387 |
setLoading(btn, true);
|
| 388 |
-
|
| 389 |
-
document.querySelector('[data-main-tab="generate"]')?.click();
|
| 390 |
try {
|
| 391 |
-
const apiKey = $("api-key")?.value
|
| 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 |
-
|
|
|
|
| 399 |
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 400 |
finally { setLoading(btn, false); }
|
| 401 |
}
|
| 402 |
|
| 403 |
-
/*
|
| 404 |
-
|
| 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
|
| 425 |
const instruction = $("s-instruction").value.trim();
|
| 426 |
-
if (!title)
|
| 427 |
-
if (instruction.length < 5)
|
| 428 |
|
| 429 |
const editId = $("edit-settings-id").value;
|
| 430 |
const persona = $("s-persona").value;
|
| 431 |
-
const
|
| 432 |
-
const
|
| 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:
|
| 439 |
instruction,
|
| 440 |
-
extra_context:
|
| 441 |
-
output_format:
|
| 442 |
persona,
|
| 443 |
-
custom_persona: persona === "custom" ? ($("s-custom-persona").value.trim() || null) : null,
|
| 444 |
-
style:
|
| 445 |
-
constraints,
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
};
|
| 450 |
|
| 451 |
const btn = $("btn-settings-save");
|
|
@@ -464,123 +572,166 @@ $("btn-settings-save")?.addEventListener("click", async () => {
|
|
| 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 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
if ($("s-enhance"))
|
|
|
|
| 480 |
if ($("s-custom-persona-field")) $("s-custom-persona-field").style.display = "none";
|
| 481 |
-
if ($("s-
|
| 482 |
-
if ($("settings-form-title")) $("settings-form-title").textContent = "➕ New
|
| 483 |
-
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
| 485 |
}
|
| 486 |
|
| 487 |
-
/* ── Load settings list ── */
|
| 488 |
async function loadSettingsList() {
|
| 489 |
try {
|
| 490 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
allSettings = data.items || [];
|
| 492 |
renderSettingsList(allSettings);
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 508 |
return;
|
| 509 |
}
|
| 510 |
container.innerHTML = items.map(s => `
|
| 511 |
-
<div class="setting-card"
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
| 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
|
| 518 |
-
<span class="tag-chip style">${esc(s.style)}</span>
|
| 519 |
-
<span class="use-count">${s.use_count}
|
| 520 |
</div>
|
| 521 |
<div class="setting-card-actions">
|
| 522 |
-
<button class="
|
| 523 |
-
<button class="
|
|
|
|
|
|
|
| 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 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
$("settings-
|
| 543 |
-
$("
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 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 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
|
|
|
|
|
|
|
|
|
| 573 |
if ($("settings-form-title")) $("settings-form-title").textContent = `✏️ Edit: ${s.title}`;
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
| 577 |
} catch (e) { toast(`Failed to load setting: ${e.message}`, "error"); }
|
| 578 |
}
|
| 579 |
window.editSetting = editSetting;
|
| 580 |
|
| 581 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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");
|
|
@@ -589,48 +740,99 @@ async function deleteSetting(sid) {
|
|
| 589 |
} catch (e) { toast(`Delete failed: ${e.message}`, "error"); }
|
| 590 |
}
|
| 591 |
window.deleteSetting = deleteSetting;
|
| 592 |
-
window.generateFromSetting = generateFromSetting;
|
| 593 |
|
| 594 |
-
/* ── Generate
|
| 595 |
$("btn-settings-generate")?.addEventListener("click", async () => {
|
| 596 |
const sid = $("edit-settings-id").value;
|
| 597 |
if (!sid) return;
|
| 598 |
-
document.querySelector('[data-
|
| 599 |
await generateFromSetting(sid);
|
| 600 |
});
|
| 601 |
|
| 602 |
-
/* ──
|
| 603 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
async function loadHistory() {
|
| 606 |
-
const
|
| 607 |
-
|
|
|
|
|
|
|
|
|
|
| 608 |
try {
|
| 609 |
-
const data = await apiFetch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
const tbody = $("history-body");
|
| 611 |
-
|
| 612 |
-
|
|
|
|
|
|
|
|
|
|
| 613 |
return;
|
| 614 |
}
|
| 615 |
-
|
|
|
|
| 616 |
<tr>
|
| 617 |
-
<td><
|
| 618 |
-
|
| 619 |
-
<td style="font-
|
|
|
|
|
|
|
|
|
|
| 620 |
<td><span class="badge badge-${e.status||'pending'}">${esc(e.status||"pending")}</span></td>
|
| 621 |
-
<td
|
| 622 |
-
<td style="white-space:nowrap;font-size:.
|
| 623 |
-
<td>
|
| 624 |
-
<button class="
|
|
|
|
| 625 |
</td>
|
| 626 |
</tr>`).join("");
|
| 627 |
-
|
|
|
|
| 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");
|
|
@@ -639,8 +841,86 @@ async function deleteHistory(id) {
|
|
| 639 |
}
|
| 640 |
window.deleteHistory = deleteHistory;
|
| 641 |
|
| 642 |
-
/* ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
(async () => {
|
| 644 |
await loadConfig();
|
| 645 |
await loadSettingsList();
|
|
|
|
| 646 |
})();
|
|
|
|
| 1 |
/**
|
| 2 |
+
* PromptForge v4.0 — client.js
|
| 3 |
+
* Upgrades: sidebar navigation, stats dashboard, full-text search,
|
| 4 |
+
* favorites/archive, setting duplicate, keyboard shortcuts, theme toggle,
|
| 5 |
+
* API key validation, char counters, tag suggestions, smooth transitions.
|
| 6 |
*/
|
| 7 |
|
| 8 |
const API = "";
|
| 9 |
let currentPromptId = null;
|
| 10 |
+
let allSettings = [];
|
| 11 |
+
let favoritesFilter = false;
|
| 12 |
+
|
| 13 |
const $ = id => document.getElementById(id);
|
| 14 |
+
const esc = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
| 15 |
const show = el => el?.classList.remove("hidden");
|
| 16 |
const hide = el => el?.classList.add("hidden");
|
|
|
|
| 17 |
|
| 18 |
+
/* ── API fetch ──────────────────────────────────────────────────────── */
|
| 19 |
async function apiFetch(path, method = "GET", body = null) {
|
| 20 |
const opts = { method, headers: { "Content-Type": "application/json" } };
|
| 21 |
if (body) opts.body = JSON.stringify(body);
|
|
|
|
| 27 |
return r.json();
|
| 28 |
}
|
| 29 |
|
| 30 |
+
/* ── Toast ──────────────────────────────────────────────────────────── */
|
| 31 |
+
function toast(msg, type = "info", duration = 4200) {
|
| 32 |
const icons = { success:"✅", error:"❌", info:"💡", warn:"⚠️" };
|
| 33 |
const t = document.createElement("div");
|
| 34 |
t.className = `toast ${type}`;
|
| 35 |
+
t.innerHTML = `<span class="toast-icon">${icons[type]||"💡"}</span><span>${msg}</span>`;
|
| 36 |
$("toast-container").appendChild(t);
|
| 37 |
+
const remove = () => { t.classList.add("leaving"); t.addEventListener("animationend", () => t.remove(), {once:true}); };
|
| 38 |
+
const timer = setTimeout(remove, duration);
|
| 39 |
+
t.addEventListener("click", () => { clearTimeout(timer); remove(); });
|
| 40 |
}
|
| 41 |
|
| 42 |
+
/* ── Loading state ──────────────────────────────────────────────────── */
|
| 43 |
function setLoading(btn, on) {
|
| 44 |
+
if (!btn) return;
|
| 45 |
btn.disabled = on;
|
| 46 |
+
if (!btn._orig) btn._orig = btn.innerHTML;
|
| 47 |
btn.innerHTML = on ? `<span class="spinner"></span> Working…` : btn._orig;
|
| 48 |
}
|
| 49 |
|
| 50 |
+
/* ── Sidebar navigation ─────────────────────────────────────────────── */
|
| 51 |
+
document.querySelectorAll(".nav-item").forEach(item => {
|
| 52 |
+
item.addEventListener("click", () => {
|
| 53 |
+
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
|
| 54 |
+
document.querySelectorAll(".page").forEach(p => { p.classList.remove("active"); hide(p); });
|
| 55 |
+
item.classList.add("active");
|
| 56 |
+
const page = $(`page-${item.dataset.page}`);
|
|
|
|
| 57 |
if (page) { show(page); page.classList.add("active"); }
|
| 58 |
+
// lazy-load data
|
| 59 |
+
if (item.dataset.page === "settings") loadSettingsList();
|
| 60 |
+
if (item.dataset.page === "history") loadHistory();
|
| 61 |
+
if (item.dataset.page === "stats") loadStats();
|
| 62 |
});
|
| 63 |
});
|
| 64 |
|
| 65 |
+
/* ── Sidebar collapse ───────────────────────────────────────────────── */
|
| 66 |
+
$("btn-sidebar-toggle")?.addEventListener("click", () => {
|
| 67 |
+
const sb = $("sidebar");
|
| 68 |
+
sb.classList.toggle("collapsed");
|
| 69 |
+
localStorage.setItem("pf_sidebar", sb.classList.contains("collapsed") ? "1" : "0");
|
| 70 |
+
});
|
| 71 |
+
if (localStorage.getItem("pf_sidebar") === "1") $("sidebar")?.classList.add("collapsed");
|
| 72 |
+
|
| 73 |
+
/* ── Theme toggle ───────────────────────────────────────────────────── */
|
| 74 |
+
function applyTheme(theme) {
|
| 75 |
+
document.documentElement.dataset.theme = theme;
|
| 76 |
+
$("btn-theme").textContent = theme === "dark" ? "🌙" : "☀️";
|
| 77 |
+
localStorage.setItem("pf_theme", theme);
|
| 78 |
+
}
|
| 79 |
+
$("btn-theme")?.addEventListener("click", () => {
|
| 80 |
+
const cur = document.documentElement.dataset.theme || "dark";
|
| 81 |
+
applyTheme(cur === "dark" ? "light" : "dark");
|
| 82 |
+
});
|
| 83 |
+
applyTheme(localStorage.getItem("pf_theme") || "dark");
|
| 84 |
+
|
| 85 |
+
/* ── Keyboard shortcuts ─────────────────────────────────────────────── */
|
| 86 |
+
$("btn-shortcuts")?.addEventListener("click", () => show($("shortcuts-modal")));
|
| 87 |
+
$("btn-shortcuts-close")?.addEventListener("click", () => hide($("shortcuts-modal")));
|
| 88 |
+
$("shortcuts-modal")?.addEventListener("click", e => { if (e.target === $("shortcuts-modal")) hide($("shortcuts-modal")); });
|
| 89 |
+
|
| 90 |
+
document.addEventListener("keydown", e => {
|
| 91 |
+
if (e.altKey) {
|
| 92 |
+
if (e.key === "b" || e.key === "B") { $("sidebar")?.classList.toggle("collapsed"); return; }
|
| 93 |
+
if (e.key === "t" || e.key === "T") { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); return; }
|
| 94 |
+
if (e.key === "1") { document.querySelector('[data-page="generate"]')?.click(); return; }
|
| 95 |
+
if (e.key === "2") { document.querySelector('[data-page="settings"]')?.click(); return; }
|
| 96 |
+
if (e.key === "3") { document.querySelector('[data-page="history"]')?.click(); return; }
|
| 97 |
+
if (e.key === "4") { document.querySelector('[data-page="stats"]')?.click(); return; }
|
| 98 |
+
}
|
| 99 |
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
| 100 |
+
if ($("page-generate")?.classList.contains("active")) $("btn-generate")?.click();
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
if (e.key === "?" && !["INPUT","TEXTAREA","SELECT"].includes(e.target.tagName)) {
|
| 104 |
+
show($("shortcuts-modal")); return;
|
| 105 |
+
}
|
| 106 |
+
if (e.key === "Escape") {
|
| 107 |
+
hide($("modal-overlay")); hide($("shortcuts-modal"));
|
| 108 |
+
}
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
/* ── Config / env ───────────────────────────────────────────────────── */
|
| 112 |
async function loadConfig() {
|
| 113 |
try {
|
| 114 |
const cfg = await apiFetch("/api/config");
|
| 115 |
+
const dotHF = $("dot-hf");
|
| 116 |
+
const dotGG = $("dot-google");
|
| 117 |
+
if (cfg.hf_key_set) dotHF?.classList.add("active");
|
| 118 |
+
if (cfg.google_key_set) dotGG?.classList.add("active");
|
| 119 |
} catch {}
|
| 120 |
}
|
| 121 |
|
| 122 |
+
/* ── API key panel ──────────────────────────────────────────────────── */
|
| 123 |
+
const PROVIDER_INFO = {
|
| 124 |
+
none: { hint:"No key needed — local engine only", placeholder:"Not required" },
|
| 125 |
+
google: { hint:"google.com/aistudio → Get API key", placeholder:"AIzaSy…" },
|
| 126 |
+
huggingface: { hint:"huggingface.co/settings/tokens", placeholder:"hf_…" },
|
| 127 |
};
|
| 128 |
|
| 129 |
+
$("btn-api-panel-toggle")?.addEventListener("click", () => {
|
| 130 |
+
const body = $("api-panel-body");
|
| 131 |
+
const chevron = $("api-chevron");
|
| 132 |
+
body.classList.toggle("open");
|
| 133 |
+
chevron.classList.toggle("open");
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
$("provider")?.addEventListener("change", () => {
|
| 137 |
const p = $("provider").value;
|
| 138 |
+
const info = PROVIDER_INFO[p] || PROVIDER_INFO.none;
|
| 139 |
+
$("key-hint").textContent = info.hint;
|
| 140 |
+
const needsKey = p !== "none";
|
| 141 |
+
$("api-key").disabled = !needsKey;
|
| 142 |
+
$("api-key").placeholder = info.placeholder;
|
| 143 |
+
$("btn-check-key").disabled = !needsKey;
|
| 144 |
+
$("model-field").style.display = needsKey ? "block" : "none";
|
| 145 |
+
$("api-panel-status").textContent = needsKey ? "AI Enhancement Available" : "Local Engine Active";
|
| 146 |
+
if (!needsKey) {
|
| 147 |
+
$("api-key").value = "";
|
| 148 |
+
$("key-dot").className = "status-dot";
|
| 149 |
+
$("key-status-text").textContent = "No key needed";
|
| 150 |
}
|
| 151 |
+
});
|
| 152 |
|
| 153 |
+
$("api-key")?.addEventListener("input", () => {
|
| 154 |
+
const has = $("api-key").value.trim().length >= 10;
|
| 155 |
+
$("key-dot").className = "status-dot" + (has ? " ok" : "");
|
| 156 |
+
$("key-status-text").textContent = has ? "Key entered — click ✓ to validate" : "Enter your API key";
|
| 157 |
+
});
|
|
|
|
|
|
|
| 158 |
|
|
|
|
|
|
|
| 159 |
$("btn-toggle-key")?.addEventListener("click", () => {
|
| 160 |
const k = $("api-key");
|
| 161 |
k.type = k.type === "password" ? "text" : "password";
|
| 162 |
$("btn-toggle-key").textContent = k.type === "password" ? "👁" : "🙈";
|
| 163 |
});
|
|
|
|
| 164 |
|
| 165 |
+
$("btn-check-key")?.addEventListener("click", async () => {
|
| 166 |
+
const provider = $("provider").value;
|
| 167 |
+
const key = $("api-key").value.trim();
|
| 168 |
+
if (!key) { toast("Enter a key first.", "warn"); return; }
|
| 169 |
+
const btn = $("btn-check-key");
|
| 170 |
+
setLoading(btn, true);
|
| 171 |
+
try {
|
| 172 |
+
const res = await apiFetch("/api/check-key", "POST", { provider, api_key: key });
|
| 173 |
+
$("key-dot").className = "status-dot " + (res.valid ? "ok" : "err");
|
| 174 |
+
$("key-status-text").textContent = res.message;
|
| 175 |
+
toast(res.message, res.valid ? "success" : "error");
|
| 176 |
+
if (res.valid) { $("dot-" + (provider === "google" ? "google" : "hf"))?.classList.add("active"); }
|
| 177 |
+
} catch (e) { toast(`Key check failed: ${e.message}`, "error"); }
|
| 178 |
+
finally { setLoading(btn, false); }
|
| 179 |
});
|
| 180 |
|
| 181 |
+
/* ── Character counters ─────────────────────────────────────────────── */
|
| 182 |
+
function bindCharCounter(textareaId, counterId, max) {
|
| 183 |
+
const ta = $(textareaId), ct = $(counterId);
|
| 184 |
+
if (!ta || !ct) return;
|
| 185 |
+
ta.addEventListener("input", () => {
|
| 186 |
+
const len = ta.value.length;
|
| 187 |
+
ct.textContent = len;
|
| 188 |
+
ct.parentElement.className = "char-counter" + (len > max * .95 ? " over" : len > max * .85 ? " warn" : "");
|
| 189 |
+
});
|
| 190 |
+
}
|
| 191 |
+
bindCharCounter("instruction", "instr-count", 8000);
|
| 192 |
+
bindCharCounter("s-instruction", "s-instr-count", 8000);
|
| 193 |
+
|
| 194 |
+
/* ── Persona / custom persona ───────────────────────────────────────── */
|
| 195 |
$("gen-persona")?.addEventListener("change", () => {
|
| 196 |
+
$("custom-persona-field").style.display = $("gen-persona").value === "custom" ? "block" : "none";
|
| 197 |
+
});
|
| 198 |
+
$("s-persona")?.addEventListener("change", () => {
|
| 199 |
+
$("s-custom-persona-field").style.display = $("s-persona").value === "custom" ? "block" : "none";
|
| 200 |
});
|
| 201 |
|
| 202 |
+
/* ── Tag autocomplete for settings form ─────────────────────────────── */
|
| 203 |
+
const COMMON_TAGS = ["react","typescript","python","frontend","backend","devops","ml","security","testing","database","mobile","writing","docker","api","fastapi","tailwind"];
|
| 204 |
+
function renderTagSugs(inputEl, containerEl) {
|
| 205 |
+
const cur = inputEl.value.split(",").map(t=>t.trim()).filter(Boolean);
|
| 206 |
+
const avail = COMMON_TAGS.filter(t => !cur.includes(t));
|
| 207 |
+
containerEl.innerHTML = avail.slice(0,8).map(t =>
|
| 208 |
+
`<span class="tag-sug" data-tag="${esc(t)}">${esc(t)}</span>`
|
| 209 |
+
).join("");
|
| 210 |
+
containerEl.querySelectorAll(".tag-sug").forEach(el => {
|
| 211 |
+
el.addEventListener("click", () => {
|
| 212 |
+
const existing = inputEl.value.trim();
|
| 213 |
+
inputEl.value = existing ? `${existing}, ${el.dataset.tag}` : el.dataset.tag;
|
| 214 |
+
renderTagSugs(inputEl, containerEl);
|
| 215 |
+
});
|
| 216 |
});
|
| 217 |
+
}
|
| 218 |
+
const tagsInput = $("s-tags"), tagSugs = $("tag-suggestions");
|
| 219 |
+
if (tagsInput && tagSugs) {
|
| 220 |
+
tagsInput.addEventListener("input", () => renderTagSugs(tagsInput, tagSugs));
|
| 221 |
+
renderTagSugs(tagsInput, tagSugs);
|
| 222 |
+
}
|
| 223 |
|
| 224 |
+
/* ── Step progress ──────────────────────────────────────────────────── */
|
| 225 |
+
function setStep(n) {
|
| 226 |
+
document.querySelectorAll(".step").forEach((s, i) => {
|
| 227 |
+
const idx = parseInt(s.dataset.step);
|
| 228 |
+
s.classList.remove("active","done");
|
| 229 |
+
if (idx < n) s.classList.add("done");
|
| 230 |
+
if (idx === n) s.classList.add("active");
|
| 231 |
+
});
|
| 232 |
+
document.querySelectorAll(".step-line").forEach((l, i) => {
|
| 233 |
+
l.classList.toggle("filled", i + 1 < n);
|
| 234 |
+
});
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* ── Copy buttons ───────────────────────────────────────────────────── */
|
| 238 |
document.addEventListener("click", e => {
|
| 239 |
+
const btn = e.target.closest(".copy-btn");
|
| 240 |
if (!btn) return;
|
| 241 |
const el = $(btn.dataset.target);
|
| 242 |
if (!el) return;
|
| 243 |
navigator.clipboard.writeText(el.textContent).then(() => {
|
| 244 |
+
const orig = btn.textContent;
|
| 245 |
btn.textContent = "✅ Copied!";
|
| 246 |
btn.classList.add("copied");
|
| 247 |
+
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = orig; }, 2000);
|
| 248 |
});
|
| 249 |
});
|
| 250 |
|
| 251 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 252 |
+
GENERATE PAGE
|
| 253 |
+
───────────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
+
/* ── STEP 1: Generate ─── */
|
| 256 |
+
$("btn-generate")?.addEventListener("click", doGenerate);
|
| 257 |
+
|
| 258 |
+
async function doGenerate() {
|
| 259 |
const instruction = $("instruction").value.trim();
|
| 260 |
+
if (instruction.length < 5) { toast("Enter a meaningful instruction (min 5 chars).", "error"); return; }
|
| 261 |
const btn = $("btn-generate");
|
| 262 |
setLoading(btn, true);
|
| 263 |
try {
|
| 264 |
+
const provider = $("provider").value;
|
| 265 |
+
const apiKey = $("api-key")?.value.trim() || null;
|
| 266 |
+
const persona = $("gen-persona")?.value || "default";
|
| 267 |
+
const style = $("gen-style")?.value || "professional";
|
| 268 |
+
const customPerso = persona === "custom" ? ($("gen-custom-persona")?.value.trim() || null) : null;
|
| 269 |
+
const constrRaw = $("gen-constraints")?.value.trim() || "";
|
| 270 |
+
const constraints = constrRaw ? constrRaw.split("\n").map(s=>s.trim()).filter(Boolean) : [];
|
| 271 |
+
const modelOvr = $("provider-model")?.value.trim() || null;
|
| 272 |
|
| 273 |
const data = await apiFetch("/api/generate", "POST", {
|
| 274 |
instruction,
|
|
|
|
| 276 |
provider, api_key: apiKey,
|
| 277 |
enhance: provider !== "none" && !!apiKey,
|
| 278 |
extra_context: $("extra-context").value.trim() || null,
|
| 279 |
+
persona, custom_persona: customPerso, style,
|
| 280 |
+
user_constraints: constraints,
|
| 281 |
+
provider_model: modelOvr || undefined,
|
| 282 |
});
|
| 283 |
currentPromptId = data.prompt_id;
|
| 284 |
renderManifest(data.manifest);
|
| 285 |
hide($("step-input")); show($("step-manifest"));
|
| 286 |
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 287 |
setStep(2);
|
| 288 |
+
updateBadge("history-badge", null, +1);
|
| 289 |
+
toast("Manifest generated — review and approve.", "success");
|
| 290 |
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 291 |
finally { setLoading(btn, false); }
|
| 292 |
+
}
|
| 293 |
|
| 294 |
+
/* ── Render manifest fields ─── */
|
| 295 |
function renderManifest(manifest) {
|
| 296 |
+
const sp = manifest.structured_prompt;
|
| 297 |
const grid = $("manifest-grid");
|
| 298 |
grid.innerHTML = "";
|
| 299 |
+
const fields = [
|
| 300 |
+
{ key:"role", label:"Role", value:sp.role, full:false },
|
| 301 |
+
{ key:"style", label:"Style & Tone", value:sp.style, full:false },
|
| 302 |
+
{ key:"task", label:"Task", value:sp.task, full:true },
|
| 303 |
+
{ key:"input_format", label:"Input Format", value:sp.input_format, full:false },
|
| 304 |
+
{ key:"output_format", label:"Output Format", value:sp.output_format, full:false },
|
| 305 |
+
{ key:"constraints", label:"Constraints", value:sp.constraints.join("\n"), full:true },
|
| 306 |
+
{ key:"safety", label:"Safety", value:sp.safety.join("\n"), full:true },
|
| 307 |
+
];
|
| 308 |
+
fields.forEach(f => {
|
| 309 |
const d = document.createElement("div");
|
| 310 |
d.className = `manifest-field${f.full?" full":""}`;
|
| 311 |
+
d.innerHTML = `<label>${esc(f.label)}</label>
|
| 312 |
+
<textarea id="field-${f.key}" rows="${f.full?3:2}">${esc(f.value)}</textarea>`;
|
| 313 |
grid.appendChild(d);
|
| 314 |
});
|
| 315 |
$("manifest-json").textContent = JSON.stringify(manifest, null, 2);
|
| 316 |
hide($("explanation-panel"));
|
| 317 |
+
|
| 318 |
+
// Word count badge
|
| 319 |
+
const wc = sp.word_count || sp.raw_prompt_text?.split(/\s+/).length || 0;
|
| 320 |
+
if (!$("wc-badge")) {
|
| 321 |
+
const b = document.createElement("span");
|
| 322 |
+
b.id = "wc-badge"; b.className = "step-tag";
|
| 323 |
+
b.style.marginLeft = "auto";
|
| 324 |
+
$("step-manifest")?.querySelector(".panel-header")?.appendChild(b);
|
| 325 |
+
}
|
| 326 |
+
$("wc-badge").textContent = `~${wc} words`;
|
| 327 |
}
|
| 328 |
|
| 329 |
+
/* ── Explain ─── */
|
| 330 |
$("btn-explain")?.addEventListener("click", async () => {
|
| 331 |
if (!currentPromptId) return;
|
| 332 |
const btn = $("btn-explain");
|
| 333 |
setLoading(btn, true);
|
| 334 |
try {
|
| 335 |
const data = await apiFetch(`/api/explain/${currentPromptId}`);
|
|
|
|
| 336 |
$("explanation-text").textContent = data.explanation;
|
| 337 |
+
$("key-decisions").innerHTML = data.key_decisions
|
| 338 |
+
.map(d => `<div class="decision-chip">${esc(d)}</div>`).join("");
|
| 339 |
+
const panel = $("explanation-panel");
|
| 340 |
show(panel);
|
| 341 |
panel.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
| 342 |
+
} catch (e) { toast(`Explanation error: ${e.message}`, "warn"); }
|
| 343 |
finally { setLoading(btn, false); }
|
| 344 |
});
|
| 345 |
|
| 346 |
+
/* ── Approve ─── */
|
| 347 |
$("btn-approve")?.addEventListener("click", async () => {
|
| 348 |
if (!currentPromptId) return;
|
| 349 |
const edits = {};
|
|
|
|
| 370 |
$("finalized-json").textContent = JSON.stringify(sp, null, 2);
|
| 371 |
}
|
| 372 |
|
| 373 |
+
/* ── Inner tabs (text/json) ─── */
|
| 374 |
+
document.querySelectorAll(".inner-tab").forEach(tab => {
|
| 375 |
+
tab.addEventListener("click", () => {
|
| 376 |
+
const group = tab.closest(".panel");
|
| 377 |
+
group.querySelectorAll(".inner-tab").forEach(t => t.classList.remove("active"));
|
| 378 |
+
group.querySelectorAll(".inner-panel").forEach(p => hide(p));
|
| 379 |
+
tab.classList.add("active");
|
| 380 |
+
const panel = group.querySelector(`#itab-${tab.dataset.tab}`);
|
| 381 |
+
if (panel) show(panel);
|
| 382 |
+
});
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
/* ── Export ─── */
|
| 386 |
async function doExport(format) {
|
| 387 |
if (!currentPromptId) return;
|
| 388 |
try {
|
|
|
|
| 401 |
$("btn-export-json")?.addEventListener("click", () => doExport("json"));
|
| 402 |
$("btn-export-txt")?.addEventListener("click", () => doExport("text"));
|
| 403 |
|
| 404 |
+
/* ── Favourite prompt ─── */
|
| 405 |
+
$("btn-favorite-prompt")?.addEventListener("click", async () => {
|
| 406 |
+
if (!currentPromptId) return;
|
| 407 |
+
try {
|
| 408 |
+
const r = await apiFetch(`/api/prompts/${currentPromptId}/favorite`, "POST");
|
| 409 |
+
$("btn-favorite-prompt").textContent = r.is_favorite ? "★ Favourited" : "☆ Favourite";
|
| 410 |
+
toast(r.is_favorite ? "Added to favourites ★" : "Removed from favourites", "info");
|
| 411 |
+
} catch (e) { toast("Failed: " + e.message, "error"); }
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
/* ── Save as Setting ─── */
|
| 415 |
$("btn-save-as-setting")?.addEventListener("click", () => {
|
| 416 |
+
const instruction = $("instruction")?.value.trim() || "";
|
| 417 |
+
const context = $("extra-context")?.value.trim() || "";
|
| 418 |
+
document.querySelector('[data-page="settings"]')?.click();
|
|
|
|
| 419 |
setTimeout(() => {
|
| 420 |
clearSettingsForm();
|
| 421 |
+
if ($("s-title")) $("s-title").value = instruction.slice(0,60) + (instruction.length > 60 ? "…" : "");
|
| 422 |
+
if ($("s-instruction")) $("s-instruction").value = instruction;
|
| 423 |
if ($("s-extra-context")) $("s-extra-context").value = context;
|
| 424 |
$("s-title")?.focus();
|
| 425 |
+
toast("Pre-filled from prompt — adjust title and save!", "info");
|
| 426 |
}, 150);
|
| 427 |
});
|
| 428 |
|
| 429 |
+
/* ── Refine ─── */
|
| 430 |
$("btn-refine")?.addEventListener("click", () => {
|
| 431 |
hide($("step-finalized")); show($("step-refine"));
|
| 432 |
$("step-refine").scrollIntoView({ behavior:"smooth", block:"start" });
|
|
|
|
| 437 |
});
|
| 438 |
$("btn-submit-refine")?.addEventListener("click", async () => {
|
| 439 |
const feedback = $("feedback").value.trim();
|
| 440 |
+
if (!feedback) { toast("Describe what to change.", "error"); return; }
|
| 441 |
const btn = $("btn-submit-refine");
|
| 442 |
setLoading(btn, true);
|
| 443 |
try {
|
| 444 |
const data = await apiFetch("/api/refine", "POST", {
|
| 445 |
prompt_id: currentPromptId, feedback,
|
| 446 |
provider: $("provider").value,
|
| 447 |
+
api_key: $("api-key")?.value.trim() || null,
|
| 448 |
});
|
| 449 |
currentPromptId = data.prompt_id;
|
| 450 |
renderManifest(data.manifest);
|
|
|
|
| 456 |
finally { setLoading(btn, false); }
|
| 457 |
});
|
| 458 |
|
| 459 |
+
/* ── Reset / New ─── */
|
| 460 |
$("btn-reset")?.addEventListener("click", () => {
|
| 461 |
hide($("step-manifest")); show($("step-input")); setStep(1);
|
| 462 |
$("instruction").value = ""; $("extra-context").value = "";
|
|
|
|
| 465 |
$("btn-new")?.addEventListener("click", () => {
|
| 466 |
hide($("step-finalized")); show($("step-input")); setStep(1);
|
| 467 |
$("instruction").value = ""; $("extra-context").value = "";
|
| 468 |
+
$("instr-count").textContent = "0";
|
| 469 |
currentPromptId = null;
|
| 470 |
$("step-input").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 471 |
});
|
| 472 |
|
| 473 |
+
/* ── Load-from-settings modal ─── */
|
| 474 |
$("btn-load-from-settings")?.addEventListener("click", async () => {
|
| 475 |
await loadSettingsForModal();
|
| 476 |
show($("modal-overlay"));
|
| 477 |
});
|
| 478 |
$("btn-modal-close")?.addEventListener("click", () => hide($("modal-overlay")));
|
| 479 |
$("modal-overlay")?.addEventListener("click", e => { if (e.target === $("modal-overlay")) hide($("modal-overlay")); });
|
|
|
|
| 480 |
$("modal-search")?.addEventListener("input", () => {
|
| 481 |
const q = $("modal-search").value.toLowerCase();
|
| 482 |
document.querySelectorAll(".modal-item").forEach(item => {
|
|
|
|
| 485 |
});
|
| 486 |
|
| 487 |
async function loadSettingsForModal() {
|
| 488 |
+
const list = $("modal-list");
|
| 489 |
try {
|
| 490 |
const data = await apiFetch("/api/instructions");
|
| 491 |
if (!data.items?.length) {
|
| 492 |
+
list.innerHTML = `<div class="modal-empty">No saved settings. Create some in the Settings page.</div>`;
|
| 493 |
return;
|
| 494 |
}
|
| 495 |
list.innerHTML = data.items.map(s => `
|
| 496 |
<div class="modal-item" data-id="${esc(s.settings_id)}" data-search="${esc((s.title+s.instruction).toLowerCase())}">
|
| 497 |
+
<div class="modal-item-title">${personaEmoji(s.persona)} ${esc(s.title)}</div>
|
| 498 |
+
<div class="modal-item-desc">${esc(s.instruction.slice(0,110))}${s.instruction.length > 110 ? "…" : ""}</div>
|
| 499 |
</div>`).join("");
|
| 500 |
document.querySelectorAll(".modal-item").forEach(item => {
|
| 501 |
item.addEventListener("click", async () => {
|
|
|
|
| 502 |
hide($("modal-overlay"));
|
| 503 |
+
await generateFromSetting(item.dataset.id);
|
| 504 |
});
|
| 505 |
});
|
| 506 |
} catch (e) {
|
| 507 |
+
list.innerHTML = `<div class="modal-empty">Failed: ${esc(e.message)}</div>`;
|
| 508 |
}
|
| 509 |
}
|
| 510 |
|
| 511 |
async function generateFromSetting(sid) {
|
| 512 |
const btn = $("btn-generate");
|
| 513 |
setLoading(btn, true);
|
| 514 |
+
document.querySelector('[data-page="generate"]')?.click();
|
|
|
|
| 515 |
try {
|
| 516 |
+
const apiKey = $("api-key")?.value.trim() || null;
|
| 517 |
const data = await apiFetch("/api/generate/from-settings", "POST", { settings_id: sid, api_key: apiKey });
|
| 518 |
currentPromptId = data.prompt_id;
|
| 519 |
renderManifest(data.manifest);
|
| 520 |
hide($("step-input")); show($("step-manifest"));
|
| 521 |
$("step-manifest").scrollIntoView({ behavior:"smooth", block:"start" });
|
| 522 |
setStep(2);
|
| 523 |
+
updateBadge("history-badge", null, +1);
|
| 524 |
+
toast(`Generated from saved setting! ✨`, "success");
|
| 525 |
} catch (e) { toast(`Error: ${e.message}`, "error"); }
|
| 526 |
finally { setLoading(btn, false); }
|
| 527 |
}
|
| 528 |
|
| 529 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 530 |
+
SETTINGS PAGE
|
| 531 |
+
───────────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
$("btn-settings-save")?.addEventListener("click", async () => {
|
| 534 |
+
const title = $("s-title").value.trim();
|
| 535 |
const instruction = $("s-instruction").value.trim();
|
| 536 |
+
if (!title) { toast("Title is required.", "error"); $("s-title").focus(); return; }
|
| 537 |
+
if (instruction.length < 5) { toast("Instruction too short.", "error"); $("s-instruction").focus(); return; }
|
| 538 |
|
| 539 |
const editId = $("edit-settings-id").value;
|
| 540 |
const persona = $("s-persona").value;
|
| 541 |
+
const constraints = ($("s-constraints").value.trim() || "").split("\n").map(s=>s.trim()).filter(Boolean);
|
| 542 |
+
const tags = ($("s-tags").value.trim() || "").split(",").map(s=>s.trim().toLowerCase()).filter(Boolean);
|
|
|
|
|
|
|
| 543 |
|
| 544 |
const payload = {
|
| 545 |
title,
|
| 546 |
+
description: $("s-description").value.trim() || null,
|
| 547 |
instruction,
|
| 548 |
+
extra_context: $("s-extra-context")?.value.trim() || null,
|
| 549 |
+
output_format: $("s-output-format").value,
|
| 550 |
persona,
|
| 551 |
+
custom_persona: persona === "custom" ? ($("s-custom-persona")?.value.trim() || null) : null,
|
| 552 |
+
style: $("s-style").value,
|
| 553 |
+
constraints, tags,
|
| 554 |
+
provider: $("s-provider").value,
|
| 555 |
+
enhance: $("s-enhance")?.checked || false,
|
| 556 |
+
is_favorite: $("s-favorite")?.checked || false,
|
| 557 |
};
|
| 558 |
|
| 559 |
const btn = $("btn-settings-save");
|
|
|
|
| 572 |
finally { setLoading(btn, false); }
|
| 573 |
});
|
| 574 |
|
|
|
|
| 575 |
$("btn-settings-clear")?.addEventListener("click", clearSettingsForm);
|
| 576 |
|
| 577 |
+
$("btn-export-all-settings")?.addEventListener("click", async () => {
|
| 578 |
+
try {
|
| 579 |
+
const data = await apiFetch("/api/instructions/export");
|
| 580 |
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type:"application/json" });
|
| 581 |
+
const a = Object.assign(document.createElement("a"), {
|
| 582 |
+
href: URL.createObjectURL(blob), download: "promptforge-settings.json"
|
| 583 |
+
});
|
| 584 |
+
a.click(); URL.revokeObjectURL(a.href);
|
| 585 |
+
toast(`Exported ${data.total} settings.`, "success");
|
| 586 |
+
} catch (e) { toast("Export failed: " + e.message, "error"); }
|
| 587 |
+
});
|
| 588 |
+
|
| 589 |
function clearSettingsForm() {
|
| 590 |
$("edit-settings-id").value = "";
|
| 591 |
["s-title","s-description","s-instruction","s-extra-context","s-constraints","s-tags","s-custom-persona"].forEach(id => {
|
| 592 |
const el = $(id); if (el) el.value = "";
|
| 593 |
});
|
| 594 |
+
$("s-persona").value = "default";
|
| 595 |
+
$("s-style").value = "professional";
|
| 596 |
+
$("s-output-format").value = "both";
|
| 597 |
+
$("s-provider").value = "none";
|
| 598 |
+
if ($("s-enhance")) $("s-enhance").checked = false;
|
| 599 |
+
if ($("s-favorite")) $("s-favorite").checked = false;
|
| 600 |
if ($("s-custom-persona-field")) $("s-custom-persona-field").style.display = "none";
|
| 601 |
+
if ($("s-instr-count")) $("s-instr-count").textContent = "0";
|
| 602 |
+
if ($("settings-form-title")) $("settings-form-title").textContent = "➕ New Setting";
|
| 603 |
+
const genBtn = $("btn-settings-generate");
|
| 604 |
+
if (genBtn) { genBtn.classList.add("hidden"); genBtn._orig = null; }
|
| 605 |
+
document.querySelectorAll(".setting-card").forEach(c => c.classList.remove("editing"));
|
| 606 |
+
// reset tag suggestions
|
| 607 |
+
if (tagsInput && tagSugs) renderTagSugs(tagsInput, tagSugs);
|
| 608 |
}
|
| 609 |
|
| 610 |
+
/* ── Load settings list ─── */
|
| 611 |
async function loadSettingsList() {
|
| 612 |
try {
|
| 613 |
+
const q = $("settings-search")?.value.trim() || "";
|
| 614 |
+
const tag = $("settings-filter-tag")?.value || "";
|
| 615 |
+
let url = "/api/instructions?";
|
| 616 |
+
if (q) url += `q=${encodeURIComponent(q)}&`;
|
| 617 |
+
if (tag) url += `tag=${encodeURIComponent(tag)}&`;
|
| 618 |
+
if (favoritesFilter) url += "favorites_only=true&";
|
| 619 |
+
|
| 620 |
+
const data = await apiFetch(url);
|
| 621 |
allSettings = data.items || [];
|
| 622 |
renderSettingsList(allSettings);
|
| 623 |
+
const n = data.total || 0;
|
| 624 |
+
if ($("settings-total-count")) $("settings-total-count").textContent = n;
|
| 625 |
+
if ($("settings-badge")) $("settings-badge").textContent = n;
|
| 626 |
+
|
| 627 |
+
// refresh tag filter
|
| 628 |
+
const tagData = await apiFetch("/api/instructions/tags");
|
| 629 |
+
const filterEl = $("settings-filter-tag");
|
| 630 |
+
if (filterEl) {
|
| 631 |
+
const cur = filterEl.value;
|
| 632 |
+
filterEl.innerHTML = `<option value="">All tags</option>` +
|
| 633 |
+
tagData.tags.map(t => `<option value="${esc(t)}" ${t===cur?"selected":""}>${esc(t)}</option>`).join("");
|
| 634 |
+
}
|
| 635 |
} catch (e) { toast(`Failed to load settings: ${e.message}`, "error"); }
|
| 636 |
}
|
| 637 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
function renderSettingsList(items) {
|
| 639 |
const container = $("settings-list");
|
| 640 |
if (!items.length) {
|
| 641 |
+
container.innerHTML = `<div class="empty-state"><div class="empty-icon">📋</div><p>No settings yet.</p></div>`;
|
| 642 |
return;
|
| 643 |
}
|
| 644 |
container.innerHTML = items.map(s => `
|
| 645 |
+
<div class="setting-card${s.is_favorite?" favorite":""}${$("edit-settings-id").value===s.settings_id?" editing":""}"
|
| 646 |
+
data-id="${esc(s.settings_id)}">
|
| 647 |
+
<div class="setting-card-top">
|
| 648 |
+
<div class="setting-card-title">${personaEmoji(s.persona)} ${esc(s.title)}</div>
|
| 649 |
+
<span class="s-star">${s.is_favorite?"★":"☆"}</span>
|
| 650 |
</div>
|
| 651 |
${s.description ? `<div class="setting-card-desc">${esc(s.description)}</div>` : ""}
|
| 652 |
<div class="setting-card-meta">
|
| 653 |
+
${(s.tags||[]).slice(0,4).map(t=>`<span class="tag-chip">${esc(t)}</span>`).join("")}
|
| 654 |
+
<span class="tag-chip style-tag">${esc(s.style)}</span>
|
| 655 |
+
<span class="use-count">× ${s.use_count||0}</span>
|
| 656 |
</div>
|
| 657 |
<div class="setting-card-actions">
|
| 658 |
+
<button class="icon-btn" title="Edit" onclick="editSetting('${esc(s.settings_id)}')">✏️</button>
|
| 659 |
+
<button class="icon-btn" title="Duplicate" onclick="duplicateSetting('${esc(s.settings_id)}')">⧉</button>
|
| 660 |
+
<button class="icon-btn" title="Favourite" onclick="toggleSettingFav('${esc(s.settings_id)}')">☆</button>
|
| 661 |
+
<button class="icon-btn btn-danger" title="Delete" onclick="deleteSetting('${esc(s.settings_id)}')">🗑</button>
|
| 662 |
<button class="btn-primary btn-sm" onclick="generateFromSetting('${esc(s.settings_id)}')">⚡</button>
|
| 663 |
</div>
|
| 664 |
</div>`).join("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
}
|
| 666 |
|
| 667 |
+
/* ── Search / filter ─── */
|
| 668 |
+
let settingsSearchTimer;
|
| 669 |
+
$("settings-search")?.addEventListener("input", () => {
|
| 670 |
+
clearTimeout(settingsSearchTimer);
|
| 671 |
+
settingsSearchTimer = setTimeout(loadSettingsList, 250);
|
| 672 |
+
});
|
| 673 |
+
$("settings-filter-tag")?.addEventListener("change", loadSettingsList);
|
| 674 |
+
$("btn-filter-favorites")?.addEventListener("click", () => {
|
| 675 |
+
favoritesFilter = !favoritesFilter;
|
| 676 |
+
$("btn-filter-favorites").textContent = favoritesFilter ? "★" : "☆";
|
| 677 |
+
$("btn-filter-favorites").style.color = favoritesFilter ? "var(--amber)" : "";
|
| 678 |
+
loadSettingsList();
|
| 679 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
|
| 681 |
+
/* ── Edit setting ─── */
|
| 682 |
async function editSetting(sid) {
|
| 683 |
try {
|
| 684 |
const s = await apiFetch(`/api/instructions/${sid}`);
|
| 685 |
$("edit-settings-id").value = s.settings_id;
|
| 686 |
+
$("s-title").value = s.title;
|
| 687 |
+
$("s-description").value = s.description || "";
|
| 688 |
+
$("s-instruction").value = s.instruction;
|
| 689 |
+
$("s-instruction").dispatchEvent(new Event("input"));
|
| 690 |
+
$("s-extra-context").value = s.extra_context || "";
|
| 691 |
+
$("s-output-format").value = s.output_format;
|
| 692 |
+
$("s-persona").value = s.persona;
|
| 693 |
+
$("s-persona").dispatchEvent(new Event("change"));
|
| 694 |
+
$("s-custom-persona").value = s.custom_persona || "";
|
| 695 |
+
$("s-style").value = s.style;
|
| 696 |
+
$("s-constraints").value = (s.constraints || []).join("\n");
|
| 697 |
+
$("s-tags").value = (s.tags || []).join(", ");
|
| 698 |
+
$("s-provider").value = s.provider;
|
| 699 |
+
if ($("s-enhance")) $("s-enhance").checked = s.enhance;
|
| 700 |
+
if ($("s-favorite")) $("s-favorite").checked = s.is_favorite;
|
| 701 |
if ($("settings-form-title")) $("settings-form-title").textContent = `✏️ Edit: ${s.title}`;
|
| 702 |
+
const genBtn = $("btn-settings-generate");
|
| 703 |
+
if (genBtn) { genBtn.classList.remove("hidden"); genBtn._orig = null; }
|
| 704 |
+
document.querySelectorAll(".setting-card").forEach(c => c.classList.toggle("editing", c.dataset.id === sid));
|
| 705 |
+
// scroll form into view
|
| 706 |
+
document.querySelector(".settings-form-col")?.scrollIntoView({ behavior:"smooth", block:"start" });
|
| 707 |
+
renderTagSugs($("s-tags"), $("tag-suggestions"));
|
| 708 |
} catch (e) { toast(`Failed to load setting: ${e.message}`, "error"); }
|
| 709 |
}
|
| 710 |
window.editSetting = editSetting;
|
| 711 |
|
| 712 |
+
/* ── Duplicate setting ─── */
|
| 713 |
+
async function duplicateSetting(sid) {
|
| 714 |
+
try {
|
| 715 |
+
const s = await apiFetch(`/api/instructions/${sid}/duplicate`, "POST");
|
| 716 |
+
toast(`Duplicated → "${s.title}"`, "success");
|
| 717 |
+
await loadSettingsList();
|
| 718 |
+
} catch (e) { toast("Duplicate failed: " + e.message, "error"); }
|
| 719 |
+
}
|
| 720 |
+
window.duplicateSetting = duplicateSetting;
|
| 721 |
+
|
| 722 |
+
/* ── Toggle favourite ─── */
|
| 723 |
+
async function toggleSettingFav(sid) {
|
| 724 |
+
try {
|
| 725 |
+
const r = await apiFetch(`/api/instructions/${sid}/favorite`, "POST");
|
| 726 |
+
toast(r.is_favorite ? "Added to favourites ★" : "Removed from favourites", "info");
|
| 727 |
+
await loadSettingsList();
|
| 728 |
+
} catch (e) { toast("Failed: " + e.message, "error"); }
|
| 729 |
+
}
|
| 730 |
+
window.toggleSettingFav = toggleSettingFav;
|
| 731 |
+
|
| 732 |
+
/* ── Delete setting ─── */
|
| 733 |
async function deleteSetting(sid) {
|
| 734 |
+
if (!confirm("Delete this instruction setting? This cannot be undone.")) return;
|
| 735 |
try {
|
| 736 |
await apiFetch(`/api/instructions/${sid}`, "DELETE");
|
| 737 |
toast("Setting deleted.", "success");
|
|
|
|
| 740 |
} catch (e) { toast(`Delete failed: ${e.message}`, "error"); }
|
| 741 |
}
|
| 742 |
window.deleteSetting = deleteSetting;
|
|
|
|
| 743 |
|
| 744 |
+
/* ── Generate now (from edit form) ─── */
|
| 745 |
$("btn-settings-generate")?.addEventListener("click", async () => {
|
| 746 |
const sid = $("edit-settings-id").value;
|
| 747 |
if (!sid) return;
|
| 748 |
+
document.querySelector('[data-page="generate"]')?.click();
|
| 749 |
await generateFromSetting(sid);
|
| 750 |
});
|
| 751 |
|
| 752 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 753 |
+
HISTORY PAGE
|
| 754 |
+
───────────────────────────────────────────────────────────────────── */
|
| 755 |
+
$("btn-history-refresh")?.addEventListener("click", loadHistory);
|
| 756 |
+
|
| 757 |
+
let histSearchTimer;
|
| 758 |
+
$("history-search")?.addEventListener("input", () => {
|
| 759 |
+
clearTimeout(histSearchTimer);
|
| 760 |
+
histSearchTimer = setTimeout(loadHistory, 300);
|
| 761 |
+
});
|
| 762 |
+
$("history-status-filter")?.addEventListener("change", loadHistory);
|
| 763 |
|
| 764 |
async function loadHistory() {
|
| 765 |
+
const status = $("history-status-filter")?.value || "";
|
| 766 |
+
const q = $("history-search")?.value.trim() || "";
|
| 767 |
+
let url = "/api/history?";
|
| 768 |
+
if (status) url += `status_filter=${status}&`;
|
| 769 |
+
|
| 770 |
try {
|
| 771 |
+
const data = await apiFetch(url);
|
| 772 |
+
let entries = data.entries || [];
|
| 773 |
+
|
| 774 |
+
// client-side search if q provided
|
| 775 |
+
if (q) {
|
| 776 |
+
const ql = q.toLowerCase();
|
| 777 |
+
entries = entries.filter(e => e.instruction?.toLowerCase().includes(ql) ||
|
| 778 |
+
e.prompt_id?.toLowerCase().includes(ql) ||
|
| 779 |
+
(e.tags||[]).join(",").includes(ql));
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
const tbody = $("history-body");
|
| 783 |
+
const total = entries.length;
|
| 784 |
+
if ($("history-badge")) $("history-badge").textContent = data.total || 0;
|
| 785 |
+
|
| 786 |
+
if (!total) {
|
| 787 |
+
tbody.innerHTML = `<tr><td class="empty-msg" colspan="8">No prompts found.</td></tr>`;
|
| 788 |
return;
|
| 789 |
}
|
| 790 |
+
|
| 791 |
+
tbody.innerHTML = entries.map(e => `
|
| 792 |
<tr>
|
| 793 |
+
<td><span class="fav-star${e.is_favorite?' active':''}"
|
| 794 |
+
onclick="toggleHistFav('${esc(e.prompt_id)}')">${e.is_favorite?"★":"☆"}</span></td>
|
| 795 |
+
<td><code style="font-size:.7rem;color:var(--text-muted)">${esc(e.prompt_id?.slice(0,8))}…</code></td>
|
| 796 |
+
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
| 797 |
+
title="${esc(e.instruction)}">${esc(e.instruction?.slice(0,55)||"—")}</td>
|
| 798 |
+
<td style="font-family:var(--font-mono);font-size:.75rem">v${e.version||1}</td>
|
| 799 |
<td><span class="badge badge-${e.status||'pending'}">${esc(e.status||"pending")}</span></td>
|
| 800 |
+
<td>${(e.tags||[]).slice(0,3).map(t=>`<span class="tag-chip">${esc(t)}</span>`).join(" ")}</td>
|
| 801 |
+
<td style="white-space:nowrap;font-size:.74rem;color:var(--text-muted)">${e.created_at?new Date(e.created_at).toLocaleDateString():"—"}</td>
|
| 802 |
+
<td style="white-space:nowrap;display:flex;gap:4px">
|
| 803 |
+
<button class="icon-btn btn-sm" title="Archive" onclick="archivePrompt('${esc(e.prompt_id)}')">📦</button>
|
| 804 |
+
<button class="icon-btn btn-danger btn-sm" title="Delete" onclick="deleteHistory('${esc(e.prompt_id)}')">🗑</button>
|
| 805 |
</td>
|
| 806 |
</tr>`).join("");
|
| 807 |
+
|
| 808 |
+
toast(`${total} prompt(s) loaded.`, "info", 2000);
|
| 809 |
} catch (e) { toast(`History error: ${e.message}`, "error"); }
|
|
|
|
| 810 |
}
|
| 811 |
|
| 812 |
+
async function toggleHistFav(pid) {
|
| 813 |
+
try {
|
| 814 |
+
const r = await apiFetch(`/api/prompts/${pid}/favorite`, "POST");
|
| 815 |
+
document.querySelectorAll(".fav-star").forEach(el => {
|
| 816 |
+
if (el.getAttribute("onclick")?.includes(pid)) {
|
| 817 |
+
el.textContent = r.is_favorite ? "★" : "☆";
|
| 818 |
+
el.classList.toggle("active", r.is_favorite);
|
| 819 |
+
}
|
| 820 |
+
});
|
| 821 |
+
} catch (e) { toast("Failed: " + e.message, "error"); }
|
| 822 |
+
}
|
| 823 |
+
window.toggleHistFav = toggleHistFav;
|
| 824 |
+
|
| 825 |
+
async function archivePrompt(pid) {
|
| 826 |
+
try {
|
| 827 |
+
await apiFetch(`/api/prompts/${pid}/archive`, "POST");
|
| 828 |
+
toast("Prompt archived.", "success");
|
| 829 |
+
loadHistory();
|
| 830 |
+
} catch (e) { toast("Archive failed: " + e.message, "error"); }
|
| 831 |
+
}
|
| 832 |
+
window.archivePrompt = archivePrompt;
|
| 833 |
+
|
| 834 |
async function deleteHistory(id) {
|
| 835 |
+
if (!confirm("Delete this prompt permanently?")) return;
|
| 836 |
try {
|
| 837 |
await apiFetch(`/api/history/${id}`, "DELETE");
|
| 838 |
toast("Deleted.", "success");
|
|
|
|
| 841 |
}
|
| 842 |
window.deleteHistory = deleteHistory;
|
| 843 |
|
| 844 |
+
/* ─────────────────────────────────────────────────────────────────────
|
| 845 |
+
STATS PAGE
|
| 846 |
+
───────────────────────────────────────────────────────────────────── */
|
| 847 |
+
$("btn-stats-refresh")?.addEventListener("click", loadStats);
|
| 848 |
+
|
| 849 |
+
async function loadStats() {
|
| 850 |
+
const grid = $("stats-grid");
|
| 851 |
+
try {
|
| 852 |
+
const s = await apiFetch("/api/stats");
|
| 853 |
+
|
| 854 |
+
grid.innerHTML = [
|
| 855 |
+
{ val:s.total_prompts, lbl:"Total Prompts", sub:"all time", hi:false },
|
| 856 |
+
{ val:s.total_settings, lbl:"Saved Settings", sub:`${s.favorite_settings} favourited`, hi:false },
|
| 857 |
+
{ val:s.total_refinements,lbl:"Refinements", sub:"iterative improvements", hi:true },
|
| 858 |
+
{ val:s.avg_constraints, lbl:"Avg Constraints", sub:"per prompt", hi:false },
|
| 859 |
+
].map(c => `
|
| 860 |
+
<div class="stat-card${c.hi?" highlight":""}">
|
| 861 |
+
<div class="stat-value">${c.val}</div>
|
| 862 |
+
<div class="stat-label">${c.lbl}</div>
|
| 863 |
+
<div class="stat-sub">${c.sub}</div>
|
| 864 |
+
</div>`).join("");
|
| 865 |
+
|
| 866 |
+
// Persona bars
|
| 867 |
+
const maxP = Math.max(1, ...Object.values(s.top_personas));
|
| 868 |
+
$("stats-personas").innerHTML = Object.entries(s.top_personas).length
|
| 869 |
+
? Object.entries(s.top_personas).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `
|
| 870 |
+
<div class="bar-row">
|
| 871 |
+
<div class="bar-row-label">${esc(k)}</div>
|
| 872 |
+
<div class="bar-track"><div class="bar-fill" style="width:${Math.round(v/maxP*100)}%"></div></div>
|
| 873 |
+
<div class="bar-count">${v}</div>
|
| 874 |
+
</div>`).join("")
|
| 875 |
+
: `<p class="muted" style="padding:4px 0">No data yet.</p>`;
|
| 876 |
+
|
| 877 |
+
// Style bars
|
| 878 |
+
const maxS = Math.max(1, ...Object.values(s.top_styles));
|
| 879 |
+
$("stats-styles").innerHTML = Object.entries(s.top_styles).length
|
| 880 |
+
? Object.entries(s.top_styles).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `
|
| 881 |
+
<div class="bar-row">
|
| 882 |
+
<div class="bar-row-label">${esc(k)}</div>
|
| 883 |
+
<div class="bar-track"><div class="bar-fill" style="width:${Math.round(v/maxS*100)}%"></div></div>
|
| 884 |
+
<div class="bar-count">${v}</div>
|
| 885 |
+
</div>`).join("")
|
| 886 |
+
: `<p class="muted" style="padding:4px 0">No data yet.</p>`;
|
| 887 |
+
|
| 888 |
+
// Status breakdown
|
| 889 |
+
$("stats-statuses").innerHTML = [
|
| 890 |
+
{ status:"pending", count:s.pending_count, color:"var(--amber)" },
|
| 891 |
+
{ status:"approved", count:s.approved_count, color:"var(--green)" },
|
| 892 |
+
{ status:"exported", count:s.exported_count, color:"var(--accent)" },
|
| 893 |
+
{ status:"archived", count:s.archived_count, color:"var(--text-faint)" },
|
| 894 |
+
].map(x => `
|
| 895 |
+
<div class="status-item">
|
| 896 |
+
<div class="status-item-count" style="color:${x.color}">${x.count}</div>
|
| 897 |
+
<div class="status-item-label">${x.status}</div>
|
| 898 |
+
</div>`).join("");
|
| 899 |
+
|
| 900 |
+
toast("Stats refreshed.", "info", 2000);
|
| 901 |
+
} catch (e) {
|
| 902 |
+
grid.innerHTML = `<div class="stat-card" style="grid-column:1/-1;text-align:center;color:var(--text-muted)">Could not load stats — is the server running?</div>`;
|
| 903 |
+
toast("Stats error: " + e.message, "error");
|
| 904 |
+
}
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
/* ── Utilities ─── */
|
| 908 |
+
function personaEmoji(persona) {
|
| 909 |
+
const m = { senior_dev:"👨💻", data_scientist:"📊", tech_writer:"✍️", product_mgr:"📋",
|
| 910 |
+
security_eng:"🔒", devops_eng:"🚀", ml_engineer:"🧠", custom:"✏️" };
|
| 911 |
+
return m[persona] || "🤖";
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
function updateBadge(id, total, delta) {
|
| 915 |
+
const el = $(id);
|
| 916 |
+
if (!el) return;
|
| 917 |
+
if (total !== null) { el.textContent = total; }
|
| 918 |
+
else { el.textContent = parseInt(el.textContent || "0") + delta; }
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
/* ── Init ─── */
|
| 922 |
(async () => {
|
| 923 |
await loadConfig();
|
| 924 |
await loadSettingsList();
|
| 925 |
+
setStep(1);
|
| 926 |
})();
|
frontend/index.html
CHANGED
|
@@ -1,322 +1,328 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8"/>
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
-
<title>PromptForge
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 8 |
-
<link href="https://fonts.googleapis.com/css2?family=
|
| 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 |
-
<!-- ──
|
| 21 |
-
<
|
| 22 |
-
<div class="
|
| 23 |
-
<div class="
|
| 24 |
-
<div class="
|
| 25 |
-
<
|
| 26 |
-
|
| 27 |
-
|
| 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 |
-
|
| 41 |
-
<
|
| 42 |
-
<
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
</
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
</div>
|
|
|
|
|
|
|
| 54 |
</div>
|
| 55 |
-
</
|
| 56 |
|
| 57 |
-
<!--
|
| 58 |
-
<
|
| 59 |
-
<!-- ═══════════════════════════════════════════════════════════════════ -->
|
| 60 |
-
<div id="tab-generate" class="tab-page active">
|
| 61 |
-
<main>
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
<
|
| 68 |
-
|
| 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="
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
<
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
-
<div class="
|
| 91 |
-
<span class="
|
| 92 |
-
<
|
| 93 |
</div>
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
|
| 98 |
<!-- Step Progress -->
|
| 99 |
-
<div class="
|
| 100 |
-
<div class="
|
| 101 |
-
<div class="
|
| 102 |
-
<div class="
|
| 103 |
-
<div class="
|
| 104 |
-
<div class="
|
| 105 |
-
<div class="
|
| 106 |
-
<div class="
|
| 107 |
-
<div class="
|
| 108 |
-
<div class="
|
| 109 |
</div>
|
| 110 |
|
| 111 |
<!-- STEP 1: Input -->
|
| 112 |
-
<
|
| 113 |
-
<div class="
|
| 114 |
-
<h2>✍️
|
| 115 |
-
<span class="step-
|
| 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="
|
| 123 |
-
<textarea id="instruction" rows="5" placeholder="e.g.
|
|
|
|
| 124 |
</div>
|
| 125 |
<div class="field">
|
| 126 |
-
<label>
|
| 127 |
-
<textarea id="extra-context" rows="2" placeholder="e.g. Support dark mode, WCAG AA
|
| 128 |
-
</div>
|
| 129 |
-
<div class="advanced-toggle-row">
|
| 130 |
-
<button class="btn-link" id="btn-toggle-advanced">⚙️ Advanced options</button>
|
| 131 |
</div>
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
<div class="adv-grid">
|
| 134 |
<div class="field">
|
| 135 |
-
<label>
|
| 136 |
-
<span class="
|
| 137 |
</label>
|
| 138 |
<select id="gen-persona">
|
| 139 |
-
<option value="default">🤖 Auto-detect
|
| 140 |
-
<option value="senior_dev">👨💻 Senior
|
| 141 |
<option value="data_scientist">📊 Data Scientist</option>
|
| 142 |
-
<option value="tech_writer">✍️
|
| 143 |
<option value="product_mgr">📋 Product Manager</option>
|
| 144 |
-
<option value="security_eng">🔒 Security
|
| 145 |
-
<option value="
|
|
|
|
|
|
|
| 146 |
</select>
|
| 147 |
</div>
|
| 148 |
<div class="field">
|
| 149 |
-
<label>
|
| 150 |
-
<span class="
|
| 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
|
| 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>
|
| 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>
|
| 168 |
-
<textarea id="gen-constraints" rows="3" placeholder="e.g. Must use async/await
|
| 169 |
</div>
|
| 170 |
-
</
|
|
|
|
| 171 |
<div class="action-row">
|
| 172 |
-
<button
|
| 173 |
-
<button id="btn-load-from-settings" class="btn-secondary">📂 Load from Settings</button>
|
| 174 |
</div>
|
| 175 |
-
</
|
| 176 |
|
| 177 |
<!-- STEP 2: Manifest Review -->
|
| 178 |
-
<
|
| 179 |
-
<div class="
|
| 180 |
-
<h2>🔍 Review &
|
| 181 |
-
<span class="step-
|
| 182 |
</div>
|
| 183 |
-
<p class="muted">Every field is editable.
|
| 184 |
-
<div
|
| 185 |
<details>
|
| 186 |
-
<summary>
|
| 187 |
-
<
|
| 188 |
-
|
| 189 |
-
|
| 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="
|
| 197 |
</div>
|
| 198 |
<div class="action-row">
|
| 199 |
-
<button
|
| 200 |
-
<button
|
| 201 |
-
<button
|
| 202 |
</div>
|
| 203 |
-
</
|
| 204 |
|
| 205 |
-
<!-- STEP 3:
|
| 206 |
-
<
|
| 207 |
-
<div class="
|
| 208 |
-
<h2>🎉
|
| 209 |
-
<span class="step-
|
| 210 |
</div>
|
| 211 |
-
<p class="muted">Your
|
| 212 |
-
<div class="
|
| 213 |
-
<button class="tab active" data-tab="text">📄 Plain Text</button>
|
| 214 |
-
<button class="tab" data-tab="json">{ } JSON</button>
|
| 215 |
</div>
|
| 216 |
-
<div
|
| 217 |
-
<
|
| 218 |
-
|
|
|
|
|
|
|
| 219 |
</div>
|
| 220 |
-
<div
|
| 221 |
-
<
|
| 222 |
-
|
|
|
|
|
|
|
| 223 |
</div>
|
| 224 |
<div class="divider"></div>
|
| 225 |
<div class="action-row">
|
| 226 |
-
<button
|
| 227 |
-
<button
|
| 228 |
-
<button
|
| 229 |
-
<button id="btn-save-as-setting"
|
| 230 |
-
<button
|
|
|
|
| 231 |
</div>
|
| 232 |
-
</
|
| 233 |
|
| 234 |
<!-- STEP 5: Refine -->
|
| 235 |
-
<
|
| 236 |
-
<div class="
|
| 237 |
<h2>🔁 Refine Prompt</h2>
|
| 238 |
-
<span class="step-
|
| 239 |
</div>
|
| 240 |
-
<p class="muted">Describe what to change.
|
| 241 |
<div class="field">
|
| 242 |
-
<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
|
| 247 |
-
<button
|
| 248 |
</div>
|
| 249 |
-
</
|
| 250 |
-
|
| 251 |
-
</main>
|
| 252 |
-
</div><!-- /tab-generate -->
|
| 253 |
|
| 254 |
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
<div class="settings-layout">
|
| 262 |
-
|
| 263 |
-
<!-- ── Left: Settings Form ────────────────────────────────── -->
|
| 264 |
<div class="settings-form-col">
|
| 265 |
-
<div class="
|
| 266 |
-
<div class="
|
| 267 |
-
<h2 id="settings-form-title">➕ New
|
| 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 |
-
|
| 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>
|
| 285 |
-
<textarea id="s-description" rows="2" placeholder="
|
| 286 |
</div>
|
| 287 |
-
|
| 288 |
<div class="field">
|
| 289 |
-
<label><span class="
|
| 290 |
-
<textarea id="s-instruction" rows="5" placeholder="The full
|
| 291 |
-
<div class="
|
| 292 |
</div>
|
| 293 |
-
|
| 294 |
<div class="field">
|
| 295 |
-
<label>
|
| 296 |
-
<textarea id="s-extra-context" rows="2" placeholder="
|
| 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
|
|
|
|
|
|
|
| 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>
|
|
@@ -327,147 +333,191 @@
|
|
| 327 |
</select>
|
| 328 |
</div>
|
| 329 |
</div>
|
| 330 |
-
|
| 331 |
<div class="field" id="s-custom-persona-field" style="display:none">
|
| 332 |
-
<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
|
| 343 |
-
<option value="text">📄 Text
|
| 344 |
-
<option value="json">{ } JSON
|
| 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
|
| 353 |
-
<option value="huggingface">🤗
|
| 354 |
<option value="google">🌐 Google Gemini</option>
|
| 355 |
</select>
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
-
|
| 359 |
<div class="field">
|
| 360 |
-
<label>
|
| 361 |
-
|
| 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>
|
| 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-
|
| 376 |
-
<span class="toggle-track">
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
| 380 |
</label>
|
| 381 |
</div>
|
| 382 |
-
|
| 383 |
<div class="action-row">
|
| 384 |
-
<button
|
| 385 |
-
<button
|
| 386 |
</div>
|
| 387 |
</div>
|
| 388 |
</div>
|
| 389 |
|
| 390 |
-
<!--
|
| 391 |
<div class="settings-list-col">
|
| 392 |
-
<div class="
|
| 393 |
-
<
|
| 394 |
-
<div class="
|
| 395 |
-
<input type="text" id="settings-search" placeholder="🔍 Search…"
|
| 396 |
-
<select id="settings-filter-tag" class="filter-select">
|
| 397 |
-
|
| 398 |
-
</select>
|
| 399 |
</div>
|
| 400 |
</div>
|
| 401 |
-
|
| 402 |
<div id="settings-list" class="settings-list">
|
| 403 |
-
<div class="
|
| 404 |
<div class="empty-icon">📋</div>
|
| 405 |
-
<p>No settings yet
|
| 406 |
</div>
|
| 407 |
</div>
|
| 408 |
</div>
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
</main>
|
| 412 |
-
</div><!-- /tab-settings -->
|
| 413 |
|
| 414 |
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 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>
|
| 434 |
<th>Date</th>
|
| 435 |
<th>Actions</th>
|
| 436 |
</tr>
|
| 437 |
</thead>
|
| 438 |
<tbody id="history-body">
|
| 439 |
-
<tr><td class="empty-msg" colspan="
|
| 440 |
</tbody>
|
| 441 |
</table>
|
| 442 |
</div>
|
| 443 |
</div>
|
| 444 |
-
</
|
| 445 |
-
</div><!-- /tab-history -->
|
| 446 |
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
<div class="
|
| 450 |
-
<
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
</div>
|
| 456 |
-
</div>
|
| 457 |
-
</footer>
|
| 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
|
| 466 |
-
<button class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
</div>
|
| 468 |
<div class="modal-body">
|
| 469 |
-
<
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
</div>
|
| 472 |
</div>
|
| 473 |
</div>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-theme="dark">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8"/>
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>PromptForge v4.0</title>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
| 9 |
<link rel="stylesheet" href="/static/style.css"/>
|
| 10 |
</head>
|
| 11 |
<body>
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
<div id="app">
|
| 14 |
|
| 15 |
+
<!-- ── Sidebar ─────────────────────────────────────────────────── -->
|
| 16 |
+
<aside id="sidebar">
|
| 17 |
+
<div class="sidebar-top">
|
| 18 |
+
<div class="brand">
|
| 19 |
+
<div class="brand-icon">⚡</div>
|
| 20 |
+
<div class="brand-info">
|
| 21 |
+
<span class="brand-name">PromptForge</span>
|
| 22 |
+
<span class="brand-ver">v4.0</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</div>
|
|
|
|
| 24 |
</div>
|
| 25 |
+
<button class="sidebar-toggle" id="btn-sidebar-toggle" title="Toggle sidebar (Alt+B)">‹</button>
|
| 26 |
</div>
|
| 27 |
+
|
| 28 |
+
<nav class="sidebar-nav">
|
| 29 |
+
<button class="nav-item active" data-page="generate">
|
| 30 |
+
<span class="nav-icon">⚡</span>
|
| 31 |
+
<span class="nav-label">Generate</span>
|
| 32 |
+
</button>
|
| 33 |
+
<button class="nav-item" data-page="settings">
|
| 34 |
+
<span class="nav-icon">⚙️</span>
|
| 35 |
+
<span class="nav-label">Settings</span>
|
| 36 |
+
<span class="nav-badge" id="settings-badge">0</span>
|
| 37 |
+
</button>
|
| 38 |
+
<button class="nav-item" data-page="history">
|
| 39 |
+
<span class="nav-icon">📜</span>
|
| 40 |
+
<span class="nav-label">History</span>
|
| 41 |
+
<span class="nav-badge" id="history-badge">0</span>
|
| 42 |
+
</button>
|
| 43 |
+
<button class="nav-item" data-page="stats">
|
| 44 |
+
<span class="nav-icon">📊</span>
|
| 45 |
+
<span class="nav-label">Stats</span>
|
| 46 |
+
</button>
|
| 47 |
+
</nav>
|
| 48 |
+
|
| 49 |
+
<div class="sidebar-bottom">
|
| 50 |
+
<div class="api-status-row" id="api-status-row">
|
| 51 |
+
<span class="api-dot-sm" id="dot-hf" title="Hugging Face">HF</span>
|
| 52 |
+
<span class="api-dot-sm" id="dot-google" title="Google Gemini">GG</span>
|
| 53 |
</div>
|
| 54 |
+
<button class="theme-toggle" id="btn-theme" title="Toggle theme (Alt+T)">🌙</button>
|
| 55 |
+
<a class="sidebar-link" href="/docs" target="_blank" title="API Docs">📖</a>
|
| 56 |
</div>
|
| 57 |
+
</aside>
|
| 58 |
|
| 59 |
+
<!-- ── Main content ───────────────────────────────────────────── -->
|
| 60 |
+
<div id="main-content">
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
<!-- PAGE: GENERATE -->
|
| 63 |
+
<div class="page active" id="page-generate">
|
| 64 |
+
<div class="page-header">
|
| 65 |
+
<div>
|
| 66 |
+
<h1 class="page-title">Generate Prompt</h1>
|
| 67 |
+
<p class="page-subtitle">Transform any instruction into a structured, production-ready prompt for Google AI Studio.</p>
|
|
|
|
|
|
|
| 68 |
</div>
|
| 69 |
+
<div class="page-header-actions">
|
| 70 |
+
<button class="btn-ghost btn-sm" id="btn-shortcuts" title="Keyboard shortcuts (?)">⌨️ Shortcuts</button>
|
| 71 |
+
<button class="btn-secondary" id="btn-load-from-settings">📂 Load Setting</button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<!-- API Key Banner -->
|
| 76 |
+
<div class="panel api-panel" id="api-panel">
|
| 77 |
+
<div class="api-panel-toggle" id="btn-api-panel-toggle">
|
| 78 |
+
<span>🔑 AI Enhancement</span>
|
| 79 |
+
<span class="api-panel-status" id="api-panel-status">Local Engine Active</span>
|
| 80 |
+
<span class="chevron" id="api-chevron">▾</span>
|
| 81 |
+
</div>
|
| 82 |
+
<div class="api-panel-body" id="api-panel-body">
|
| 83 |
+
<div class="api-grid">
|
| 84 |
+
<div class="field">
|
| 85 |
+
<label>Provider</label>
|
| 86 |
+
<select id="provider">
|
| 87 |
+
<option value="none">⚡ Local Engine</option>
|
| 88 |
+
<option value="google">🌐 Google Gemini</option>
|
| 89 |
+
<option value="huggingface">🤗 Hugging Face</option>
|
| 90 |
+
</select>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="field" id="api-key-group">
|
| 93 |
+
<label>API Key <span class="key-hint" id="key-hint">Select provider first</span></label>
|
| 94 |
+
<div class="input-with-action">
|
| 95 |
+
<input type="password" id="api-key" placeholder="Not required" disabled/>
|
| 96 |
+
<button class="icon-btn" id="btn-toggle-key" title="Show/hide">👁</button>
|
| 97 |
+
<button class="icon-btn" id="btn-check-key" title="Validate key" disabled>✓</button>
|
| 98 |
+
</div>
|
| 99 |
+
<div class="field-note" id="key-status-note">
|
| 100 |
+
<span class="status-dot" id="key-dot"></span>
|
| 101 |
+
<span id="key-status-text">No key entered</span>
|
| 102 |
+
</div>
|
| 103 |
</div>
|
| 104 |
+
<div class="field" id="model-field" style="display:none">
|
| 105 |
+
<label>Model Override <span class="lbl-opt">optional</span></label>
|
| 106 |
+
<input type="text" id="provider-model" placeholder="e.g. gemini-1.5-pro"/>
|
| 107 |
</div>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
</div>
|
| 111 |
|
| 112 |
<!-- Step Progress -->
|
| 113 |
+
<div class="stepper" id="stepper">
|
| 114 |
+
<div class="step active" data-step="1"><div class="step-dot"><span>1</span></div><div class="step-label">Input</div></div>
|
| 115 |
+
<div class="step-line"></div>
|
| 116 |
+
<div class="step" data-step="2"><div class="step-dot"><span>2</span></div><div class="step-label">Review</div></div>
|
| 117 |
+
<div class="step-line"></div>
|
| 118 |
+
<div class="step" data-step="3"><div class="step-dot"><span>3</span></div><div class="step-label">Finalise</div></div>
|
| 119 |
+
<div class="step-line"></div>
|
| 120 |
+
<div class="step" data-step="4"><div class="step-dot"><span>4</span></div><div class="step-label">Export</div></div>
|
| 121 |
+
<div class="step-line"></div>
|
| 122 |
+
<div class="step" data-step="5"><div class="step-dot"><span>5</span></div><div class="step-label">Refine</div></div>
|
| 123 |
</div>
|
| 124 |
|
| 125 |
<!-- STEP 1: Input -->
|
| 126 |
+
<div class="panel" id="step-input">
|
| 127 |
+
<div class="panel-header">
|
| 128 |
+
<h2>✍️ Your Instruction</h2>
|
| 129 |
+
<span class="step-tag">STEP 01</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
+
<div class="info-note">💡 Describe any task. PromptForge structures it with role, constraints, style, safety guardrails, and examples — ready for Google AI Studio.</div>
|
| 132 |
<div class="field">
|
| 133 |
+
<label>Instruction <span class="required">*</span></label>
|
| 134 |
+
<textarea id="instruction" rows="5" placeholder="e.g. Create a TypeScript React component with TailwindCSS and Jest unit tests." maxlength="8000"></textarea>
|
| 135 |
+
<div class="char-counter"><span id="instr-count">0</span> / 8000</div>
|
| 136 |
</div>
|
| 137 |
<div class="field">
|
| 138 |
+
<label>Extra Context <span class="lbl-opt">optional</span></label>
|
| 139 |
+
<textarea id="extra-context" rows="2" placeholder="e.g. Support dark mode, WCAG AA, hooks only." maxlength="2000"></textarea>
|
|
|
|
|
|
|
|
|
|
| 140 |
</div>
|
| 141 |
+
|
| 142 |
+
<details class="advanced-block" id="advanced-block">
|
| 143 |
+
<summary>⚙️ Advanced Options</summary>
|
| 144 |
<div class="adv-grid">
|
| 145 |
<div class="field">
|
| 146 |
+
<label>Persona
|
| 147 |
+
<span class="tip" title="Sets the AI's domain expertise and communication style.">?</span>
|
| 148 |
</label>
|
| 149 |
<select id="gen-persona">
|
| 150 |
+
<option value="default">🤖 Auto-detect</option>
|
| 151 |
+
<option value="senior_dev">👨💻 Senior Dev</option>
|
| 152 |
<option value="data_scientist">📊 Data Scientist</option>
|
| 153 |
+
<option value="tech_writer">✍️ Tech Writer</option>
|
| 154 |
<option value="product_mgr">📋 Product Manager</option>
|
| 155 |
+
<option value="security_eng">🔒 Security Eng</option>
|
| 156 |
+
<option value="devops_eng">🚀 DevOps Eng</option>
|
| 157 |
+
<option value="ml_engineer">🧠 ML Engineer</option>
|
| 158 |
+
<option value="custom">✏️ Custom…</option>
|
| 159 |
</select>
|
| 160 |
</div>
|
| 161 |
<div class="field">
|
| 162 |
+
<label>Style
|
| 163 |
+
<span class="tip" title="Controls verbosity, tone, and explanation depth.">?</span>
|
| 164 |
</label>
|
| 165 |
<select id="gen-style">
|
| 166 |
<option value="professional">💼 Professional</option>
|
| 167 |
<option value="concise">⚡ Concise</option>
|
| 168 |
<option value="detailed">📖 Detailed</option>
|
| 169 |
+
<option value="beginner">🎓 Beginner</option>
|
| 170 |
<option value="formal">📄 Formal</option>
|
| 171 |
<option value="creative">🎨 Creative</option>
|
| 172 |
</select>
|
| 173 |
</div>
|
| 174 |
</div>
|
| 175 |
<div class="field" id="custom-persona-field" style="display:none">
|
| 176 |
+
<label>Custom Persona</label>
|
| 177 |
<input type="text" id="gen-custom-persona" placeholder="e.g. Expert Kubernetes architect with CNCF certification"/>
|
| 178 |
</div>
|
| 179 |
<div class="field">
|
| 180 |
+
<label>Constraints <span class="lbl-opt">one per line</span></label>
|
| 181 |
+
<textarea id="gen-constraints" rows="3" placeholder="e.g. Must use async/await No external dependencies Rate limiting required"></textarea>
|
| 182 |
</div>
|
| 183 |
+
</details>
|
| 184 |
+
|
| 185 |
<div class="action-row">
|
| 186 |
+
<button class="btn-primary" id="btn-generate">⚡ Generate Manifest <kbd>⌘↵</kbd></button>
|
|
|
|
| 187 |
</div>
|
| 188 |
+
</div>
|
| 189 |
|
| 190 |
<!-- STEP 2: Manifest Review -->
|
| 191 |
+
<div class="panel hidden" id="step-manifest">
|
| 192 |
+
<div class="panel-header">
|
| 193 |
+
<h2>🔍 Review & Edit</h2>
|
| 194 |
+
<span class="step-tag">STEP 02</span>
|
| 195 |
</div>
|
| 196 |
+
<p class="muted">Every field is editable. Edit anything before approving.</p>
|
| 197 |
+
<div class="manifest-grid" id="manifest-grid"></div>
|
| 198 |
<details>
|
| 199 |
+
<summary>Raw JSON manifest</summary>
|
| 200 |
+
<div class="code-block">
|
| 201 |
+
<button class="copy-btn" data-target="manifest-json">📋 Copy</button>
|
| 202 |
+
<pre id="manifest-json"></pre>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</div>
|
| 204 |
+
</details>
|
| 205 |
+
<div class="explanation-panel hidden" id="explanation-panel">
|
| 206 |
+
<div class="explanation-header">🧠 Why was this structured this way?</div>
|
| 207 |
<div id="explanation-text" class="explanation-body"></div>
|
| 208 |
+
<div id="key-decisions" class="decision-chips"></div>
|
| 209 |
</div>
|
| 210 |
<div class="action-row">
|
| 211 |
+
<button class="btn-primary" id="btn-approve">✅ Approve</button>
|
| 212 |
+
<button class="btn-secondary" id="btn-explain">🧠 Explain</button>
|
| 213 |
+
<button class="btn-ghost" id="btn-reset">↩ Start Over</button>
|
| 214 |
</div>
|
| 215 |
+
</div>
|
| 216 |
|
| 217 |
+
<!-- STEP 3: Finalised -->
|
| 218 |
+
<div class="panel hidden" id="step-finalized">
|
| 219 |
+
<div class="panel-header">
|
| 220 |
+
<h2>🎉 Finalised Prompt</h2>
|
| 221 |
+
<span class="step-tag">STEP 03</span>
|
| 222 |
</div>
|
| 223 |
+
<p class="muted">Your prompt is ready. Copy directly into Google AI Studio.</p>
|
| 224 |
+
<div class="inner-tabs">
|
| 225 |
+
<button class="inner-tab active" data-tab="text">📄 Plain Text</button>
|
| 226 |
+
<button class="inner-tab" data-tab="json">{ } JSON</button>
|
| 227 |
</div>
|
| 228 |
+
<div class="inner-panel active" id="itab-text">
|
| 229 |
+
<div class="code-block">
|
| 230 |
+
<button class="copy-btn" data-target="finalized-text">📋 Copy</button>
|
| 231 |
+
<pre id="finalized-text"></pre>
|
| 232 |
+
</div>
|
| 233 |
</div>
|
| 234 |
+
<div class="inner-panel hidden" id="itab-json">
|
| 235 |
+
<div class="code-block">
|
| 236 |
+
<button class="copy-btn" data-target="finalized-json">📋 Copy</button>
|
| 237 |
+
<pre id="finalized-json"></pre>
|
| 238 |
+
</div>
|
| 239 |
</div>
|
| 240 |
<div class="divider"></div>
|
| 241 |
<div class="action-row">
|
| 242 |
+
<button class="btn-secondary" id="btn-export-json">⬇ Export JSON</button>
|
| 243 |
+
<button class="btn-secondary" id="btn-export-txt">⬇ Export Text</button>
|
| 244 |
+
<button class="btn-secondary" id="btn-refine">🔁 Refine</button>
|
| 245 |
+
<button class="btn-secondary" id="btn-save-as-setting">💾 Save as Setting</button>
|
| 246 |
+
<button class="btn-ghost" id="btn-favorite-prompt" title="Star this prompt">☆ Favourite</button>
|
| 247 |
+
<button class="btn-primary" id="btn-new">➕ New Prompt</button>
|
| 248 |
</div>
|
| 249 |
+
</div>
|
| 250 |
|
| 251 |
<!-- STEP 5: Refine -->
|
| 252 |
+
<div class="panel hidden" id="step-refine">
|
| 253 |
+
<div class="panel-header">
|
| 254 |
<h2>🔁 Refine Prompt</h2>
|
| 255 |
+
<span class="step-tag">STEP 05</span>
|
| 256 |
</div>
|
| 257 |
+
<p class="muted">Describe what to change. A new version will be created for approval.</p>
|
| 258 |
<div class="field">
|
| 259 |
+
<label>Your Feedback</label>
|
| 260 |
<textarea id="feedback" rows="3" placeholder="e.g. Add ARIA labels, keyboard navigation, and a dark-mode variant prop."></textarea>
|
| 261 |
</div>
|
| 262 |
<div class="action-row">
|
| 263 |
+
<button class="btn-primary" id="btn-submit-refine">🔁 Submit Refinement</button>
|
| 264 |
+
<button class="btn-ghost" id="btn-cancel-refine">Cancel</button>
|
| 265 |
</div>
|
| 266 |
+
</div>
|
| 267 |
+
</div><!-- /page-generate -->
|
|
|
|
|
|
|
| 268 |
|
| 269 |
|
| 270 |
+
<!-- PAGE: SETTINGS -->
|
| 271 |
+
<div class="page hidden" id="page-settings">
|
| 272 |
+
<div class="page-header">
|
| 273 |
+
<div>
|
| 274 |
+
<h1 class="page-title">Instruction Settings</h1>
|
| 275 |
+
<p class="page-subtitle">Save, manage, and reuse instruction templates.</p>
|
| 276 |
+
</div>
|
| 277 |
+
<div class="page-header-actions">
|
| 278 |
+
<button class="btn-ghost btn-sm" id="btn-export-all-settings" title="Download all settings as JSON">⬇ Export All</button>
|
| 279 |
+
<button class="btn-secondary btn-sm" id="btn-settings-clear">✕ Clear Form</button>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
|
| 283 |
<div class="settings-layout">
|
| 284 |
+
<!-- Form -->
|
|
|
|
| 285 |
<div class="settings-form-col">
|
| 286 |
+
<div class="panel">
|
| 287 |
+
<div class="panel-header">
|
| 288 |
+
<h2 id="settings-form-title">➕ New Setting</h2>
|
|
|
|
|
|
|
|
|
|
| 289 |
</div>
|
|
|
|
| 290 |
<input type="hidden" id="edit-settings-id"/>
|
| 291 |
|
| 292 |
<div class="field">
|
| 293 |
+
<label>Title <span class="required">*</span></label>
|
| 294 |
+
<input type="text" id="s-title" maxlength="120" placeholder="e.g. React Component Generator"/>
|
|
|
|
|
|
|
|
|
|
| 295 |
</div>
|
|
|
|
| 296 |
<div class="field">
|
| 297 |
+
<label>Description <span class="lbl-opt">optional</span></label>
|
| 298 |
+
<textarea id="s-description" rows="2" placeholder="When to use this setting…"></textarea>
|
| 299 |
</div>
|
|
|
|
| 300 |
<div class="field">
|
| 301 |
+
<label>Instruction <span class="required">*</span></label>
|
| 302 |
+
<textarea id="s-instruction" rows="5" maxlength="8000" placeholder="The full task instruction that will drive prompt generation…"></textarea>
|
| 303 |
+
<div class="char-counter"><span id="s-instr-count">0</span> / 8000</div>
|
| 304 |
</div>
|
|
|
|
| 305 |
<div class="field">
|
| 306 |
+
<label>Extra Context <span class="lbl-opt">optional</span></label>
|
| 307 |
+
<textarea id="s-extra-context" rows="2" placeholder="Background info, constraints, requirements…"></textarea>
|
| 308 |
</div>
|
| 309 |
+
<div class="two-col">
|
|
|
|
| 310 |
<div class="field">
|
| 311 |
+
<label>Persona</label>
|
|
|
|
|
|
|
|
|
|
| 312 |
<select id="s-persona">
|
| 313 |
<option value="default">🤖 Auto-detect</option>
|
| 314 |
<option value="senior_dev">👨💻 Senior Dev</option>
|
| 315 |
<option value="data_scientist">📊 Data Scientist</option>
|
| 316 |
<option value="tech_writer">✍️ Tech Writer</option>
|
| 317 |
<option value="product_mgr">📋 Product Manager</option>
|
| 318 |
+
<option value="security_eng">🔒 Security Eng</option>
|
| 319 |
+
<option value="devops_eng">🚀 DevOps Eng</option>
|
| 320 |
+
<option value="ml_engineer">🧠 ML Engineer</option>
|
| 321 |
<option value="custom">✏️ Custom…</option>
|
| 322 |
</select>
|
| 323 |
</div>
|
| 324 |
<div class="field">
|
| 325 |
+
<label>Style</label>
|
|
|
|
|
|
|
|
|
|
| 326 |
<select id="s-style">
|
| 327 |
<option value="professional">💼 Professional</option>
|
| 328 |
<option value="concise">⚡ Concise</option>
|
|
|
|
| 333 |
</select>
|
| 334 |
</div>
|
| 335 |
</div>
|
|
|
|
| 336 |
<div class="field" id="s-custom-persona-field" style="display:none">
|
| 337 |
+
<label>Custom Persona Text</label>
|
| 338 |
<input type="text" id="s-custom-persona" placeholder="e.g. Expert Rust systems programmer"/>
|
| 339 |
</div>
|
| 340 |
+
<div class="two-col">
|
|
|
|
| 341 |
<div class="field">
|
| 342 |
+
<label>Output Format</label>
|
|
|
|
|
|
|
| 343 |
<select id="s-output-format">
|
| 344 |
+
<option value="both">📦 Both</option>
|
| 345 |
+
<option value="text">📄 Text</option>
|
| 346 |
+
<option value="json">{ } JSON</option>
|
| 347 |
</select>
|
| 348 |
</div>
|
| 349 |
<div class="field">
|
| 350 |
+
<label>AI Enhancement</label>
|
|
|
|
|
|
|
| 351 |
<select id="s-provider">
|
| 352 |
+
<option value="none">⚡ None</option>
|
| 353 |
+
<option value="huggingface">🤗 HuggingFace</option>
|
| 354 |
<option value="google">🌐 Google Gemini</option>
|
| 355 |
</select>
|
| 356 |
</div>
|
| 357 |
</div>
|
|
|
|
| 358 |
<div class="field">
|
| 359 |
+
<label>Constraints <span class="lbl-opt">one per line</span></label>
|
| 360 |
+
<textarea id="s-constraints" rows="4" placeholder="TypeScript strict mode WCAG 2.1 AA Include unit tests"></textarea>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
</div>
|
|
|
|
| 362 |
<div class="field">
|
| 363 |
+
<label>Tags <span class="lbl-opt">comma-separated</span></label>
|
| 364 |
<input type="text" id="s-tags" placeholder="react, typescript, frontend"/>
|
| 365 |
+
<div class="tag-suggestions" id="tag-suggestions"></div>
|
| 366 |
</div>
|
| 367 |
+
<div class="toggle-row">
|
|
|
|
| 368 |
<label class="toggle-label">
|
| 369 |
+
<input type="checkbox" id="s-enhance" class="toggle-cb"/>
|
| 370 |
+
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
| 371 |
+
Enable AI enhancement on generate
|
| 372 |
+
</label>
|
| 373 |
+
<label class="toggle-label">
|
| 374 |
+
<input type="checkbox" id="s-favorite" class="toggle-cb"/>
|
| 375 |
+
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
| 376 |
+
Favourite / Pin
|
| 377 |
</label>
|
| 378 |
</div>
|
|
|
|
| 379 |
<div class="action-row">
|
| 380 |
+
<button class="btn-primary" id="btn-settings-save">💾 Save Setting</button>
|
| 381 |
+
<button class="btn-secondary hidden" id="btn-settings-generate">⚡ Generate Now</button>
|
| 382 |
</div>
|
| 383 |
</div>
|
| 384 |
</div>
|
| 385 |
|
| 386 |
+
<!-- List -->
|
| 387 |
<div class="settings-list-col">
|
| 388 |
+
<div class="list-header">
|
| 389 |
+
<span>Saved <span class="count-badge" id="settings-total-count">0</span></span>
|
| 390 |
+
<div class="list-controls">
|
| 391 |
+
<input type="text" id="settings-search" class="search-input" placeholder="🔍 Search…"/>
|
| 392 |
+
<select id="settings-filter-tag" class="filter-select"><option value="">All tags</option></select>
|
| 393 |
+
<button class="icon-btn" id="btn-filter-favorites" title="Show favourites only">☆</button>
|
|
|
|
| 394 |
</div>
|
| 395 |
</div>
|
|
|
|
| 396 |
<div id="settings-list" class="settings-list">
|
| 397 |
+
<div class="empty-state">
|
| 398 |
<div class="empty-icon">📋</div>
|
| 399 |
+
<p>No settings yet — create your first one!</p>
|
| 400 |
</div>
|
| 401 |
</div>
|
| 402 |
</div>
|
| 403 |
+
</div>
|
| 404 |
+
</div><!-- /page-settings -->
|
|
|
|
|
|
|
| 405 |
|
| 406 |
|
| 407 |
+
<!-- PAGE: HISTORY -->
|
| 408 |
+
<div class="page hidden" id="page-history">
|
| 409 |
+
<div class="page-header">
|
| 410 |
+
<div>
|
| 411 |
+
<h1 class="page-title">Prompt History</h1>
|
| 412 |
+
<p class="page-subtitle">All generated prompts with status tracking.</p>
|
|
|
|
|
|
|
|
|
|
| 413 |
</div>
|
| 414 |
+
<div class="page-header-actions">
|
| 415 |
+
<select id="history-status-filter" class="filter-select">
|
| 416 |
+
<option value="">All statuses</option>
|
| 417 |
+
<option value="pending">Pending</option>
|
| 418 |
+
<option value="approved">Approved</option>
|
| 419 |
+
<option value="exported">Exported</option>
|
| 420 |
+
<option value="archived">Archived</option>
|
| 421 |
+
</select>
|
| 422 |
+
<input type="text" id="history-search" class="search-input" placeholder="🔍 Search…"/>
|
| 423 |
+
<button class="btn-ghost btn-sm" id="btn-history-refresh">↺ Refresh</button>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
<div class="panel">
|
| 427 |
<div class="table-wrap">
|
| 428 |
<table id="history-table">
|
| 429 |
<thead>
|
| 430 |
<tr>
|
| 431 |
+
<th style="width:30px">★</th>
|
| 432 |
<th>ID</th>
|
| 433 |
<th>Instruction</th>
|
| 434 |
<th>Ver</th>
|
| 435 |
<th>Status</th>
|
| 436 |
+
<th>Tags</th>
|
| 437 |
<th>Date</th>
|
| 438 |
<th>Actions</th>
|
| 439 |
</tr>
|
| 440 |
</thead>
|
| 441 |
<tbody id="history-body">
|
| 442 |
+
<tr><td class="empty-msg" colspan="8">Click ↺ Refresh to load history.</td></tr>
|
| 443 |
</tbody>
|
| 444 |
</table>
|
| 445 |
</div>
|
| 446 |
</div>
|
| 447 |
+
</div><!-- /page-history -->
|
|
|
|
| 448 |
|
| 449 |
+
|
| 450 |
+
<!-- PAGE: STATS -->
|
| 451 |
+
<div class="page hidden" id="page-stats">
|
| 452 |
+
<div class="page-header">
|
| 453 |
+
<div>
|
| 454 |
+
<h1 class="page-title">Dashboard</h1>
|
| 455 |
+
<p class="page-subtitle">Overview of your PromptForge activity.</p>
|
| 456 |
+
</div>
|
| 457 |
+
<button class="btn-ghost btn-sm" id="btn-stats-refresh">↺ Refresh</button>
|
| 458 |
+
</div>
|
| 459 |
+
|
| 460 |
+
<div class="stats-grid" id="stats-grid">
|
| 461 |
+
<!-- Populated by JS -->
|
| 462 |
+
<div class="stat-card skeleton"></div>
|
| 463 |
+
<div class="stat-card skeleton"></div>
|
| 464 |
+
<div class="stat-card skeleton"></div>
|
| 465 |
+
<div class="stat-card skeleton"></div>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
<div class="stats-row">
|
| 469 |
+
<div class="panel" id="stats-personas-panel">
|
| 470 |
+
<div class="panel-header"><h2>Top Personas</h2></div>
|
| 471 |
+
<div id="stats-personas" class="bar-chart"></div>
|
| 472 |
+
</div>
|
| 473 |
+
<div class="panel" id="stats-styles-panel">
|
| 474 |
+
<div class="panel-header"><h2>Style Distribution</h2></div>
|
| 475 |
+
<div id="stats-styles" class="bar-chart"></div>
|
| 476 |
+
</div>
|
| 477 |
</div>
|
|
|
|
|
|
|
| 478 |
|
| 479 |
+
<div class="panel" id="stats-activity-panel">
|
| 480 |
+
<div class="panel-header"><h2>Status Breakdown</h2></div>
|
| 481 |
+
<div id="stats-statuses" class="status-breakdown"></div>
|
| 482 |
+
</div>
|
| 483 |
+
</div><!-- /page-stats -->
|
| 484 |
+
|
| 485 |
+
</div><!-- /main-content -->
|
| 486 |
</div><!-- #app -->
|
| 487 |
|
| 488 |
<!-- Load-from-settings modal -->
|
| 489 |
<div id="modal-overlay" class="modal-overlay hidden">
|
| 490 |
<div class="modal-box">
|
| 491 |
<div class="modal-header">
|
| 492 |
+
<h3>📂 Load from Setting</h3>
|
| 493 |
+
<button class="icon-btn" id="btn-modal-close">✕</button>
|
| 494 |
+
</div>
|
| 495 |
+
<div class="modal-body">
|
| 496 |
+
<input type="text" id="modal-search" class="search-input" style="width:100%;margin-bottom:12px" placeholder="🔍 Search settings…"/>
|
| 497 |
+
<div id="modal-list" class="modal-list"></div>
|
| 498 |
+
</div>
|
| 499 |
+
</div>
|
| 500 |
+
</div>
|
| 501 |
+
|
| 502 |
+
<!-- Keyboard shortcuts modal -->
|
| 503 |
+
<div id="shortcuts-modal" class="modal-overlay hidden">
|
| 504 |
+
<div class="modal-box" style="max-width:420px">
|
| 505 |
+
<div class="modal-header">
|
| 506 |
+
<h3>⌨️ Keyboard Shortcuts</h3>
|
| 507 |
+
<button class="icon-btn" id="btn-shortcuts-close">✕</button>
|
| 508 |
</div>
|
| 509 |
<div class="modal-body">
|
| 510 |
+
<table class="shortcuts-table">
|
| 511 |
+
<tr><td><kbd>⌘↵</kbd> / <kbd>Ctrl+↵</kbd></td><td>Generate prompt</td></tr>
|
| 512 |
+
<tr><td><kbd>Alt+B</kbd></td><td>Toggle sidebar</td></tr>
|
| 513 |
+
<tr><td><kbd>Alt+T</kbd></td><td>Toggle theme</td></tr>
|
| 514 |
+
<tr><td><kbd>Alt+1</kbd></td><td>Go to Generate</td></tr>
|
| 515 |
+
<tr><td><kbd>Alt+2</kbd></td><td>Go to Settings</td></tr>
|
| 516 |
+
<tr><td><kbd>Alt+3</kbd></td><td>Go to History</td></tr>
|
| 517 |
+
<tr><td><kbd>Alt+4</kbd></td><td>Go to Stats</td></tr>
|
| 518 |
+
<tr><td><kbd>?</kbd></td><td>Show this panel</td></tr>
|
| 519 |
+
<tr><td><kbd>Esc</kbd></td><td>Close modal / reset</td></tr>
|
| 520 |
+
</table>
|
| 521 |
</div>
|
| 522 |
</div>
|
| 523 |
</div>
|
frontend/style.css
CHANGED
|
@@ -1,278 +1,450 @@
|
|
| 1 |
-
/* PromptForge
|
| 2 |
-
@import url('https://fonts.googleapis.com/css2?family=
|
| 3 |
|
|
|
|
| 4 |
:root {
|
| 5 |
-
--
|
| 6 |
-
--
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
--
|
| 10 |
-
--
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
--
|
| 18 |
-
--
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
/* ── App shell ── */
|
| 29 |
-
#app{
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
.
|
| 41 |
-
.
|
| 42 |
-
.
|
| 43 |
-
.
|
| 44 |
-
.
|
| 45 |
-
.
|
| 46 |
-
.
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
.
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
.
|
| 54 |
-
.
|
| 55 |
-
|
| 56 |
-
.
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
.
|
| 63 |
-
.
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
.
|
| 67 |
-
|
| 68 |
-
.
|
| 69 |
-
.
|
| 70 |
-
.
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
.
|
| 74 |
-
.api-
|
| 75 |
-
.api-
|
| 76 |
-
|
| 77 |
-
.
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
.
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
.
|
| 87 |
-
|
| 88 |
-
.
|
| 89 |
-
.
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
.
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
.
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
.
|
| 102 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
/* ── Form elements ── */
|
| 105 |
-
.field{margin-bottom:
|
| 106 |
-
label{display:flex;align-items:center;gap:6px;font-size:.72rem;font-weight:600;
|
| 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 |
-
.
|
| 135 |
-
.
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
.
|
| 139 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
/* ── Buttons ── */
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
.btn-primary:
|
| 147 |
-
.btn-
|
| 148 |
-
.btn-
|
| 149 |
-
.btn-secondary
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
.btn-
|
| 153 |
-
.
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
/* ── Manifest grid ── */
|
| 158 |
-
.manifest-grid{display:grid;grid-template-columns:1fr 1fr;gap:
|
| 159 |
-
.manifest-field label{font-size:.
|
| 160 |
-
.manifest-field textarea{min-height:
|
| 161 |
-
.manifest-field.full{grid-column:1/-1}
|
| 162 |
-
@media(max-width:
|
| 163 |
-
|
| 164 |
-
/* ──
|
| 165 |
-
.
|
| 166 |
-
.
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
.
|
| 170 |
-
.
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
.
|
| 189 |
-
.
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
.
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
.
|
| 196 |
-
|
| 197 |
-
.
|
| 198 |
-
|
| 199 |
-
.
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
.settings-
|
| 203 |
-
|
| 204 |
-
.
|
| 205 |
-
.
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
/* ── Setting card ── */
|
| 209 |
-
.setting-card{background:var(--
|
| 210 |
-
|
| 211 |
-
.setting-card
|
| 212 |
-
.setting-card
|
| 213 |
-
.setting-card-
|
| 214 |
-
.setting-card-
|
| 215 |
-
.
|
| 216 |
-
.
|
| 217 |
-
.
|
| 218 |
-
|
| 219 |
-
.setting-card:
|
| 220 |
-
.
|
| 221 |
-
|
| 222 |
-
.
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
.
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
.
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
/* ── Modal ── */
|
| 239 |
-
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.
|
| 240 |
-
|
| 241 |
-
.modal-
|
| 242 |
-
.modal-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
.modal-
|
| 246 |
-
|
| 247 |
-
.modal-
|
| 248 |
-
.modal-
|
| 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(--
|
| 251 |
-
.modal-item:hover{border-color:var(--
|
| 252 |
-
.modal-item-title{font-size:.
|
| 253 |
-
.modal-item-desc{font-size:.
|
| 254 |
-
.modal-empty{text-align:center;padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
/* ── Toast ── */
|
| 257 |
-
#toast-container{position:fixed;bottom:24px;right:24px;display:flex;flex-direction:column;gap:
|
| 258 |
-
.toast{display:flex;align-items:center;gap:10px;background:
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
.toast.success{border-left:
|
| 263 |
-
.toast.error{border-left:
|
| 264 |
-
.toast.warn{border-left:
|
| 265 |
-
.toast.info{border-left:
|
| 266 |
-
.toast-icon{font-size:
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
.
|
| 272 |
-
.
|
| 273 |
-
.
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
.hidden{display:none!important}
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* PromptForge v4.0 — Dark-first editorial design system */
|
| 2 |
+
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
|
| 3 |
|
| 4 |
+
/* ── Design tokens ── */
|
| 5 |
:root {
|
| 6 |
+
--font-ui: 'Space Grotesk', system-ui, sans-serif;
|
| 7 |
+
--font-mono: 'IBM Plex Mono', 'Fira Code', monospace;
|
| 8 |
+
|
| 9 |
+
/* Dark theme (default) */
|
| 10 |
+
--bg: #0e0f14;
|
| 11 |
+
--bg-2: #14151c;
|
| 12 |
+
--bg-3: #1c1d27;
|
| 13 |
+
--border: rgba(255,255,255,.08);
|
| 14 |
+
--border-focus: #7c6dfa;
|
| 15 |
+
|
| 16 |
+
--text: #f0f0f8;
|
| 17 |
+
--text-soft: #b8b9cc;
|
| 18 |
+
--text-muted:#7c7d96;
|
| 19 |
+
--text-faint:#4a4b62;
|
| 20 |
+
|
| 21 |
+
--accent: #7c6dfa;
|
| 22 |
+
--accent-dk: #5a4de8;
|
| 23 |
+
--accent-lt: rgba(124,109,250,.12);
|
| 24 |
+
--accent-mid:rgba(124,109,250,.30);
|
| 25 |
+
--green: #34d399;
|
| 26 |
+
--green-lt: rgba(52,211,153,.12);
|
| 27 |
+
--red: #f87171;
|
| 28 |
+
--red-lt: rgba(248,113,113,.12);
|
| 29 |
+
--amber: #fbbf24;
|
| 30 |
+
--amber-lt: rgba(251,191,36,.12);
|
| 31 |
+
--teal: #2dd4bf;
|
| 32 |
+
--violet: #a78bfa;
|
| 33 |
+
|
| 34 |
+
--sidebar-w: 220px;
|
| 35 |
+
--header-h: 64px;
|
| 36 |
+
--r-sm: 6px; --r-md: 10px; --r-lg: 14px; --r-xl: 18px;
|
| 37 |
+
--shadow: 0 4px 24px rgba(0,0,0,.4);
|
| 38 |
+
--shadow-sm: 0 1px 6px rgba(0,0,0,.3);
|
| 39 |
+
--transition: 200ms cubic-bezier(.4,0,.2,1);
|
| 40 |
}
|
| 41 |
+
|
| 42 |
+
[data-theme="light"] {
|
| 43 |
+
--bg: #f5f5f9;
|
| 44 |
+
--bg-2: #ffffff;
|
| 45 |
+
--bg-3: #eeeef5;
|
| 46 |
+
--border: rgba(0,0,0,.10);
|
| 47 |
+
--border-focus: #7c6dfa;
|
| 48 |
+
--text: #111128;
|
| 49 |
+
--text-soft: #373757;
|
| 50 |
+
--text-muted:#6b6b8a;
|
| 51 |
+
--text-faint:#9999b8;
|
| 52 |
+
--accent-lt: rgba(124,109,250,.10);
|
| 53 |
+
--accent-mid:rgba(124,109,250,.25);
|
| 54 |
+
--shadow: 0 4px 24px rgba(0,0,0,.08);
|
| 55 |
+
--shadow-sm: 0 1px 6px rgba(0,0,0,.06);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* ── Reset ── */
|
| 59 |
+
*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
|
| 60 |
+
html { scroll-behavior:smooth; }
|
| 61 |
+
body { background:var(--bg); color:var(--text); font-family:var(--font-ui); font-size:14px;
|
| 62 |
+
line-height:1.6; min-height:100vh; -webkit-font-smoothing:antialiased; overflow-x:hidden; }
|
| 63 |
+
::-webkit-scrollbar { width:4px; height:4px; }
|
| 64 |
+
::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
|
| 65 |
+
::-webkit-scrollbar-thumb:hover { background:var(--accent-mid); }
|
| 66 |
+
button { cursor:pointer; font-family:var(--font-ui); }
|
| 67 |
|
| 68 |
/* ── App shell ── */
|
| 69 |
+
#app { display:flex; min-height:100vh; }
|
| 70 |
+
|
| 71 |
+
/* ── Sidebar ── */
|
| 72 |
+
#sidebar {
|
| 73 |
+
width:var(--sidebar-w); flex-shrink:0;
|
| 74 |
+
background:var(--bg-2); border-right:1px solid var(--border);
|
| 75 |
+
display:flex; flex-direction:column;
|
| 76 |
+
position:fixed; top:0; left:0; bottom:0;
|
| 77 |
+
transition:width var(--transition), transform var(--transition);
|
| 78 |
+
z-index:100; overflow:hidden;
|
| 79 |
+
}
|
| 80 |
+
#sidebar.collapsed { width:56px; }
|
| 81 |
+
#sidebar.collapsed .brand-info,
|
| 82 |
+
#sidebar.collapsed .nav-label,
|
| 83 |
+
#sidebar.collapsed .nav-badge,
|
| 84 |
+
#sidebar.collapsed .api-status-row { opacity:0; pointer-events:none; width:0; overflow:hidden; }
|
| 85 |
+
#sidebar.collapsed .brand-icon { margin:0 auto; }
|
| 86 |
+
#sidebar.collapsed .nav-item { justify-content:center; padding:12px 0; }
|
| 87 |
+
#sidebar.collapsed .sidebar-bottom { justify-content:center; flex-direction:column; gap:8px; }
|
| 88 |
+
|
| 89 |
+
.sidebar-top { display:flex; align-items:center; justify-content:space-between; padding:18px 14px 14px; gap:10px; }
|
| 90 |
+
.brand { display:flex; align-items:center; gap:10px; text-decoration:none; overflow:hidden; }
|
| 91 |
+
.brand-icon { width:34px; height:34px; flex-shrink:0; background:linear-gradient(135deg,var(--accent),var(--violet));
|
| 92 |
+
border-radius:var(--r-md); display:grid; place-items:center; font-size:16px; box-shadow:0 2px 10px var(--accent-mid); }
|
| 93 |
+
.brand-name { font-size:.95rem; font-weight:700; color:var(--text); white-space:nowrap; }
|
| 94 |
+
.brand-ver { font-family:var(--font-mono); font-size:.58rem; color:var(--accent); background:var(--accent-lt);
|
| 95 |
+
border:1px solid var(--accent-mid); padding:1px 6px; border-radius:20px; white-space:nowrap; }
|
| 96 |
+
.sidebar-toggle { background:none; border:1px solid var(--border); border-radius:var(--r-sm); color:var(--text-muted);
|
| 97 |
+
width:24px; height:24px; display:grid; place-items:center; font-size:12px; flex-shrink:0;
|
| 98 |
+
transition:all var(--transition); }
|
| 99 |
+
.sidebar-toggle:hover { border-color:var(--accent-mid); color:var(--accent); }
|
| 100 |
+
#sidebar.collapsed .sidebar-toggle { transform:rotate(180deg); }
|
| 101 |
+
|
| 102 |
+
.sidebar-nav { flex:1; padding:8px 8px; display:flex; flex-direction:column; gap:2px; overflow:hidden; }
|
| 103 |
+
.nav-item { display:flex; align-items:center; gap:10px; padding:9px 10px; border:none; background:transparent;
|
| 104 |
+
border-radius:var(--r-md); color:var(--text-muted); font-size:.85rem; font-weight:500;
|
| 105 |
+
width:100%; text-align:left; transition:all var(--transition); white-space:nowrap; overflow:hidden; }
|
| 106 |
+
.nav-item:hover { background:var(--bg-3); color:var(--text); }
|
| 107 |
+
.nav-item.active { background:var(--accent-lt); color:var(--accent); font-weight:600; }
|
| 108 |
+
.nav-icon { font-size:1rem; flex-shrink:0; width:20px; text-align:center; }
|
| 109 |
+
.nav-label { flex:1; overflow:hidden; text-overflow:ellipsis; }
|
| 110 |
+
.nav-badge { font-family:var(--font-mono); font-size:.58rem; font-weight:700; background:var(--accent);
|
| 111 |
+
color:#fff; border-radius:10px; padding:1px 6px; min-width:18px; text-align:center; }
|
| 112 |
+
|
| 113 |
+
.sidebar-bottom { display:flex; align-items:center; gap:8px; padding:12px 12px; border-top:1px solid var(--border); overflow:hidden; }
|
| 114 |
+
.api-status-row { display:flex; gap:4px; flex:1; overflow:hidden; }
|
| 115 |
+
.api-dot-sm { font-family:var(--font-mono); font-size:.58rem; font-weight:700; padding:2px 6px;
|
| 116 |
+
border-radius:4px; background:var(--bg-3); color:var(--text-faint); border:1px solid var(--border);
|
| 117 |
+
transition:all .3s; cursor:default; white-space:nowrap; }
|
| 118 |
+
.api-dot-sm.active { background:var(--green-lt); color:var(--green); border-color:var(--green); }
|
| 119 |
+
.theme-toggle,.sidebar-link { background:none; border:1px solid var(--border); border-radius:var(--r-sm);
|
| 120 |
+
color:var(--text-muted); width:28px; height:28px; display:grid; place-items:center; font-size:13px;
|
| 121 |
+
text-decoration:none; flex-shrink:0; transition:all var(--transition); }
|
| 122 |
+
.theme-toggle:hover,.sidebar-link:hover { border-color:var(--accent-mid); color:var(--accent); background:var(--accent-lt); }
|
| 123 |
+
|
| 124 |
+
/* ── Main content ── */
|
| 125 |
+
#main-content { margin-left:var(--sidebar-w); flex:1; min-width:0; transition:margin-left var(--transition); }
|
| 126 |
+
#sidebar.collapsed ~ #main-content { margin-left:56px; }
|
| 127 |
+
|
| 128 |
+
.page { display:none; padding:28px 32px 80px; max-width:960px; }
|
| 129 |
+
.page.active { display:block; }
|
| 130 |
+
@media(max-width:768px){ .page { padding:16px 16px 60px; } }
|
| 131 |
+
|
| 132 |
+
.page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:24px; flex-wrap:wrap; }
|
| 133 |
+
.page-title { font-size:1.4rem; font-weight:700; color:var(--text); letter-spacing:-.4px; }
|
| 134 |
+
.page-subtitle { font-size:.82rem; color:var(--text-muted); margin-top:2px; }
|
| 135 |
+
.page-header-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
| 136 |
+
|
| 137 |
+
/* ── Panels ── */
|
| 138 |
+
.panel { background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-xl);
|
| 139 |
+
padding:22px 24px; margin-bottom:16px; animation:fadeUp .3s ease both; }
|
| 140 |
+
.panel-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; gap:12px; }
|
| 141 |
+
.panel-header h2 { font-size:.95rem; font-weight:700; color:var(--text); }
|
| 142 |
+
.step-tag { font-family:var(--font-mono); font-size:.58rem; font-weight:600; letter-spacing:1px;
|
| 143 |
+
color:var(--accent); background:var(--accent-lt); border:1px solid var(--accent-mid);
|
| 144 |
+
padding:2px 8px; border-radius:20px; white-space:nowrap; }
|
| 145 |
+
@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
| 146 |
+
|
| 147 |
+
/* ── API Panel ── */
|
| 148 |
+
.api-panel { padding:0; overflow:hidden; }
|
| 149 |
+
.api-panel-toggle { display:flex; align-items:center; gap:12px; padding:14px 20px; cursor:pointer;
|
| 150 |
+
user-select:none; font-size:.85rem; font-weight:600; color:var(--text-soft); }
|
| 151 |
+
.api-panel-toggle:hover { background:var(--bg-3); }
|
| 152 |
+
.api-panel-status { margin-left:auto; font-size:.75rem; font-family:var(--font-mono); color:var(--accent); }
|
| 153 |
+
.chevron { font-size:.7rem; transition:transform var(--transition); }
|
| 154 |
+
.chevron.open { transform:rotate(180deg); }
|
| 155 |
+
.api-panel-body { padding:0 20px 18px; border-top:1px solid var(--border); display:none; }
|
| 156 |
+
.api-panel-body.open { display:block; animation:fadeUp .2s ease both; }
|
| 157 |
+
.api-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:14px; }
|
| 158 |
+
@media(max-width:580px){ .api-grid { grid-template-columns:1fr; } }
|
| 159 |
+
|
| 160 |
+
/* ── Stepper ── */
|
| 161 |
+
.stepper { display:flex; align-items:center; justify-content:center; margin-bottom:20px; padding:4px 0; }
|
| 162 |
+
.step { display:flex; flex-direction:column; align-items:center; gap:4px; }
|
| 163 |
+
.step-dot { width:28px; height:28px; border-radius:50%; border:2px solid var(--border);
|
| 164 |
+
display:grid; place-items:center; font-size:.68rem; font-weight:700; color:var(--text-muted);
|
| 165 |
+
background:var(--bg-2); transition:all .3s cubic-bezier(.34,1.56,.64,1); }
|
| 166 |
+
.step.active .step-dot { border-color:var(--accent); background:var(--accent); color:#fff;
|
| 167 |
+
box-shadow:0 0 0 4px var(--accent-lt); transform:scale(1.1); }
|
| 168 |
+
.step.done .step-dot { border-color:var(--green); background:var(--green); color:#fff; font-size:0; }
|
| 169 |
+
.step.done .step-dot::before { content:'✓'; font-size:.75rem; }
|
| 170 |
+
.step-label { font-size:.58rem; font-weight:600; letter-spacing:.4px; text-transform:uppercase; color:var(--text-faint); }
|
| 171 |
+
.step.active .step-label { color:var(--accent); }
|
| 172 |
+
.step.done .step-label { color:var(--green); }
|
| 173 |
+
.step-line { width:48px; height:2px; background:var(--border); margin-bottom:18px; border-radius:2px;
|
| 174 |
+
position:relative; overflow:hidden; }
|
| 175 |
+
.step-line::after { content:''; display:block; position:absolute; inset-y:0; left:0; width:0;
|
| 176 |
+
background:linear-gradient(90deg,var(--accent),var(--green)); transition:width .5s ease; border-radius:2px; }
|
| 177 |
+
.step-line.filled::after { width:100%; }
|
| 178 |
|
| 179 |
/* ── Form elements ── */
|
| 180 |
+
.field { margin-bottom:14px; }
|
| 181 |
+
label { display:flex; align-items:center; gap:6px; font-size:.72rem; font-weight:600;
|
| 182 |
+
letter-spacing:.3px; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
| 183 |
+
.required { color:var(--red); font-size:.7rem; }
|
| 184 |
+
.lbl-opt { color:var(--text-faint); font-size:.64rem; text-transform:none; letter-spacing:0; font-weight:400; margin-left:auto; }
|
| 185 |
+
input[type="text"], input[type="password"], textarea, select {
|
| 186 |
+
width:100%; padding:9px 12px; background:var(--bg-3); border:1.5px solid var(--border);
|
| 187 |
+
border-radius:var(--r-md); color:var(--text); font-family:var(--font-ui); font-size:.86rem;
|
| 188 |
+
outline:none; transition:border-color var(--transition), box-shadow var(--transition), background var(--transition);
|
| 189 |
+
resize:vertical;
|
| 190 |
+
}
|
| 191 |
+
input:focus, textarea:focus, select:focus {
|
| 192 |
+
border-color:var(--border-focus); background:var(--bg-2);
|
| 193 |
+
box-shadow:0 0 0 3px var(--accent-lt);
|
| 194 |
+
}
|
| 195 |
+
input::placeholder, textarea::placeholder { color:var(--text-faint); font-style:italic; }
|
| 196 |
+
textarea { min-height:80px; line-height:1.65; }
|
| 197 |
+
select { appearance:none; cursor:pointer; resize:none;
|
| 198 |
+
background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237c6dfa' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
|
| 199 |
+
background-repeat:no-repeat; background-position:right 12px center; padding-right:32px; }
|
| 200 |
+
.char-counter { font-family:var(--font-mono); font-size:.68rem; color:var(--text-faint); text-align:right; margin-top:3px; }
|
| 201 |
+
.char-counter.warn { color:var(--amber); }
|
| 202 |
+
.char-counter.over { color:var(--red); }
|
| 203 |
+
.field-note { font-size:.72rem; color:var(--text-muted); margin-top:4px; display:flex; align-items:center; gap:5px; }
|
| 204 |
+
.status-dot { width:6px; height:6px; border-radius:50%; background:var(--text-faint); flex-shrink:0; transition:all .3s; }
|
| 205 |
+
.status-dot.ok { background:var(--green); box-shadow:0 0 6px var(--green); }
|
| 206 |
+
.status-dot.err { background:var(--red); box-shadow:0 0 6px var(--red); }
|
| 207 |
+
.input-with-action { display:flex; gap:4px; }
|
| 208 |
+
.input-with-action input { flex:1; }
|
| 209 |
+
.two-col { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
| 210 |
+
@media(max-width:540px){ .two-col { grid-template-columns:1fr; } }
|
| 211 |
+
|
| 212 |
+
/* ── Advanced block ── */
|
| 213 |
+
.advanced-block { background:var(--bg-3); border:1px solid var(--border); border-radius:var(--r-lg); padding:14px 16px; margin-bottom:14px; }
|
| 214 |
+
.advanced-block > summary { font-size:.83rem; font-weight:600; color:var(--text-muted); cursor:pointer; user-select:none; list-style:none; }
|
| 215 |
+
.advanced-block > summary:hover { color:var(--accent); }
|
| 216 |
+
.advanced-block > summary::-webkit-details-marker { display:none; }
|
| 217 |
+
.adv-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; }
|
| 218 |
+
@media(max-width:540px){ .adv-grid { grid-template-columns:1fr; } }
|
| 219 |
+
.tip { width:14px; height:14px; border-radius:50%; background:var(--bg); border:1px solid var(--border);
|
| 220 |
+
font-size:.62rem; display:inline-grid; place-items:center; cursor:default; color:var(--text-muted); margin-left:4px; }
|
| 221 |
+
.info-note { background:var(--accent-lt); border:1px solid var(--accent-mid); border-radius:var(--r-md);
|
| 222 |
+
padding:10px 14px; font-size:.82rem; color:var(--text-soft); margin-bottom:16px; line-height:1.5; }
|
| 223 |
+
|
| 224 |
+
/* ── Toggle ── */
|
| 225 |
+
.toggle-row { display:flex; flex-wrap:wrap; gap:16px; margin-bottom:14px; }
|
| 226 |
+
.toggle-label { display:flex; align-items:center; gap:10px; cursor:pointer; font-size:.83rem;
|
| 227 |
+
font-weight:500; color:var(--text-soft); text-transform:none; letter-spacing:0; }
|
| 228 |
+
.toggle-cb { display:none; }
|
| 229 |
+
.toggle-track { width:38px; height:21px; background:var(--bg-3); border:1.5px solid var(--border);
|
| 230 |
+
border-radius:12px; position:relative; transition:all .25s; flex-shrink:0; }
|
| 231 |
+
.toggle-cb:checked + .toggle-track { background:var(--accent); border-color:var(--accent); }
|
| 232 |
+
.toggle-thumb { position:absolute; width:15px; height:15px; background:var(--text-muted);
|
| 233 |
+
border-radius:50%; top:2px; left:2px; transition:left .25s; }
|
| 234 |
+
.toggle-cb:checked + .toggle-track .toggle-thumb { left:19px; background:#fff; }
|
| 235 |
|
| 236 |
/* ── Buttons ── */
|
| 237 |
+
.btn-primary { display:inline-flex; align-items:center; gap:6px; border:none; border-radius:var(--r-md);
|
| 238 |
+
font-size:.86rem; font-weight:700; padding:10px 20px;
|
| 239 |
+
background:linear-gradient(135deg,var(--accent),var(--violet)); color:#fff;
|
| 240 |
+
box-shadow:0 2px 12px var(--accent-mid); transition:transform .2s, box-shadow .2s; }
|
| 241 |
+
.btn-primary:hover { transform:translateY(-1px); box-shadow:0 4px 20px var(--accent-mid); }
|
| 242 |
+
.btn-primary:active { transform:none; }
|
| 243 |
+
.btn-primary:disabled { opacity:.5; cursor:not-allowed; transform:none; }
|
| 244 |
+
.btn-secondary { display:inline-flex; align-items:center; gap:6px; border:1.5px solid var(--border);
|
| 245 |
+
border-radius:var(--r-md); background:var(--bg-2); color:var(--text-soft);
|
| 246 |
+
font-size:.84rem; font-weight:500; padding:9px 16px; transition:all var(--transition); }
|
| 247 |
+
.btn-secondary:hover { border-color:var(--accent-mid); color:var(--accent); background:var(--accent-lt); }
|
| 248 |
+
.btn-ghost { display:inline-flex; align-items:center; gap:6px; border:none; background:transparent;
|
| 249 |
+
color:var(--text-muted); font-size:.82rem; padding:8px 14px; border-radius:var(--r-md);
|
| 250 |
+
transition:all var(--transition); }
|
| 251 |
+
.btn-ghost:hover { background:var(--bg-3); color:var(--text); }
|
| 252 |
+
.btn-danger { border-color:transparent; color:var(--red); }
|
| 253 |
+
.btn-danger:hover { border-color:var(--red); background:var(--red-lt); }
|
| 254 |
+
.btn-sm { font-size:.76rem; padding:6px 12px; }
|
| 255 |
+
.icon-btn { width:32px; height:32px; border:1.5px solid var(--border); border-radius:var(--r-sm);
|
| 256 |
+
background:var(--bg-3); color:var(--text-muted); display:grid; place-items:center; font-size:13px;
|
| 257 |
+
flex-shrink:0; transition:all var(--transition); }
|
| 258 |
+
.icon-btn:hover { border-color:var(--accent-mid); color:var(--accent); background:var(--accent-lt); }
|
| 259 |
+
.icon-btn:disabled { opacity:.4; cursor:not-allowed; }
|
| 260 |
+
.action-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; align-items:center; }
|
| 261 |
+
kbd { font-family:var(--font-mono); font-size:.64rem; background:var(--bg-3); border:1px solid var(--border);
|
| 262 |
+
border-radius:4px; padding:1px 5px; color:var(--text-muted); }
|
| 263 |
+
.spinner { display:inline-block; width:12px; height:12px; border:2px solid rgba(255,255,255,.3);
|
| 264 |
+
border-top-color:#fff; border-radius:50%; animation:spin .7s linear infinite; }
|
| 265 |
+
@keyframes spin { to { transform:rotate(360deg); } }
|
| 266 |
|
| 267 |
/* ── Manifest grid ── */
|
| 268 |
+
.manifest-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; }
|
| 269 |
+
.manifest-field label { font-size:.65rem; }
|
| 270 |
+
.manifest-field textarea { min-height:64px; font-size:.82rem; }
|
| 271 |
+
.manifest-field.full { grid-column:1/-1; }
|
| 272 |
+
@media(max-width:540px){ .manifest-grid { grid-template-columns:1fr; } .manifest-field.full { grid-column:auto; } }
|
| 273 |
+
|
| 274 |
+
/* ── Code block ── */
|
| 275 |
+
.code-block { position:relative; }
|
| 276 |
+
.copy-btn { position:absolute; top:8px; right:8px; background:var(--bg-3); border:1px solid var(--border);
|
| 277 |
+
border-radius:var(--r-sm); color:var(--text-muted); font-family:var(--font-mono);
|
| 278 |
+
font-size:.68rem; padding:4px 10px; cursor:pointer; transition:all var(--transition); z-index:1; }
|
| 279 |
+
.copy-btn:hover { border-color:var(--accent-mid); color:var(--accent); }
|
| 280 |
+
.copy-btn.copied { border-color:var(--green); color:var(--green); background:var(--green-lt); }
|
| 281 |
+
pre { background:var(--bg-3); border:1px solid var(--border); border-radius:var(--r-lg); padding:14px 16px;
|
| 282 |
+
font-family:var(--font-mono); font-size:.78rem; line-height:1.8; color:var(--text-soft);
|
| 283 |
+
white-space:pre-wrap; word-break:break-word; overflow-x:auto; }
|
| 284 |
+
details { margin-top:10px; }
|
| 285 |
+
summary { font-size:.8rem; font-weight:600; color:var(--text-muted); cursor:pointer; padding:8px 0;
|
| 286 |
+
user-select:none; list-style:none; display:flex; align-items:center; gap:6px; }
|
| 287 |
+
summary:hover { color:var(--accent); }
|
| 288 |
+
summary::-webkit-details-marker { display:none; }
|
| 289 |
+
summary::before { content:'▶'; font-size:.65rem; transition:transform .2s; }
|
| 290 |
+
details[open] summary::before { transform:rotate(90deg); }
|
| 291 |
+
|
| 292 |
+
/* ── Inner tabs ── */
|
| 293 |
+
.inner-tabs { display:flex; gap:2px; background:var(--bg-3); border:1px solid var(--border);
|
| 294 |
+
border-radius:var(--r-md); padding:3px; width:fit-content; margin-bottom:12px; }
|
| 295 |
+
.inner-tab { border:none; background:transparent; border-radius:var(--r-sm); padding:6px 14px;
|
| 296 |
+
font-size:.78rem; font-weight:500; color:var(--text-muted); cursor:pointer; transition:all var(--transition); }
|
| 297 |
+
.inner-tab.active { background:var(--bg-2); color:var(--accent); font-weight:700; box-shadow:var(--shadow-sm); }
|
| 298 |
+
.inner-panel { display:block; }
|
| 299 |
+
.inner-panel.hidden { display:none; }
|
| 300 |
+
|
| 301 |
+
/* ── Explanation ── */
|
| 302 |
+
.explanation-panel { background:linear-gradient(135deg,var(--accent-lt),rgba(167,139,250,.08));
|
| 303 |
+
border:1px solid var(--accent-mid); border-radius:var(--r-lg); padding:16px 18px; margin-top:14px; animation:fadeUp .3s ease; }
|
| 304 |
+
.explanation-header { font-size:.85rem; font-weight:700; color:var(--accent); margin-bottom:10px; }
|
| 305 |
+
.explanation-body { font-size:.82rem; color:var(--text-soft); line-height:1.7; white-space:pre-wrap; margin-bottom:12px; }
|
| 306 |
+
.decision-chips { display:flex; flex-wrap:wrap; gap:6px; }
|
| 307 |
+
.decision-chip { display:inline-flex; align-items:center; gap:5px; background:var(--bg-2); border:1px solid var(--border);
|
| 308 |
+
border-radius:20px; padding:3px 10px; font-size:.72rem; color:var(--text-muted); }
|
| 309 |
+
.decision-chip::before { content:'·'; color:var(--accent); font-size:1rem; line-height:1; }
|
| 310 |
+
|
| 311 |
+
/* ── Settings layout ── */
|
| 312 |
+
.settings-layout { display:grid; grid-template-columns:1fr 360px; gap:16px; align-items:start; }
|
| 313 |
+
@media(max-width:800px){ .settings-layout { grid-template-columns:1fr; } }
|
| 314 |
+
.settings-list-col { position:sticky; top:24px; }
|
| 315 |
+
.list-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; gap:8px;
|
| 316 |
+
flex-wrap:wrap; font-size:.85rem; font-weight:700; color:var(--text); }
|
| 317 |
+
.list-controls { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
| 318 |
+
.count-badge { font-family:var(--font-mono); font-size:.62rem; font-weight:700; background:var(--accent-lt);
|
| 319 |
+
color:var(--accent); border:1px solid var(--accent-mid); border-radius:10px; padding:1px 7px; margin-left:6px; }
|
| 320 |
+
.search-input { padding:7px 11px; border:1.5px solid var(--border); border-radius:var(--r-md);
|
| 321 |
+
font-size:.8rem; background:var(--bg-3); color:var(--text); outline:none; transition:border-color var(--transition); }
|
| 322 |
+
.search-input:focus { border-color:var(--border-focus); }
|
| 323 |
+
.filter-select { padding:6px 26px 6px 9px; border:1.5px solid var(--border); border-radius:var(--r-md);
|
| 324 |
+
font-size:.76rem; background:var(--bg-3); color:var(--text-soft); outline:none; cursor:pointer;
|
| 325 |
+
appearance:none; background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237c7d96' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
|
| 326 |
+
background-repeat:no-repeat; background-position:right 8px center; resize:none; }
|
| 327 |
+
.settings-list { display:flex; flex-direction:column; gap:8px; max-height:calc(100vh - 200px); overflow-y:auto; }
|
| 328 |
|
| 329 |
/* ── Setting card ── */
|
| 330 |
+
.setting-card { background:var(--bg-2); border:1.5px solid var(--border); border-radius:var(--r-lg);
|
| 331 |
+
padding:12px 14px; cursor:pointer; transition:all var(--transition); position:relative; }
|
| 332 |
+
.setting-card:hover { border-color:var(--accent-mid); box-shadow:var(--shadow-sm); transform:translateY(-1px); }
|
| 333 |
+
.setting-card.editing { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-lt); }
|
| 334 |
+
.setting-card.favorite .s-star { color:var(--amber); }
|
| 335 |
+
.setting-card-top { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
|
| 336 |
+
.setting-card-title { font-size:.86rem; font-weight:700; color:var(--text); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
| 337 |
+
.s-star { font-size:.85rem; color:var(--text-faint); }
|
| 338 |
+
.setting-card-desc { font-size:.74rem; color:var(--text-muted); margin-bottom:8px; overflow:hidden;
|
| 339 |
+
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; line-height:1.4; }
|
| 340 |
+
.setting-card-meta { display:flex; align-items:center; gap:5px; flex-wrap:wrap; }
|
| 341 |
+
.tag-chip { font-size:.62rem; font-weight:600; padding:2px 7px; border-radius:10px;
|
| 342 |
+
background:var(--accent-lt); color:var(--accent); border:1px solid var(--accent-mid); }
|
| 343 |
+
.tag-chip.style-tag { background:rgba(45,212,191,.1); color:var(--teal); border-color:rgba(45,212,191,.3); }
|
| 344 |
+
.use-count { font-family:var(--font-mono); font-size:.62rem; color:var(--text-faint); margin-left:auto; }
|
| 345 |
+
.setting-card-actions { position:absolute; top:8px; right:8px; display:flex; gap:3px; opacity:0; transition:opacity var(--transition); }
|
| 346 |
+
.setting-card:hover .setting-card-actions { opacity:1; }
|
| 347 |
+
.tag-suggestions { display:flex; flex-wrap:wrap; gap:4px; margin-top:5px; }
|
| 348 |
+
.tag-sug { font-size:.65rem; padding:2px 8px; border-radius:10px; border:1px dashed var(--border);
|
| 349 |
+
color:var(--text-muted); cursor:pointer; transition:all var(--transition); background:var(--bg-3); }
|
| 350 |
+
.tag-sug:hover { border-color:var(--accent-mid); color:var(--accent); background:var(--accent-lt); }
|
| 351 |
+
|
| 352 |
+
/* ── History table ── */
|
| 353 |
+
.table-wrap { overflow-x:auto; border-radius:var(--r-lg); border:1px solid var(--border); }
|
| 354 |
+
table { width:100%; border-collapse:collapse; font-size:.81rem; }
|
| 355 |
+
thead { background:var(--bg-3); }
|
| 356 |
+
th { padding:10px 12px; text-align:left; font-size:.65rem; font-weight:700; text-transform:uppercase;
|
| 357 |
+
letter-spacing:.5px; color:var(--text-muted); border-bottom:1px solid var(--border); white-space:nowrap; }
|
| 358 |
+
td { padding:10px 12px; color:var(--text-soft); border-bottom:1px solid var(--border); }
|
| 359 |
+
tr:last-child td { border-bottom:none; }
|
| 360 |
+
tr:hover td { background:var(--bg-3); }
|
| 361 |
+
.empty-msg { color:var(--text-faint); font-style:italic; text-align:center; padding:24px 12px !important; }
|
| 362 |
+
.badge { display:inline-block; padding:2px 8px; border-radius:20px; font-family:var(--font-mono);
|
| 363 |
+
font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.3px; }
|
| 364 |
+
.badge-pending { background:var(--amber-lt); color:var(--amber); }
|
| 365 |
+
.badge-approved { background:var(--green-lt); color:var(--green); }
|
| 366 |
+
.badge-exported { background:var(--accent-lt); color:var(--accent); }
|
| 367 |
+
.badge-archived { background:var(--bg-3); color:var(--text-faint); }
|
| 368 |
+
.fav-star { color:var(--text-faint); cursor:pointer; font-size:.9rem; transition:all var(--transition); }
|
| 369 |
+
.fav-star.active { color:var(--amber); }
|
| 370 |
+
|
| 371 |
+
/* ── Stats ── */
|
| 372 |
+
.stats-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:16px; }
|
| 373 |
+
@media(max-width:720px){ .stats-grid { grid-template-columns:repeat(2,1fr); } }
|
| 374 |
+
@media(max-width:400px){ .stats-grid { grid-template-columns:1fr; } }
|
| 375 |
+
.stat-card { background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-xl);
|
| 376 |
+
padding:20px; display:flex; flex-direction:column; gap:6px; transition:border-color var(--transition); }
|
| 377 |
+
.stat-card:hover { border-color:var(--accent-mid); }
|
| 378 |
+
.stat-value { font-size:2rem; font-weight:700; color:var(--text); font-family:var(--font-mono); line-height:1; }
|
| 379 |
+
.stat-label { font-size:.72rem; color:var(--text-muted); font-weight:600; text-transform:uppercase; letter-spacing:.3px; }
|
| 380 |
+
.stat-sub { font-size:.72rem; color:var(--text-faint); }
|
| 381 |
+
.stat-card.highlight { border-color:var(--accent-mid); background:var(--accent-lt); }
|
| 382 |
+
.stat-card.highlight .stat-value { color:var(--accent); }
|
| 383 |
+
.stat-card.skeleton { animation:pulse 1.5s ease infinite; }
|
| 384 |
+
@keyframes pulse { 0%,100%{opacity:.4} 50%{opacity:.7} }
|
| 385 |
+
.stats-row { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:16px; }
|
| 386 |
+
@media(max-width:600px){ .stats-row { grid-template-columns:1fr; } }
|
| 387 |
+
.bar-chart { display:flex; flex-direction:column; gap:8px; padding-top:4px; }
|
| 388 |
+
.bar-row { display:flex; align-items:center; gap:10px; }
|
| 389 |
+
.bar-row-label { font-size:.76rem; color:var(--text-soft); width:130px; flex-shrink:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
| 390 |
+
.bar-track { flex:1; height:6px; background:var(--bg-3); border-radius:3px; overflow:hidden; }
|
| 391 |
+
.bar-fill { height:100%; background:linear-gradient(90deg,var(--accent),var(--violet)); border-radius:3px; transition:width 1s ease; }
|
| 392 |
+
.bar-count { font-family:var(--font-mono); font-size:.68rem; color:var(--text-muted); width:24px; text-align:right; }
|
| 393 |
+
.status-breakdown { display:flex; flex-wrap:wrap; gap:10px; padding-top:4px; }
|
| 394 |
+
.status-item { display:flex; align-items:center; gap:8px; background:var(--bg-3); border:1px solid var(--border);
|
| 395 |
+
border-radius:var(--r-md); padding:10px 14px; flex:1; min-width:120px; }
|
| 396 |
+
.status-item-count { font-size:1.4rem; font-weight:700; font-family:var(--font-mono); line-height:1; }
|
| 397 |
+
.status-item-label { font-size:.7rem; color:var(--text-muted); font-weight:600; text-transform:uppercase; }
|
| 398 |
|
| 399 |
/* ── Modal ── */
|
| 400 |
+
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); backdrop-filter:blur(4px);
|
| 401 |
+
z-index:500; display:flex; align-items:center; justify-content:center; padding:20px; }
|
| 402 |
+
.modal-overlay.hidden { display:none !important; }
|
| 403 |
+
.modal-box { background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-xl);
|
| 404 |
+
width:100%; max-width:540px; max-height:80vh; display:flex; flex-direction:column;
|
| 405 |
+
box-shadow:var(--shadow); animation:fadeUp .25s ease; overflow:hidden; }
|
| 406 |
+
.modal-header { display:flex; align-items:center; justify-content:space-between;
|
| 407 |
+
padding:16px 20px; border-bottom:1px solid var(--border); }
|
| 408 |
+
.modal-header h3 { font-size:.9rem; font-weight:700; }
|
| 409 |
+
.modal-body { padding:16px 20px; overflow-y:auto; flex:1; }
|
| 410 |
+
.modal-list { display:flex; flex-direction:column; gap:8px; }
|
| 411 |
+
.modal-item { padding:12px 14px; border:1.5px solid var(--border); border-radius:var(--r-md); cursor:pointer; transition:all var(--transition); }
|
| 412 |
+
.modal-item:hover { border-color:var(--accent); background:var(--accent-lt); }
|
| 413 |
+
.modal-item-title { font-size:.86rem; font-weight:600; color:var(--text); margin-bottom:2px; }
|
| 414 |
+
.modal-item-desc { font-size:.74rem; color:var(--text-muted); }
|
| 415 |
+
.modal-empty { text-align:center; padding:24px; color:var(--text-faint); font-style:italic; }
|
| 416 |
+
.shortcuts-table { width:100%; border-collapse:collapse; }
|
| 417 |
+
.shortcuts-table tr { border-bottom:1px solid var(--border); }
|
| 418 |
+
.shortcuts-table tr:last-child { border-bottom:none; }
|
| 419 |
+
.shortcuts-table td { padding:10px 8px; font-size:.82rem; color:var(--text-soft); }
|
| 420 |
+
.shortcuts-table td:first-child { width:160px; }
|
| 421 |
|
| 422 |
/* ── Toast ── */
|
| 423 |
+
#toast-container { position:fixed; bottom:24px; right:24px; display:flex; flex-direction:column; gap:8px; z-index:9999; pointer-events:none; }
|
| 424 |
+
.toast { display:flex; align-items:center; gap:10px; background:var(--bg-2); border:1px solid var(--border);
|
| 425 |
+
border-radius:var(--r-md); padding:10px 16px; box-shadow:var(--shadow); font-size:.82rem; color:var(--text);
|
| 426 |
+
pointer-events:all; animation:slideIn .3s cubic-bezier(.34,1.56,.64,1) both; min-width:240px; max-width:360px; }
|
| 427 |
+
.toast.leaving { animation:slideOut .3s ease forwards; }
|
| 428 |
+
.toast.success { border-left:3px solid var(--green); }
|
| 429 |
+
.toast.error { border-left:3px solid var(--red); }
|
| 430 |
+
.toast.warn { border-left:3px solid var(--amber); }
|
| 431 |
+
.toast.info { border-left:3px solid var(--accent); }
|
| 432 |
+
.toast-icon { font-size:.95rem; flex-shrink:0; }
|
| 433 |
+
@keyframes slideIn { from{opacity:0;transform:translateX(20px)} to{opacity:1;transform:translateX(0)} }
|
| 434 |
+
@keyframes slideOut { from{opacity:1;transform:translateX(0)} to{opacity:0;transform:translateX(20px)} }
|
| 435 |
+
|
| 436 |
+
/* ── Misc ── */
|
| 437 |
+
.muted { font-size:.84rem; color:var(--text-muted); margin-bottom:14px; line-height:1.6; }
|
| 438 |
+
.divider { height:1px; background:var(--border); margin:16px 0; }
|
| 439 |
+
.empty-state { text-align:center; padding:40px 20px; color:var(--text-muted); }
|
| 440 |
+
.empty-icon { font-size:2.5rem; margin-bottom:10px; opacity:.4; }
|
| 441 |
+
.empty-state p { font-size:.84rem; }
|
| 442 |
+
.hidden { display:none !important; }
|
| 443 |
+
|
| 444 |
+
/* ── Mobile responsive ── */
|
| 445 |
+
@media(max-width:600px){
|
| 446 |
+
#sidebar { transform:translateX(-100%); }
|
| 447 |
+
#sidebar.mobile-open { transform:translateX(0); }
|
| 448 |
+
#main-content { margin-left:0 !important; }
|
| 449 |
+
.stepper .step-line { width:24px; }
|
| 450 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PromptForge v4.0 — Python dependencies
|
| 2 |
+
# Install: pip install -r requirements.txt
|
| 3 |
+
|
| 4 |
+
# ── Core framework ────────────────────────────────────────────────────
|
| 5 |
+
fastapi>=0.111.0,<0.120.0
|
| 6 |
+
uvicorn[standard]>=0.29.0,<0.35.0
|
| 7 |
+
pydantic>=2.7.0,<3.0.0
|
| 8 |
+
python-multipart>=0.0.9
|
| 9 |
+
|
| 10 |
+
# ── HTTP client (AI providers) ────────────────────────────────────────
|
| 11 |
+
httpx>=0.27.0,<0.30.0
|
| 12 |
+
|
| 13 |
+
# ── Dev / testing ─────────────────────────────────────────────────────
|
| 14 |
+
pytest>=8.2.0
|
| 15 |
+
pytest-asyncio>=0.23.0
|
| 16 |
+
httpx>=0.27.0 # also needed by TestClient
|
| 17 |
+
|
| 18 |
+
# ── Optional: static file serving ────────────────────────────────────
|
| 19 |
+
aiofiles>=23.2.1
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_promptforge.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PromptForge v4.0 — Test suite
|
| 3 |
+
Run: pytest tests/test_promptforge.py -v --asyncio-mode=auto
|
| 4 |
+
|
| 5 |
+
Coverage:
|
| 6 |
+
Health / Config (3) Settings CRUD + extras (16)
|
| 7 |
+
Generate (8) Batch (2)
|
| 8 |
+
Approve (3) Export (4)
|
| 9 |
+
Refine (3) Explain (2)
|
| 10 |
+
History (6) Search (2)
|
| 11 |
+
Stats (1) Favorite/Archive (4)
|
| 12 |
+
Unit tests (12)
|
| 13 |
+
"""
|
| 14 |
+
import json, pytest
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from httpx import AsyncClient, ASGITransport
|
| 17 |
+
from fastapi.testclient import TestClient
|
| 18 |
+
|
| 19 |
+
import store, instruction_store
|
| 20 |
+
from main import app
|
| 21 |
+
from schemas import (
|
| 22 |
+
InstructionSettingsCreate, OutputFormat, PersonaType, StyleType, AIProvider
|
| 23 |
+
)
|
| 24 |
+
from prompt_logic import (
|
| 25 |
+
build_manifest, build_manifest_from_settings, apply_edits,
|
| 26 |
+
refine_with_feedback, generate_explanation, _resolve_role,
|
| 27 |
+
_build_constraints, _auto_tag, _infer_output_format,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# ── Helpers ───────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
@pytest.fixture(autouse=True)
|
| 33 |
+
def clean_stores():
|
| 34 |
+
"""Reset both in-memory stores before each test."""
|
| 35 |
+
store._DB.clear()
|
| 36 |
+
instruction_store._DB.clear()
|
| 37 |
+
yield
|
| 38 |
+
store._DB.clear()
|
| 39 |
+
instruction_store._DB.clear()
|
| 40 |
+
|
| 41 |
+
@pytest.fixture
|
| 42 |
+
def client():
|
| 43 |
+
return TestClient(app)
|
| 44 |
+
|
| 45 |
+
@pytest.fixture
|
| 46 |
+
def sample_setting_payload():
|
| 47 |
+
return {
|
| 48 |
+
"title": "Test Setting",
|
| 49 |
+
"description": "A test instruction setting for unit tests.",
|
| 50 |
+
"instruction": "Create a TypeScript React component with TailwindCSS and Jest tests.",
|
| 51 |
+
"extra_context": "Support dark mode. Use hooks only.",
|
| 52 |
+
"output_format": "both",
|
| 53 |
+
"persona": "senior_dev",
|
| 54 |
+
"style": "professional",
|
| 55 |
+
"constraints": ["TypeScript strict mode", "WCAG 2.1 AA"],
|
| 56 |
+
"tags": ["react", "typescript"],
|
| 57 |
+
"provider": "none",
|
| 58 |
+
"enhance": False,
|
| 59 |
+
"is_favorite": False,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
@pytest.fixture
|
| 63 |
+
def created_setting(client, sample_setting_payload):
|
| 64 |
+
r = client.post("/api/instructions", json=sample_setting_payload)
|
| 65 |
+
assert r.status_code == 201
|
| 66 |
+
return r.json()
|
| 67 |
+
|
| 68 |
+
@pytest.fixture
|
| 69 |
+
def generated_prompt(client):
|
| 70 |
+
r = client.post("/api/generate", json={
|
| 71 |
+
"instruction": "Write a Python FastAPI endpoint with Pydantic validation and tests.",
|
| 72 |
+
"output_format": "both",
|
| 73 |
+
"provider": "none",
|
| 74 |
+
"enhance": False,
|
| 75 |
+
"persona": "senior_dev",
|
| 76 |
+
"style": "detailed",
|
| 77 |
+
})
|
| 78 |
+
assert r.status_code == 200
|
| 79 |
+
return r.json()
|
| 80 |
+
|
| 81 |
+
@pytest.fixture
|
| 82 |
+
def approved_prompt(client, generated_prompt):
|
| 83 |
+
pid = generated_prompt["prompt_id"]
|
| 84 |
+
r = client.post("/api/approve", json={"prompt_id": pid})
|
| 85 |
+
assert r.status_code == 200
|
| 86 |
+
return r.json()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 90 |
+
# 1. HEALTH / CONFIG
|
| 91 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 92 |
+
|
| 93 |
+
def test_health(client):
|
| 94 |
+
r = client.get("/health")
|
| 95 |
+
assert r.status_code == 200
|
| 96 |
+
data = r.json()
|
| 97 |
+
assert data["status"] == "ok"
|
| 98 |
+
assert data["version"] == "4.0.0"
|
| 99 |
+
|
| 100 |
+
def test_config(client):
|
| 101 |
+
r = client.get("/api/config")
|
| 102 |
+
assert r.status_code == 200
|
| 103 |
+
data = r.json()
|
| 104 |
+
assert "hf_key_set" in data
|
| 105 |
+
assert "google_key_set" in data
|
| 106 |
+
assert data["version"] == "4.0.0"
|
| 107 |
+
|
| 108 |
+
def test_stats_empty(client):
|
| 109 |
+
r = client.get("/api/stats")
|
| 110 |
+
assert r.status_code == 200
|
| 111 |
+
s = r.json()
|
| 112 |
+
assert s["total_prompts"] == 0
|
| 113 |
+
assert "top_personas" in s
|
| 114 |
+
assert "uptime_since" in s
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 118 |
+
# 2. INSTRUCTION SETTINGS CRUD
|
| 119 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 120 |
+
|
| 121 |
+
def test_create_setting(client, sample_setting_payload):
|
| 122 |
+
r = client.post("/api/instructions", json=sample_setting_payload)
|
| 123 |
+
assert r.status_code == 201
|
| 124 |
+
s = r.json()
|
| 125 |
+
assert s["title"] == sample_setting_payload["title"]
|
| 126 |
+
assert s["settings_id"]
|
| 127 |
+
assert s["use_count"] == 0
|
| 128 |
+
assert s["version"] == 1
|
| 129 |
+
|
| 130 |
+
def test_get_setting(client, created_setting):
|
| 131 |
+
sid = created_setting["settings_id"]
|
| 132 |
+
r = client.get(f"/api/instructions/{sid}")
|
| 133 |
+
assert r.status_code == 200
|
| 134 |
+
assert r.json()["settings_id"] == sid
|
| 135 |
+
|
| 136 |
+
def test_list_settings(client, created_setting):
|
| 137 |
+
r = client.get("/api/instructions")
|
| 138 |
+
assert r.status_code == 200
|
| 139 |
+
data = r.json()
|
| 140 |
+
assert data["total"] >= 1
|
| 141 |
+
|
| 142 |
+
def test_update_setting(client, created_setting):
|
| 143 |
+
sid = created_setting["settings_id"]
|
| 144 |
+
r = client.patch(f"/api/instructions/{sid}", json={"title": "Updated Title"})
|
| 145 |
+
assert r.status_code == 200
|
| 146 |
+
assert r.json()["title"] == "Updated Title"
|
| 147 |
+
assert r.json()["version"] == 2
|
| 148 |
+
|
| 149 |
+
def test_delete_setting(client, created_setting):
|
| 150 |
+
sid = created_setting["settings_id"]
|
| 151 |
+
r = client.delete(f"/api/instructions/{sid}")
|
| 152 |
+
assert r.status_code == 200
|
| 153 |
+
r2 = client.get(f"/api/instructions/{sid}")
|
| 154 |
+
assert r2.status_code == 404
|
| 155 |
+
|
| 156 |
+
def test_404_get_setting(client):
|
| 157 |
+
r = client.get("/api/instructions/nonexistent-id")
|
| 158 |
+
assert r.status_code == 404
|
| 159 |
+
|
| 160 |
+
def test_404_update_setting(client):
|
| 161 |
+
r = client.patch("/api/instructions/bad-id", json={"title": "X"})
|
| 162 |
+
assert r.status_code == 404
|
| 163 |
+
|
| 164 |
+
def test_duplicate_setting(client, created_setting):
|
| 165 |
+
sid = created_setting["settings_id"]
|
| 166 |
+
r = client.post(f"/api/instructions/{sid}/duplicate")
|
| 167 |
+
assert r.status_code == 200
|
| 168 |
+
copy = r.json()
|
| 169 |
+
assert "copy" in copy["title"].lower()
|
| 170 |
+
assert copy["settings_id"] != sid
|
| 171 |
+
|
| 172 |
+
def test_favorite_setting(client, created_setting):
|
| 173 |
+
sid = created_setting["settings_id"]
|
| 174 |
+
r = client.post(f"/api/instructions/{sid}/favorite")
|
| 175 |
+
assert r.status_code == 200
|
| 176 |
+
assert r.json()["is_favorite"] is True
|
| 177 |
+
# toggle again
|
| 178 |
+
r2 = client.post(f"/api/instructions/{sid}/favorite")
|
| 179 |
+
assert r2.json()["is_favorite"] is False
|
| 180 |
+
|
| 181 |
+
def test_export_all_settings(client, created_setting):
|
| 182 |
+
r = client.get("/api/instructions/export")
|
| 183 |
+
assert r.status_code == 200
|
| 184 |
+
data = r.json()
|
| 185 |
+
assert "settings" in data
|
| 186 |
+
assert data["total"] >= 1
|
| 187 |
+
|
| 188 |
+
def test_get_all_tags(client, created_setting):
|
| 189 |
+
r = client.get("/api/instructions/tags")
|
| 190 |
+
assert r.status_code == 200
|
| 191 |
+
data = r.json()
|
| 192 |
+
assert "tags" in data
|
| 193 |
+
assert "react" in data["tags"]
|
| 194 |
+
|
| 195 |
+
def test_list_settings_filter_tag(client, created_setting):
|
| 196 |
+
r = client.get("/api/instructions?tag=react")
|
| 197 |
+
assert r.status_code == 200
|
| 198 |
+
data = r.json()
|
| 199 |
+
assert data["total"] >= 1
|
| 200 |
+
|
| 201 |
+
def test_list_settings_search(client, created_setting):
|
| 202 |
+
r = client.get("/api/instructions?q=TypeScript")
|
| 203 |
+
assert r.status_code == 200
|
| 204 |
+
assert r.json()["total"] >= 1
|
| 205 |
+
|
| 206 |
+
def test_instruction_too_short(client):
|
| 207 |
+
r = client.post("/api/instructions", json={
|
| 208 |
+
"title": "Short", "instruction": "hi",
|
| 209 |
+
})
|
| 210 |
+
assert r.status_code == 422
|
| 211 |
+
|
| 212 |
+
def test_title_required(client):
|
| 213 |
+
r = client.post("/api/instructions", json={"instruction": "Some long instruction here."})
|
| 214 |
+
assert r.status_code == 422
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 218 |
+
# 3. GENERATE
|
| 219 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 220 |
+
|
| 221 |
+
def test_generate_basic(client):
|
| 222 |
+
r = client.post("/api/generate", json={
|
| 223 |
+
"instruction": "Create a responsive navbar component.",
|
| 224 |
+
"output_format": "both", "provider": "none",
|
| 225 |
+
})
|
| 226 |
+
assert r.status_code == 200
|
| 227 |
+
data = r.json()
|
| 228 |
+
assert data["success"] is True
|
| 229 |
+
assert data["prompt_id"]
|
| 230 |
+
sp = data["manifest"]["structured_prompt"]
|
| 231 |
+
assert len(sp["raw_prompt_text"]) > 50, "raw_prompt_text must be copy-paste ready"
|
| 232 |
+
assert sp["role"]
|
| 233 |
+
assert sp["task"]
|
| 234 |
+
assert sp["constraints"]
|
| 235 |
+
|
| 236 |
+
def test_generate_copy_paste_ready(client):
|
| 237 |
+
r = client.post("/api/generate", json={
|
| 238 |
+
"instruction": "Write a Python function to parse JSON safely.",
|
| 239 |
+
"output_format": "text",
|
| 240 |
+
})
|
| 241 |
+
assert r.status_code == 200
|
| 242 |
+
raw = r.json()["manifest"]["structured_prompt"]["raw_prompt_text"]
|
| 243 |
+
assert len(raw) > 100
|
| 244 |
+
assert "## ROLE" in raw
|
| 245 |
+
assert "## TASK" in raw
|
| 246 |
+
assert "## CONSTRAINTS" in raw
|
| 247 |
+
|
| 248 |
+
def test_generate_with_persona(client):
|
| 249 |
+
r = client.post("/api/generate", json={
|
| 250 |
+
"instruction": "Set up a Kubernetes cluster with helm charts.",
|
| 251 |
+
"persona": "devops_eng",
|
| 252 |
+
})
|
| 253 |
+
assert r.status_code == 200
|
| 254 |
+
role = r.json()["manifest"]["structured_prompt"]["role"].lower()
|
| 255 |
+
assert any(k in role for k in ["devops","cloud","platform","engineer"])
|
| 256 |
+
|
| 257 |
+
def test_generate_with_ml_persona(client):
|
| 258 |
+
r = client.post("/api/generate", json={
|
| 259 |
+
"instruction": "Fine-tune a BERT model on custom dataset.",
|
| 260 |
+
"persona": "ml_engineer",
|
| 261 |
+
})
|
| 262 |
+
assert r.status_code == 200
|
| 263 |
+
assert r.json()["success"] is True
|
| 264 |
+
|
| 265 |
+
def test_generate_with_style(client):
|
| 266 |
+
r = client.post("/api/generate", json={
|
| 267 |
+
"instruction": "Explain how garbage collection works in Python.",
|
| 268 |
+
"style": "beginner",
|
| 269 |
+
})
|
| 270 |
+
assert r.status_code == 200
|
| 271 |
+
assert "beginner" in r.json()["manifest"]["structured_prompt"]["style"].lower()
|
| 272 |
+
|
| 273 |
+
def test_generate_with_constraints(client):
|
| 274 |
+
r = client.post("/api/generate", json={
|
| 275 |
+
"instruction": "Build a REST API.",
|
| 276 |
+
"user_constraints": ["No external libraries", "Under 50 lines"],
|
| 277 |
+
})
|
| 278 |
+
assert r.status_code == 200
|
| 279 |
+
constraints = r.json()["manifest"]["structured_prompt"]["constraints"]
|
| 280 |
+
assert any("No external" in c for c in constraints)
|
| 281 |
+
|
| 282 |
+
def test_generate_instruction_too_short(client):
|
| 283 |
+
r = client.post("/api/generate", json={"instruction": "hi"})
|
| 284 |
+
assert r.status_code == 422
|
| 285 |
+
|
| 286 |
+
def test_generate_from_settings(client, created_setting):
|
| 287 |
+
sid = created_setting["settings_id"]
|
| 288 |
+
r = client.post("/api/generate/from-settings", json={"settings_id": sid})
|
| 289 |
+
assert r.status_code == 200
|
| 290 |
+
data = r.json()
|
| 291 |
+
assert data["success"] is True
|
| 292 |
+
assert data["manifest"]["settings_id"] == sid
|
| 293 |
+
# use_count should increment
|
| 294 |
+
s = client.get(f"/api/instructions/{sid}").json()
|
| 295 |
+
assert s["use_count"] == 1
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 299 |
+
# 4. BATCH GENERATE
|
| 300 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 301 |
+
|
| 302 |
+
def test_batch_generate(client):
|
| 303 |
+
r = client.post("/api/generate/batch", json={"requests": [
|
| 304 |
+
{"instruction": "Write a Python hello world function."},
|
| 305 |
+
{"instruction": "Write a SQL query to list all users."},
|
| 306 |
+
]})
|
| 307 |
+
assert r.status_code == 200
|
| 308 |
+
data = r.json()
|
| 309 |
+
assert data["total"] == 2
|
| 310 |
+
assert data["failed"] == 0
|
| 311 |
+
|
| 312 |
+
def test_batch_single(client):
|
| 313 |
+
r = client.post("/api/generate/batch", json={"requests": [
|
| 314 |
+
{"instruction": "Explain what a binary tree is."}
|
| 315 |
+
]})
|
| 316 |
+
assert r.status_code == 200
|
| 317 |
+
assert r.json()["total"] == 1
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 321 |
+
# 5. EXPLAIN
|
| 322 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 323 |
+
|
| 324 |
+
def test_explain(client, generated_prompt):
|
| 325 |
+
pid = generated_prompt["prompt_id"]
|
| 326 |
+
r = client.get(f"/api/explain/{pid}")
|
| 327 |
+
assert r.status_code == 200
|
| 328 |
+
data = r.json()
|
| 329 |
+
assert data["explanation"]
|
| 330 |
+
assert isinstance(data["key_decisions"], list)
|
| 331 |
+
assert len(data["key_decisions"]) >= 3
|
| 332 |
+
|
| 333 |
+
def test_explain_404(client):
|
| 334 |
+
r = client.get("/api/explain/nonexistent")
|
| 335 |
+
assert r.status_code == 404
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 339 |
+
# 6. APPROVE
|
| 340 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 341 |
+
|
| 342 |
+
def test_approve_prompt(client, generated_prompt):
|
| 343 |
+
pid = generated_prompt["prompt_id"]
|
| 344 |
+
r = client.post("/api/approve", json={"prompt_id": pid})
|
| 345 |
+
assert r.status_code == 200
|
| 346 |
+
data = r.json()
|
| 347 |
+
assert data["success"] is True
|
| 348 |
+
assert data["finalized_prompt"]["raw_prompt_text"]
|
| 349 |
+
|
| 350 |
+
def test_approve_with_edits(client, generated_prompt):
|
| 351 |
+
pid = generated_prompt["prompt_id"]
|
| 352 |
+
r = client.post("/api/approve", json={
|
| 353 |
+
"prompt_id": pid,
|
| 354 |
+
"edits": {"role": "Custom QA Engineer", "style": "concise style applied"},
|
| 355 |
+
})
|
| 356 |
+
assert r.status_code == 200
|
| 357 |
+
sp = r.json()["finalized_prompt"]
|
| 358 |
+
assert sp["role"] == "Custom QA Engineer"
|
| 359 |
+
|
| 360 |
+
def test_approve_404(client):
|
| 361 |
+
r = client.post("/api/approve", json={"prompt_id": "bad-id"})
|
| 362 |
+
assert r.status_code == 404
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 366 |
+
# 7. EXPORT
|
| 367 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 368 |
+
|
| 369 |
+
def test_export_json(client, approved_prompt):
|
| 370 |
+
pid = approved_prompt["prompt_id"]
|
| 371 |
+
r = client.post("/api/export", json={"prompt_id": pid, "export_format": "json"})
|
| 372 |
+
assert r.status_code == 200
|
| 373 |
+
data = r.json()
|
| 374 |
+
assert data["success"] is True
|
| 375 |
+
export_data = data["data"]
|
| 376 |
+
for key in ["role","task","input_format","output_format","constraints","safety","raw_prompt_text"]:
|
| 377 |
+
assert key in export_data, f"Missing key in JSON export: {key}"
|
| 378 |
+
|
| 379 |
+
def test_export_text(client, approved_prompt):
|
| 380 |
+
pid = approved_prompt["prompt_id"]
|
| 381 |
+
r = client.post("/api/export", json={"prompt_id": pid, "export_format": "text"})
|
| 382 |
+
assert r.status_code == 200
|
| 383 |
+
assert isinstance(r.json()["data"], str)
|
| 384 |
+
assert len(r.json()["data"]) > 50
|
| 385 |
+
|
| 386 |
+
def test_export_both(client, approved_prompt):
|
| 387 |
+
pid = approved_prompt["prompt_id"]
|
| 388 |
+
r = client.post("/api/export", json={"prompt_id": pid, "export_format": "both"})
|
| 389 |
+
assert r.status_code == 200
|
| 390 |
+
data = r.json()["data"]
|
| 391 |
+
assert "json" in data and "text" in data
|
| 392 |
+
|
| 393 |
+
def test_export_requires_approval(client, generated_prompt):
|
| 394 |
+
pid = generated_prompt["prompt_id"]
|
| 395 |
+
r = client.post("/api/export", json={"prompt_id": pid, "export_format": "json"})
|
| 396 |
+
assert r.status_code == 400
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 400 |
+
# 8. REFINE
|
| 401 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 402 |
+
|
| 403 |
+
def test_refine_prompt(client, generated_prompt):
|
| 404 |
+
pid = generated_prompt["prompt_id"]
|
| 405 |
+
r = client.post("/api/refine", json={
|
| 406 |
+
"prompt_id": pid,
|
| 407 |
+
"feedback": "Add WCAG accessibility constraints and keyboard navigation requirements.",
|
| 408 |
+
})
|
| 409 |
+
assert r.status_code == 200
|
| 410 |
+
data = r.json()
|
| 411 |
+
assert data["success"] is True
|
| 412 |
+
assert data["manifest"]["version"] == 2
|
| 413 |
+
|
| 414 |
+
def test_refine_increments_version(client, generated_prompt):
|
| 415 |
+
pid = generated_prompt["prompt_id"]
|
| 416 |
+
for i, fb in enumerate(["Add tests.", "Add logging.", "Add metrics."], 2):
|
| 417 |
+
r = client.post("/api/refine", json={"prompt_id": pid, "feedback": fb})
|
| 418 |
+
assert r.status_code == 200
|
| 419 |
+
assert r.json()["manifest"]["version"] == i
|
| 420 |
+
|
| 421 |
+
def test_refine_404(client):
|
| 422 |
+
r = client.post("/api/refine", json={"prompt_id": "bad-id", "feedback": "some feedback"})
|
| 423 |
+
assert r.status_code == 404
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 427 |
+
# 9. HISTORY
|
| 428 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 429 |
+
|
| 430 |
+
def test_history_empty(client):
|
| 431 |
+
r = client.get("/api/history")
|
| 432 |
+
assert r.status_code == 200
|
| 433 |
+
assert r.json()["total"] == 0
|
| 434 |
+
|
| 435 |
+
def test_history_after_generate(client, generated_prompt):
|
| 436 |
+
r = client.get("/api/history")
|
| 437 |
+
assert r.status_code == 200
|
| 438 |
+
assert r.json()["total"] == 1
|
| 439 |
+
|
| 440 |
+
def test_history_status_filter(client, generated_prompt, approved_prompt):
|
| 441 |
+
r = client.get("/api/history?status_filter=pending")
|
| 442 |
+
entries = r.json()["entries"]
|
| 443 |
+
assert all(e["status"] == "pending" for e in entries)
|
| 444 |
+
|
| 445 |
+
def test_delete_history(client, generated_prompt):
|
| 446 |
+
pid = generated_prompt["prompt_id"]
|
| 447 |
+
r = client.delete(f"/api/history/{pid}")
|
| 448 |
+
assert r.status_code == 200
|
| 449 |
+
r2 = client.get("/api/history")
|
| 450 |
+
assert r2.json()["total"] == 0
|
| 451 |
+
|
| 452 |
+
def test_favorite_prompt(client, generated_prompt):
|
| 453 |
+
pid = generated_prompt["prompt_id"]
|
| 454 |
+
r = client.post(f"/api/prompts/{pid}/favorite")
|
| 455 |
+
assert r.status_code == 200
|
| 456 |
+
assert r.json()["is_favorite"] is True
|
| 457 |
+
|
| 458 |
+
def test_archive_prompt(client, generated_prompt):
|
| 459 |
+
pid = generated_prompt["prompt_id"]
|
| 460 |
+
r = client.post(f"/api/prompts/{pid}/archive")
|
| 461 |
+
assert r.status_code == 200
|
| 462 |
+
assert r.json()["status"] == "archived"
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 466 |
+
# 10. SEARCH
|
| 467 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 468 |
+
|
| 469 |
+
def test_search_prompts(client, generated_prompt):
|
| 470 |
+
r = client.get("/api/search?q=fastapi")
|
| 471 |
+
assert r.status_code == 200
|
| 472 |
+
data = r.json()
|
| 473 |
+
assert data["total"] >= 1
|
| 474 |
+
assert "snippet" in data["results"][0]
|
| 475 |
+
|
| 476 |
+
def test_search_no_results(client):
|
| 477 |
+
r = client.get("/api/search?q=zzznoresults999")
|
| 478 |
+
assert r.status_code == 200
|
| 479 |
+
assert r.json()["total"] == 0
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 483 |
+
# 11. STATS
|
| 484 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 485 |
+
|
| 486 |
+
def test_stats_after_activity(client, approved_prompt):
|
| 487 |
+
r = client.get("/api/stats")
|
| 488 |
+
assert r.status_code == 200
|
| 489 |
+
s = r.json()
|
| 490 |
+
assert s["total_prompts"] >= 1
|
| 491 |
+
assert s["approved_count"] >= 1
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 495 |
+
# 12. UNIT TESTS — prompt_logic.py
|
| 496 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 497 |
+
|
| 498 |
+
def test_build_manifest_structure():
|
| 499 |
+
m = build_manifest("Create a React component")
|
| 500 |
+
sp = m.structured_prompt
|
| 501 |
+
assert m.prompt_id
|
| 502 |
+
assert m.status == "pending"
|
| 503 |
+
assert sp.role
|
| 504 |
+
assert sp.task
|
| 505 |
+
assert sp.input_format
|
| 506 |
+
assert sp.output_format
|
| 507 |
+
assert isinstance(sp.constraints, list)
|
| 508 |
+
assert isinstance(sp.safety, list)
|
| 509 |
+
assert len(sp.safety) >= 4
|
| 510 |
+
assert len(sp.raw_prompt_text) > 50
|
| 511 |
+
assert sp.word_count > 0
|
| 512 |
+
|
| 513 |
+
def test_build_manifest_heuristic_role():
|
| 514 |
+
m = build_manifest("Write pytest tests with coverage for a FastAPI app.")
|
| 515 |
+
assert any(k in m.structured_prompt.role.lower() for k in ["test","qa","backend"])
|
| 516 |
+
|
| 517 |
+
def test_build_manifest_devops_persona():
|
| 518 |
+
m = build_manifest("Deploy to Kubernetes", persona=PersonaType.devops_eng)
|
| 519 |
+
assert "devops" in m.structured_prompt.role.lower() or "platform" in m.structured_prompt.role.lower()
|
| 520 |
+
|
| 521 |
+
def test_build_manifest_ml_persona():
|
| 522 |
+
m = build_manifest("Fine-tune GPT-2 on a dataset", persona=PersonaType.ml_engineer)
|
| 523 |
+
assert "machine learning" in m.structured_prompt.role.lower() or "ml" in m.structured_prompt.role.lower()
|
| 524 |
+
|
| 525 |
+
def test_build_manifest_custom_persona():
|
| 526 |
+
m = build_manifest("Do something", persona=PersonaType.custom, custom_persona="Rust Embedded Systems Engineer")
|
| 527 |
+
assert m.structured_prompt.role == "Rust Embedded Systems Engineer"
|
| 528 |
+
|
| 529 |
+
def test_auto_tagging():
|
| 530 |
+
tags = _auto_tag("create a react component with typescript and tailwind css")
|
| 531 |
+
assert "react" in tags
|
| 532 |
+
assert "typescript" in tags or "frontend" in tags
|
| 533 |
+
|
| 534 |
+
def test_constraint_injection():
|
| 535 |
+
constraints = _build_constraints("use typescript and add unit tests", [])
|
| 536 |
+
assert any("TypeScript" in c for c in constraints)
|
| 537 |
+
assert any("test" in c.lower() for c in constraints)
|
| 538 |
+
|
| 539 |
+
def test_user_constraints_merged():
|
| 540 |
+
constraints = _build_constraints("simple task", ["Use PostgreSQL", "Limit to 200 lines"])
|
| 541 |
+
assert any("PostgreSQL" in c for c in constraints)
|
| 542 |
+
assert any("200 lines" in c for c in constraints)
|
| 543 |
+
|
| 544 |
+
def test_output_format_inference():
|
| 545 |
+
assert "JSON" in _infer_output_format("return json with all fields")
|
| 546 |
+
assert "Markdown" in _infer_output_format("write markdown documentation")
|
| 547 |
+
assert "code" in _infer_output_format("create a python function").lower() or "code" in _infer_output_format("write code").lower()
|
| 548 |
+
|
| 549 |
+
def test_apply_edits():
|
| 550 |
+
m = build_manifest("Write a function in Python")
|
| 551 |
+
edited = apply_edits(m, {"role": "Overridden Role"})
|
| 552 |
+
assert edited.structured_prompt.role == "Overridden Role"
|
| 553 |
+
assert edited.status == "approved"
|
| 554 |
+
|
| 555 |
+
def test_refine_with_feedback():
|
| 556 |
+
m = build_manifest("Write a simple API")
|
| 557 |
+
refined = refine_with_feedback(m, "Add rate limiting and caching.")
|
| 558 |
+
assert refined.version == 2
|
| 559 |
+
assert refined.prompt_id == m.prompt_id
|
| 560 |
+
assert "REFINEMENT REQUEST" in refined.instruction
|
| 561 |
+
|
| 562 |
+
def test_generate_explanation_returns_content():
|
| 563 |
+
m = build_manifest("Build a GraphQL server", persona=PersonaType.senior_dev)
|
| 564 |
+
explanation, decisions = generate_explanation(m)
|
| 565 |
+
assert explanation
|
| 566 |
+
assert len(decisions) >= 3
|
| 567 |
+
assert any("Role" in d for d in decisions)
|