Spaces:
Sleeping
Sleeping
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 +7 -0
- README.md +27 -31
- api.py +155 -0
- frontend/.gitignore +41 -0
- frontend/AGENTS.md +5 -0
- frontend/CLAUDE.md +1 -0
- frontend/README.md +36 -0
- frontend/eslint.config.mjs +18 -0
- frontend/next.config.ts +7 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +27 -0
- frontend/postcss.config.mjs +7 -0
- frontend/public/file.svg +1 -0
- frontend/public/globe.svg +1 -0
- frontend/public/next.svg +1 -0
- frontend/public/vercel.svg +1 -0
- frontend/public/window.svg +1 -0
- frontend/src/app/favicon.ico +0 -0
- frontend/src/app/globals.css +66 -0
- frontend/src/app/layout.tsx +15 -0
- frontend/src/app/page.tsx +9 -0
- frontend/src/components/ClientApp.tsx +537 -0
- frontend/tsconfig.json +34 -0
- oauth_doc.md +129 -0
- requirements.txt +4 -1
.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
|
| 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.
|
| 38 |
-
|
|
|
|
| 39 |
```bash
|
| 40 |
python -m venv venv
|
|
|
|
| 41 |
```
|
| 42 |
-
3.
|
| 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 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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 |
-
##
|
| 62 |
-
|
| 63 |
-
|
| 64 |
```bash
|
| 65 |
-
|
| 66 |
```
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
huggingface_hub
|
| 8 |
fictional-names
|
| 9 |
openai
|
| 10 |
+
fastapi
|
| 11 |
+
uvicorn
|
| 12 |
+
pydantic
|
| 13 |
+
python-multipart
|