topguy commited on
Commit
4eef090
·
1 Parent(s): cef8efc

Migrate to Next.js & FastAPI Architecture with Premium UI

Browse files

- Decouples monolithic Gradio app into FastAPI backend (`api.py`) and Next.js frontend (`frontend/`).
- Implements Stitch MCP-inspired 'Alchemist's Workshop' aesthetic with glassmorphism and Cinzel typography.
- Replaces generic Gradio grids with precisely constrained, center-aligned HTML forms using Tailwind v4.
- Preserves 100% functional parity including JSON Save/Load, Example Characters, and Image Generation.
- Updates README documentation to reflect the new decoupled setup instructions.

.gemini-commit-msg.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ Migrate to Next.js & FastAPI Architecture with Premium UI
2
+
3
+ - Decouples monolithic Gradio app into FastAPI backend (`api.py`) and Next.js frontend (`frontend/`).
4
+ - Implements Stitch MCP-inspired 'Alchemist's Workshop' aesthetic with glassmorphism and Cinzel typography.
5
+ - Replaces generic Gradio grids with precisely constrained, center-aligned HTML forms using Tailwind v4.
6
+ - Preserves 100% functional parity including JSON Save/Load, Example Characters, and Image Generation.
7
+ - Updates README documentation to reflect the new decoupled setup instructions.
README.md CHANGED
@@ -11,7 +11,7 @@ hf_oauth: true
11
 
12
  # RPGPortrait Prompt Builder Pro
13
 
14
- RPGPortrait is a Gradio-based web application that helps users build highly detailed prompts and generate character portraits using advanced AI like Gemini, Hugging Face, and Imagen.
15
 
16
  ## Features
17
  - **25+ Character Parameters**: Deep customization including Identity, Appearance, Equipment, Environment, VFX, and Technical settings.
@@ -32,47 +32,43 @@ RPGPortrait is a Gradio-based web application that helps users build highly deta
32
  - **🛡️ Robust Error Handling**: AI refinement errors are logged to the console and displayed in the UI status area without polluting your current prompt.
33
  - **YAML Data Storage**: Easily add or modify races, classes, backgrounds, and templates in `features.yaml`.
34
 
35
- ## Installation
36
 
37
- 1. **Clone the repository** (or navigate to the project folder).
38
- 2. **Create a virtual environment**:
 
39
  ```bash
40
  python -m venv venv
 
41
  ```
42
- 3. **Activate the environment**:
43
- - Windows: `.\venv\Scripts\activate`
44
- - Linux/Mac: `source venv/bin/activate`
45
- 4. **Install dependencies**:
46
  ```bash
47
  pip install -r requirements.txt
 
48
  ```
49
- 5. **Set up API Keys**:
50
- - Create a `.env` file in the root directory.
51
- - Add your keys and connection info:
52
- ```env
53
- GEMINI_API_KEY=your_gemini_key
54
- HF_TOKEN=your_huggingface_token # Required for Cloud backends
55
- COMFY_HOST=127.0.0.1
56
- COMFY_PORT=8188
57
- OLLAMA_HOST=127.0.0.1
58
- OLLAMA_PORT=11434
59
  ```
60
 
61
- ## Usage
62
-
63
- 1. **Run the application**:
64
  ```bash
65
- python app.py
66
  ```
67
- 2. **Access the UI**: Open your browser and navigate to `http://127.0.0.1:7860`.
68
- 3. **Build your prompt**: Select features in the left column; the technical prompt updates in real-time.
69
- 4. **Refine Prompt**:
70
- - Choose a **Refinement Backend** in the configuration panel.
71
- - Click **🧠 Refine Prompt** in the right column to polish your description.
72
- 5. **Generate Image**:
73
- - Select an **Image Generation Backend**.
74
- - Click **🖼️ Generate Image** (located directly under the portrait output) to create your character.
75
- 6. **Save/Load**: Use the 💾 and 📂 buttons to manage your character library.
 
 
76
 
77
  ## License
78
 
 
11
 
12
  # RPGPortrait Prompt Builder Pro
13
 
14
+ RPGPortrait is a decoupled Next.js + FastAPI web application that helps users build highly detailed prompts and generate character portraits using advanced AI like Gemini, Hugging Face, and local models.
15
 
16
  ## Features
17
  - **25+ Character Parameters**: Deep customization including Identity, Appearance, Equipment, Environment, VFX, and Technical settings.
 
32
  - **🛡️ Robust Error Handling**: AI refinement errors are logged to the console and displayed in the UI status area without polluting your current prompt.
33
  - **YAML Data Storage**: Easily add or modify races, classes, backgrounds, and templates in `features.yaml`.
34
 
35
+ ## Installation & Setup
36
 
37
+ ### 1. Backend (FastAPI)
38
+ 1. Navigate to the project root directory.
39
+ 2. Create and activate a virtual environment:
40
  ```bash
41
  python -m venv venv
42
+ .\venv\Scripts\activate # Windows
43
  ```
44
+ 3. Install dependencies:
 
 
 
45
  ```bash
46
  pip install -r requirements.txt
47
+ pip install fastapi uvicorn
48
  ```
49
+ 4. Set up `.env` with API keys (GEMINI_API_KEY, HF_TOKEN, etc.).
50
+ 5. Run the FastAPI server:
51
+ ```bash
52
+ python api.py
 
 
 
 
 
 
53
  ```
54
 
55
+ ### 2. Frontend (Next.js)
56
+ 1. Open a new terminal and navigate to the `frontend/` directory.
57
+ 2. Install npm dependencies:
58
  ```bash
59
+ npm install
60
  ```
61
+ 3. Run the Next.js developer server (Tailwind v4):
62
+ ```bash
63
+ npm run dev
64
+ ```
65
+ 4. Access the premium UI at `http://localhost:3000`.
66
+
67
+ ## Usage
68
+ 1. **Build your prompt**: Select features in the left column; the prompt updates on the right.
69
+ 2. **Refine Prompt**: Use the "Enhance Narrative" feature to intelligently polish your description.
70
+ 3. **Generate Image**: Click Synthesize to create your character icon or render using your selected AI backend.
71
+ 4. **Save/Load**: Use the Load and Save buttons in the sidebar state management to back up configurations.
72
 
73
  ## License
74
 
