Really-amin commited on
Commit
e8fd4b7
·
verified ·
1 Parent(s): 1dba7cc

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

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