Really-amin commited on
Commit
7732582
·
verified ·
1 Parent(s): f26b4a0

Upload PromptForge v1.0 — Structured prompt generator for Google AI Studio

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