api.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.responses import FileResponse
5
+ from pydantic import BaseModel
6
+ from typing import List, Optional, Any
7
+
8
+ from modules.config import FEATURE_SEQUENCE, SECTIONS
9
+ from modules.core_logic import (
10
+ generate_prompt as core_generate_prompt,
11
+ handle_regeneration as core_handle_regeneration,
12
+ save_character as core_save_character,
13
+ load_character as core_load_character
14
+ )
15
+ from modules.integrations import (
16
+ refine_master,
17
+ generate_name_master,
18
+ generate_image_master,
19
+ get_ollama_models,
20
+ check_comfy_availability
21
+ )
22
+ from modules.name_generator import generate_fantasy_name
23
+
24
+ app = FastAPI(title="Chronicle Portrait Studio API")
25
+
26
+ # Setup CORS
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=["*"], # Since frontend might run on different port
30
+ allow_credentials=True,
31
+ allow_methods=["*"],
32
+ allow_headers=["*"],
33
+ )
34
+
35
+ class PromptRequest(BaseModel):
36
+ character_name: str
37
+ features: List[str]
38
+ randomization: List[bool]
39
+ extra_info: List[str]
40
+
41
+ def to_args(self):
42
+ return [self.character_name] + self.features + self.randomization + self.extra_info
43
+
44
+ class NamingRequest(BaseModel):
45
+ race: str
46
+
47
+ class RegenerationRequest(BaseModel):
48
+ current_values: List[str]
49
+ checkboxes: List[bool]
50
+
51
+ def to_args(self):
52
+ return self.current_values + self.checkboxes
53
+
54
+ class RefinementRequest(BaseModel):
55
+ prompt: str
56
+ backend: str
57
+ ollama_model: Optional[str] = None
58
+ hf_text_model: Optional[str] = None
59
+ hf_text_provider: Optional[str] = None
60
+ manual_token: Optional[str] = None
61
+ character_name: Optional[str] = None
62
+
63
+ class ImageGenerationRequest(BaseModel):
64
+ refined_prompt: str
65
+ technical_prompt: str
66
+ aspect_ratio: str
67
+ backend: str
68
+ hf_image_model: Optional[str] = None
69
+ hf_image_provider: Optional[str] = None
70
+ manual_token: Optional[str] = None
71
+ character_name: Optional[str] = None
72
+
73
+ @app.get("/api/config")
74
+ def get_config():
75
+ """Returns static config info needed by frontend to render dropdowns, etc."""
76
+ from modules.core_logic import features_data, get_example_list
77
+ return {
78
+ "features_data": features_data,
79
+ "feature_sequence": FEATURE_SEQUENCE,
80
+ "sections": SECTIONS,
81
+ "ollama_models": get_ollama_models(),
82
+ "comfy_active": check_comfy_availability(),
83
+ "examples": get_example_list()
84
+ }
85
+
86
+ @app.get("/api/example/{filename}")
87
+ def get_example(filename: str):
88
+ from modules.config import EXAMPLES_DIR
89
+ import os
90
+ path = os.path.join(EXAMPLES_DIR, filename)
91
+ if os.path.exists(path):
92
+ return FileResponse(path, media_type="application/json")
93
+ raise HTTPException(status_code=404, detail="Example not found")
94
+
95
+ @app.post("/api/generate_prompt")
96
+ def generate_prompt(req: PromptRequest):
97
+ args = req.to_args()
98
+ prompt = core_generate_prompt(*args)
99
+ return {"prompt": prompt}
100
+
101
+ @app.post("/api/regenerate_features")
102
+ def regenerate_features(req: RegenerationRequest):
103
+ args = req.to_args()
104
+ new_values = core_handle_regeneration(*args)
105
+ return {"new_values": new_values}
106
+
107
+ @app.post("/api/generate_name")
108
+ def proxy_generate_name(req: NamingRequest):
109
+ name = generate_fantasy_name(req.race)
110
+ return {"name": name}
111
+
112
+ @app.post("/api/refine_prompt")
113
+ def refine_prompt(req: RefinementRequest):
114
+ result, error_msg = refine_master(
115
+ req.prompt,
116
+ req.backend,
117
+ req.ollama_model,
118
+ req.hf_text_model,
119
+ req.hf_text_provider,
120
+ req.manual_token,
121
+ req.character_name
122
+ )
123
+ # result can be a gr.update() if it fails, and error_msg will have content
124
+ if error_msg:
125
+ raise HTTPException(status_code=400, detail=error_msg)
126
+ return {"refined_prompt": result}
127
+
128
+ @app.post("/api/generate_image")
129
+ def generate_image(req: ImageGenerationRequest):
130
+ img, img_path, status_msg = generate_image_master(
131
+ req.refined_prompt,
132
+ req.technical_prompt,
133
+ req.aspect_ratio,
134
+ req.backend,
135
+ req.hf_image_model,
136
+ req.hf_image_provider,
137
+ req.manual_token,
138
+ req.character_name
139
+ )
140
+ if img_path and os.path.exists(img_path):
141
+ return FileResponse(img_path, media_type="image/png", headers={"X-Status-Msg": status_msg})
142
+ else:
143
+ raise HTTPException(status_code=500, detail=status_msg or "Failed to generate image.")
144
+
145
+ @app.post("/api/save_character")
146
+ def save_character(req: PromptRequest):
147
+ args = req.to_args()
148
+ file_path = core_save_character(*args)
149
+ if file_path and os.path.exists(file_path):
150
+ return FileResponse(file_path, media_type="application/json", filename=os.path.basename(file_path))
151
+ raise HTTPException(status_code=500, detail="Failed to save character state.")
152
+
153
+ if __name__ == "__main__":
154
+ import uvicorn
155
+ uvicorn.run("api:app", host="127.0.0.1", port=8000, reload=True)
frontend/.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
frontend/AGENTS.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <!-- BEGIN:nextjs-agent-rules -->
2
+ # This is NOT the Next.js you know
3
+
4
+ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
5
+ <!-- END:nextjs-agent-rules -->
frontend/CLAUDE.md ADDED
@@ -0,0 +1 @@
 
 
1
+ @AGENTS.md
frontend/README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
+
3
+ ## Getting Started
4
+
5
+ First, run the development server:
6
+
7
+ ```bash
8
+ npm run dev
9
+ # or
10
+ yarn dev
11
+ # or
12
+ pnpm dev
13
+ # or
14
+ bun dev
15
+ ```
16
+
17
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
+
19
+ You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
+
21
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
+
23
+ ## Learn More
24
+
25
+ To learn more about Next.js, take a look at the following resources:
26
+
27
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
+
30
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
+
32
+ ## Deploy on Vercel
33
+
34
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
+
36
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
frontend/next.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "lucide-react": "^1.6.0",
13
+ "next": "16.2.1",
14
+ "react": "19.2.4",
15
+ "react-dom": "19.2.4"
16
+ },
17
+ "devDependencies": {
18
+ "@tailwindcss/postcss": "^4",
19
+ "@types/node": "^20",
20
+ "@types/react": "^19",
21
+ "@types/react-dom": "^19",
22
+ "eslint": "^9",
23
+ "eslint-config-next": "16.2.1",
24
+ "tailwindcss": "^4",
25
+ "typescript": "^5"
26
+ }
27
+ }
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
frontend/public/file.svg ADDED
frontend/public/globe.svg ADDED
frontend/public/next.svg ADDED
frontend/public/vercel.svg ADDED
frontend/public/window.svg ADDED
frontend/src/app/favicon.ico ADDED
frontend/src/app/globals.css ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap');
2
+
3
+ @import "tailwindcss";@layer base {
4
+ html, body {
5
+ background-color: #080604;
6
+ color: #E2D1B3;
7
+ font-family: 'Inter', system-ui, sans-serif;
8
+ /* Subtle noise texture over the deep dark background */
9
+ background-image: radial-gradient(circle at 50% 0%, #1a1410 0%, #080604 70%);
10
+ }
11
+ }
12
+
13
+ @layer components {
14
+ .glass-panel {
15
+ background-color: rgba(20, 16, 12, 0.8);
16
+ backdrop-filter: blur(12px);
17
+ -webkit-backdrop-filter: blur(12px);
18
+ border: 1px solid #2D241A;
19
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
20
+ }
21
+
22
+ .font-cinzel {
23
+ font-family: 'Cinzel', serif;
24
+ }
25
+
26
+ .gold-gradient-text {
27
+ background-image: linear-gradient(to right, #DAB062, #F3DE9A, #DAB062);
28
+ color: transparent;
29
+ background-clip: text;
30
+ -webkit-background-clip: text;
31
+ }
32
+
33
+ .input-alchemist {
34
+ width: 100%;
35
+ background-color: #0a0805;
36
+ border: 1px solid #2D241A;
37
+ border-radius: 0.5rem;
38
+ padding: 0.75rem 1.25rem;
39
+ font-size: 0.875rem;
40
+ line-height: 1.25rem;
41
+ color: #E2D1B3;
42
+ transition: all 150ms;
43
+ font-weight: 300;
44
+ }
45
+
46
+ .input-alchemist:focus {
47
+ outline: none;
48
+ border-color: #DAB062;
49
+ box-shadow: 0 0 0 1px rgba(218, 176, 98, 0.5);
50
+ }
51
+ }
52
+
53
+ /* Custom Scrollbar for premium feel */
54
+ ::-webkit-scrollbar {
55
+ width: 8px;
56
+ }
57
+ ::-webkit-scrollbar-track {
58
+ background: #080604;
59
+ }
60
+ ::-webkit-scrollbar-thumb {
61
+ background: #2D241A;
62
+ border-radius: 4px;
63
+ }
64
+ ::-webkit-scrollbar-thumb:hover {
65
+ background: #DAB062;
66
+ }
frontend/src/app/layout.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './globals.css';
2
+
3
+ export default function RootLayout({
4
+ children,
5
+ }: Readonly<{
6
+ children: React.ReactNode;
7
+ }>) {
8
+ return (
9
+ <html lang="en">
10
+ <body>
11
+ {children}
12
+ </body>
13
+ </html>
14
+ );
15
+ }
frontend/src/app/page.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import ClientApp from '../components/ClientApp';
2
+
3
+ export default function Home() {
4
+ return (
5
+ <main className="min-h-screen p-4">
6
+ <ClientApp />
7
+ </main>
8
+ );
9
+ }
frontend/src/components/ClientApp.tsx ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useRef } from 'react';
4
+ import { User, Sparkles, Sword, Globe, Palette, Settings, Image as ImageIcon, Wand2, Dices, Save, Download, Upload, Copy } from 'lucide-react';
5
+
6
+ const API_URL = "http://127.0.0.1:8000";
7
+
8
+ const tabs = [
9
+ { id: 'identity', label: 'Identity & Race', icon: User },
10
+ { id: 'appearance', label: 'Physical Traits', icon: Sparkles },
11
+ { id: 'equipment', label: 'Gear & Weapons', icon: Sword },
12
+ { id: 'environment', label: 'Atmosphere', icon: Globe },
13
+ { id: 'style', label: 'Render Style', icon: Palette }
14
+ ];
15
+
16
+ export default function ClientApp() {
17
+ const [config, setConfig] = useState<any>(null);
18
+ const [activeTab, setActiveTab] = useState('identity');
19
+
20
+ const [characterName, setCharacterName] = useState("Unnamed Hero");
21
+ const [features, setFeatures] = useState<string[]>([]);
22
+ const [randomization, setRandomization] = useState<boolean[]>([]);
23
+ const [extraInfo, setExtraInfo] = useState<string[]>(Array(5).fill(""));
24
+ const [promptOutput, setPromptOutput] = useState("");
25
+ const [refinedOutput, setRefinedOutput] = useState("");
26
+ const [imageSrc, setImageSrc] = useState<string | null>(null);
27
+ const [statusMsg, setStatusMsg] = useState("");
28
+
29
+ const [refinementBackend, setRefinementBackend] = useState("Gemini (Cloud)");
30
+ const [imageBackend, setImageBackend] = useState("ComfyUI (Local)");
31
+ const [ollamaModel, setOllamaModel] = useState("");
32
+ const [hfTextModel, setHfTextModel] = useState("Qwen/Qwen2.5-72B-Instruct");
33
+ const [hfTextProvider, setHfTextProvider] = useState("auto");
34
+ const [hfImageModel, setHfImageModel] = useState("black-forest-labs/FLUX.1-dev");
35
+ const [hfImageProvider, setHfImageProvider] = useState("auto");
36
+ const [manualToken, setManualToken] = useState("");
37
+
38
+ const fileInputRef = useRef<HTMLInputElement>(null);
39
+
40
+ useEffect(() => {
41
+ fetch(`${API_URL}/api/config`)
42
+ .then(res => res.json())
43
+ .then(data => {
44
+ setConfig(data);
45
+ const initFeatures = data.feature_sequence.map(([cat, sub]: any) => {
46
+ const ks = Object.keys(data.features_data[cat]?.[sub] || {});
47
+ return ks.length > 0 ? ks[0] : "None";
48
+ });
49
+ setFeatures(initFeatures);
50
+ setRandomization(Array(data.feature_sequence.length).fill(false));
51
+ })
52
+ .catch(err => console.error("Failed to load config", err));
53
+ }, []);
54
+
55
+ const updatePrompt = async (newFeatures = features, newExtraInfo = extraInfo, newName = characterName) => {
56
+ if (!config || newFeatures.length === 0) return;
57
+ try {
58
+ const res = await fetch(`${API_URL}/api/generate_prompt`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({
62
+ character_name: newName,
63
+ features: newFeatures,
64
+ randomization: randomization,
65
+ extra_info: newExtraInfo
66
+ })
67
+ });
68
+ const data = await res.json();
69
+ setPromptOutput(data.prompt);
70
+ } catch (e) {
71
+ console.error(e);
72
+ }
73
+ };
74
+
75
+ useEffect(() => {
76
+ if (config && features.length > 0) updatePrompt();
77
+ }, [features, extraInfo, characterName]);
78
+
79
+ const handleFeatureChange = (index: number, val: string) => {
80
+ const f = [...features];
81
+ f[index] = val;
82
+ setFeatures(f);
83
+ };
84
+
85
+ const handleExtraInfoChange = (index: number, val: string) => {
86
+ const e = [...extraInfo];
87
+ e[index] = val;
88
+ setExtraInfo(e);
89
+ };
90
+
91
+ const handleRandomize = async () => {
92
+ try {
93
+ setStatusMsg("Randomizing flagged parameters...");
94
+ const res = await fetch(`${API_URL}/api/regenerate_features`, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ current_values: features, checkboxes: randomization })
98
+ });
99
+ const data = await res.json();
100
+ setFeatures(data.new_values);
101
+ setRefinedOutput("");
102
+ setStatusMsg("Randomization complete.");
103
+ } catch (e) { console.error(e); }
104
+ };
105
+
106
+ const applyLoadedData = (data: any) => {
107
+ if (!config) return;
108
+ setCharacterName(data.name || "Unnamed Hero");
109
+ const loadedFeatures = config.feature_sequence.map((seq: any) => data.features?.[seq[2]] || "None");
110
+ setFeatures(loadedFeatures);
111
+
112
+ const loadedRandom = config.feature_sequence.map((seq: any) => data.randomization?.[seq[2]] || false);
113
+ setRandomization(loadedRandom);
114
+
115
+ const loadedExtra = config.sections.map((sec: string) => data.extra_info?.[sec.toLowerCase()] || "");
116
+ setExtraInfo(loadedExtra);
117
+ setStatusMsg(`Loaded ${data.name || "character"} successfully!`);
118
+ };
119
+
120
+ const handleLoadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
121
+ const file = e.target.files?.[0];
122
+ if (!file) return;
123
+ const reader = new FileReader();
124
+ reader.onload = (evt) => {
125
+ try {
126
+ const data = JSON.parse(evt.target?.result as string);
127
+ applyLoadedData(data);
128
+ } catch (err) { setStatusMsg("Error: Invalid JSON file."); }
129
+ };
130
+ reader.readAsText(file);
131
+ if (fileInputRef.current) fileInputRef.current.value = "";
132
+ };
133
+
134
+ const handleSaveFile = () => {
135
+ if (!config) return;
136
+ const data: any = { name: characterName, features: {}, randomization: {}, extra_info: {} };
137
+ config.feature_sequence.forEach((seq: any, i: number) => {
138
+ data.features[seq[2]] = features[i];
139
+ data.randomization[seq[2]] = randomization[i];
140
+ });
141
+ config.sections.forEach((sec: string, i: number) => {
142
+ data.extra_info[sec.toLowerCase()] = extraInfo[i];
143
+ });
144
+
145
+ const blob = new Blob([JSON.stringify(data, null, 4)], { type: 'application/json' });
146
+ const url = URL.createObjectURL(blob);
147
+ const a = document.createElement('a');
148
+ a.href = url;
149
+ a.download = `${characterName.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'character'}_data.json`;
150
+ document.body.appendChild(a);
151
+ a.click();
152
+ document.body.removeChild(a);
153
+ URL.revokeObjectURL(url);
154
+ setStatusMsg("Character configuration saved!");
155
+ };
156
+
157
+ const handleLoadExample = async (filename: string) => {
158
+ if (!filename) return;
159
+ try {
160
+ const res = await fetch(`${API_URL}/api/example/${filename}`);
161
+ if(res.ok) {
162
+ const data = await res.json();
163
+ applyLoadedData(data);
164
+ } else {
165
+ setStatusMsg("Failed to load example.");
166
+ }
167
+ } catch (e) {
168
+ setStatusMsg("Failed to load example.");
169
+ }
170
+ };
171
+
172
+ const handleRefine = async () => {
173
+ setStatusMsg("Refining prompt...");
174
+ try {
175
+ const res = await fetch(`${API_URL}/api/refine_prompt`, {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({ prompt: promptOutput, backend: refinementBackend, ollama_model: ollamaModel, hf_text_model: hfTextModel, hf_text_provider: hfTextProvider, manual_token: manualToken, character_name: characterName })
179
+ });
180
+ if (res.ok) {
181
+ const data = await res.json();
182
+ setRefinedOutput(data.refined_prompt);
183
+ setStatusMsg("Refinement complete!");
184
+ } else {
185
+ const err = await res.json();
186
+ setStatusMsg(`Error: ${err.detail}`);
187
+ }
188
+ } catch (e) { setStatusMsg("Refinement failed."); }
189
+ };
190
+
191
+ const handleGenerateImage = async () => {
192
+ setStatusMsg("Synthesizing portrait...");
193
+ setImageSrc(null);
194
+ try {
195
+ const aspectIndex = config.feature_sequence.findIndex((seq: any) => seq[2] === "aspect_ratio");
196
+ const ar = aspectIndex !== -1 ? features[aspectIndex] : "1:1";
197
+
198
+ const res = await fetch(`${API_URL}/api/generate_image`, {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ refined_prompt: refinedOutput, technical_prompt: promptOutput, aspect_ratio: ar, backend: imageBackend, hf_image_model: hfImageModel, hf_image_provider: hfImageProvider, manual_token: manualToken, character_name: characterName })
202
+ });
203
+
204
+ if (res.ok) {
205
+ const blob = await res.blob();
206
+ setImageSrc(URL.createObjectURL(blob));
207
+ setStatusMsg(res.headers.get("X-Status-Msg") || "Synthesis complete!");
208
+ } else {
209
+ const err = await res.json();
210
+ setStatusMsg(`Synthesis Error: ${err.detail}`);
211
+ }
212
+ } catch (e) { setStatusMsg("Synthesis failed to initialize."); }
213
+ };
214
+
215
+ const handleDownloadImage = () => {
216
+ if (!imageSrc) return;
217
+ const a = document.createElement('a');
218
+ a.href = imageSrc;
219
+ a.download = `${characterName.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'portrait'}.png`;
220
+ document.body.appendChild(a);
221
+ a.click();
222
+ document.body.removeChild(a);
223
+ };
224
+
225
+ const handleCopy = (text: string) => {
226
+ if(!text) return;
227
+ navigator.clipboard.writeText(text);
228
+ setStatusMsg("Copied to clipboard!");
229
+ };
230
+
231
+ const renderFeatureUI = (cat: string, subcat: string, label: string) => {
232
+ const idx = config.feature_sequence.findIndex((seq: any) => seq[0] === cat && seq[1] === subcat);
233
+ if (idx === -1) return null;
234
+ let mappedIdx = idx;
235
+ if (subcat === 'accessory' && label.includes('2')) mappedIdx = idx + 1;
236
+ const choices = Object.keys(config.features_data[cat]?.[subcat] || {});
237
+
238
+ return (
239
+ <div className="mb-8 relative group" key={`${cat}-${subcat}-${label}`}>
240
+ <label className="block text-[10px] uppercase tracking-[0.2em] text-[#DAB062] mb-2 font-bold">{label}</label>
241
+ <div className="flex items-center gap-3">
242
+ <select
243
+ className="input-alchemist flex-1 appearance-none cursor-pointer"
244
+ value={features[mappedIdx] || ""}
245
+ onChange={(e) => handleFeatureChange(mappedIdx, e.target.value)}
246
+ >
247
+ {choices.map(c => <option key={c} value={c}>{c}</option>)}
248
+ </select>
249
+ <div className="flex flex-col items-center justify-center">
250
+ <button
251
+ onClick={() => {
252
+ const r = [...randomization];
253
+ r[mappedIdx] = !r[mappedIdx];
254
+ setRandomization(r);
255
+ }}
256
+ className={`p-2 rounded-md transition-all duration-300 ${randomization[mappedIdx] ? 'bg-[#DAB062] text-[#080604] shadow-[0_0_12px_rgba(218,176,98,0.6)]' : 'bg-[#0a0805] text-[#A89880] hover:text-[#DAB062] border border-[#2D241A] hover:border-[#DAB062]/50'}`}
257
+ title="Toggle Randomization"
258
+ >
259
+ <Dices size={16} />
260
+ </button>
261
+ </div>
262
+ </div>
263
+ </div>
264
+ );
265
+ };
266
+
267
+ if (!config) return <div className="h-screen w-full flex items-center justify-center font-cinzel text-2xl text-[#DAB062]">Igniting forge...</div>;
268
+
269
+ return (
270
+ <div className="flex h-screen w-full overflow-hidden text-[#E2D1B3] antialiased">
271
+
272
+ {/* Hidden file input for loading JSON */}
273
+ <input type="file" accept=".json" className="hidden" ref={fileInputRef} onChange={handleLoadFile} />
274
+
275
+ {/* 1. Left Sidebar Navigation */}
276
+ <div className="w-[300px] border-r border-[#2D241A] bg-[#0a0805]/95 backdrop-blur-xl flex flex-col relative z-20">
277
+ <div className="p-8 border-b border-[#2D241A] text-center">
278
+ <h1 className="font-cinzel font-bold text-3xl gold-gradient-text drop-shadow-md mb-2">Chronicle</h1>
279
+ <p className="text-[#A89880] text-sm uppercase tracking-[0.2em]">Portrait Studio</p>
280
+ </div>
281
+
282
+ <nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto custom-scrollbar">
283
+ <div className="text-[10px] uppercase tracking-widest text-[#665D4F] mb-4 px-4">Creation Aspects</div>
284
+ {tabs.map(t => {
285
+ const Icon = t.icon;
286
+ const isActive = activeTab === t.id;
287
+ return (
288
+ <button
289
+ key={t.id}
290
+ onClick={() => setActiveTab(t.id)}
291
+ className={`w-full flex items-center gap-4 px-4 py-3 rounded-lg transition-all duration-300 group
292
+ ${isActive ? 'bg-[#18130E] border border-[#3D3224] shadow-[inset_0_0_15px_rgba(218,176,98,0.05)]' : 'border border-transparent hover:bg-[#120E0A]'}`}
293
+ >
294
+ <Icon size={20} className={isActive ? 'text-[#DAB062]' : 'text-[#665D4F] group-hover:text-[#A89880]'} />
295
+ <span className={`text-sm tracking-wide flex-1 text-left ${isActive ? 'text-[#E2D1B3] font-medium' : 'text-[#8C7F6B] group-hover:text-[#A89880]'}`}>
296
+ {t.label}
297
+ </span>
298
+ </button>
299
+ );
300
+ })}
301
+
302
+ <div className="h-4"></div>
303
+
304
+ <div className="text-[10px] uppercase tracking-widest text-[#A89880] mb-4 px-4">State Management</div>
305
+ <div className="px-2 grid grid-cols-2 gap-2 mb-4">
306
+ <button onClick={() => fileInputRef.current?.click()} className="flex items-center justify-center gap-2 px-3 py-2 border border-[#2D241A] bg-[#0E151A]/50 rounded font-medium text-[#A89880] hover:text-[#DAB062] hover:border-[#DAB062]/50 transition-colors text-xs tracking-wider">
307
+ <Upload size={14} /> Load
308
+ </button>
309
+ <button onClick={handleSaveFile} className="flex items-center justify-center gap-2 px-3 py-2 border border-[#2D241A] bg-[#0E151A]/50 rounded font-medium text-[#A89880] hover:text-[#DAB062] hover:border-[#DAB062]/50 transition-colors text-xs tracking-wider">
310
+ <Save size={14} /> Save
311
+ </button>
312
+ </div>
313
+
314
+ <div className="px-2 mb-6">
315
+ <label className="text-[10px] block uppercase tracking-widest text-[#A89880] mb-2 px-2">Load Example</label>
316
+ <select
317
+ onChange={(e) => handleLoadExample(e.target.value)}
318
+ className="input-alchemist py-2 text-xs"
319
+ defaultValue=""
320
+ >
321
+ <option value="" disabled>Select a Preset...</option>
322
+ {config.examples?.map((ex: string) => <option key={ex} value={ex}>{ex.replace('.json', '')}</option>)}
323
+ </select>
324
+ </div>
325
+
326
+ <div className="text-[10px] uppercase tracking-widest text-[#A89880] mb-4 px-4 border-t border-[#2D241A] pt-6">Global Actions</div>
327
+ <button onClick={handleRandomize} className="w-full mx-2 flex items-center justify-center gap-3 px-4 py-3 border border-[#3A4E59] bg-[#0E151A] rounded-lg text-[#61C2DF] hover:bg-[#121B22] hover:border-[#61C2DF] transition-all group" style={{ width: 'calc(100% - 1rem)'}}>
328
+ <Dices size={18} />
329
+ <span className="text-sm tracking-wide font-medium">Randomize Flags</span>
330
+ </button>
331
+ </nav>
332
+
333
+ {/* Backend Quick Config at Bottom */}
334
+ <div className="p-6 border-t border-[#2D241A] bg-[#080604]">
335
+ <div className="flex items-center gap-2 mb-4 text-[#A89880]">
336
+ <Settings size={16} />
337
+ <span className="text-xs uppercase tracking-wider font-semibold">Active Engines</span>
338
+ </div>
339
+ <select value={refinementBackend} onChange={e => setRefinementBackend(e.target.value)} className="input-alchemist mb-3 text-xs" title="Text Backbone">
340
+ <option>Gemini (Cloud)</option>
341
+ <option>Hugging Face (Cloud)</option>
342
+ <option>Ollama (Local)</option>
343
+ </select>
344
+ <select value={imageBackend} onChange={e => setImageBackend(e.target.value)} className="input-alchemist text-xs" title="Image Engine">
345
+ <option>ComfyUI (Local)</option>
346
+ <option>Gemini (Cloud)</option>
347
+ <option>Hugging Face (Cloud)</option>
348
+ </select>
349
+ </div>
350
+ </div>
351
+
352
+ {/* 2. Main Middle Area (Form Aspects) */}
353
+ <div className="flex-1 flex flex-col relative z-10 overflow-y-auto shadow-inner custom-scrollbar bg-[#0f0c08]">
354
+ <div className="sticky top-0 h-12 bg-gradient-to-b from-[#080604] to-transparent z-10 pointer-events-none w-full"></div>
355
+
356
+ <div className="max-w-2xl mx-auto w-full px-8 pb-24">
357
+ {/* Section Headers */}
358
+ <div className="mb-10 pl-2">
359
+ <h2 className="font-cinzel text-3xl text-[#E2D1B3] mb-3">{tabs.find(t => t.id === activeTab)?.label}</h2>
360
+ <div className="w-16 h-1 bg-[#DAB062] rounded-full opacity-50"></div>
361
+ </div>
362
+
363
+ <div className="glass-panel rounded-2xl p-10 mb-8 relative overflow-hidden group">
364
+ <div className="absolute top-0 right-0 w-96 h-96 bg-[#DAB062]/5 rounded-full blur-3xl -mr-32 -mt-32 pointer-events-none transition-all duration-700 group-hover:bg-[#DAB062]/10"></div>
365
+
366
+ <div className="relative z-10 w-full">
367
+ {activeTab === 'identity' && (
368
+ <div className="w-full">
369
+ <div className="mb-10 p-6 bg-[#0a0805] border border-[#2D241A] rounded-xl shadow-inner relative group">
370
+ <label className="block text-[10px] uppercase tracking-[0.2em] text-[#DAB062] mb-3 font-bold">Subject Name</label>
371
+ <input type="text" value={characterName} onChange={e => setCharacterName(e.target.value)} className="w-full bg-[#120E0A] border border-[#3D3224] text-[#E2D1B3] font-cinzel text-xl font-semibold rounded-lg px-4 py-3 outline-none focus:border-[#DAB062] focus:ring-1 focus:ring-[#DAB062]/50 transition-all placeholder-[#E2D1B3]/20" placeholder="Enter name..."/>
372
+ </div>
373
+ <div className="grid grid-cols-2 gap-x-12 gap-y-8">
374
+ {renderFeatureUI('identity', 'race', 'Race')}
375
+ {renderFeatureUI('identity', 'class', 'Class')}
376
+ {renderFeatureUI('identity', 'gender', 'Gender')}
377
+ {renderFeatureUI('identity', 'age', 'Age')}
378
+ </div>
379
+ <div className="mt-10">
380
+ <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Lore / Background Text</label>
381
+ <textarea value={extraInfo[0]} onChange={e => handleExtraInfoChange(0, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea>
382
+ </div>
383
+
384
+ <div className="my-12 border-t border-[#2D241A]/50"></div>
385
+
386
+ <h3 className="font-cinzel text-2xl text-[#DAB062] mb-8 drop-shadow-sm">Expression & Stance</h3>
387
+ <div className="grid grid-cols-2 gap-x-12 gap-y-8">
388
+ {renderFeatureUI('expression_pose', 'expression', 'Expression')}
389
+ {renderFeatureUI('expression_pose', 'pose', 'Pose')}
390
+ </div>
391
+ </div>
392
+ )}
393
+
394
+ {activeTab === 'appearance' && (
395
+ <div className="w-full">
396
+ <div className="grid grid-cols-2 gap-x-16 gap-y-12">
397
+ {renderFeatureUI('appearance', 'hair_color', 'Hair Color')}
398
+ {renderFeatureUI('appearance', 'hair_style', 'Hair Style')}
399
+ {renderFeatureUI('appearance', 'eye_color', 'Eye Color')}
400
+ {renderFeatureUI('appearance', 'build', 'Build')}
401
+ {renderFeatureUI('appearance', 'skin_tone', 'Skin Tone')}
402
+ {renderFeatureUI('appearance', 'distinguishing_feature', 'Main Feature')}
403
+ </div>
404
+ <div className="mt-10">
405
+ <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Additional Visuals</label>
406
+ <textarea value={extraInfo[1]} onChange={e => handleExtraInfoChange(1, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea>
407
+ </div>
408
+ </div>
409
+ )}
410
+
411
+ {activeTab === 'equipment' && (
412
+ <div className="w-full">
413
+ <div className="grid grid-cols-2 gap-x-16 gap-y-12">
414
+ {renderFeatureUI('equipment', 'armor', 'Primary Gear')}
415
+ {renderFeatureUI('equipment', 'weapon', 'Weapon Variant')}
416
+ {renderFeatureUI('equipment', 'accessory', 'Acc. 1')}
417
+ {renderFeatureUI('equipment', 'accessory', 'Acc. 2')}
418
+ {renderFeatureUI('equipment', 'material', 'Material Finish')}
419
+ </div>
420
+ <div className="mt-10">
421
+ <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Custom Equipment Description</label>
422
+ <textarea value={extraInfo[2]} onChange={e => handleExtraInfoChange(2, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea>
423
+ </div>
424
+ </div>
425
+ )}
426
+
427
+ {activeTab === 'environment' && (
428
+ <div className="w-full">
429
+ <div className="grid grid-cols-2 gap-x-16 gap-y-12">
430
+ {renderFeatureUI('environment', 'background', 'Backdrop')}
431
+ {renderFeatureUI('environment', 'lighting', 'Illumination')}
432
+ {renderFeatureUI('environment', 'atmosphere', 'Atmosphere')}
433
+ </div>
434
+ <div className="mt-10">
435
+ <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Custom Environment Info</label>
436
+ <textarea value={extraInfo[3]} onChange={e => handleExtraInfoChange(3, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea>
437
+ </div>
438
+ </div>
439
+ )}
440
+
441
+ {activeTab === 'style' && (
442
+ <div className="w-full">
443
+ <div className="grid grid-cols-2 gap-x-16 gap-y-12">
444
+ {renderFeatureUI('vfx_style', 'vfx', 'Particle Effects')}
445
+ {renderFeatureUI('vfx_style', 'style', 'Illustration Style')}
446
+ {renderFeatureUI('vfx_style', 'mood', 'Color Grading/Mood')}
447
+ {renderFeatureUI('vfx_style', 'camera', 'Camera Setup')}
448
+ {renderFeatureUI('technical', 'aspect_ratio', 'Aspect Ratio')}
449
+ </div>
450
+ <div className="mt-10">
451
+ <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Additional Technical Prompts</label>
452
+ <textarea value={extraInfo[4]} onChange={e => handleExtraInfoChange(4, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea>
453
+ </div>
454
+ </div>
455
+ )}
456
+ </div>
457
+ </div>
458
+ </div>
459
+ </div>
460
+
461
+ {/* 3. Right Crucible Area (Output & Synthesis) */}
462
+ <div className="w-[450px] border-l border-[#2D241A] bg-[#0C0A08] flex flex-col flex-shrink-0 shadow-[-10px_0_30px_rgba(0,0,0,0.5)] z-30">
463
+
464
+ {/* Output Image Canvas */}
465
+ <div className="h-[45%] p-6 border-b border-[#2D241A] relative flex items-center justify-center overflow-hidden">
466
+ <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/dark-matter.png')] opacity-10 pointer-events-none"></div>
467
+
468
+ {imageSrc ? (
469
+ <div className="relative w-full h-full flex items-center justify-center rounded-lg overflow-hidden group">
470
+ <div className="absolute inset-0 border border-[#DAB062] opacity-30 pointer-events-none z-10 rounded-lg"></div>
471
+ <img src={imageSrc} alt="Portrait" className="max-w-full max-h-full object-contain drop-shadow-[0_0_20px_rgba(218,176,98,0.2)] transition-transform duration-700 ease-in-out group-hover:scale-[1.02]" />
472
+
473
+ {/* Context menu mock overlay */}
474
+ <div className="absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
475
+ <button onClick={handleDownloadImage} title="Download Portrait" className="bg-black/60 backdrop-blur-md text-[#E2D1B3] p-2 rounded-md hover:text-[#DAB062] hover:bg-black/80 border border-[#2D241A] transition-all"><Download size={18}/></button>
476
+ </div>
477
+ </div>
478
+ ) : (
479
+ <div className="flex flex-col items-center justify-center text-center opacity-40">
480
+ <ImageIcon size={56} className="text-[#A89880] mb-4 stroke-1" />
481
+ <p className="font-cinzel text-[#A89880] tracking-widest text-sm">Visual Synthesis Pending</p>
482
+ </div>
483
+ )}
484
+ </div>
485
+
486
+ {/* Action Panel */}
487
+ <div className="flex-1 p-6 flex flex-col overflow-y-auto custom-scrollbar">
488
+
489
+ <div className="flex-1 flex flex-col group relative shrink-0 min-h-[160px]">
490
+ <div className="flex items-center justify-between mb-4">
491
+ <label className="text-xs uppercase tracking-widest text-[#A89880] font-medium flex items-center gap-2"><Sparkles size={14} className="text-[#61C2DF]" /> Narrative Prompt</label>
492
+ <button onClick={() => handleCopy(refinedOutput || promptOutput)} title="Copy Narrative" className="p-1.5 rounded-lg bg-transparent border border-[#1A2E38] hover:bg-[#1A2E38]/50 text-[#A89880] hover:text-[#61C2DF] transition-all"><Copy size={14} /></button>
493
+ </div>
494
+ <textarea
495
+ value={refinedOutput}
496
+ onChange={e => setRefinedOutput(e.target.value)}
497
+ placeholder="Click refine to generate an artistic prompt... or type manually."
498
+ className="flex-1 bg-[#12181C] border border-[#1A2E38] rounded-xl p-4 text-sm text-[#E0F2FE] tracking-wide resize-none focus:outline-none focus:border-[#61C2DF] focus:ring-1 focus:ring-[#61C2DF]/30 custom-scrollbar shadow-inner transition-colors group-hover:border-[#223F4D]"
499
+ ></textarea>
500
+ <button
501
+ onClick={handleRefine}
502
+ className="mt-4 w-full shrink-0 relative overflow-hidden group bg-gradient-to-r from-[#1A3F4D] via-[#2F657D] to-[#1A3F4D] text-[#E0F2FE] font-cinzel font-bold text-sm tracking-widest py-3.5 rounded-xl shadow-[0_0_15px_rgba(97,194,223,0.15)] transition-all hover:shadow-[0_0_25px_rgba(97,194,223,0.3)]"
503
+ >
504
+ <div className="absolute inset-0 bg-white/10 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-out"></div>
505
+ <span className="relative z-10 flex items-center justify-center gap-3">
506
+ <Sparkles size={18} /> Enhance Narrative
507
+ </span>
508
+ </button>
509
+ </div>
510
+
511
+ <div className="my-4 border-t border-[#2D241A]/30"></div>
512
+
513
+ <div className="flex flex-col group relative shrink-0">
514
+ <div className="flex items-center justify-between mb-4">
515
+ <label className="text-xs uppercase tracking-widest text-[#A89880] font-medium">Autogenerated Base Prompt</label>
516
+ <button onClick={() => handleCopy(promptOutput)} title="Copy Base Prompt" className="p-1.5 rounded-lg bg-transparent border border-[#2D241A] hover:bg-[#2D241A]/50 text-[#A89880] hover:text-[#DAB062] transition-all"><Copy size={14} /></button>
517
+ </div>
518
+ <textarea readOnly value={promptOutput} className="h-40 bg-[#080604] border border-[#1A2E38] rounded-xl p-4 text-xs text-[#E2D1B3] resize-none custom-scrollbar shadow-inner outline-none leading-relaxed"></textarea>
519
+ </div>
520
+
521
+ <div className="text-center text-[#DAB062] font-cinzel text-xs tracking-[0.2em] my-5 min-h-[20px]">{statusMsg}</div>
522
+
523
+ <button
524
+ onClick={handleGenerateImage}
525
+ className="w-full shrink-0 relative overflow-hidden group bg-gradient-to-r from-[#8C6D3B] via-[#DAB062] to-[#8C6D3B] text-[#0a0805] font-cinzel font-bold text-base tracking-widest py-4 rounded-xl shadow-[0_0_20px_rgba(218,176,98,0.2)] transition-all hover:shadow-[0_0_30px_rgba(218,176,98,0.4)]"
526
+ >
527
+ <div className="absolute inset-0 bg-white/20 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-out"></div>
528
+ <span className="relative z-10 flex items-center justify-center gap-3">
529
+ <Wand2 size={22} /> Synthesize
530
+ </span>
531
+ </button>
532
+
533
+ </div>
534
+ </div>
535
+ </div>
536
+ );
537
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }
oauth_doc.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Adding a Sign-In with HF button to your Space
2
+
3
+ You can enable a built-in sign-in flow in your Space by seamlessly creating and associating an [OAuth/OpenID connect](https://developer.okta.com/blog/2019/10/21/illustrated-guide-to-oauth-and-oidc) app so users can log in with their HF account.
4
+
5
+ This enables new use cases for your Space. For instance, when combined with [Persistent Storage](https://huggingface.co/docs/hub/spaces-storage), a generative AI Space could allow users to log in to access their previous generations, only accessible to them.
6
+
7
+ > [!TIP]
8
+ > This guide will take you through the process of integrating a *Sign-In with HF* button into any Space. If you're seeking a fast and simple method to implement this in a **Gradio** Space, take a look at its [built-in integration](https://www.gradio.app/guides/sharing-your-app#o-auth-login-via-hugging-face).
9
+
10
+ > [!TIP]
11
+ > You can also use the HF OAuth flow to create a "Sign in with HF" flow in any website or App, outside of Spaces. [Read our general OAuth page](./oauth).
12
+
13
+ ## Create an OAuth app
14
+
15
+ All you need to do is add `hf_oauth: true` to your Space's metadata inside your `README.md` file.
16
+
17
+ Here's an example of metadata for a Gradio Space:
18
+
19
+ ```yaml
20
+ title: Gradio Oauth Test
21
+ emoji: 🏆
22
+ colorFrom: pink
23
+ colorTo: pink
24
+ sdk: gradio
25
+ sdk_version: 3.40.0
26
+ python_version: 3.10.6
27
+ app_file: app.py
28
+
29
+ hf_oauth: true
30
+ # optional, default duration is 8 hours/480 minutes. Max duration is 30 days/43200 minutes.
31
+ hf_oauth_expiration_minutes: 480
32
+ # optional, see "Scopes" below. "openid profile" is always included.
33
+ hf_oauth_scopes:
34
+ - read-repos
35
+ - write-repos
36
+ - manage-repos
37
+ - inference-api
38
+ # optional, restrict access to members of specific organizations
39
+ hf_oauth_authorized_org: ORG_NAME
40
+ hf_oauth_authorized_org:
41
+ - ORG_NAME1
42
+ - ORG_NAME2
43
+ ```
44
+
45
+ You can check out the [configuration reference docs](./spaces-config-reference) for more information.
46
+
47
+ This will add the following [environment variables](https://huggingface.co/docs/hub/spaces-overview#helper-environment-variables) to your space:
48
+
49
+ - `OAUTH_CLIENT_ID`: the client ID of your OAuth app (public)
50
+ - `OAUTH_CLIENT_SECRET`: the client secret of your OAuth app
51
+ - `OAUTH_SCOPES`: scopes accessible by your OAuth app.
52
+ - `OPENID_PROVIDER_URL`: The URL of the OpenID provider. The OpenID metadata will be available at [`{OPENID_PROVIDER_URL}/.well-known/openid-configuration`](https://huggingface.co/.well-known/openid-configuration).
53
+
54
+ As for any other environment variable, you can use them in your code by using `os.getenv("OAUTH_CLIENT_ID")`, for example.
55
+
56
+ ## Redirect URLs
57
+
58
+ You can use any redirect URL you want, as long as it targets your Space.
59
+
60
+ Note that `SPACE_HOST` is [available](https://huggingface.co/docs/hub/spaces-overview#helper-environment-variables) as an environment variable.
61
+
62
+ For example, you can use `https://{SPACE_HOST}/login/callback` as a redirect URI.
63
+
64
+ ## Scopes
65
+
66
+ The following scopes are always included for Spaces:
67
+
68
+ - `openid`: Get the ID token in addition to the access token.
69
+ - `profile`: Get the user's profile information (username, avatar, etc.)
70
+
71
+ Those scopes are optional and can be added by setting `hf_oauth_scopes` in your Space's metadata:
72
+
73
+ - `email`: Get the user's email address.
74
+ - `read-billing`: Know whether the user has a payment method set up.
75
+ - `read-repos`: Get read access to the user's personal repos.
76
+ - `contribute-repos`: Can create repositories and access those created by this app. Cannot access any other repositories unless additional permissions are granted.
77
+ - `write-repos`: Get write/read access to the user's personal repos.
78
+ - `manage-repos`: Get full access to the user's personal repos. Also grants repo creation and deletion.
79
+ - `inference-api`: Get access to the [Inference Providers](https://huggingface.co/docs/inference-providers/index), you will be able to make inference requests on behalf of the user.
80
+ - `jobs`: Run [jobs](https://huggingface.co/docs/huggingface_hub/main/en/guides/jobs)
81
+ - `webhooks`: Manage [webhooks](https://huggingface.co/docs/huggingface_hub/main/en/guides/webhooks)
82
+ - `write-discussions`: Open discussions and Pull Requests on behalf of the user as well as interact with discussions (including reactions, posting/editing comments, closing discussions, ...). To open Pull Requests on private repos, you need to request the `read-repos` scope as well.
83
+
84
+ ## Accessing organization resources
85
+
86
+ By default, the oauth app does not need to access organization resources.
87
+
88
+ But some scopes like `read-repos` or `read-billing` apply to organizations as well.
89
+
90
+ The user can select which organizations to grant access to when authorizing the app. If you require access to a specific organization, you can add `orgIds=ORG_ID` as a query parameter to the OAuth authorization URL. You have to replace `ORG_ID` with the organization ID, which is available in the `organizations.sub` field of the userinfo response.
91
+
92
+ ## Adding the button to your Space
93
+
94
+ You now have all the information to add a "Sign-in with HF" button to your Space. Some libraries ([Python](https://github.com/lepture/authlib), [NodeJS](https://github.com/panva/node-openid-client)) can help you implement the OpenID/OAuth protocol.
95
+
96
+ Gradio and huggingface.js also provide **built-in support**, making implementing the Sign-in with HF button a breeze; you can check out the associated guides with [gradio](https://www.gradio.app/guides/sharing-your-app#o-auth-login-via-hugging-face) and with [huggingface.js](https://huggingface.co/docs/huggingface.js/hub/README#oauth-login).
97
+
98
+ Basically, you need to:
99
+
100
+ - Redirect the user to `https://huggingface.co/oauth/authorize?redirect_uri={REDIRECT_URI}&scope=openid%20profile&client_id={CLIENT_ID}&state={STATE}`, where `STATE` is a random string that you will need to verify later.
101
+ - Handle the callback on `/auth/callback` or `/login/callback` (or your own custom callback URL) and verify the `state` parameter.
102
+ - Use the `code` query parameter to get an access token and id token from `https://huggingface.co/oauth/token` (POST request with `client_id`, `code`, `grant_type=authorization_code` and `redirect_uri` as form data, and with `Authorization: Basic {base64(client_id:client_secret)}` as a header).
103
+
104
+ > [!WARNING]
105
+ > You should use `target=_blank` on the button to open the sign-in page in a new tab, unless you run the space outside its `iframe`. Otherwise, you might encounter issues with cookies on some browsers.
106
+
107
+ ## Examples:
108
+
109
+ - [Gradio test app](https://huggingface.co/spaces/Wauplin/gradio-oauth-test)
110
+ - [HuggingChat (NodeJS/SvelteKit)](https://huggingface.co/spaces/huggingchat/chat-ui)
111
+ - [Inference Widgets (Auth.js/SvelteKit)](https://huggingface.co/spaces/huggingfacejs/inference-widgets), uses the `inference-api` scope to make inference requests on behalf of the user.
112
+ - [Client-Side in a Static Space (huggingface.js)](https://huggingface.co/spaces/huggingfacejs/client-side-oauth) - very simple JavaScript example.
113
+
114
+ JS Code example:
115
+
116
+ ```js
117
+ import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "@huggingface/hub";
118
+
119
+ const oauthResult = await oauthHandleRedirectIfPresent();
120
+
121
+ if (!oauthResult) {
122
+ // If the user is not logged in, redirect to the login page
123
+ window.location.href = await oauthLoginUrl();
124
+ }
125
+
126
+ // You can use oauthResult.accessToken, oauthResult.userInfo among other things
127
+ console.log(oauthResult);
128
+ ```
129
+
requirements.txt CHANGED
@@ -7,4 +7,7 @@ Pillow
7
  huggingface_hub
8
  fictional-names
9
  openai
10
- python-dotenv
 
 
 
 
7
  huggingface_hub
8
  fictional-names
9
  openai
10
+ fastapi
11
+ uvicorn
12
+ pydantic
13
+ python-multipart