Adding files.
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# Ignore git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore virtual environments
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Ignore data (it will be mounted as a volume instead)
|
||||
data/
|
||||
|
||||
# Ignore local secrets (they will be mounted or passed as env vars)
|
||||
.env
|
||||
credentials.json
|
||||
token.json
|
||||
|
||||
# Ignore cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
|
||||
# Ignore local libs (dependencies are installed in the image)
|
||||
libs/
|
||||
token.json
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
__pycache__/
|
||||
run_*/
|
||||
*.docx
|
||||
*.epub
|
||||
data/
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies required for Pillow (image processing)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements files
|
||||
COPY requirements.txt .
|
||||
COPY modules/requirements_web.txt ./modules/
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir -r modules/requirements_web.txt
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Set Python path and run
|
||||
ENV PYTHONPATH=/app
|
||||
EXPOSE 5000
|
||||
CMD ["python", "-m", "modules.web_app"]
|
||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# BookApp Modules
|
||||
61
config.py
Normal file
61
config.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Ensure .env is loaded from the script's directory (VS Code fix)
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
|
||||
def get_clean_env(key, default=None):
|
||||
val = os.getenv(key, default)
|
||||
return val.strip() if val else None
|
||||
|
||||
API_KEY = get_clean_env("GEMINI_API_KEY")
|
||||
GCP_PROJECT = get_clean_env("GCP_PROJECT")
|
||||
GCP_LOCATION = get_clean_env("GCP_LOCATION", "us-central1")
|
||||
MODEL_LOGIC_HINT = get_clean_env("MODEL_LOGIC", "AUTO")
|
||||
MODEL_WRITER_HINT = get_clean_env("MODEL_WRITER", "AUTO")
|
||||
MODEL_ARTIST_HINT = get_clean_env("MODEL_ARTIST", "AUTO")
|
||||
DEFAULT_BLUEPRINT = "book_def.json"
|
||||
|
||||
# --- SECURITY & ADMIN ---
|
||||
FLASK_SECRET = get_clean_env("FLASK_SECRET_KEY", "dev-secret-key-change-this")
|
||||
ADMIN_USER = get_clean_env("ADMIN_USERNAME")
|
||||
ADMIN_PASSWORD = get_clean_env("ADMIN_PASSWORD")
|
||||
|
||||
if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.")
|
||||
|
||||
# --- DATA DIRECTORIES ---
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
|
||||
PERSONAS_DIR = os.path.join(DATA_DIR, "personas")
|
||||
PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json")
|
||||
FONTS_DIR = os.path.join(DATA_DIR, "fonts")
|
||||
|
||||
# --- ENSURE DIRECTORIES EXIST ---
|
||||
# Critical: Create data folders immediately to prevent DB initialization errors
|
||||
for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]:
|
||||
if not os.path.exists(d): os.makedirs(d, exist_ok=True)
|
||||
|
||||
# --- AUTHENTICATION ---
|
||||
GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
if GOOGLE_CREDS:
|
||||
# Resolve to absolute path relative to this config file if not absolute
|
||||
if not os.path.isabs(GOOGLE_CREDS):
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS)
|
||||
|
||||
if os.path.exists(GOOGLE_CREDS):
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS
|
||||
else:
|
||||
print(f"⚠️ Warning: GOOGLE_APPLICATION_CREDENTIALS file not found at: {GOOGLE_CREDS}")
|
||||
|
||||
# --- DEFINITIONS ---
|
||||
LENGTH_DEFINITIONS = {
|
||||
"01": {"label": "Chapter Book", "words": "5,000 - 10,000", "chapters": 10, "depth": 1},
|
||||
"1": {"label": "Flash Fiction", "words": "500 - 1,500", "chapters": 1, "depth": 1},
|
||||
"2": {"label": "Short Story", "words": "5,000 - 10,000", "chapters": 5, "depth": 1},
|
||||
"2b": {"label": "Young Adult", "words": "50,000 - 70,000", "chapters": 25, "depth": 3},
|
||||
"3": {"label": "Novella", "words": "20,000 - 40,000", "chapters": 15, "depth": 2},
|
||||
"4": {"label": "Novel", "words": "60,000 - 80,000", "chapters": 30, "depth": 3},
|
||||
"5": {"label": "Epic", "words": "100,000+", "chapters": 50, "depth": 4}
|
||||
}
|
||||
1
credentials.json
Normal file
1
credentials.json
Normal file
@@ -0,0 +1 @@
|
||||
{"installed":{"client_id":"439342248586-7c31edogtkpr1ml95a12c8mfr9vtan6r.apps.googleusercontent.com","project_id":"tarotdeck-479923","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
bookapp:
|
||||
build: .
|
||||
container_name: bookapp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
working_dir: /app
|
||||
volumes:
|
||||
# --- PERSISTENCE (Data & Login) ---
|
||||
# These mounts ensure your projects and login tokens survive container resets
|
||||
# HOST_PATH is defined in Portainer Environment Variables (e.g. /opt/bookapp)
|
||||
- ${HOST_PATH:-.}/data:/app/data
|
||||
- ${HOST_PATH:-.}/token.json:/app/token.json
|
||||
- ${HOST_PATH:-.}/credentials.json:/app/credentials.json
|
||||
# --- DEVELOPMENT (Code Sync) ---
|
||||
# UNCOMMENT these lines only if you are developing and want to see changes instantly.
|
||||
# For production/deployment, keep them commented out so the container uses the built image code.
|
||||
# - ./modules:/app/modules
|
||||
# - ./templates:/app/templates
|
||||
# - ./main.py:/app/main.py
|
||||
# - ./wizard.py:/app/wizard.py
|
||||
# - ./config.py:/app/config.py
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json
|
||||
- PYTHONPATH=/app
|
||||
- FLASK_SECRET_KEY=change_this_to_a_random_string
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=change_me_in_portainer
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||
320
main.py
Normal file
320
main.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import json, os, time, sys, shutil
|
||||
import config
|
||||
from rich.prompt import Confirm
|
||||
from modules import ai, story, marketing, export, utils
|
||||
|
||||
def process_book(bp, folder, context="", resume=False):
|
||||
# Create lock file to indicate active processing
|
||||
lock_path = os.path.join(folder, ".in_progress")
|
||||
with open(lock_path, "w") as f: f.write("running")
|
||||
|
||||
total_start = time.time()
|
||||
|
||||
# 1. Check completion
|
||||
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
|
||||
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.")
|
||||
|
||||
# Clean up zombie lock file if it exists
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
return
|
||||
|
||||
# 2. Load or Create Blueprint
|
||||
bp_path = os.path.join(folder, "blueprint_initial.json")
|
||||
t_step = time.time()
|
||||
if resume and os.path.exists(bp_path):
|
||||
utils.log("RESUME", "Loading existing blueprint...")
|
||||
saved_bp = utils.load_json(bp_path)
|
||||
# Merge latest metadata from Bible (passed in bp) into saved blueprint
|
||||
if saved_bp:
|
||||
if 'book_metadata' in bp and 'book_metadata' in saved_bp:
|
||||
for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
|
||||
if k in bp['book_metadata']:
|
||||
saved_bp['book_metadata'][k] = bp['book_metadata'][k]
|
||||
if 'series_metadata' in bp:
|
||||
saved_bp['series_metadata'] = bp['series_metadata']
|
||||
bp = saved_bp
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
else:
|
||||
bp = utils.normalize_settings(bp)
|
||||
bp = story.enrich(bp, folder, context)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Ensure Persona Exists (Auto-create if missing)
|
||||
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']:
|
||||
bp['book_metadata']['author_details'] = story.create_initial_persona(bp, folder)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 3. Events (Plan & Expand)
|
||||
events_path = os.path.join(folder, "events.json")
|
||||
t_step = time.time()
|
||||
if resume and os.path.exists(events_path):
|
||||
utils.log("RESUME", "Loading existing events...")
|
||||
events = utils.load_json(events_path)
|
||||
else:
|
||||
events = story.plan_structure(bp, folder)
|
||||
depth = bp['length_settings']['depth']
|
||||
target_chaps = bp['length_settings']['chapters']
|
||||
for d in range(1, depth+1):
|
||||
events = story.expand(events, d, target_chaps, bp, folder)
|
||||
time.sleep(1)
|
||||
with open(events_path, "w") as f: json.dump(events, f, indent=2)
|
||||
utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 4. Chapter Plan
|
||||
chapters_path = os.path.join(folder, "chapters.json")
|
||||
t_step = time.time()
|
||||
if resume and os.path.exists(chapters_path):
|
||||
utils.log("RESUME", "Loading existing chapter plan...")
|
||||
chapters = utils.load_json(chapters_path)
|
||||
else:
|
||||
chapters = story.create_chapter_plan(events, bp, folder)
|
||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||
utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
|
||||
|
||||
# 5. Writing Loop
|
||||
ms_path = os.path.join(folder, "manuscript.json")
|
||||
ms = utils.load_json(ms_path) if (resume and os.path.exists(ms_path)) else []
|
||||
|
||||
# Load Tracking
|
||||
events_track_path = os.path.join(folder, "tracking_events.json")
|
||||
chars_track_path = os.path.join(folder, "tracking_characters.json")
|
||||
warn_track_path = os.path.join(folder, "tracking_warnings.json")
|
||||
|
||||
tracking = {"events": [], "characters": {}, "content_warnings": []}
|
||||
if resume:
|
||||
if os.path.exists(events_track_path):
|
||||
tracking['events'] = utils.load_json(events_track_path)
|
||||
if os.path.exists(chars_track_path):
|
||||
tracking['characters'] = utils.load_json(chars_track_path)
|
||||
if os.path.exists(warn_track_path):
|
||||
tracking['content_warnings'] = utils.load_json(warn_track_path)
|
||||
|
||||
summary = "The story begins."
|
||||
if ms:
|
||||
# Generate summary from ALL written chapters to maintain continuity
|
||||
utils.log("RESUME", "Rebuilding 'Story So Far' from existing manuscript...")
|
||||
try:
|
||||
combined_text = "\n".join([f"Chapter {c['num']}: {c['content']}" for c in ms])
|
||||
resp_sum = ai.model_writer.generate_content(f"Create a detailed, cumulative 'Story So Far' summary from the following text. Use dense, factual bullet points. Focus on character meetings, relationships, and known information:\n{combined_text}")
|
||||
utils.log_usage(folder, "writer-flash", resp_sum.usage_metadata)
|
||||
summary = resp_sum.text
|
||||
except: summary = "The story continues."
|
||||
|
||||
t_step = time.time()
|
||||
session_chapters = 0
|
||||
session_time = 0
|
||||
|
||||
for i in range(len(ms), len(chapters)):
|
||||
ch_start = time.time()
|
||||
ch = chapters[i]
|
||||
|
||||
# Pass previous chapter content for continuity if available
|
||||
prev_content = ms[-1]['content'] if ms else None
|
||||
|
||||
txt = story.write_chapter(ch, bp, folder, summary, tracking, prev_content)
|
||||
|
||||
# Refine Persona to match the actual output (Consistency Loop)
|
||||
if (i == 0 or i % 3 == 0) and txt:
|
||||
bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder)
|
||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Look ahead for context to ensure relevant details are captured
|
||||
next_info = ""
|
||||
if i + 1 < len(chapters):
|
||||
next_ch = chapters[i+1]
|
||||
next_info = f"\nUPCOMING CONTEXT (Prioritize details relevant to this): {next_ch.get('title')} - {json.dumps(next_ch.get('beats', []))}"
|
||||
|
||||
try:
|
||||
update_prompt = f"""
|
||||
Update the 'Story So Far' summary to include the events of this new chapter.
|
||||
|
||||
STYLE: Dense, factual, chronological bullet points. Avoid narrative prose.
|
||||
GOAL: Maintain a perfect memory of the plot for continuity.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. CUMULATIVE: Do NOT remove old events. Append and integrate new information.
|
||||
2. TRACKING: Explicitly note who met whom, who knows what, and current locations.
|
||||
3. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved.
|
||||
|
||||
CURRENT STORY SO FAR:
|
||||
{summary}
|
||||
|
||||
NEW CHAPTER CONTENT:
|
||||
{txt}
|
||||
{next_info}
|
||||
"""
|
||||
resp_sum = ai.model_writer.generate_content(update_prompt)
|
||||
utils.log_usage(folder, "writer-flash", resp_sum.usage_metadata)
|
||||
summary = resp_sum.text
|
||||
except:
|
||||
try:
|
||||
resp_fallback = ai.model_writer.generate_content(f"Summarize plot points:\n{txt}")
|
||||
utils.log_usage(folder, "writer-flash", resp_fallback.usage_metadata)
|
||||
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
|
||||
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
|
||||
|
||||
ms.append({'num': ch['chapter_number'], 'title': ch['title'], 'pov_character': ch.get('pov_character'), 'content': txt})
|
||||
|
||||
with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
|
||||
|
||||
# Update Tracking
|
||||
tracking = story.update_tracking(folder, ch['chapter_number'], txt, tracking)
|
||||
with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2)
|
||||
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
|
||||
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
|
||||
|
||||
duration = time.time() - ch_start
|
||||
session_chapters += 1
|
||||
session_time += duration
|
||||
avg_time = session_time / session_chapters
|
||||
eta = avg_time * (len(chapters) - (i + 1))
|
||||
utils.log("TIMING", f" -> Chapter {ch['chapter_number']} finished in {duration:.1f}s | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s")
|
||||
|
||||
utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s")
|
||||
|
||||
# Harvest
|
||||
t_step = time.time()
|
||||
bp = story.harvest_metadata(bp, folder, ms)
|
||||
with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# Create Assets
|
||||
marketing.create_marketing_assets(bp, folder, tracking)
|
||||
|
||||
# Update Persona
|
||||
story.update_persona_sample(bp, folder)
|
||||
|
||||
export.compile_files(bp, ms, folder)
|
||||
utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
|
||||
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
|
||||
|
||||
# Remove lock file on success
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
|
||||
# --- 6. ENTRY POINT ---
|
||||
def run_generation(target=None, specific_run_id=None):
|
||||
ai.init_models()
|
||||
|
||||
if not target: target = config.DEFAULT_BLUEPRINT
|
||||
data = utils.load_json(target)
|
||||
|
||||
if not data:
|
||||
utils.log("SYSTEM", f"Could not load {target}")
|
||||
return
|
||||
|
||||
# --- NEW BIBLE FORMAT SUPPORT ---
|
||||
if 'project_metadata' in data and 'books' in data:
|
||||
utils.log("SYSTEM", "Detected Bible Format. Starting Series Generation...")
|
||||
|
||||
# Determine Run Directory: projects/{Project}/runs/bible/run_X
|
||||
# target is likely .../projects/{Project}/bible.json
|
||||
project_dir = os.path.dirname(os.path.abspath(target))
|
||||
runs_base = os.path.join(project_dir, "runs", "bible")
|
||||
|
||||
run_dir = None
|
||||
resume_mode = False
|
||||
|
||||
if specific_run_id:
|
||||
# WEB/WORKER MODE: Non-interactive, specific ID
|
||||
run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
|
||||
if not os.path.exists(run_dir): os.makedirs(run_dir)
|
||||
resume_mode = True # Always try to resume if files exist in this specific run
|
||||
else:
|
||||
# CLI MODE: Interactive checks
|
||||
latest_run = utils.get_latest_run_folder(runs_base)
|
||||
if latest_run:
|
||||
has_lock = False
|
||||
for root, dirs, files in os.walk(latest_run):
|
||||
if ".in_progress" in files:
|
||||
has_lock = True
|
||||
break
|
||||
|
||||
if has_lock:
|
||||
if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True):
|
||||
run_dir = latest_run
|
||||
resume_mode = True
|
||||
elif Confirm.ask(f"Delete artifacts in '{os.path.basename(latest_run)}' and start over?", default=False):
|
||||
shutil.rmtree(latest_run)
|
||||
os.makedirs(latest_run)
|
||||
run_dir = latest_run
|
||||
|
||||
if not run_dir: run_dir = utils.get_run_folder(runs_base)
|
||||
utils.log("SYSTEM", f"Run Directory: {run_dir}")
|
||||
|
||||
previous_context = ""
|
||||
|
||||
for i, book in enumerate(data['books']):
|
||||
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}")
|
||||
|
||||
# Adapter: Bible -> Blueprint
|
||||
meta = data['project_metadata']
|
||||
bp = {
|
||||
"book_metadata": {
|
||||
"title": book.get('title'),
|
||||
"filename": book.get('filename'),
|
||||
"author": meta.get('author'),
|
||||
"genre": meta.get('genre'),
|
||||
"target_audience": meta.get('target_audience'),
|
||||
"style": meta.get('style', {}),
|
||||
"author_details": meta.get('author_details', {}),
|
||||
"author_bio": meta.get('author_bio', ''),
|
||||
},
|
||||
"length_settings": meta.get('length_settings', {}),
|
||||
"characters": data.get('characters', []),
|
||||
"manual_instruction": book.get('manual_instruction', ''),
|
||||
"plot_beats": book.get('plot_beats', []),
|
||||
"series_metadata": {
|
||||
"is_series": meta.get('is_series', False),
|
||||
"series_title": meta.get('title', ''),
|
||||
"book_number": book.get('book_number', i+1),
|
||||
"total_books": len(data['books'])
|
||||
}
|
||||
}
|
||||
|
||||
# Create Book Subfolder
|
||||
safe_title = "".join([c for c in book.get('title', f"Book_{i+1}") if c.isalnum() or c=='_']).replace(" ", "_")
|
||||
book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
|
||||
if not os.path.exists(book_folder): os.makedirs(book_folder)
|
||||
|
||||
# Process
|
||||
process_book(bp, book_folder, context=previous_context, resume=resume_mode)
|
||||
|
||||
# Update Context for next book
|
||||
final_bp_path = os.path.join(book_folder, "final_blueprint.json")
|
||||
if os.path.exists(final_bp_path):
|
||||
final_bp = utils.load_json(final_bp_path)
|
||||
|
||||
# --- Update World Bible with new characters ---
|
||||
# This ensures future books know about characters invented in this book
|
||||
new_chars = final_bp.get('characters', [])
|
||||
|
||||
# RELOAD BIBLE to avoid race conditions (User might have edited it in UI)
|
||||
if os.path.exists(target):
|
||||
current_bible = utils.load_json(target)
|
||||
|
||||
# 1. Merge New Characters
|
||||
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
|
||||
for char in new_chars:
|
||||
if char['name'].lower() not in existing_names:
|
||||
current_bible['characters'].append(char)
|
||||
|
||||
# 2. Sync Generated Book Metadata (Title, Beats) back to Bible
|
||||
for b in current_bible.get('books', []):
|
||||
if b.get('book_number') == book.get('book_number'):
|
||||
b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
|
||||
b['plot_beats'] = final_bp.get('plot_beats', b.get('plot_beats'))
|
||||
b['manual_instruction'] = final_bp.get('manual_instruction', b.get('manual_instruction'))
|
||||
break
|
||||
|
||||
with open(target, 'w') as f: json.dump(current_bible, f, indent=2)
|
||||
utils.log("SERIES", "Updated World Bible with new characters and plot data.")
|
||||
|
||||
last_beat = final_bp.get('plot_beats', [])[-1] if final_bp.get('plot_beats') else "End of book."
|
||||
previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}"
|
||||
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
target_arg = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
run_generation(target_arg)
|
||||
19
make_admin.py
Normal file
19
make_admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import sys
|
||||
from modules.web_app import app
|
||||
from modules.web_db import db, User
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python make_admin.py <username>")
|
||||
sys.exit(1)
|
||||
|
||||
username = sys.argv[1]
|
||||
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
user.is_admin = True
|
||||
db.session.commit()
|
||||
print(f"✅ Success: User '{username}' has been promoted to Admin.")
|
||||
else:
|
||||
print(f"❌ Error: User '{username}' not found. Please register via the Web UI first.")
|
||||
215
modules/ai.py
Normal file
215
modules/ai.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import warnings
|
||||
import google.generativeai as genai
|
||||
import config
|
||||
from . import utils
|
||||
|
||||
# Suppress Vertex AI warnings
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="vertexai")
|
||||
|
||||
try:
|
||||
import vertexai
|
||||
from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel
|
||||
HAS_VERTEX = True
|
||||
except ImportError:
|
||||
HAS_VERTEX = False
|
||||
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
HAS_OAUTH = True
|
||||
except ImportError:
|
||||
HAS_OAUTH = False
|
||||
|
||||
model_logic = None
|
||||
model_writer = None
|
||||
model_artist = None
|
||||
model_image = None
|
||||
|
||||
def get_optimal_model(base_type="pro"):
|
||||
try:
|
||||
models = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
|
||||
candidates = [m.name for m in models if base_type in m.name]
|
||||
if not candidates: return f"models/gemini-1.5-{base_type}"
|
||||
def score(n):
|
||||
# Prioritize stable models (higher quotas) over experimental/beta ones
|
||||
if "exp" in n or "beta" in n: return 0
|
||||
if "latest" in n: return 50
|
||||
return 100
|
||||
return sorted(candidates, key=score, reverse=True)[0]
|
||||
except: return f"models/gemini-1.5-{base_type}"
|
||||
|
||||
def get_default_models():
|
||||
return {
|
||||
"logic": {"model": "models/gemini-1.5-pro", "reason": "Fallback: Default Pro model selected."},
|
||||
"writer": {"model": "models/gemini-1.5-flash", "reason": "Fallback: Default Flash model selected."},
|
||||
"artist": {"model": "models/gemini-1.5-flash", "reason": "Fallback: Default Flash model selected."},
|
||||
"ranking": []
|
||||
}
|
||||
|
||||
def select_best_models(force_refresh=False):
|
||||
"""
|
||||
Uses a safe bootstrapper model to analyze available models and pick the best ones.
|
||||
Caches the result for 24 hours.
|
||||
"""
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
cached_models = None
|
||||
|
||||
# 1. Check Cache
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f:
|
||||
cached = json.load(f)
|
||||
cached_models = cached.get('models', {})
|
||||
# Check if within 24 hours (86400 seconds)
|
||||
if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400:
|
||||
models = cached_models
|
||||
# Validate format (must be dicts with reasons, not just strings)
|
||||
if isinstance(models.get('logic'), dict) and 'reason' in models['logic']:
|
||||
utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).")
|
||||
return models
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.")
|
||||
|
||||
try:
|
||||
utils.log("SYSTEM", "Refreshing AI model list from API...")
|
||||
models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
|
||||
|
||||
bootstrapper = "models/gemini-1.5-flash"
|
||||
if bootstrapper not in models:
|
||||
candidates = [m for m in models if 'flash' in m]
|
||||
bootstrapper = candidates[0] if candidates else "models/gemini-pro"
|
||||
utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}")
|
||||
|
||||
model = genai.GenerativeModel(bootstrapper)
|
||||
prompt = f"Analyze this list of available Google Gemini models:\n{json.dumps(models)}\n\nSelect the best model for each of these three roles based on these criteria:\n- Most recent version with best features and ability.\n- Beta versions are okay, but avoid 'experimental' if a stable beta/prod version exists.\n- Consider quota efficiency (Flash is cheaper/faster, Pro is smarter).\n\nROLES:\n1. LOGIC: For complex reasoning, JSON structuring, and plot planning.\n2. WRITER: For creative fiction writing, prose generation, and speed.\n3. ARTIST: For generating visual art prompts and design instructions.\n\nAlso provide a 'ranking' list of ALL models analyzed, ordered from best/most useful to worst/least useful, with a short reason.\n\nReturn JSON: {{ 'logic': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'writer': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'artist': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'ranking': [ {{ 'model': 'model_name', 'reason': 'reasoning' }} ] }}"
|
||||
|
||||
response = model.generate_content(prompt)
|
||||
selection = json.loads(utils.clean_json(response.text))
|
||||
|
||||
if not os.path.exists(config.DATA_DIR): os.makedirs(config.DATA_DIR)
|
||||
with open(cache_path, 'w') as f:
|
||||
json.dump({"timestamp": int(time.time()), "models": selection, "available_at_time": models}, f, indent=2)
|
||||
return selection
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"AI Model Selection failed: {e}.")
|
||||
|
||||
# 3. Fallback to Stale Cache if available (Better than heuristics)
|
||||
# Relaxed check: If we successfully loaded ANY JSON from the cache, use it.
|
||||
if cached_models:
|
||||
utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.")
|
||||
return cached_models
|
||||
|
||||
utils.log("SYSTEM", "Falling back to heuristics.")
|
||||
fallback = get_default_models()
|
||||
|
||||
# Save fallback to cache if file doesn't exist OR if we couldn't load it (corrupt/None)
|
||||
# This ensures we have a valid file on disk for the web UI to read.
|
||||
try:
|
||||
with open(cache_path, 'w') as f:
|
||||
json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2)
|
||||
except: pass
|
||||
return fallback
|
||||
|
||||
def init_models(force=False):
|
||||
global model_logic, model_writer, model_artist, model_image
|
||||
if model_logic and not force: return
|
||||
genai.configure(api_key=config.API_KEY)
|
||||
|
||||
# Check cache to skip frequent validation
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
skip_validation = False
|
||||
if not force and os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f: cached = json.load(f)
|
||||
if time.time() - cached.get('timestamp', 0) < 86400: skip_validation = True
|
||||
except: pass
|
||||
|
||||
if not skip_validation:
|
||||
# Validate Gemini API Key
|
||||
utils.log("SYSTEM", "Validating credentials...")
|
||||
try:
|
||||
list(genai.list_models(page_size=1))
|
||||
utils.log("SYSTEM", "✅ Gemini API Key is valid.")
|
||||
except Exception as e:
|
||||
# Check if we have a cache file we can rely on before exiting
|
||||
if os.path.exists(cache_path):
|
||||
utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.")
|
||||
else:
|
||||
utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.")
|
||||
|
||||
utils.log("SYSTEM", "Selecting optimal models via AI...")
|
||||
selected_models = select_best_models(force_refresh=force)
|
||||
|
||||
def get_model_name(role_data):
|
||||
if isinstance(role_data, dict): return role_data.get('model')
|
||||
return role_data
|
||||
|
||||
logic_name = get_model_name(selected_models['logic']) if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
||||
writer_name = get_model_name(selected_models['writer']) if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
|
||||
artist_name = get_model_name(selected_models['artist']) if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT
|
||||
utils.log("SYSTEM", f"Models: Logic={logic_name} | Writer={writer_name} | Artist={artist_name}")
|
||||
|
||||
model_logic = genai.GenerativeModel(logic_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
model_writer = genai.GenerativeModel(writer_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
model_artist = genai.GenerativeModel(artist_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
|
||||
# Initialize Image Model (Default to None)
|
||||
model_image = None
|
||||
if hasattr(genai, 'ImageGenerationModel'):
|
||||
try: model_image = genai.ImageGenerationModel("imagen-3.0-generate-001")
|
||||
except: pass
|
||||
|
||||
img_source = "Gemini API" if model_image else "None"
|
||||
|
||||
if HAS_VERTEX and config.GCP_PROJECT:
|
||||
creds = None
|
||||
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account
|
||||
if HAS_OAUTH:
|
||||
gac = config.GOOGLE_CREDS # Use persistent config, not volatile env var
|
||||
if gac and os.path.exists(gac):
|
||||
try:
|
||||
with open(gac, 'r') as f: data = json.load(f)
|
||||
if 'installed' in data or 'web' in data:
|
||||
# It's an OAuth Client ID. Unset env var to avoid library crash.
|
||||
if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
|
||||
del os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
|
||||
|
||||
token_path = os.path.join(os.path.dirname(os.path.abspath(gac)), 'token.json')
|
||||
SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||
|
||||
if os.path.exists(token_path):
|
||||
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception:
|
||||
utils.log("SYSTEM", "Token refresh failed. Re-authenticating...")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
else:
|
||||
utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
with open(token_path, 'w') as token: token.write(creds.to_json())
|
||||
|
||||
utils.log("SYSTEM", "✅ Authenticated via OAuth Client ID.")
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}")
|
||||
|
||||
vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds)
|
||||
utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})")
|
||||
|
||||
# Override with Vertex Image Model if available
|
||||
try:
|
||||
model_image = VertexImageModel.from_pretrained("imagen-3.0-generate-001")
|
||||
img_source = "Vertex AI"
|
||||
except: pass
|
||||
|
||||
utils.log("SYSTEM", f"Image Generation Provider: {img_source}")
|
||||
74
modules/export.py
Normal file
74
modules/export.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
import markdown
|
||||
from docx import Document
|
||||
from ebooklib import epub
|
||||
from . import utils
|
||||
|
||||
def create_readme(folder, bp):
|
||||
meta = bp['book_metadata']
|
||||
ls = bp['length_settings']
|
||||
content = f"""# {meta['title']}\n**Generated by BookApp**\n\n## Stats Used\n- **Type:** {ls.get('label', 'Custom')}\n- **Planned Chapters:** {ls['chapters']}\n- **Logic Depth:** {ls['depth']}\n- **Target Words:** {ls.get('words', 'Unknown')}"""
|
||||
with open(os.path.join(folder, "README.md"), "w") as f: f.write(content)
|
||||
|
||||
def compile_files(bp, ms, folder):
|
||||
utils.log("SYSTEM", "Compiling EPUB and DOCX...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
title = meta.get('title', 'Untitled')
|
||||
|
||||
if meta.get('filename'):
|
||||
safe = meta['filename']
|
||||
else:
|
||||
safe = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_")
|
||||
|
||||
doc = Document(); doc.add_heading(title, 0)
|
||||
book = epub.EpubBook(); book.set_title(title); spine = ['nav']
|
||||
|
||||
# Add Cover if exists
|
||||
cover_path = os.path.join(folder, "cover.png")
|
||||
if os.path.exists(cover_path):
|
||||
with open(cover_path, 'rb') as f:
|
||||
book.set_cover("cover.png", f.read())
|
||||
|
||||
for c in ms:
|
||||
# Determine filename/type
|
||||
num_str = str(c['num']).lower()
|
||||
if num_str == '0' or 'prologue' in num_str:
|
||||
filename = "prologue.xhtml"
|
||||
default_header = f"Prologue: {c['title']}"
|
||||
elif 'epilogue' in num_str:
|
||||
filename = "epilogue.xhtml"
|
||||
default_header = f"Epilogue: {c['title']}"
|
||||
else:
|
||||
filename = f"ch_{c['num']}.xhtml"
|
||||
default_header = f"Ch {c['num']}: {c['title']}"
|
||||
|
||||
# Check for AI-generated header in content
|
||||
content = c['content'].strip()
|
||||
clean_content = content.replace("```markdown", "").replace("```", "").strip()
|
||||
lines = clean_content.split('\n')
|
||||
|
||||
ai_header = None
|
||||
body_content = clean_content
|
||||
|
||||
if lines and lines[0].strip().startswith('# '):
|
||||
ai_header = lines[0].strip().replace('#', '').strip()
|
||||
header = ai_header
|
||||
body_content = "\n".join(lines[1:]).strip()
|
||||
else:
|
||||
header = default_header
|
||||
|
||||
doc.add_heading(header, 1)
|
||||
doc.add_paragraph(body_content)
|
||||
|
||||
ch = epub.EpubHtml(title=header, file_name=filename)
|
||||
|
||||
clean_content = clean_content.replace(f"{folder}\\", "").replace(f"{folder}/", "")
|
||||
html_content = markdown.markdown(clean_content)
|
||||
ch.content = html_content if ai_header else f"<h1>{header}</h1>{html_content}"
|
||||
|
||||
book.add_item(ch); spine.append(ch)
|
||||
|
||||
doc.save(os.path.join(folder, f"{safe}.docx"))
|
||||
book.spine = spine; book.add_item(epub.EpubNcx()); book.add_item(epub.EpubNav())
|
||||
epub.write_epub(os.path.join(folder, f"{safe}.epub"), book, {})
|
||||
create_readme(folder, bp)
|
||||
350
modules/marketing.py
Normal file
350
modules/marketing.py
Normal file
@@ -0,0 +1,350 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import textwrap
|
||||
import requests
|
||||
import google.generativeai as genai
|
||||
from . import utils
|
||||
import config
|
||||
from modules import ai
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageStat
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
def download_font(font_name):
|
||||
"""Attempts to download a Google Font from GitHub."""
|
||||
if not font_name: font_name = "Roboto"
|
||||
if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR)
|
||||
|
||||
# Handle CSS-style lists (e.g. "Roboto, sans-serif")
|
||||
if "," in font_name: font_name = font_name.split(",")[0].strip()
|
||||
|
||||
# Handle filenames provided by AI
|
||||
if font_name.lower().endswith(('.ttf', '.otf')):
|
||||
font_name = os.path.splitext(font_name)[0]
|
||||
|
||||
font_name = font_name.strip().strip("'").strip('"')
|
||||
for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]:
|
||||
if font_name.endswith(suffix):
|
||||
font_name = font_name[:-len(suffix)]
|
||||
font_name = font_name.strip()
|
||||
|
||||
clean_name = font_name.replace(" ", "").lower()
|
||||
font_filename = f"{clean_name}.ttf"
|
||||
font_path = os.path.join(config.FONTS_DIR, font_filename)
|
||||
|
||||
if os.path.exists(font_path) and os.path.getsize(font_path) > 1000:
|
||||
utils.log("ASSETS", f"Using cached font: {font_path}")
|
||||
return font_path
|
||||
|
||||
utils.log("ASSETS", f"Downloading font: {font_name}...")
|
||||
compact_name = font_name.replace(" ", "")
|
||||
title_compact = "".join(x.title() for x in font_name.split())
|
||||
|
||||
patterns = [
|
||||
f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf",
|
||||
f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf",
|
||||
f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf",
|
||||
f"{title_compact}-Regular.otf",
|
||||
]
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"}
|
||||
for license_type in ["ofl", "apache", "ufl"]:
|
||||
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
|
||||
for pattern in patterns:
|
||||
try:
|
||||
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5)
|
||||
if r.status_code == 200 and len(r.content) > 1000:
|
||||
with open(font_path, 'wb') as f: f.write(r.content)
|
||||
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
|
||||
return font_path
|
||||
except Exception: continue
|
||||
|
||||
if clean_name != "roboto":
|
||||
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.")
|
||||
return download_font("Roboto")
|
||||
return None
|
||||
|
||||
def evaluate_image_quality(image_path, prompt, model, folder=None):
|
||||
if not HAS_PIL: return None, "PIL not installed"
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
response = model.generate_content([f"Analyze this generated image against the description: '{prompt}'.\nRate accuracy/relevance on a scale of 1-10.\nProvide a 1-sentence critique.\nReturn JSON: {{'score': int, 'reason': 'string'}}", img])
|
||||
if folder: utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
return data.get('score'), data.get('reason')
|
||||
except Exception as e: return None, str(e)
|
||||
|
||||
def generate_blurb(bp, folder):
|
||||
utils.log("MARKETING", "Generating blurb...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
prompt = f"""
|
||||
Write a compelling back-cover blurb (approx 150-200 words) for this book.
|
||||
TITLE: {meta.get('title')}
|
||||
GENRE: {meta.get('genre')}
|
||||
LOGLINE: {bp.get('manual_instruction')}
|
||||
PLOT: {json.dumps(bp.get('plot_beats', []))}
|
||||
CHARACTERS: {json.dumps(bp.get('characters', []))}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, "writer-flash", response.usage_metadata)
|
||||
blurb = response.text
|
||||
with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb)
|
||||
with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb)
|
||||
except:
|
||||
utils.log("MARKETING", "Failed to generate blurb.")
|
||||
|
||||
def generate_cover(bp, folder, tracking=None, feedback=None):
|
||||
if not HAS_PIL:
|
||||
utils.log("MARKETING", "Pillow not installed. Skipping image cover.")
|
||||
return
|
||||
|
||||
utils.log("MARKETING", "Generating cover...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
series = bp.get('series_metadata', {})
|
||||
|
||||
orientation = meta.get('style', {}).get('page_orientation', 'Portrait')
|
||||
ar = "3:4"
|
||||
if orientation == "Landscape": ar = "4:3"
|
||||
elif orientation == "Square": ar = "1:1"
|
||||
|
||||
visual_context = ""
|
||||
if tracking:
|
||||
visual_context = "IMPORTANT VISUAL CONTEXT:\n"
|
||||
if 'events' in tracking:
|
||||
visual_context += f"Key Events/Themes: {json.dumps(tracking['events'][-5:])}\n"
|
||||
if 'characters' in tracking:
|
||||
visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n"
|
||||
|
||||
# Feedback Analysis
|
||||
regenerate_image = True
|
||||
design_instruction = ""
|
||||
|
||||
if feedback and feedback.strip():
|
||||
utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...")
|
||||
analysis_prompt = f"""
|
||||
User Feedback on Book Cover: "{feedback}"
|
||||
Determine if the user wants to:
|
||||
1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT).
|
||||
2. Create a completely new background image (REGENERATE_IMAGE).
|
||||
|
||||
NOTE: If the feedback is generic (e.g. "regenerate", "try again") or does not explicitly mention keeping the image/changing text only, default to REGENERATE_IMAGE.
|
||||
Return JSON: {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for the Art Director" }}
|
||||
"""
|
||||
try:
|
||||
resp = ai.model_logic.generate_content(analysis_prompt)
|
||||
decision = json.loads(utils.clean_json(resp.text))
|
||||
if decision.get('action') == 'REGENERATE_LAYOUT':
|
||||
regenerate_image = False
|
||||
utils.log("MARKETING", "Feedback indicates keeping image. Regenerating layout only.")
|
||||
design_instruction = decision.get('instruction', feedback)
|
||||
except:
|
||||
utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.")
|
||||
|
||||
design_prompt = f"""
|
||||
Act as an Art Director. Design the cover for this book.
|
||||
TITLE: {meta.get('title')}
|
||||
GENRE: {meta.get('genre')}
|
||||
TONE: {meta.get('style', {}).get('tone')}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. CHARACTER APPEARANCE: Strictly adhere to the provided character descriptions (hair, eyes, race, age, clothing) in the Visual Context.
|
||||
2. GENRE EXPRESSIONS: Ensure character facial expressions and body language heavily reflect the GENRE (e.g. Horror = terrified/menacing, Romance = longing/soft, Thriller = intense/alert).
|
||||
|
||||
{visual_context}
|
||||
{f"USER FEEDBACK: {feedback}" if feedback else ""}
|
||||
{f"INSTRUCTION: {design_instruction}" if design_instruction else ""}
|
||||
|
||||
Provide JSON output:
|
||||
{{
|
||||
"font_name": "Name of a popular Google Font (e.g. Roboto, Cinzel, Oswald, Playfair Display)",
|
||||
"primary_color": "#HexCode (Background)",
|
||||
"text_color": "#HexCode (Contrast)",
|
||||
"art_prompt": "A detailed description of the cover art for an image generator. Explicitly describe characters based on the visual context."
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_artist.generate_content(design_prompt)
|
||||
utils.log_usage(folder, "artist-flash", response.usage_metadata)
|
||||
design = json.loads(utils.clean_json(response.text))
|
||||
|
||||
bg_color = design.get('primary_color', '#252570')
|
||||
text_color = design.get('text_color', '#FFFFFF')
|
||||
|
||||
art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}")
|
||||
with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
|
||||
f.write(art_prompt)
|
||||
|
||||
img = None
|
||||
image_generated = False
|
||||
width, height = 600, 900
|
||||
|
||||
best_img_score = 0
|
||||
best_img_path = None
|
||||
|
||||
if regenerate_image:
|
||||
for i in range(1, 6):
|
||||
utils.log("MARKETING", f"Generating cover art (Attempt {i}/5)...")
|
||||
try:
|
||||
if not ai.model_image: raise ImportError("No Image Generation Model available.")
|
||||
|
||||
status = "success"
|
||||
try:
|
||||
result = ai.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||
except Exception as e:
|
||||
if "resource" in str(e).lower() and ai.HAS_VERTEX:
|
||||
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 2...")
|
||||
fb_model = ai.VertexImageModel.from_pretrained("imagegeneration@006")
|
||||
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||
status = "success_fallback"
|
||||
else: raise e
|
||||
|
||||
attempt_path = os.path.join(folder, f"cover_art_attempt_{i}.png")
|
||||
result.images[0].save(attempt_path)
|
||||
utils.log_usage(folder, "imagen", image_count=1)
|
||||
|
||||
score, critique = evaluate_image_quality(attempt_path, art_prompt, ai.model_logic, folder)
|
||||
if score is None: score = 0
|
||||
|
||||
utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}")
|
||||
utils.log_image_attempt(folder, "cover", art_prompt, f"cover_art_{i}.png", status, score=score, critique=critique)
|
||||
|
||||
if score > best_img_score:
|
||||
best_img_score = score
|
||||
best_img_path = attempt_path
|
||||
|
||||
if score == 10:
|
||||
utils.log("MARKETING", " -> Perfect image accepted.")
|
||||
break
|
||||
|
||||
if "scar" in critique.lower() or "deform" in critique.lower() or "blur" in critique.lower():
|
||||
art_prompt += " (Ensure high quality, clear skin, no scars, sharp focus)."
|
||||
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Image generation failed: {e}")
|
||||
if "quota" in str(e).lower(): break
|
||||
|
||||
if best_img_path and os.path.exists(best_img_path):
|
||||
final_art_path = os.path.join(folder, "cover_art.png")
|
||||
if best_img_path != final_art_path:
|
||||
shutil.copy(best_img_path, final_art_path)
|
||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||
image_generated = True
|
||||
else:
|
||||
utils.log("MARKETING", "Falling back to solid color cover.")
|
||||
img = Image.new('RGB', (width, height), color=bg_color)
|
||||
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
|
||||
else:
|
||||
# Load existing art
|
||||
final_art_path = os.path.join(folder, "cover_art.png")
|
||||
if os.path.exists(final_art_path):
|
||||
utils.log("MARKETING", "Using existing cover art (Layout update only).")
|
||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||
else:
|
||||
utils.log("MARKETING", "Existing art not found. Forcing regeneration.")
|
||||
# Fallback to solid color if we were supposed to reuse but couldn't find it
|
||||
img = Image.new('RGB', (width, height), color=bg_color)
|
||||
|
||||
font_path = download_font(design.get('font_name') or 'Arial')
|
||||
|
||||
best_layout_score = 0
|
||||
best_layout_path = None
|
||||
|
||||
base_layout_prompt = f"""
|
||||
Act as a Senior Book Cover Designer. Analyze this 600x900 cover art.
|
||||
BOOK DETAILS: Title: {meta.get('title')}, Author: {meta.get('author')}, Genre: {meta.get('genre')}
|
||||
TASK: Determine best (x, y) coordinates for Title and Author. Do NOT place text over faces.
|
||||
RETURN JSON: {{ "title": {{ "x": int, "y": int, "font_size": int, "font_name": "String", "color": "#Hex" }}, "author": {{ "x": int, "y": int, "font_size": int, "font_name": "String", "color": "#Hex" }} }}
|
||||
"""
|
||||
|
||||
if feedback:
|
||||
base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly."
|
||||
|
||||
layout_prompt = base_layout_prompt
|
||||
|
||||
for attempt in range(1, 6):
|
||||
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...")
|
||||
try:
|
||||
response = ai.model_logic.generate_content([layout_prompt, img])
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
layout = json.loads(utils.clean_json(response.text))
|
||||
if isinstance(layout, list): layout = layout[0] if layout else {}
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Layout generation failed: {e}")
|
||||
continue
|
||||
|
||||
img_copy = img.copy()
|
||||
draw = ImageDraw.Draw(img_copy)
|
||||
|
||||
def draw_element(key, text_override=None):
|
||||
elem = layout.get(key)
|
||||
if not elem: return
|
||||
if isinstance(elem, list): elem = elem[0] if elem else {}
|
||||
text = text_override if text_override else elem.get('text')
|
||||
if not text: return
|
||||
|
||||
f_name = elem.get('font_name') or 'Arial'
|
||||
f_path = download_font(f_name)
|
||||
try:
|
||||
if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40))
|
||||
else: raise IOError("Font not found")
|
||||
except: font = ImageFont.load_default()
|
||||
|
||||
x, y = elem.get('x', 300), elem.get('y', 450)
|
||||
color = elem.get('color') or '#FFFFFF'
|
||||
|
||||
avg_char_w = font.getlength("A")
|
||||
wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20
|
||||
lines = textwrap.wrap(text, width=wrap_w)
|
||||
|
||||
line_heights = []
|
||||
for l in lines:
|
||||
bbox = draw.textbbox((0, 0), l, font=font)
|
||||
line_heights.append(bbox[3] - bbox[1] + 10)
|
||||
|
||||
total_h = sum(line_heights)
|
||||
current_y = y - (total_h // 2)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
lx = x - ((bbox[2] - bbox[0]) / 2)
|
||||
draw.text((lx, current_y), line, font=font, fill=color)
|
||||
current_y += line_heights[i]
|
||||
|
||||
draw_element('title', meta.get('title'))
|
||||
draw_element('author', meta.get('author'))
|
||||
|
||||
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
|
||||
img_copy.save(attempt_path)
|
||||
|
||||
# Evaluate Layout
|
||||
eval_prompt = f"Analyze this book cover layout. Is the text legible? Is the contrast good? Does it look professional? Title: {meta.get('title')}"
|
||||
score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_logic, folder)
|
||||
if score is None: score = 0
|
||||
|
||||
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
|
||||
|
||||
if score > best_layout_score:
|
||||
best_layout_score = score
|
||||
best_layout_path = attempt_path
|
||||
|
||||
if score == 10:
|
||||
utils.log("MARKETING", " -> Perfect layout accepted.")
|
||||
break
|
||||
|
||||
layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this."
|
||||
|
||||
if best_layout_path:
|
||||
shutil.copy(best_layout_path, os.path.join(folder, "cover.png"))
|
||||
|
||||
except Exception as e:
|
||||
utils.log("MARKETING", f"Cover generation failed: {e}")
|
||||
|
||||
def create_marketing_assets(bp, folder, tracking=None):
|
||||
generate_blurb(bp, folder)
|
||||
generate_cover(bp, folder, tracking)
|
||||
15
modules/requirements_web.txt
Normal file
15
modules/requirements_web.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
flask
|
||||
flask-login
|
||||
flask-sqlalchemy
|
||||
huey
|
||||
werkzeug
|
||||
google-generativeai
|
||||
python-dotenv
|
||||
rich
|
||||
markdown
|
||||
python-docx
|
||||
EbookLib
|
||||
requests
|
||||
Pillow
|
||||
google-cloud-aiplatform
|
||||
google-auth-oauthlib
|
||||
626
modules/story.py
Normal file
626
modules/story.py
Normal file
@@ -0,0 +1,626 @@
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import config
|
||||
from modules import ai
|
||||
from . import utils
|
||||
|
||||
def enrich(bp, folder, context=""):
|
||||
utils.log("ENRICHER", "Fleshing out details from description...")
|
||||
|
||||
# If book_metadata is missing, create empty dict so AI can fill it
|
||||
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
||||
if 'characters' not in bp: bp['characters'] = []
|
||||
if 'plot_beats' not in bp: bp['plot_beats'] = []
|
||||
|
||||
prompt = f"""
|
||||
You are a Creative Director.
|
||||
The user has provided a minimal description. You must build a full Book Bible.
|
||||
|
||||
USER DESCRIPTION: "{bp.get('manual_instruction', 'A generic story')}"
|
||||
CONTEXT (Sequel): {context}
|
||||
|
||||
TASK:
|
||||
1. Generate a catchy Title.
|
||||
2. Define the Genre and Tone.
|
||||
3. Determine the Time Period (e.g. "Modern", "1920s", "Sci-Fi Future").
|
||||
4. Define Formatting Rules for text messages, thoughts, and chapter headers.
|
||||
5. Create Protagonist and Antagonist/Love Interest.
|
||||
- IF SEQUEL: Decide if we continue with previous protagonists or shift to side characters based on USER DESCRIPTION.
|
||||
- IF NEW CHARACTERS: Create them.
|
||||
- IF RETURNING: Reuse details from CONTEXT.
|
||||
6. Outline 5-7 core Plot Beats.
|
||||
7. Define a 'structure_prompt' describing the narrative arc (e.g. "Hero's Journey", "3-Act Structure", "Detective Procedural").
|
||||
|
||||
RETURN JSON in this EXACT format:
|
||||
{{
|
||||
"book_metadata": {{ "title": "Book Title", "genre": "Genre", "content_warnings": ["Violence", "Major Character Death"], "structure_prompt": "...", "style": {{ "tone": "Tone", "time_period": "Modern", "formatting_rules": ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"] }} }},
|
||||
"characters": [ {{ "name": "Name", "role": "Role", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
|
||||
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
# Merge AI response with existing data (don't overwrite if user provided specific keys)
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
response_text = response.text
|
||||
cleaned_json = utils.clean_json(response_text)
|
||||
ai_data = json.loads(cleaned_json)
|
||||
|
||||
# Smart Merge: Only fill missing fields
|
||||
if 'book_metadata' not in bp:
|
||||
bp['book_metadata'] = {}
|
||||
|
||||
if 'title' not in bp['book_metadata']:
|
||||
bp['book_metadata']['title'] = ai_data.get('book_metadata', {}).get('title')
|
||||
if 'structure_prompt' not in bp['book_metadata']:
|
||||
bp['book_metadata']['structure_prompt'] = ai_data.get('book_metadata', {}).get('structure_prompt')
|
||||
if 'content_warnings' not in bp['book_metadata']:
|
||||
bp['book_metadata']['content_warnings'] = ai_data.get('book_metadata', {}).get('content_warnings', [])
|
||||
|
||||
# Merge Style (Flexible)
|
||||
if 'style' not in bp['book_metadata']:
|
||||
bp['book_metadata']['style'] = {}
|
||||
|
||||
# Handle AI returning legacy keys or new style key
|
||||
source_style = ai_data.get('book_metadata', {}).get('style', {})
|
||||
|
||||
for k, v in source_style.items():
|
||||
if k not in bp['book_metadata']['style']:
|
||||
bp['book_metadata']['style'][k] = v
|
||||
|
||||
if 'characters' not in bp or not bp['characters']:
|
||||
bp['characters'] = ai_data.get('characters', [])
|
||||
if 'plot_beats' not in bp or not bp['plot_beats']:
|
||||
bp['plot_beats'] = ai_data.get('plot_beats', [])
|
||||
|
||||
return bp
|
||||
except Exception as e:
|
||||
utils.log("ENRICHER", f"Enrichment failed: {e}")
|
||||
return bp
|
||||
|
||||
def plan_structure(bp, folder):
|
||||
utils.log("ARCHITECT", "Creating structure...")
|
||||
|
||||
if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict):
|
||||
po = bp['plot_outline']
|
||||
if 'beats' in po and isinstance(po['beats'], list):
|
||||
events = []
|
||||
for act in po['beats']:
|
||||
if 'plot_points' in act and isinstance(act['plot_points'], list):
|
||||
for pp in act['plot_points']:
|
||||
desc = pp.get('description')
|
||||
point = pp.get('point', 'Event')
|
||||
if desc: events.append({"description": desc, "purpose": point})
|
||||
if events:
|
||||
utils.log("ARCHITECT", f"Using {len(events)} events from Plot Outline as base structure.")
|
||||
return events
|
||||
|
||||
structure_type = bp.get('book_metadata', {}).get('structure_prompt')
|
||||
|
||||
if not structure_type:
|
||||
label = bp.get('length_settings', {}).get('label', 'Novel')
|
||||
structures = {
|
||||
"Chapter Book": "Create a simple episodic structure with clear chapter hooks.",
|
||||
"Young Adult": "Create a character-driven arc with high emotional stakes and a clear 'Coming of Age' theme.",
|
||||
"Flash Fiction": "Create a single, impactful scene structure with a twist.",
|
||||
"Short Story": "Create a concise narrative arc (Inciting Incident -> Rising Action -> Climax -> Resolution).",
|
||||
"Novella": "Create a standard 3-Act Structure.",
|
||||
"Novel": "Create a detailed 3-Act Structure with A and B plots.",
|
||||
"Epic": "Create a complex, multi-arc structure (Hero's Journey) with extensive world-building events."
|
||||
}
|
||||
structure_type = structures.get(label, "Create a 3-Act Structure.")
|
||||
|
||||
beats_context = []
|
||||
if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict):
|
||||
po = bp['plot_outline']
|
||||
if 'beats' in po:
|
||||
for act in po['beats']:
|
||||
beats_context.append(f"ACT {act.get('act', '?')}: {act.get('title', '')} - {act.get('summary', '')}")
|
||||
for pp in act.get('plot_points', []):
|
||||
beats_context.append(f" * {pp.get('point', 'Beat')}: {pp.get('description', '')}")
|
||||
|
||||
if not beats_context:
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
|
||||
prompt = f"{structure_type}\nTITLE: {bp['book_metadata']['title']}\nBEATS: {json.dumps(beats_context)}\nReturn JSON: {{'events': [{{'description':'...', 'purpose':'...'}}]}}"
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))['events']
|
||||
except:
|
||||
return []
|
||||
|
||||
def expand(events, pass_num, target_chapters, bp, folder):
|
||||
utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}")
|
||||
|
||||
beats_context = []
|
||||
if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict):
|
||||
po = bp['plot_outline']
|
||||
if 'beats' in po:
|
||||
for act in po['beats']:
|
||||
beats_context.append(f"ACT {act.get('act', '?')}: {act.get('title', '')} - {act.get('summary', '')}")
|
||||
for pp in act.get('plot_points', []):
|
||||
beats_context.append(f" * {pp.get('point', 'Beat')}: {pp.get('description', '')}")
|
||||
|
||||
if not beats_context:
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
|
||||
prompt = f"""
|
||||
You are a Story Architect.
|
||||
Goal: Flesh out this outline for a {target_chapters}-chapter book.
|
||||
Current Status: {len(events)} beats.
|
||||
|
||||
ORIGINAL OUTLINE:
|
||||
{json.dumps(beats_context)}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Look for jumps in time or logic.
|
||||
2. Insert new intermediate events to smooth the pacing.
|
||||
3. Deepen subplots while staying true to the ORIGINAL OUTLINE.
|
||||
4. Do NOT remove or drastically alter the original outline points; expand AROUND them.
|
||||
|
||||
CURRENT EVENTS:
|
||||
{json.dumps(events)}
|
||||
|
||||
Return JSON: {{'events': [ ...updated full list... ]}}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_events = json.loads(utils.clean_json(response.text))['events']
|
||||
|
||||
if len(new_events) > len(events):
|
||||
utils.log("ARCHITECT", f" -> Added {len(new_events) - len(events)} new beats.")
|
||||
elif len(str(new_events)) > len(str(events)) + 20:
|
||||
utils.log("ARCHITECT", f" -> Fleshed out descriptions (Text grew by {len(str(new_events)) - len(str(events))} chars).")
|
||||
else:
|
||||
utils.log("ARCHITECT", " -> No significant changes.")
|
||||
return new_events
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f" -> Pass skipped due to error: {e}")
|
||||
return events
|
||||
|
||||
def create_chapter_plan(events, bp, folder):
|
||||
utils.log("ARCHITECT", "Finalizing Chapters...")
|
||||
target = bp['length_settings']['chapters']
|
||||
words = bp['length_settings'].get('words', 'Flexible')
|
||||
|
||||
include_prologue = bp.get('length_settings', {}).get('include_prologue', False)
|
||||
include_epilogue = bp.get('length_settings', {}).get('include_epilogue', False)
|
||||
|
||||
structure_instructions = ""
|
||||
if include_prologue: structure_instructions += "- Include a 'Prologue' (chapter_number: 0) to set the scene.\n"
|
||||
if include_epilogue: structure_instructions += "- Include an 'Epilogue' (chapter_number: 'Epilogue') to wrap up.\n"
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
pov_chars = style.get('pov_characters', [])
|
||||
pov_instruction = ""
|
||||
if pov_chars:
|
||||
pov_instruction = f"- Assign a 'pov_character' for each chapter from this list: {json.dumps(pov_chars)}."
|
||||
|
||||
prompt = f"""
|
||||
Group events into Chapters.
|
||||
TARGET CHAPTERS: {target} (Approximate. Feel free to adjust +/- 20% for better pacing).
|
||||
TARGET WORDS: {words} (Total for the book).
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Vary chapter pacing. Options: 'Very Fast', 'Fast', 'Standard', 'Slow', 'Very Slow'.
|
||||
- Assign an estimated word count to each chapter based on its pacing and content.
|
||||
{structure_instructions}
|
||||
{pov_instruction}
|
||||
|
||||
EVENTS: {json.dumps(events)}
|
||||
Return JSON: [{{'chapter_number':1, 'title':'...', 'pov_character': 'Name', 'pacing': 'Standard', 'estimated_words': 2000, 'beats':[...]}}]
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
plan = json.loads(utils.clean_json(response.text))
|
||||
|
||||
target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '')
|
||||
target_val = 0
|
||||
if '-' in target_str:
|
||||
try:
|
||||
parts = target_str.split('-')
|
||||
target_val = int((int(parts[0]) + int(parts[1])) / 2)
|
||||
except: pass
|
||||
else:
|
||||
try: target_val = int(target_str)
|
||||
except: pass
|
||||
|
||||
if target_val > 0:
|
||||
variance = random.uniform(0.90, 1.10)
|
||||
target_val = int(target_val * variance)
|
||||
utils.log("ARCHITECT", f"Target adjusted with variance ({variance:.2f}x): {target_val} words.")
|
||||
|
||||
current_sum = sum(int(c.get('estimated_words', 0)) for c in plan)
|
||||
if current_sum > 0:
|
||||
factor = target_val / current_sum
|
||||
utils.log("ARCHITECT", f"Adjusting chapter lengths by {factor:.2f}x to match target.")
|
||||
for c in plan:
|
||||
c['estimated_words'] = int(c.get('estimated_words', 0) * factor)
|
||||
|
||||
return plan
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Failed to create chapter plan: {e}")
|
||||
return []
|
||||
|
||||
def update_tracking(folder, chapter_num, chapter_text, current_tracking):
|
||||
utils.log("TRACKER", f"Updating world state & character visuals for Ch {chapter_num}...")
|
||||
|
||||
prompt = f"""
|
||||
Analyze this chapter text to update the Story Bible.
|
||||
|
||||
CURRENT TRACKING DATA:
|
||||
{json.dumps(current_tracking)}
|
||||
|
||||
NEW CHAPTER TEXT:
|
||||
{chapter_text[:500000]}
|
||||
|
||||
TASK:
|
||||
1. EVENTS: Append 1-3 concise bullet points summarizing key plot events in this chapter to the 'events' list.
|
||||
2. CHARACTERS: Update entries for any characters appearing in the scene.
|
||||
- "descriptors": List of strings. Add PERMANENT physical traits (height, hair, eyes), specific items (jewelry, weapons). Avoid duplicates.
|
||||
- "likes_dislikes": List of strings. Add specific preferences, likes, or dislikes mentioned (e.g., "Hates coffee", "Loves jazz").
|
||||
- "last_worn": String. Update if specific clothing is described. IMPORTANT: If a significant time jump occurred (e.g. next day) and no new clothing is described, reset this to "Unknown".
|
||||
- "major_events": List of strings. Log significant life-altering events occurring in THIS chapter (e.g. "Lost an arm", "Married", "Betrayed by X").
|
||||
3. CONTENT_WARNINGS: List of strings. Identify specific triggers present in this chapter (e.g. "Graphic Violence", "Sexual Assault", "Torture", "Self-Harm"). Append to existing list.
|
||||
|
||||
RETURN JSON with the SAME structure as CURRENT TRACKING DATA (events list, characters dict, content_warnings list).
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("TRACKER", f"Failed to update tracking: {e}")
|
||||
return current_tracking
|
||||
|
||||
def evaluate_chapter_quality(text, chapter_title, model, folder):
|
||||
prompt = f"""
|
||||
Analyze this book chapter text.
|
||||
CHAPTER TITLE: {chapter_title}
|
||||
|
||||
CRITERIA:
|
||||
1. ORGANIC FEEL: Does it sound like a human wrote it? Are "AI-isms" (e.g. 'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement') absent?
|
||||
2. ENGAGEMENT: Is it interesting? Does it hook the reader?
|
||||
3. REPETITION: Is sentence structure varied? Are words repeated unnecessarily?
|
||||
4. PROGRESSION: Does the story move forward, or is it spinning its wheels?
|
||||
|
||||
Rate on a scale of 1-10.
|
||||
Provide a concise critique focusing on the biggest flaw.
|
||||
|
||||
Return JSON: {{'score': int, 'critique': 'string'}}
|
||||
"""
|
||||
try:
|
||||
response = model.generate_content([prompt, text[:30000]])
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
return data.get('score', 0), data.get('critique', 'No critique provided.')
|
||||
except Exception as e:
|
||||
return 0, f"Evaluation error: {str(e)}"
|
||||
|
||||
def create_initial_persona(bp, folder):
|
||||
utils.log("SYSTEM", "Generating initial Author Persona based on genre/tone...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
prompt = f"""
|
||||
Create a fictional 'Author Persona' best suited to write this book.
|
||||
|
||||
BOOK DETAILS:
|
||||
Title: {meta.get('title')}
|
||||
Genre: {meta.get('genre')}
|
||||
Tone: {style.get('tone')}
|
||||
Target Audience: {meta.get('target_audience')}
|
||||
|
||||
TASK:
|
||||
Create a profile for the ideal writer of this book.
|
||||
Return JSON: {{ "name": "Pen Name", "bio": "Description of writing style (voice, sentence structure, vocabulary)...", "age": "...", "gender": "..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Persona generation failed: {e}")
|
||||
return {"name": "AI Author", "bio": "Standard, balanced writing style."}
|
||||
|
||||
def refine_persona(bp, text, folder):
|
||||
utils.log("SYSTEM", "Refining Author Persona based on recent chapters...")
|
||||
ad = bp.get('book_metadata', {}).get('author_details', {})
|
||||
current_bio = ad.get('bio', 'Standard style.')
|
||||
|
||||
prompt = f"""
|
||||
Analyze this text sample from the book.
|
||||
|
||||
TEXT:
|
||||
{text[:3000]}
|
||||
|
||||
CURRENT AUTHOR BIO:
|
||||
{current_bio}
|
||||
|
||||
TASK:
|
||||
Refine the Author Bio to better match the actual text produced.
|
||||
Highlight specific stylistic quirks, sentence patterns, or vocabulary choices found in the text.
|
||||
The goal is to ensure future chapters sound exactly like this one.
|
||||
|
||||
Return JSON: {{ "bio": "Updated bio..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_bio = json.loads(utils.clean_json(response.text)).get('bio')
|
||||
if new_bio:
|
||||
ad['bio'] = new_bio
|
||||
utils.log("SYSTEM", " -> Persona bio updated.")
|
||||
return ad
|
||||
except: pass
|
||||
return ad
|
||||
|
||||
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None):
|
||||
pacing = chap.get('pacing', 'Standard')
|
||||
est_words = chap.get('estimated_words', 'Flexible')
|
||||
utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}")
|
||||
ls = bp['length_settings']
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
pov_char = chap.get('pov_character', '')
|
||||
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
persona_info = meta['author_bio']
|
||||
else:
|
||||
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('age'): persona_info += f"Age: {ad['age']}\n"
|
||||
if ad.get('gender'): persona_info += f"Gender: {ad['gender']}\n"
|
||||
if ad.get('race'): persona_info += f"Race: {ad['race']}\n"
|
||||
if ad.get('nationality'): persona_info += f"Nationality: {ad['nationality']}\n"
|
||||
if ad.get('language'): persona_info += f"Language: {ad['language']}\n"
|
||||
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
samples = []
|
||||
if ad.get('sample_text'):
|
||||
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
|
||||
|
||||
if ad.get('sample_files'):
|
||||
for fname in ad['sample_files']:
|
||||
fpath = os.path.join(config.PERSONAS_DIR, fname)
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read(3000)
|
||||
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
|
||||
except: pass
|
||||
|
||||
if samples:
|
||||
persona_info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
|
||||
|
||||
char_visuals = ""
|
||||
if tracking and 'characters' in tracking:
|
||||
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
|
||||
for name, data in tracking['characters'].items():
|
||||
desc = ", ".join(data.get('descriptors', []))
|
||||
likes = ", ".join(data.get('likes_dislikes', []))
|
||||
worn = data.get('last_worn', 'Unknown')
|
||||
char_visuals += f"- {name}: {desc}\n * Likes/Dislikes: {likes}\n"
|
||||
|
||||
major = data.get('major_events', [])
|
||||
if major: char_visuals += f" * Major Events: {'; '.join(major)}\n"
|
||||
|
||||
if worn and worn != 'Unknown':
|
||||
char_visuals += f" * Last Worn: {worn} (NOTE: Only relevant if scene is continuous from previous chapter)\n"
|
||||
|
||||
style_block = "\n".join([f"- {k.replace('_', ' ').title()}: {v}" for k, v in style.items() if isinstance(v, (str, int, float))])
|
||||
if 'tropes' in style and isinstance(style['tropes'], list):
|
||||
style_block += f"\n- Tropes: {', '.join(style['tropes'])}"
|
||||
|
||||
if 'formatting_rules' in style and isinstance(style['formatting_rules'], list):
|
||||
style_block += "\n- Formatting Rules:\n * " + "\n * ".join(style['formatting_rules'])
|
||||
|
||||
prev_context_block = ""
|
||||
if prev_content:
|
||||
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (For Tone & Continuity):\n{prev_content}\n"
|
||||
|
||||
prompt = f"""
|
||||
Write Chapter {chap['chapter_number']}: {chap['title']}
|
||||
|
||||
PACING GUIDE:
|
||||
- Format: {ls.get('label', 'Story')}
|
||||
- Chapter Pacing: {pacing}
|
||||
- Target Word Count: ~{est_words} (Use this as a guide, but prioritize story flow. Allow flexibility.)
|
||||
- POV Character: {pov_char if pov_char else 'Protagonist'}
|
||||
|
||||
STYLE & FORMATTING:
|
||||
{style_block}
|
||||
|
||||
AUTHOR VOICE (CRITICAL):
|
||||
{persona_info}
|
||||
|
||||
INSTRUCTION:
|
||||
Write the scene.
|
||||
- Start with the Chapter Header formatted as Markdown H1 (e.g. '# Chapter X: Title'). Follow the 'Formatting Rules' for the header style.
|
||||
|
||||
- DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state.
|
||||
- SHOW, DON'T TELL: Focus on immediate action and internal reaction. Don't summarize feelings; show the physical manifestation of them.
|
||||
- SENSORY DETAILS: Use specific, grounding sensory details (smell, touch, sound) rather than generic descriptions.
|
||||
- AVOID CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to').
|
||||
- MAINTAIN CONTINUITY: Pay close attention to the PREVIOUS CONTEXT. Characters must NOT know things that haven't happened yet or haven't been revealed to them.
|
||||
- CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers.
|
||||
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
|
||||
- 'Very Fast': Rapid fire, pure action/dialogue, minimal description.
|
||||
- 'Fast': Punchy, keep it moving.
|
||||
- 'Standard': Balanced dialogue and description.
|
||||
- 'Slow': Detailed, atmospheric, immersive.
|
||||
- 'Very Slow': Deep introspection, heavy sensory detail, slow burn.
|
||||
|
||||
PREVIOUS CONTEXT (Story So Far): {prev_sum}
|
||||
{prev_context_block}
|
||||
CHARACTERS: {json.dumps(bp['characters'])}
|
||||
{char_visuals}
|
||||
SCENE BEATS: {json.dumps(chap['beats'])}
|
||||
|
||||
Output Markdown.
|
||||
"""
|
||||
current_text = ""
|
||||
try:
|
||||
resp_draft = ai.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, "writer-flash", resp_draft.usage_metadata)
|
||||
current_text = resp_draft.text
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||
|
||||
# Refinement Loop
|
||||
max_attempts = 3
|
||||
best_score = 0
|
||||
best_text = current_text
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...")
|
||||
score, critique = evaluate_chapter_quality(current_text, chap['title'], ai.model_logic, folder)
|
||||
|
||||
if "Evaluation error" in critique:
|
||||
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
|
||||
if best_score == 0: best_text = current_text
|
||||
break
|
||||
|
||||
utils.log("WRITER", f" Score: {score}/10. Critique: {critique}")
|
||||
|
||||
if score >= 8:
|
||||
utils.log("WRITER", " Quality threshold met.")
|
||||
return current_text
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_text = current_text
|
||||
|
||||
if attempt == max_attempts:
|
||||
utils.log("WRITER", " Max attempts reached. Using best version.")
|
||||
return best_text
|
||||
|
||||
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
|
||||
refine_prompt = f"""
|
||||
Act as a Senior Editor. Rewrite this chapter to fix the issues identified below.
|
||||
|
||||
CRITIQUE TO ADDRESS:
|
||||
{critique}
|
||||
|
||||
ADDITIONAL OBJECTIVES:
|
||||
1. NATURAL FLOW: Fix stilted phrasing. Ensure the prose flows naturally for the genre ({meta.get('genre', 'Fiction')}) and tone ({style.get('tone', 'Standard')}).
|
||||
2. HUMANIZATION: Remove robotic phrasing. Ensure dialogue has subtext, interruptions, and distinct voices. Remove "AI-isms" (e.g. 'testament to', 'tapestry of', 'symphony of').
|
||||
3. SENTENCE VARIETY: Check for and fix repetitive sentence starts or uniform sentence lengths. The prose should have a dynamic rhythm.
|
||||
4. CONTINUITY: Ensure consistency with the Story So Far.
|
||||
|
||||
STORY SO FAR:
|
||||
{prev_sum}
|
||||
{prev_context_block}
|
||||
|
||||
CURRENT DRAFT:
|
||||
{current_text}
|
||||
|
||||
Return the polished, final version of the chapter in Markdown.
|
||||
"""
|
||||
try:
|
||||
resp_refine = ai.model_writer.generate_content(refine_prompt)
|
||||
utils.log_usage(folder, "writer-flash", resp_refine.usage_metadata)
|
||||
current_text = resp_refine.text
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"Refinement failed: {e}")
|
||||
return best_text
|
||||
|
||||
return best_text
|
||||
|
||||
def harvest_metadata(bp, folder, full_manuscript):
|
||||
utils.log("HARVESTER", "Scanning for new characters...")
|
||||
full_text = "\n".join([c['content'] for c in full_manuscript])[:50000]
|
||||
prompt = f"Identify new significant characters NOT in:\n{json.dumps(bp['characters'])}\nTEXT:\n{full_text}\nReturn JSON: {{'new_characters': [{{'name':'...', 'role':'...', 'description':'...'}}]}}"
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
|
||||
if new_chars:
|
||||
utils.log("HARVESTER", f"Found {len(new_chars)} new chars.")
|
||||
bp['characters'].extend(new_chars)
|
||||
except: pass
|
||||
return bp
|
||||
|
||||
def update_persona_sample(bp, folder):
|
||||
utils.log("SYSTEM", "Extracting author persona from manuscript...")
|
||||
|
||||
ms_path = os.path.join(folder, "manuscript.json")
|
||||
if not os.path.exists(ms_path): return
|
||||
ms = utils.load_json(ms_path)
|
||||
if not ms: return
|
||||
|
||||
# 1. Extract Text Sample
|
||||
full_text = "\n".join([c.get('content', '') for c in ms])
|
||||
if len(full_text) < 500: return
|
||||
|
||||
# 2. Save Sample File
|
||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
safe_title = "".join([c for c in meta.get('title', 'book') if c.isalnum() or c=='_']).replace(" ", "_")[:20]
|
||||
timestamp = int(time.time())
|
||||
filename = f"sample_{safe_title}_{timestamp}.txt"
|
||||
filepath = os.path.join(config.PERSONAS_DIR, filename)
|
||||
|
||||
sample_text = full_text[:3000]
|
||||
with open(filepath, 'w', encoding='utf-8') as f: f.write(sample_text)
|
||||
|
||||
# 3. Update or Create Persona
|
||||
author_name = meta.get('author', 'Unknown Author')
|
||||
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
if author_name not in personas:
|
||||
utils.log("SYSTEM", f"Generating new persona profile for '{author_name}'...")
|
||||
prompt = f"Analyze this writing style (Tone, Voice, Vocabulary). Write a 1-sentence author bio describing it.\nTEXT: {sample_text[:1000]}"
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
bio = response.text.strip()
|
||||
except: bio = "Style analysis unavailable."
|
||||
|
||||
personas[author_name] = {
|
||||
"name": author_name,
|
||||
"bio": bio,
|
||||
"sample_files": [filename],
|
||||
"sample_text": sample_text[:500]
|
||||
}
|
||||
else:
|
||||
utils.log("SYSTEM", f"Updating persona '{author_name}' with new sample.")
|
||||
if 'sample_files' not in personas[author_name]: personas[author_name]['sample_files'] = []
|
||||
if filename not in personas[author_name]['sample_files']:
|
||||
personas[author_name]['sample_files'].append(filename)
|
||||
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||
|
||||
def refine_bible(bible, instruction, folder):
|
||||
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
|
||||
prompt = f"""
|
||||
Act as a Book Editor.
|
||||
CURRENT JSON: {json.dumps(bible)}
|
||||
USER INSTRUCTION: {instruction}
|
||||
|
||||
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
|
||||
RETURN ONLY THE JSON.
|
||||
"""
|
||||
try:
|
||||
response = ai.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, "logic-pro", response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Refinement failed: {e}")
|
||||
return None
|
||||
202
modules/utils.py
Normal file
202
modules/utils.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import os
|
||||
import json
|
||||
import datetime
|
||||
import time
|
||||
import config
|
||||
import threading
|
||||
|
||||
SAFETY_SETTINGS = [
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
||||
]
|
||||
|
||||
# Thread-local storage for logging context
|
||||
_log_context = threading.local()
|
||||
|
||||
def set_log_file(filepath):
|
||||
_log_context.log_file = filepath
|
||||
|
||||
def set_log_callback(callback):
|
||||
_log_context.callback = callback
|
||||
|
||||
def clean_json(text):
|
||||
text = text.replace("```json", "").replace("```", "").strip()
|
||||
# Robust extraction: find first { or [ and last } or ]
|
||||
start_obj = text.find('{')
|
||||
start_arr = text.find('[')
|
||||
if start_obj == -1 and start_arr == -1: return text
|
||||
if start_obj != -1 and (start_arr == -1 or start_obj < start_arr):
|
||||
return text[start_obj:text.rfind('}')+1]
|
||||
else:
|
||||
return text[start_arr:text.rfind(']')+1]
|
||||
|
||||
# --- SHARED UTILS ---
|
||||
def log(phase, msg):
|
||||
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
|
||||
line = f"[{timestamp}] {phase:<15} | {msg}"
|
||||
print(line)
|
||||
|
||||
# Write to thread-specific log file if set
|
||||
if getattr(_log_context, 'log_file', None):
|
||||
with open(_log_context.log_file, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
# Trigger callback if set (e.g. for Database logging)
|
||||
if getattr(_log_context, 'callback', None):
|
||||
try: _log_context.callback(phase, msg)
|
||||
except: pass
|
||||
|
||||
def load_json(path):
|
||||
return json.load(open(path, 'r')) if os.path.exists(path) else None
|
||||
|
||||
def create_default_personas():
|
||||
# Initialize empty personas file if it doesn't exist
|
||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||
if not os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump({}, f, indent=2)
|
||||
except: pass
|
||||
|
||||
def get_length_presets():
|
||||
"""Returns a dict mapping Label -> Settings for use in main.py"""
|
||||
presets = {}
|
||||
for k, v in config.LENGTH_DEFINITIONS.items():
|
||||
presets[v['label']] = v
|
||||
return presets
|
||||
|
||||
def log_image_attempt(folder, img_type, prompt, filename, status, error=None, score=None, critique=None):
|
||||
log_path = os.path.join(folder, "image_log.json")
|
||||
entry = {
|
||||
"timestamp": int(time.time()),
|
||||
"type": img_type,
|
||||
"prompt": prompt,
|
||||
"filename": filename,
|
||||
"status": status,
|
||||
"error": str(error) if error else None,
|
||||
"score": score,
|
||||
"critique": critique
|
||||
}
|
||||
data = []
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
with open(log_path, 'r') as f: data = json.load(f)
|
||||
except:
|
||||
pass
|
||||
data.append(entry)
|
||||
with open(log_path, 'w') as f: json.dump(data, f, indent=2)
|
||||
|
||||
def get_run_folder(base_name):
|
||||
if not os.path.exists(base_name): os.makedirs(base_name)
|
||||
runs = [d for d in os.listdir(base_name) if d.startswith("run_")]
|
||||
next_num = max([int(r.split("_")[1]) for r in runs if r.split("_")[1].isdigit()] + [0]) + 1
|
||||
folder = os.path.join(base_name, f"run_{next_num}")
|
||||
os.makedirs(folder)
|
||||
return folder
|
||||
|
||||
def get_latest_run_folder(base_name):
|
||||
if not os.path.exists(base_name): return None
|
||||
runs = [d for d in os.listdir(base_name) if d.startswith("run_")]
|
||||
if not runs: return None
|
||||
runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||
return os.path.join(base_name, runs[-1])
|
||||
|
||||
def log_usage(folder, model_label, usage_metadata=None, image_count=0):
|
||||
if not folder or not os.path.exists(folder): return
|
||||
|
||||
log_path = os.path.join(folder, "usage_log.json")
|
||||
|
||||
entry = {
|
||||
"timestamp": int(time.time()),
|
||||
"model": model_label,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"images": image_count
|
||||
}
|
||||
|
||||
if usage_metadata:
|
||||
try:
|
||||
entry["input_tokens"] = usage_metadata.prompt_token_count
|
||||
entry["output_tokens"] = usage_metadata.candidates_token_count
|
||||
except: pass
|
||||
|
||||
data = {"log": [], "totals": {"input_tokens": 0, "output_tokens": 0, "images": 0, "est_cost_usd": 0.0}}
|
||||
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
loaded = json.load(open(log_path, 'r'))
|
||||
if isinstance(loaded, list): data["log"] = loaded
|
||||
else: data = loaded
|
||||
except: pass
|
||||
|
||||
data["log"].append(entry)
|
||||
|
||||
# Recalculate totals
|
||||
t_in = sum(x.get('input_tokens', 0) for x in data["log"])
|
||||
t_out = sum(x.get('output_tokens', 0) for x in data["log"])
|
||||
t_img = sum(x.get('images', 0) for x in data["log"])
|
||||
|
||||
cost = 0.0
|
||||
for x in data["log"]:
|
||||
m = x.get('model', '').lower()
|
||||
i = x.get('input_tokens', 0)
|
||||
o = x.get('output_tokens', 0)
|
||||
imgs = x.get('images', 0)
|
||||
|
||||
if 'flash' in m:
|
||||
cost += (i / 1_000_000 * 0.075) + (o / 1_000_000 * 0.30)
|
||||
elif 'pro' in m or 'logic' in m:
|
||||
cost += (i / 1_000_000 * 3.50) + (o / 1_000_000 * 10.50)
|
||||
elif 'imagen' in m or imgs > 0:
|
||||
cost += (imgs * 0.04)
|
||||
|
||||
data["totals"] = {
|
||||
"input_tokens": t_in,
|
||||
"output_tokens": t_out,
|
||||
"images": t_img,
|
||||
"est_cost_usd": round(cost, 4)
|
||||
}
|
||||
|
||||
with open(log_path, 'w') as f: json.dump(data, f, indent=2)
|
||||
|
||||
def normalize_settings(bp):
|
||||
"""
|
||||
CRITICAL: Enforces defaults.
|
||||
1. If series_metadata is missing, force it to SINGLE mode.
|
||||
2. If length_settings is missing, force explicit numbers.
|
||||
"""
|
||||
# Force Series Default (1 Book)
|
||||
if 'series_metadata' not in bp:
|
||||
bp['series_metadata'] = {
|
||||
"is_series": False,
|
||||
"mode": "single",
|
||||
"series_title": "Standalone",
|
||||
"total_books_to_generate": 1
|
||||
}
|
||||
|
||||
# Check for empty series count just in case
|
||||
if bp['series_metadata'].get('total_books_to_generate') is None:
|
||||
bp['series_metadata']['total_books_to_generate'] = 1
|
||||
|
||||
# Force Length Defaults
|
||||
settings = bp.get('length_settings', {})
|
||||
label = settings.get('label', 'Novella') # Default to Novella if nothing provided
|
||||
|
||||
# Get defaults based on label (or Novella if unknown)
|
||||
presets = get_length_presets()
|
||||
defaults = presets.get(label, presets['Novella'])
|
||||
|
||||
if 'chapters' not in settings: settings['chapters'] = defaults['chapters']
|
||||
if 'words' not in settings: settings['words'] = defaults['words']
|
||||
|
||||
# Smart Depth Calculation (if not manually set)
|
||||
if 'depth' not in settings:
|
||||
c = int(settings['chapters'])
|
||||
if c <= 5: settings['depth'] = 1
|
||||
elif c <= 20: settings['depth'] = 2
|
||||
elif c <= 40: settings['depth'] = 3
|
||||
else: settings['depth'] = 4
|
||||
|
||||
bp['length_settings'] = settings
|
||||
return bp
|
||||
1132
modules/web_app.py
Normal file
1132
modules/web_app.py
Normal file
File diff suppressed because it is too large
Load Diff
47
modules/web_db.py
Normal file
47
modules/web_db.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password = db.Column(db.String(150), nullable=False)
|
||||
api_key = db.Column(db.String(200), nullable=True) # Optional: User-specific Gemini Key
|
||||
total_spend = db.Column(db.Float, default=0.0)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
class Project(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
folder_path = db.Column(db.String(300), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
class Run(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||
status = db.Column(db.String(50), default="queued") # queued, running, completed, failed
|
||||
start_time = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
end_time = db.Column(db.DateTime, nullable=True)
|
||||
log_file = db.Column(db.String(300), nullable=True)
|
||||
cost = db.Column(db.Float, default=0.0)
|
||||
|
||||
# Relationships
|
||||
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
def duration(self):
|
||||
if self.end_time and self.start_time:
|
||||
return str(self.end_time - self.start_time).split('.')[0]
|
||||
return "Running..."
|
||||
|
||||
class LogEntry(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
phase = db.Column(db.String(50))
|
||||
message = db.Column(db.Text)
|
||||
218
modules/web_tasks.py
Normal file
218
modules/web_tasks.py
Normal file
@@ -0,0 +1,218 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from huey import SqliteHuey
|
||||
from .web_db import db, Run, User, Project
|
||||
from . import utils
|
||||
import main
|
||||
import config
|
||||
|
||||
# Configure Huey (Task Queue)
|
||||
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
|
||||
|
||||
def db_log_callback(db_path, run_id, phase, msg):
|
||||
"""Writes log entry directly to SQLite to avoid Flask Context issues in threads."""
|
||||
for _ in range(5):
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=5) as conn:
|
||||
conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)",
|
||||
(run_id, datetime.utcnow(), phase, str(msg)))
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
time.sleep(0.1)
|
||||
except: break
|
||||
|
||||
@huey.task()
|
||||
def generate_book_task(run_id, project_path, bible_path, allow_copy=True):
|
||||
"""
|
||||
Background task to run the book generation.
|
||||
"""
|
||||
# 1. Setup Logging
|
||||
log_filename = f"system_log_{run_id}.txt"
|
||||
log_path = os.path.join(project_path, "runs", "bible", f"run_{run_id}", log_filename)
|
||||
|
||||
# Log to project root initially until run folder is created by main
|
||||
initial_log = os.path.join(project_path, log_filename)
|
||||
utils.set_log_file(initial_log)
|
||||
|
||||
# Hook up Database Logging
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
|
||||
# Set Status to Running
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=10) as conn:
|
||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
|
||||
utils.log("SYSTEM", f"Starting Job #{run_id}")
|
||||
|
||||
try:
|
||||
# 1.5 Copy Forward Logic (Series Optimization)
|
||||
# Check for previous runs and copy completed books to skip re-generation
|
||||
runs_dir = os.path.join(project_path, "runs", "bible")
|
||||
if allow_copy and os.path.exists(runs_dir):
|
||||
# Get all run folders except current
|
||||
all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"]
|
||||
# Sort by ID (ascending)
|
||||
all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||
|
||||
if all_runs:
|
||||
latest_run_dir = os.path.join(runs_dir, all_runs[-1])
|
||||
current_run_dir = os.path.join(runs_dir, f"run_{run_id}")
|
||||
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
|
||||
|
||||
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
|
||||
for item in os.listdir(latest_run_dir):
|
||||
# Copy only folders that look like books and have a manuscript
|
||||
if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
|
||||
if os.path.exists(os.path.join(latest_run_dir, item, "manuscript.json")):
|
||||
src = os.path.join(latest_run_dir, item)
|
||||
dst = os.path.join(current_run_dir, item)
|
||||
try:
|
||||
shutil.copytree(src, dst)
|
||||
utils.log("SYSTEM", f" -> Copied {item} (Skipping generation).")
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
|
||||
|
||||
# 2. Run Generation
|
||||
# We call the existing entry point
|
||||
main.run_generation(bible_path, specific_run_id=run_id)
|
||||
|
||||
utils.log("SYSTEM", "Job Complete.")
|
||||
status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Job Failed: {e}")
|
||||
status = "failed"
|
||||
|
||||
# 3. Calculate Cost & Cleanup
|
||||
# Use the specific run folder we know main.py used
|
||||
run_dir = os.path.join(project_path, "runs", "bible", f"run_{run_id}")
|
||||
|
||||
total_cost = 0.0
|
||||
final_log_path = initial_log
|
||||
|
||||
if os.path.exists(run_dir):
|
||||
# Move our log file there
|
||||
final_log_path = os.path.join(run_dir, "web_console.log")
|
||||
if os.path.exists(initial_log):
|
||||
try:
|
||||
os.rename(initial_log, final_log_path)
|
||||
except OSError:
|
||||
# If rename fails (e.g. across filesystems), copy and delete
|
||||
shutil.copy2(initial_log, final_log_path)
|
||||
os.remove(initial_log)
|
||||
|
||||
# Calculate Total Cost from all Book subfolders
|
||||
# usage_log.json is inside each Book folder
|
||||
for item in os.listdir(run_dir):
|
||||
item_path = os.path.join(run_dir, item)
|
||||
if os.path.isdir(item_path) and item.startswith("Book_"):
|
||||
usage_path = os.path.join(item_path, "usage_log.json")
|
||||
if os.path.exists(usage_path):
|
||||
data = utils.load_json(usage_path)
|
||||
total_cost += data.get('totals', {}).get('est_cost_usd', 0.0)
|
||||
|
||||
# 4. Update Database with Final Status
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=10) as conn:
|
||||
conn.execute("UPDATE run SET status = ?, cost = ?, end_time = ?, log_file = ? WHERE id = ?",
|
||||
(status, total_cost, datetime.utcnow(), final_log_path, run_id))
|
||||
except Exception as e:
|
||||
print(f"Failed to update run status in DB: {e}")
|
||||
|
||||
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
|
||||
|
||||
@huey.task()
|
||||
def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
# Hook up Database Logging & Status
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
|
||||
# Truncate log file to ensure clean slate
|
||||
log_filename = f"system_log_{run_id}.txt"
|
||||
initial_log = os.path.join(project_path, log_filename)
|
||||
with open(initial_log, 'w', encoding='utf-8') as f: f.write("")
|
||||
utils.set_log_file(initial_log)
|
||||
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
|
||||
utils.log("SYSTEM", "Starting Artifact Regeneration...")
|
||||
|
||||
# 1. Setup Paths
|
||||
run_dir = os.path.join(project_path, "runs", "bible", f"run_{run_id}")
|
||||
|
||||
# Detect Book Subfolder
|
||||
book_dir = run_dir
|
||||
if os.path.exists(run_dir):
|
||||
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
|
||||
if subdirs: book_dir = os.path.join(run_dir, subdirs[0])
|
||||
|
||||
bible_path = os.path.join(project_path, "bible.json")
|
||||
|
||||
if not os.path.exists(run_dir) or not os.path.exists(bible_path):
|
||||
utils.log("ERROR", "Run directory or Bible not found.")
|
||||
return
|
||||
|
||||
# 2. Load Data
|
||||
bible = utils.load_json(bible_path)
|
||||
final_bp_path = os.path.join(book_dir, "final_blueprint.json")
|
||||
ms_path = os.path.join(book_dir, "manuscript.json")
|
||||
|
||||
if not os.path.exists(final_bp_path) or not os.path.exists(ms_path):
|
||||
utils.log("ERROR", f"Blueprint or Manuscript not found in {book_dir}")
|
||||
return
|
||||
|
||||
bp = utils.load_json(final_bp_path)
|
||||
ms = utils.load_json(ms_path)
|
||||
|
||||
# 3. Update Blueprint with new Metadata from Bible
|
||||
meta = bible.get('project_metadata', {})
|
||||
if 'book_metadata' in bp:
|
||||
# Sync all core metadata
|
||||
for k in ['author', 'genre', 'target_audience', 'style']:
|
||||
if k in meta:
|
||||
bp['book_metadata'][k] = meta[k]
|
||||
|
||||
if bp.get('series_metadata', {}).get('is_series'):
|
||||
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title'))
|
||||
# Find specific book title from Bible
|
||||
b_num = bp['series_metadata'].get('book_number')
|
||||
for b in bible.get('books', []):
|
||||
if b.get('book_number') == b_num:
|
||||
bp['book_metadata']['title'] = b.get('title', bp['book_metadata'].get('title'))
|
||||
break
|
||||
else:
|
||||
bp['book_metadata']['title'] = meta.get('title', bp['book_metadata'].get('title'))
|
||||
|
||||
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# 4. Regenerate
|
||||
try:
|
||||
main.ai.init_models()
|
||||
|
||||
tracking = None
|
||||
events_path = os.path.join(book_dir, "tracking_events.json")
|
||||
if os.path.exists(events_path):
|
||||
tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))}
|
||||
|
||||
main.marketing.generate_cover(bp, book_dir, tracking, feedback=feedback)
|
||||
main.export.compile_files(bp, ms, book_dir)
|
||||
|
||||
utils.log("SYSTEM", "Regeneration Complete.")
|
||||
final_status = 'completed'
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Regeneration Failed: {e}")
|
||||
final_status = 'failed'
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id))
|
||||
except: pass
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
google-generativeai
|
||||
google-cloud-aiplatform
|
||||
google-auth-oauthlib
|
||||
python-docx
|
||||
EbookLib
|
||||
python-dotenv
|
||||
rich
|
||||
Pillow
|
||||
requests
|
||||
markdown
|
||||
104
templates/admin_dashboard.html
Normal file
104
templates/admin_dashboard.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2><i class="fas fa-shield-alt me-2 text-warning"></i>Admin Dashboard</h2>
|
||||
<p class="text-muted">System management and user administration.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- User Management -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-users me-2"></i>Users ({{ users|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.id }}</td>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>
|
||||
{% if u.is_admin %}<span class="badge bg-warning text-dark">Admin</span>{% else %}<span class="badge bg-secondary">User</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.id != current_user.id %}
|
||||
<form action="/admin/user/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Delete user {{ u.username }} and ALL their projects? This cannot be undone.');">
|
||||
<a href="{{ url_for('impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
||||
<i class="fas fa-user-secret"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted small">Current</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Stats & Reset -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6 mb-3">
|
||||
<h3>{{ projects|length }}</h3>
|
||||
<span class="text-muted">Total Projects</span>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<h3>{{ users|length }}</h3>
|
||||
<span class="text-muted">Total Users</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-exclamation-triangle me-2"></i>Danger Zone</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Factory Reset:</strong> This will delete <strong>ALL</strong> projects, books, and users (except your admin account). It resets the system to a fresh state.</p>
|
||||
|
||||
<form action="/admin/reset" method="POST" onsubmit="return confirmReset()">
|
||||
<button type="submit" class="btn btn-danger w-100">
|
||||
<i class="fas fa-radiation me-2"></i>Perform Factory Reset
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmReset() {
|
||||
return confirm("⚠️ WARNING: This will wipe ALL data on the server except your account.\n\nAre you absolutely sure?");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
60
templates/admin_spend.html
Normal file
60
templates/admin_spend.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2><i class="fas fa-chart-line me-2 text-success"></i>Spend Report</h2>
|
||||
<p class="text-muted">Aggregate cost analysis per user.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Period Overview</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="?days=7" class="btn btn-outline-secondary {{ 'active' if days == 7 }}">7 Days</a>
|
||||
<a href="?days=30" class="btn btn-outline-secondary {{ 'active' if days == 30 }}">30 Days</a>
|
||||
<a href="?days=90" class="btn btn-outline-secondary {{ 'active' if days == 90 }}">90 Days</a>
|
||||
<a href="?days=0" class="btn btn-outline-secondary {{ 'active' if days == 0 }}">All Time</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-12">
|
||||
<h2 class="text-success fw-bold">${{ "%.4f"|format(total) }}</h2>
|
||||
<span class="text-muted">Total Spend ({{ 'Last ' ~ days ~ ' Days' if days > 0 else 'All Time' }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th class="text-center">Runs Generated</th>
|
||||
<th class="text-end">Total Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in report %}
|
||||
<tr>
|
||||
<td class="fw-bold">{{ row.username }}</td>
|
||||
<td class="text-center"><span class="badge bg-secondary">{{ row.runs }}</span></td>
|
||||
<td class="text-end font-monospace">${{ "%.4f"|format(row.cost) }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center py-4 text-muted">No spending data found for this period.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
86
templates/base.html
Normal file
86
templates/base.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BookApp AI</title>
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- FontAwesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
body { background-color: #f8f9fa; }
|
||||
.navbar-brand { font-weight: bold; letter-spacing: 1px; }
|
||||
.card { border: none; box-shadow: 0 4px 6px rgba(0,0,0,0.1); transition: transform 0.2s; }
|
||||
.card:hover { transform: translateY(-2px); }
|
||||
.btn-primary { background-color: #2c3e50; border-color: #2c3e50; }
|
||||
.btn-primary:hover { background-color: #1a252f; border-color: #1a252f; }
|
||||
.console-log {
|
||||
background-color: #1e1e1e; color: #00ff00;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
padding: 15px; border-radius: 5px;
|
||||
height: 400px; overflow-y: scroll;
|
||||
white-space: pre-wrap; font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if session.get('original_admin_id') %}
|
||||
<div class="bg-danger text-white text-center py-2 shadow-sm" style="position: sticky; top: 0; z-index: 1050;">
|
||||
<strong><i class="fas fa-user-secret me-2"></i>Viewing site as {{ current_user.username }}</strong>
|
||||
<a href="{{ url_for('stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/"><i class="fas fa-book-open me-2"></i>BookApp AI</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item me-3">
|
||||
<a class="nav-link text-warning" href="/admin"><i class="fas fa-shield-alt me-1"></i> Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item me-3">
|
||||
<a class="nav-link" href="/personas"><i class="fas fa-users me-1"></i> Personas</a>
|
||||
</li>
|
||||
<li class="nav-item me-3">
|
||||
<span class="text-light small">
|
||||
<i class="fas fa-coins text-warning"></i> Spend: ${{ "%.2f"|format(current_user.total_spend) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<span class="text-light me-3">Hello, {{ current_user.username }}</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-sm btn-outline-light" href="/logout">Logout</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
98
templates/dashboard.html
Normal file
98
templates/dashboard.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2><i class="fas fa-layer-group me-2"></i>Your Projects</h2>
|
||||
<p class="text-muted">Manage your book series and generation tasks.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="/personas" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-users me-2"></i>Personas
|
||||
</a>
|
||||
<a href="/system/status" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-server me-2"></i>System Status
|
||||
</a>
|
||||
<button class="btn btn-outline-info me-2" onclick="optimizeModels()">
|
||||
<i class="fas fa-sync me-2"></i>Find New Models
|
||||
</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newProjectModal">
|
||||
<i class="fas fa-plus me-2"></i>New Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for p in projects %}
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ p.name }}</h5>
|
||||
<p class="card-text text-muted small">Created: {{ p.created_at.strftime('%Y-%m-%d') }}</p>
|
||||
<a href="/project/{{ p.id }}" class="btn btn-outline-primary stretched-link">Open Project</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12 text-center py-5">
|
||||
<h4 class="text-muted">No projects yet. Start writing!</h4>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- New Project Modal -->
|
||||
<div class="modal fade" id="newProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/setup" method="POST" onsubmit="showLoading(this)">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">New Project Wizard</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted">Describe your story idea, and the AI will suggest a title, genre, and structure for you.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Story Concept / Idea</label>
|
||||
<textarea name="concept" class="form-control" rows="6" placeholder="e.g. A detective in 1920s London discovers magic is real..." required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" id="analyzeBtn">Analyze & Next <i class="fas fa-arrow-right ms-2"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showLoading(form) {
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>AI Analyzing...';
|
||||
}
|
||||
|
||||
function optimizeModels() {
|
||||
if (!confirm("This will check for rate limits and re-select the best available models. Continue?")) return;
|
||||
|
||||
const btn = event.target.closest('button');
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Optimizing...';
|
||||
|
||||
fetch('/system/optimize_models', { method: 'POST' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
window.location.reload(); // Reload to see flashed message
|
||||
} else {
|
||||
alert('Optimization request failed.');
|
||||
}
|
||||
}).catch(err => {
|
||||
alert('Network error during optimization.');
|
||||
}).finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
27
templates/login.html
Normal file
27
templates/login.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-4">
|
||||
<h3 class="text-center mb-4">Login</h3>
|
||||
<form method="POST" action="/login">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center mt-3">
|
||||
<small>Don't have an account? <a href="/register">Register here</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
121
templates/persona_edit.html
Normal file
121
templates/persona_edit.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('save_persona') }}" method="POST">
|
||||
<input type="hidden" name="old_name" value="{{ name }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name / Pseudonym</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ persona.get('name', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Age</label>
|
||||
<input type="text" name="age" class="form-control" value="{{ persona.get('age', '') }}">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Gender</label>
|
||||
<input type="text" name="gender" class="form-control" value="{{ persona.get('gender', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Race / Ethnicity</label>
|
||||
<input type="text" name="race" class="form-control" value="{{ persona.get('race', '') }}">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Nationality</label>
|
||||
<input type="text" name="nationality" class="form-control" value="{{ persona.get('nationality', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Primary Language</label>
|
||||
<input type="text" name="language" class="form-control" value="{{ persona.get('language', 'Standard English') }}">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Voice Keywords</label>
|
||||
<input type="text" name="voice_keywords" id="voiceKeywords" class="form-control" value="{{ persona.get('voice_keywords', '') }}" placeholder="e.g. Sarcastic, Fast-paced, Minimalist">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Style Inspirations</label>
|
||||
<input type="text" name="style_inspirations" id="styleInspirations" class="form-control" value="{{ persona.get('style_inspirations', '') }}" placeholder="e.g. Hemingway, Noir Fiction">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bio / Writing Style</label>
|
||||
<textarea name="bio" id="bioField" class="form-control" rows="4">{{ persona.get('bio', '') }}</textarea>
|
||||
<div class="form-text">Describe the voice, tone, and stylistic quirks.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label class="form-label mb-0">Sample Text (Manual)</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="analyzePersona()">
|
||||
<i class="fas fa-magic me-1"></i> Analyze & Auto-Fill
|
||||
</button>
|
||||
</div>
|
||||
<textarea name="sample_text" id="sampleText" class="form-control" rows="6">{{ persona.get('sample_text', '') }}</textarea>
|
||||
<div class="form-text">Paste a paragraph of text. The AI can analyze this to fill the fields above.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Persona</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function analyzePersona() {
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Analyzing...';
|
||||
|
||||
const data = {
|
||||
name: document.querySelector('input[name="name"]').value,
|
||||
age: document.querySelector('input[name="age"]').value,
|
||||
gender: document.querySelector('input[name="gender"]').value,
|
||||
nationality: document.querySelector('input[name="nationality"]').value,
|
||||
sample_text: document.getElementById('sampleText').value
|
||||
};
|
||||
|
||||
fetch('/persona/analyze', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(resp => {
|
||||
if (resp.error) {
|
||||
alert("Error: " + resp.error);
|
||||
} else {
|
||||
if (resp.bio) document.getElementById('bioField').value = resp.bio;
|
||||
if (resp.voice_keywords) document.getElementById('voiceKeywords').value = resp.voice_keywords;
|
||||
if (resp.style_inspirations) document.getElementById('styleInspirations').value = resp.style_inspirations;
|
||||
}
|
||||
})
|
||||
.catch(err => alert("Request failed."))
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
32
templates/personas.html
Normal file
32
templates/personas.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-users me-2"></i>Author Personas</h2>
|
||||
<a href="{{ url_for('new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for name, p in personas.items() %}
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ p.name }}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">{{ p.age }} {{ p.gender }} | {{ p.nationality }}</h6>
|
||||
<p class="card-text small">{{ p.bio[:150] }}...</p>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top-0 d-flex justify-content-between">
|
||||
<a href="{{ url_for('edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<form action="{{ url_for('delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">No personas found. Create one to define a writing voice.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
515
templates/project.html
Normal file
515
templates/project.html
Normal file
@@ -0,0 +1,515 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<h1 class="mb-0 me-3">{{ project.name }}</h1>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editProjectModal"><i class="fas fa-edit"></i></button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="badge bg-secondary">{{ bible.project_metadata.genre }}</span>
|
||||
<span class="badge bg-info text-dark">{{ bible.project_metadata.target_audience }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<form action="/project/{{ project.id }}/run" method="POST" class="d-inline">
|
||||
<button class="btn btn-success shadow px-4 py-2" {% if active_run and active_run.status in ['running', 'queued'] %}disabled{% endif %}>
|
||||
<i class="fas fa-play me-2"></i>{{ 'Generating...' if runs and runs[0].status in ['running', 'queued'] else 'Generate New Book' }}
|
||||
</button>
|
||||
</form>
|
||||
{% if runs and runs[0].status in ['running', 'queued'] %}
|
||||
<form action="/run/{{ runs[0].id }}/stop" method="POST" class="d-inline ms-2">
|
||||
<button class="btn btn-danger shadow px-3 py-2" title="Stop/Cancel Run" onclick="return confirm('Are you sure you want to stop this job? If the server restarted, this will simply unlock the UI.')">
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- LATEST RUN CARD -->
|
||||
<div class="card mb-4 border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 px-4">
|
||||
<h4 class="card-title"><i class="fas fa-bolt text-warning me-2"></i>Active Run (ID: {{ active_run.id if active_run else '-' }})</h4>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
{% if active_run %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<span class="badge bg-{{ 'success' if active_run.status == 'completed' else 'warning' if active_run.status in ['running', 'queued'] else 'danger' if active_run.status in ['failed', 'cancelled', 'interrupted'] else 'secondary' }} fs-6 me-3 status-badge-{{ active_run.id }}">
|
||||
{{ active_run.status|upper }}
|
||||
</span>
|
||||
<span class="text-muted small">Run #{{ active_run.id }} started at {{ active_run.start_time.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
<span class="ms-auto text-muted fw-bold">Cost: $<span class="cost-{{ active_run.id }}">{{ "%.4f"|format(active_run.cost) }}</span></span>
|
||||
</div>
|
||||
|
||||
<!-- DOWNLOADS -->
|
||||
{% if artifacts or cover_image %}
|
||||
<div class="row mt-3">
|
||||
{% if cover_image %}
|
||||
<div class="col-md-4 text-center mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<img src="/project/{{ active_run.id }}/download?file={{ cover_image }}&t={{ active_run.end_time.timestamp() if active_run.end_time else 0 }}" class="card-img-top" alt="Book Cover">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-{{ '8' if cover_image else '12' }}">
|
||||
<div class="alert alert-{{ 'success' if active_run.status == 'completed' else 'warning' }} h-100">
|
||||
<h5 class="alert-heading"><i class="fas fa-{{ 'check-circle' if active_run.status == 'completed' else 'folder-open' }} me-2"></i>{{ 'Book Generated!' if active_run.status == 'completed' else 'Files Available' }}</h5>
|
||||
<p>{{ 'Your manuscript and ebook files are ready.' if active_run.status == 'completed' else 'Files generated during this run (may be partial).' }}</p>
|
||||
<hr>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for file in artifacts %}
|
||||
<a href="/project/{{ active_run.id }}/download?file={{ file.path }}" class="btn btn-success">
|
||||
<i class="fas fa-download me-1"></i> Download {{ file.type }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<button class="btn btn-outline-dark" data-bs-toggle="modal" data-bs-target="#regenerateModal">
|
||||
<i class="fas fa-paint-brush me-1"></i> Regenerate Cover / Files
|
||||
</button>
|
||||
</div>
|
||||
{% if blurb_content %}
|
||||
<div class="card mt-3 border-0 bg-light">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title fw-bold text-muted">Back Cover Blurb</h6>
|
||||
<p class="card-text fst-italic">{{ blurb_content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- STATUS BAR -->
|
||||
{% if active_run and active_run.status in ['running', 'queued'] %}
|
||||
<div class="card bg-light border-0 mb-3" id="statusBar">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="spinner-border text-primary spinner-border-sm me-2" role="status"></div>
|
||||
<strong class="text-primary" id="statusPhase">Initializing...</strong>
|
||||
</div>
|
||||
<h5 class="card-title mb-3" id="statusMessage">Preparing environment...</h5>
|
||||
<div class="progress" style="height: 10px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: 100%"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block" id="statusTime"></small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- CONSOLE -->
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-link text-decoration-none p-0 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#consoleCollapse">
|
||||
<i class="fas fa-terminal me-1"></i> {{ 'Hide' if active_run.status in ['running', 'queued'] else 'Show' }} Console Logs
|
||||
</button>
|
||||
<div class="collapse {{ 'show' if active_run.status in ['running', 'queued'] else '' }}" id="consoleCollapse">
|
||||
<div id="consoleOutput" class="console-log bg-dark text-success p-3 rounded" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">Loading logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p>No books generated yet.</p>
|
||||
<p>Click the <strong>Generate New Book</strong> button to start writing.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RUN HISTORY -->
|
||||
<div class="card mb-4 border-0 shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-history me-2"></i>Run History</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Cost</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in runs %}
|
||||
<tr class="{{ 'table-primary' if active_run and r.id == active_run.id else '' }}">
|
||||
<td>#{{ r.id }}</td>
|
||||
<td>{{ r.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if r.status == 'completed' else 'warning' if r.status in ['running', 'queued'] else 'danger' if r.status in ['failed', 'cancelled', 'interrupted'] else 'secondary' }}">
|
||||
{{ r.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>${{ "%.4f"|format(r.cost) }}</td>
|
||||
<td>
|
||||
<a href="/project/{{ project.id }}/run/{{ r.id }}" class="btn btn-sm btn-outline-primary">
|
||||
{{ 'View Active' if active_run and r.id == active_run.id else 'View' }}
|
||||
</a>
|
||||
{% if r.status in ['failed', 'cancelled', 'interrupted'] %}
|
||||
<form action="/run/{{ r.id }}/restart" method="POST" class="d-inline ms-1">
|
||||
<input type="hidden" name="mode" value="resume">
|
||||
<button class="btn btn-sm btn-outline-success" title="Resume from last step">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if r.status not in ['running', 'queued'] %}
|
||||
<form action="/run/{{ r.id }}/restart" method="POST" class="d-inline ms-1" onsubmit="return confirm('This will delete all files for this run and start over. Are you sure?');">
|
||||
<input type="hidden" name="mode" value="restart">
|
||||
<button class="btn btn-sm btn-outline-danger" title="Re-run (Wipe & Restart)">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted">No runs found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERIES OVERVIEW -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-{{ 'layer-group' if bible.project_metadata.is_series else 'book' }} me-2"></i>
|
||||
{{ 'Series Overview' if bible.project_metadata.is_series else 'Book Overview' }}
|
||||
</h4>
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addBookModal">
|
||||
<i class="fas fa-plus me-1"></i> {{ 'Add Book' if bible.project_metadata.is_series else 'Extend to Series' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row flex-nowrap overflow-auto pb-3" style="gap: 1rem;">
|
||||
{% for book in bible.books %}
|
||||
<div class="col-md-4 col-lg-3" style="min-width: 300px;">
|
||||
<div class="card h-100 shadow-sm border-top border-4 {{ 'border-success' if generated_books.get(book.book_number) else 'border-secondary' }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="badge bg-light text-dark border">Book {{ book.book_number }}</span>
|
||||
{% if generated_books.get(book.book_number) %}
|
||||
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Generated</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Planned</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5 class="card-title text-truncate" title="{{ book.title }}">{{ book.title }}</h5>
|
||||
<p class="card-text small text-muted" style="height: 60px; overflow: hidden;">
|
||||
{{ book.manual_instruction or "No summary provided." }}
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editBookModal{{ book.book_number }}" title="Edit Details">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</button>
|
||||
{% if not generated_books.get(book.book_number) %}
|
||||
<form action="/project/{{ project.id }}/delete_book/{{ book.book_number }}" method="POST" onsubmit="return confirm('Remove this book from the plan?');">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Book Modal (Specific to this book) -->
|
||||
<div class="modal fade" id="editBookModal{{ book.book_number }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/book/{{ book.book_number }}/update" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Book {{ book.book_number }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" name="title" class="form-control" value="{{ book.title }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Plot Summary / Instruction</label>
|
||||
<textarea name="instruction" class="form-control" rows="6">{{ book.manual_instruction }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Add Book Card -->
|
||||
<div class="col-md-4 col-lg-3" style="min-width: 200px;">
|
||||
<div class="card h-100 border-dashed d-flex align-items-center justify-content-center bg-light" style="border: 2px dashed #ccc; cursor: pointer;" data-bs-toggle="modal" data-bs-target="#addBookModal">
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fas fa-plus-circle fa-2x mb-2"></i>
|
||||
<br>Add Next Book
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WORLD BIBLE & LINKED SERIES -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-globe me-2"></i>World Bible & Characters</h5>
|
||||
<div>
|
||||
<a href="/project/{{ project.id }}/review" class="btn btn-sm btn-outline-info me-1">
|
||||
<i class="fas fa-list-alt me-1"></i> Full Review
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#importCharModal">
|
||||
<i class="fas fa-link me-1"></i> Link / Import Series
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary ms-1" data-bs-toggle="modal" data-bs-target="#refineBibleModal">
|
||||
<i class="fas fa-magic me-1"></i> Refine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 border-end">
|
||||
<h6 class="text-muted text-uppercase small fw-bold mb-3">Project Metadata</h6>
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-4">Author</dt><dd class="col-8">{{ bible.project_metadata.author }}</dd>
|
||||
<dt class="col-4">Genre</dt><dd class="col-8">{{ bible.project_metadata.genre }}</dd>
|
||||
<dt class="col-4">Tone</dt><dd class="col-8">{{ bible.project_metadata.style.tone }}</dd>
|
||||
<dt class="col-4">Series</dt><dd class="col-8">{{ 'Yes' if bible.project_metadata.is_series else 'No' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<h6 class="text-muted text-uppercase small fw-bold mb-3">Characters ({{ bible.characters|length }})</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for c in bible.characters %}
|
||||
<span class="badge bg-light text-dark border" title="{{ c.description }}">
|
||||
<i class="fas fa-user me-1 text-secondary"></i> {{ c.name }}
|
||||
<span class="text-muted fw-normal">| {{ c.role }}</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">No characters defined.</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal fade" id="editProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/update" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Project Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3"><label class="form-label">Book Title</label><input type="text" name="title" class="form-control" value="{{ bible.project_metadata.title }}" required></div>
|
||||
<div class="mb-3"><label class="form-label">Author Name</label><input type="text" name="author" class="form-control" value="{{ bible.project_metadata.author }}" required></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Book Modal -->
|
||||
<div class="modal fade" id="addBookModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/add_book" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add Book to Series</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Book Title</label>
|
||||
<input type="text" name="title" class="form-control" placeholder="e.g. The Two Towers" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Plot Summary / Instruction</label>
|
||||
<textarea name="instruction" class="form-control" rows="4" placeholder="Briefly describe the plot. Mention if it follows the main cast or shifts to side characters/new settings."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Book</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Characters Modal -->
|
||||
<div class="modal fade" id="importCharModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/import_characters" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Link Related Series</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">Import characters from another project to establish a shared universe.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Source Project</label>
|
||||
<select name="source_project_id" class="form-select" required>
|
||||
<option value="">-- Select Project --</option>
|
||||
{% for p in other_projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Import Characters</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regenerate/Feedback Modal -->
|
||||
<div class="modal fade" id="regenerateModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ active_run.id if active_run else 0 }}/regenerate_artifacts" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Update Files & Cover</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">This will regenerate the EPUB/DOCX files with any metadata changes. You can also provide feedback to fix the cover.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Cover Feedback (Optional)</label>
|
||||
<textarea name="feedback" class="form-control" rows="3" placeholder="e.g. 'Change the title color to gold', 'The text is hard to read', or 'I don't like the image, make it darker'."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Regenerate</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refine Bible Modal -->
|
||||
<div class="modal fade" id="refineBibleModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="/project/{{ project.id }}/refine_bible" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Refine Bible with AI</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">Ask the AI to change characters, plot points, or settings.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Instruction</label>
|
||||
<textarea name="instruction" class="form-control" rows="3" placeholder="e.g. 'Change the ending of Book 1', 'Add a character named Bob', 'Make the tone darker'." required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update Bible</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Bible JSON Modal -->
|
||||
<div class="modal fade" id="fullBibleModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Full Bible JSON</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body bg-light">
|
||||
<pre class="small">{{ bible | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let activeInterval = null;
|
||||
// Only auto-poll if we have a latest run
|
||||
let currentRunId = {{ active_run.id if active_run else 'null' }};
|
||||
|
||||
function fetchLog() {
|
||||
if (!currentRunId) return;
|
||||
|
||||
fetch(`/run/${currentRunId}/status`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const consoleDiv = document.getElementById('consoleOutput');
|
||||
if (consoleDiv) {
|
||||
consoleDiv.innerText = data.log || "Waiting for logs...";
|
||||
consoleDiv.scrollTop = consoleDiv.scrollHeight;
|
||||
}
|
||||
|
||||
// Update Sidebar Badge & Cost
|
||||
const badge = document.querySelector(`.status-badge-${currentRunId}`);
|
||||
if (badge) {
|
||||
badge.innerText = data.status.toUpperCase();
|
||||
badge.className = `badge fs-6 me-3 status-badge-${currentRunId} bg-${data.status === 'completed' ? 'success' : (data.status === 'running' || data.status === 'queued') ? 'warning' : (data.status === 'failed' || data.status === 'interrupted' || data.status === 'cancelled') ? 'danger' : 'secondary'}`;
|
||||
}
|
||||
const costSpan = document.querySelector(`.cost-${currentRunId}`);
|
||||
if (costSpan) costSpan.innerText = parseFloat(data.cost).toFixed(4);
|
||||
|
||||
// Update Status Bar
|
||||
if (data.progress && data.progress.message) {
|
||||
const phaseEl = document.getElementById('statusPhase');
|
||||
const msgEl = document.getElementById('statusMessage');
|
||||
const timeEl = document.getElementById('statusTime');
|
||||
|
||||
if (phaseEl) {
|
||||
// Map phases to icons
|
||||
const icons = {
|
||||
"WRITER": "✍️", "ARCHITECT": "🏗️", "MARKETING": "🎨",
|
||||
"ENRICHER": "🧠", "SYSTEM": "⚙️", "TIMING": "⏱️", "TRACKER": "🔎"
|
||||
};
|
||||
const icon = icons[data.progress.phase] || "🔄";
|
||||
phaseEl.innerText = `${icon} ${data.progress.phase}`;
|
||||
}
|
||||
if (msgEl) msgEl.innerText = data.progress.message;
|
||||
if (timeEl) {
|
||||
const date = new Date(data.progress.timestamp * 1000);
|
||||
timeEl.innerText = "Last update: " + date.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
// If running, keep polling
|
||||
if (data.status === 'running' || data.status === 'queued') {
|
||||
if (!activeInterval) activeInterval = setInterval(fetchLog, 2000);
|
||||
} else {
|
||||
if (activeInterval) clearInterval(activeInterval);
|
||||
activeInterval = null;
|
||||
// Reload page on completion to show download buttons
|
||||
if (data.status === 'completed' && !document.querySelector('.alert-success')) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{% if active_run %}
|
||||
fetchLog();
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
92
templates/project_review.html
Normal file
92
templates/project_review.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-10">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0"><i class="fas fa-check-circle me-2"></i>Review Bible & Story Beats</h4>
|
||||
<a href="/project/{{ project.id }}" class="btn btn-light btn-sm fw-bold">Looks Good, Start Writing <i class="fas fa-arrow-right ms-1"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">The AI has generated your characters and plot structure. Review them below and refine if needed.</p>
|
||||
|
||||
<!-- Refinement Bar -->
|
||||
<form action="/project/{{ project.id }}/refine_bible" method="POST" class="mb-4" onsubmit="showLoading(this)">
|
||||
<div class="input-group shadow-sm">
|
||||
<span class="input-group-text bg-warning text-dark"><i class="fas fa-magic"></i></span>
|
||||
<input type="text" name="instruction" class="form-control" placeholder="AI Instruction: e.g. 'Change the ending of Book 1', 'Add a plot point about the ring', 'Make the tone darker'" required>
|
||||
<button type="submit" class="btn btn-warning">Refine with AI</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Characters Section -->
|
||||
<h5 class="text-primary border-bottom pb-2 mb-3">👥 Characters</h5>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-hover table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 20%">Name</th>
|
||||
<th style="width: 20%">Role</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in bible.characters %}
|
||||
<tr>
|
||||
<td class="fw-bold">{{ c.name }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.role }}</span></td>
|
||||
<td class="small">{{ c.description }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted">No characters generated.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Books & Beats Section -->
|
||||
<h5 class="text-primary border-bottom pb-2 mb-3">📚 Plot Structure</h5>
|
||||
{% for book in bible.books %}
|
||||
<div class="card mb-4 border-light bg-light">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-dark">
|
||||
<span class="badge bg-dark me-2">Book {{ book.book_number }}</span>
|
||||
{{ book.title }}
|
||||
</h5>
|
||||
<p class="card-text text-muted fst-italic border-start border-4 border-warning ps-3 mb-3">
|
||||
{{ book.manual_instruction }}
|
||||
</p>
|
||||
|
||||
<h6 class="fw-bold mt-3">Plot Beats:</h6>
|
||||
<ol class="list-group list-group-numbered">
|
||||
{% for beat in book.plot_beats %}
|
||||
<li class="list-group-item bg-white">{{ beat }}</li>
|
||||
{% else %}
|
||||
<li class="list-group-item text-muted">No plot beats generated.</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
<a href="/project/{{ project.id }}" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-check me-2"></i>Finalize & Go to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showLoading(form) {
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Refining...';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
161
templates/project_setup.html
Normal file
161
templates/project_setup.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0"><i class="fas fa-magic me-2"></i>Project Setup</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">The AI has analyzed your concept. Review and edit the suggestions below before creating your project.</p>
|
||||
|
||||
<form method="POST" onsubmit="showLoading(this)">
|
||||
<input type="hidden" name="concept" value="{{ concept }}">
|
||||
<input type="hidden" name="persona_key" id="persona_key_input" value="{{ persona_key|default('') }}">
|
||||
|
||||
<!-- Refinement Bar -->
|
||||
<div class="input-group mb-4 shadow-sm">
|
||||
<span class="input-group-text bg-warning text-dark"><i class="fas fa-magic"></i></span>
|
||||
<input type="text" name="refine_instruction" class="form-control" placeholder="AI Instruction: e.g. 'Make it a trilogy', 'Change genre to Cyberpunk', 'Make the tone darker'">
|
||||
<button type="submit" formaction="/project/setup/refine" class="btn btn-warning">Refine with AI</button>
|
||||
</div>
|
||||
|
||||
<!-- Core Metadata -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-bold">Book Title</label>
|
||||
<input type="text" name="title" class="form-control form-control-lg" value="{{ s.title }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Genre</label>
|
||||
<input type="text" name="genre" class="form-control" value="{{ s.genre }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Target Audience</label>
|
||||
<input type="text" name="audience" class="form-control" value="{{ s.target_audience }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold text-primary">Select Author Persona (Optional)</label>
|
||||
<select class="form-select" onchange="applyPersona(this)">
|
||||
<option value="">-- Select a Persona to Auto-fill --</option>
|
||||
{% for key, p in personas.items() %}
|
||||
<option value="{{ key }}" data-name="{{ p.name }}" data-bio="{{ p.bio }}">{{ key }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Author Name</label>
|
||||
<input type="text" name="author" class="form-control" value="{{ current_user.username }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Author Bio / Persona</label>
|
||||
<textarea name="author_bio" class="form-control" rows="2">{{ s.author_bio }}</textarea>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Structure -->
|
||||
<h5 class="text-primary mb-3">Structure & Length</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12 mb-2">
|
||||
<label class="form-label">Length Category</label>
|
||||
<select name="length_category" class="form-select">
|
||||
{% for key, val in lengths.items() %}
|
||||
<option value="{{ key }}" {% if key == s.length_category %}selected{% endif %}>
|
||||
{{ val.label }} ({{ val.words }} words)
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Est. Chapters</label>
|
||||
<input type="number" name="chapters" class="form-control" value="{{ s.estimated_chapters }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Est. Word Count</label>
|
||||
<input type="text" name="words" class="form-control" value="{{ s.estimated_word_count }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="include_prologue" {% if s.include_prologue %}checked{% endif %}>
|
||||
<label class="form-check-label">Include Prologue</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="include_epilogue" {% if s.include_epilogue %}checked{% endif %}>
|
||||
<label class="form-check-label">Include Epilogue</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="is_series" {% if s.is_series %}checked{% endif %}>
|
||||
<label class="form-check-label">Is Series</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<label class="form-label ms-2 small">Book Count:</label>
|
||||
<input type="number" name="series_count" class="form-control d-inline-block py-0 px-2" style="width: 60px; height: 25px;" value="{{ s.series_count|default(1) }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Style -->
|
||||
<h5 class="text-primary mb-3">Style & Tone</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Tone</label><input type="text" name="tone" class="form-control" value="{{ s.tone }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">POV Style</label><input type="text" name="pov_style" class="form-control" value="{{ s.pov_style }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Time Period</label><input type="text" name="time_period" class="form-control" value="{{ s.time_period }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Spice Level</label><input type="text" name="spice" class="form-control" value="{{ s.spice }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Violence</label><input type="text" name="violence" class="form-control" value="{{ s.violence }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Narrative Tense</label><input type="text" name="narrative_tense" class="form-control" value="{{ s.narrative_tense }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Language Style</label><input type="text" name="language_style" class="form-control" value="{{ s.language_style }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Dialogue Style</label><input type="text" name="dialogue_style" class="form-control" value="{{ s.dialogue_style }}"></div>
|
||||
<div class="col-md-6 mb-2"><label class="form-label">Page Orientation</label>
|
||||
<select name="page_orientation" class="form-select"><option value="Portrait" {% if s.page_orientation == 'Portrait' %}selected{% endif %}>Portrait</option><option value="Landscape" {% if s.page_orientation == 'Landscape' %}selected{% endif %}>Landscape</option><option value="Square" {% if s.page_orientation == 'Square' %}selected{% endif %}>Square</option></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Tropes (comma separated)</label>
|
||||
<input type="text" name="tropes" class="form-control" value="{{ s.tropes|join(', ') }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Formatting Rules (comma separated)</label>
|
||||
<input type="text" name="formatting_rules" class="form-control" value="{{ s.formatting_rules|join(', ') }}">
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" formaction="/project/create" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-check me-2"></i>Create Project & Generate Bible
|
||||
</button>
|
||||
<a href="/" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showLoading(form) {
|
||||
// We don't disable buttons here because formaction needs the specific button click
|
||||
// But we can show a global spinner or change cursor
|
||||
document.body.style.cursor = 'wait';
|
||||
}
|
||||
|
||||
function applyPersona(select) {
|
||||
const opt = select.options[select.selectedIndex];
|
||||
if (opt.value) {
|
||||
document.querySelector('input[name="author"]').value = opt.dataset.name || opt.value;
|
||||
document.querySelector('textarea[name="author_bio"]').value = opt.dataset.bio || '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
27
templates/register.html
Normal file
27
templates/register.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card p-4">
|
||||
<h3 class="text-center mb-4">Register</h3>
|
||||
<form method="POST" action="/register">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-success">Create Account</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center mt-3">
|
||||
<small>Already have an account? <a href="/login">Login here</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
298
templates/run_details.html
Normal file
298
templates/run_details.html
Normal file
@@ -0,0 +1,298 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
|
||||
<p class="text-muted mb-0">Project: <a href="{{ url_for('view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
|
||||
<i class="fas fa-scroll me-2"></i>Show Bible
|
||||
</button>
|
||||
<a href="{{ url_for('view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Bible Viewer -->
|
||||
<div class="collapse mb-4" id="bibleCollapse">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-book-open me-2"></i>Project Bible</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if bible %}
|
||||
<ul class="nav nav-tabs" id="bibleTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="meta-tab" data-bs-toggle="tab" data-bs-target="#meta" type="button" role="tab">Metadata</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="chars-tab" data-bs-toggle="tab" data-bs-target="#chars" type="button" role="tab">Characters</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="plot-tab" data-bs-toggle="tab" data-bs-target="#plot" type="button" role="tab">Plot</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content p-3 border border-top-0">
|
||||
<!-- Metadata Tab -->
|
||||
<div class="tab-pane fade show active" id="meta" role="tabpanel">
|
||||
<dl class="row mb-0">
|
||||
{% for k, v in bible.project_metadata.items() %}
|
||||
{% if k not in ['style', 'length_settings', 'author_details'] %}
|
||||
<dt class="col-sm-3 text-capitalize">{{ k.replace('_', ' ') }}</dt>
|
||||
<dd class="col-sm-9">{{ v }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if bible.project_metadata.style %}
|
||||
<dt class="col-sm-12 mt-3 border-bottom pb-2">Style Settings</dt>
|
||||
{% for k, v in bible.project_metadata.style.items() %}
|
||||
<dt class="col-sm-3 text-capitalize mt-2">{{ k.replace('_', ' ') }}</dt>
|
||||
<dd class="col-sm-9 mt-2">{{ v|join(', ') if v is sequence and v is not string else v }}</dd>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Characters Tab -->
|
||||
<div class="tab-pane fade" id="chars" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="table-light"><tr><th>Name</th><th>Role</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in bible.characters %}
|
||||
<tr>
|
||||
<td class="fw-bold">{{ c.name }}</td>
|
||||
<td><span class="badge bg-secondary">{{ c.role }}</span></td>
|
||||
<td class="small">{{ c.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plot Tab -->
|
||||
<div class="tab-pane fade" id="plot" role="tabpanel">
|
||||
{% for book in bible.books %}
|
||||
<div class="mb-3">
|
||||
<h6 class="fw-bold text-primary">Book {{ book.book_number }}: {{ book.title }}</h6>
|
||||
<p class="fst-italic small text-muted">{{ book.manual_instruction }}</p>
|
||||
<ol class="list-group list-group-numbered">
|
||||
{% for beat in book.plot_beats %}
|
||||
<li class="list-group-item list-group-item-action border-0 py-1">{{ beat }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">Bible data not found for this project.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="fw-bold" id="status-text">Status: {{ run.status|title }}</span>
|
||||
<span class="text-muted" id="run-duration">{{ run.duration() }}</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="status-bar" class="progress-bar {% if run.status == 'running' %}progress-bar-striped progress-bar-animated{% elif run.status == 'failed' %}bg-danger{% else %}bg-success{% endif %}"
|
||||
role="progressbar" style="width: {% if run.status == 'completed' %}100%{% elif run.status == 'running' %}100%{% else %}5%{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Left Column: Cover Art -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-image me-2"></i>Cover Art</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
{% if has_cover %}
|
||||
<img src="{{ url_for('download_artifact', run_id=run.id, file='cover.png') }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover">
|
||||
{% else %}
|
||||
<div class="alert alert-secondary py-5">
|
||||
<i class="fas fa-image fa-3x mb-3"></i><br>
|
||||
No cover generated yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST">
|
||||
<label class="form-label text-start w-100 small fw-bold">Regenerate Art & Files</label>
|
||||
<textarea name="feedback" class="form-control mb-2" rows="2" placeholder="Feedback (e.g. 'Make the font larger', 'Use a darker theme')..."></textarea>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-sync me-2"></i>Regenerate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Blurb & Stats -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<!-- Blurb -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-align-left me-2"></i>Blurb</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if blurb %}
|
||||
<p class="card-text" style="white-space: pre-wrap;">{{ blurb }}</p>
|
||||
{% else %}
|
||||
<p class="text-muted fst-italic">Blurb not generated yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-6">
|
||||
<div class="card shadow-sm text-center">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Total Cost</h6>
|
||||
<h3 class="text-success" id="run-cost">${{ "%.4f"|format(run.cost) }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card shadow-sm text-center">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted">Artifacts</h6>
|
||||
<h3>
|
||||
{% if has_cover %}<i class="fas fa-check text-success me-2"></i>{% endif %}
|
||||
{% if blurb %}<i class="fas fa-check text-success"></i>{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracking & Warnings -->
|
||||
{% if tracking and (tracking.content_warnings or tracking.characters) %}
|
||||
<div class="card shadow-sm mb-4 border-warning">
|
||||
<div class="card-header bg-warning-subtle">
|
||||
<h5 class="mb-0 text-warning-emphasis"><i class="fas fa-exclamation-triangle me-2"></i>Story Tracking & Warnings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if tracking.content_warnings %}
|
||||
<div class="mb-3">
|
||||
<h6 class="fw-bold">Content Warnings Detected:</h6>
|
||||
<div>
|
||||
{% for w in tracking.content_warnings %}
|
||||
<span class="badge bg-danger me-1 mb-1">{{ w }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tracking.characters %}
|
||||
{# Check if any character actually has major events to display #}
|
||||
{% set has_events = namespace(value=false) %}
|
||||
{% for name, data in tracking.characters.items() %}
|
||||
{% if data.major_events %}{% set has_events.value = true %}{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if has_events.value %}
|
||||
<h6 class="fw-bold">Major Character Events:</h6>
|
||||
<div class="accordion" id="charEventsAcc">
|
||||
{% for name, data in tracking.characters.items() %}
|
||||
{% if data.major_events %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#ce-{{ loop.index }}">
|
||||
{{ name }}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="ce-{{ loop.index }}" class="accordion-collapse collapse" data-bs-parent="#charEventsAcc">
|
||||
<div class="accordion-body small py-2">
|
||||
<ul class="mb-0 ps-3">
|
||||
{% for evt in data.major_events %}
|
||||
<li>{{ evt }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Collapsible Log -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#logCollapse">
|
||||
<h5 class="mb-0"><i class="fas fa-terminal me-2"></i>System Log</h5>
|
||||
<span class="badge bg-secondary" id="log-badge">Click to Toggle</span>
|
||||
</div>
|
||||
<div id="logCollapse" class="collapse {% if run.status == 'running' %}show{% endif %}">
|
||||
<div class="card-body bg-dark p-0">
|
||||
<pre id="console-log" class="console-log m-0 p-3" style="color: #00ff00; background-color: #1e1e1e; height: 400px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">{{ log_content or "Waiting for logs..." }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const runId = {{ run.id }};
|
||||
const consoleEl = document.getElementById('console-log');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
const costEl = document.getElementById('run-cost');
|
||||
|
||||
function updateLog() {
|
||||
fetch(`/run/${runId}/status`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update Status Text
|
||||
statusText.innerText = "Status: " + data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
||||
costEl.innerText = '$' + parseFloat(data.cost).toFixed(4);
|
||||
|
||||
// Update Status Bar
|
||||
if (data.status === 'running' || data.status === 'queued') {
|
||||
statusBar.className = "progress-bar progress-bar-striped progress-bar-animated";
|
||||
statusBar.style.width = "100%";
|
||||
} else if (data.status === 'failed') {
|
||||
statusBar.className = "progress-bar bg-danger";
|
||||
statusBar.style.width = "100%";
|
||||
} else {
|
||||
statusBar.className = "progress-bar bg-success";
|
||||
statusBar.style.width = "100%";
|
||||
}
|
||||
|
||||
// Update Log (only if changed to avoid scroll jitter)
|
||||
if (consoleEl.innerText !== data.log) {
|
||||
const isScrolledToBottom = consoleEl.scrollHeight - consoleEl.clientHeight <= consoleEl.scrollTop + 50;
|
||||
consoleEl.innerText = data.log;
|
||||
if (isScrolledToBottom) {
|
||||
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll if running
|
||||
if (data.status === 'running' || data.status === 'queued') {
|
||||
setTimeout(updateLog, 2000);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
|
||||
// Start polling
|
||||
updateLog();
|
||||
</script>
|
||||
{% endblock %}
|
||||
124
templates/system_status.html
Normal file
124
templates/system_status.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2><i class="fas fa-server me-2"></i>System Status</h2>
|
||||
<p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
||||
<form action="{{ url_for('optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-sync me-2"></i>Refresh & Optimize
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-robot me-2"></i>AI Model Selection</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 15%">Role</th>
|
||||
<th style="width: 25%">Selected Model</th>
|
||||
<th>Selection Reasoning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if models %}
|
||||
{% for role, info in models.items() %}
|
||||
{% if role != 'ranking' %}
|
||||
<tr>
|
||||
<td class="fw-bold text-uppercase">{{ role }}</td>
|
||||
<td>
|
||||
{% if info is mapping %}
|
||||
<span class="badge bg-info text-dark">{{ info.model }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ info }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{% if info is mapping %}
|
||||
{{ info.reason }}
|
||||
{% else %}
|
||||
<em>Legacy format. Please refresh models.</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>No model configuration found.
|
||||
<br>Click <strong>Refresh & Optimize</strong> to scan available Gemini models.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ranked Models -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-list-ol me-2"></i>All Models Ranked</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 10%">Rank</th>
|
||||
<th style="width: 30%">Model Name</th>
|
||||
<th>Reasoning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if models and models.ranking %}
|
||||
{% for item in models.ranking %}
|
||||
<tr>
|
||||
<td class="fw-bold">{{ loop.index }}</td>
|
||||
<td><span class="badge bg-secondary">{{ item.model }}</span></td>
|
||||
<td class="small text-muted">{{ item.reason }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted">
|
||||
No ranking data available. Refresh models to generate.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Info -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-clock me-2"></i>Cache Status</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0">
|
||||
<strong>Last Scan:</strong>
|
||||
{% if cache and cache.timestamp %}
|
||||
{{ datetime.fromtimestamp(cache.timestamp).strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-muted small mb-0">Model selection is cached for 24 hours to save API calls.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
1
token.json
Normal file
1
token.json
Normal file
@@ -0,0 +1 @@
|
||||
{"token": "ya29.a0AUMWg_LapeQuhE1RaEXk3UXG6TFddvj20wJTGSXUgrjBXgYBfOgqcMB12cP6v45KeINagLdKl5CdpmHBG9DUQvbBKWyjmcJ0EcxKv4LYsN44oiBEj2WtMmGL1O-IQpVNW0VmvZMaTCIXDUPKAwhA3J4wloxPSxJvJthJqQBH0KiCTXsTHEU1JKh-Vav5fP9trQKC1apyaCgYKAdsSARcSFQHGX2MiDs7hVIiZ1AGETtxcNIjAOw0207", "refresh_token": "1//05Ft8UFKmbfEICgYIARAAGAUSNwF-L9Ir9XeHoMRS2YxoAzDyluLptjFriBcTVFdqUdCuWf2XsRXVFG8HgDyRFVE8iT92fz7Eft4", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "439342248586-7c31edogtkpr1ml95a12c8mfr9vtan6r.apps.googleusercontent.com", "client_secret": "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", "scopes": ["https://www.googleapis.com/auth/cloud-platform"], "universe_domain": "googleapis.com", "account": "", "expiry": "2026-01-17T00:44:44.671371Z"}
|
||||
858
wizard.py
Normal file
858
wizard.py
Normal file
@@ -0,0 +1,858 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import config
|
||||
import google.generativeai as genai
|
||||
from flask import Flask
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt, IntPrompt, Confirm
|
||||
from rich.table import Table
|
||||
from modules import ai, utils
|
||||
from modules.web_db import db, User, Project
|
||||
|
||||
console = Console()
|
||||
genai.configure(api_key=config.API_KEY)
|
||||
|
||||
# Validate Key on Launch
|
||||
try:
|
||||
list(genai.list_models(page_size=1))
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]❌ CRITICAL: Gemini API Key check failed.[/bold red]")
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
console.print("Please check your .env file and ensure GEMINI_API_KEY is correct.")
|
||||
Prompt.ask("Press Enter to exit...")
|
||||
sys.exit(1)
|
||||
|
||||
logic_name = ai.get_optimal_model("pro") if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
||||
model = genai.GenerativeModel(logic_name, safety_settings=utils.SAFETY_SETTINGS)
|
||||
|
||||
# --- DB SETUP FOR WIZARD ---
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
db.init_app(app)
|
||||
|
||||
# --- DEFINITIONS ---
|
||||
if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
|
||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||
|
||||
class BookWizard:
|
||||
def __init__(self):
|
||||
self.project_name = "New_Project"
|
||||
self.project_path = None
|
||||
self.user = None
|
||||
self.data = {
|
||||
"project_metadata": {},
|
||||
"characters": [],
|
||||
"books": []
|
||||
}
|
||||
utils.create_default_personas()
|
||||
|
||||
def _get_or_create_wizard_user(self):
|
||||
# Find or create a default user for CLI operations
|
||||
wizard_user = User.query.filter_by(username="wizard").first()
|
||||
if not wizard_user:
|
||||
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]")
|
||||
wizard_user = User(username="wizard", password="!", is_admin=True) # Password not used for CLI
|
||||
db.session.add(wizard_user)
|
||||
db.session.commit()
|
||||
return wizard_user
|
||||
|
||||
def clear(self): os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
def ask_gemini_json(self, prompt):
|
||||
text = None
|
||||
try:
|
||||
response = model.generate_content(prompt + "\nReturn ONLY valid JSON.")
|
||||
text = utils.clean_json(response.text)
|
||||
return json.loads(text)
|
||||
except Exception as e:
|
||||
console.print(f"[red]AI Error: {e}[/red]")
|
||||
if text: console.print(f"[dim]Raw output: {text[:150]}...[/dim]")
|
||||
return []
|
||||
|
||||
def ask_gemini_text(self, prompt):
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
return response.text.strip()
|
||||
except Exception as e:
|
||||
console.print(f"[red]AI Error: {e}[/red]")
|
||||
return ""
|
||||
|
||||
def manage_personas(self):
|
||||
while True:
|
||||
self.clear()
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
console.print(Panel("[bold cyan]🎭 Manage Author Personas[/bold cyan]"))
|
||||
options = list(personas.keys())
|
||||
|
||||
for i, name in enumerate(options):
|
||||
console.print(f"[{i+1}] {name}")
|
||||
|
||||
console.print(f"[{len(options)+1}] Create New Persona")
|
||||
console.print(f"[{len(options)+2}] Back")
|
||||
console.print(f"[{len(options)+3}] Exit")
|
||||
|
||||
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, len(options)+4)]))
|
||||
|
||||
if choice == len(options) + 2: break
|
||||
elif choice == len(options) + 3: sys.exit()
|
||||
|
||||
selected_key = None
|
||||
details = {}
|
||||
|
||||
if choice == len(options) + 1:
|
||||
# Create
|
||||
console.print("[yellow]Define New Persona[/yellow]")
|
||||
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
|
||||
else:
|
||||
# Edit/Delete Menu for specific persona
|
||||
selected_key = options[choice-1]
|
||||
details = personas[selected_key]
|
||||
if isinstance(details, str): details = {"bio": details}
|
||||
|
||||
console.print(f"\n[bold]Selected: {selected_key}[/bold]")
|
||||
console.print("1. Edit")
|
||||
console.print("2. Delete")
|
||||
console.print("3. Cancel")
|
||||
|
||||
sub = int(Prompt.ask("Action", choices=["1", "2", "3"], default="1"))
|
||||
if sub == 2:
|
||||
if Confirm.ask(f"Delete '{selected_key}'?", default=False):
|
||||
del personas[selected_key]
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||
continue
|
||||
elif sub == 3:
|
||||
continue
|
||||
|
||||
# Edit Fields
|
||||
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
|
||||
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
|
||||
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
|
||||
details['race'] = Prompt.ask("Race/Ethnicity", default=details.get('race', "Unknown"))
|
||||
details['nationality'] = Prompt.ask("Nationality/Country", default=details.get('nationality', "Unknown"))
|
||||
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
|
||||
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
|
||||
|
||||
# Samples
|
||||
console.print("\n[bold]Style Samples[/bold]")
|
||||
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
|
||||
|
||||
curr_files = details.get('sample_files', [])
|
||||
files_str = ",".join(curr_files)
|
||||
new_files = Prompt.ask("Sample Text Files (comma sep filenames)", default=files_str)
|
||||
details['sample_files'] = [x.strip() for x in new_files.split(',') if x.strip()]
|
||||
|
||||
details['sample_text'] = Prompt.ask("Manual Sample Paragraph", default=details.get('sample_text', ""))
|
||||
|
||||
if Confirm.ask("Save Persona?", default=True):
|
||||
personas[selected_key] = details
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||
|
||||
def select_mode(self):
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel("[bold blue]🧙♂️ BookApp Setup Wizard[/bold blue]"))
|
||||
console.print("1. Create New Project")
|
||||
console.print("2. Open Existing Project")
|
||||
console.print("3. Manage Author Personas")
|
||||
console.print("4. Exit")
|
||||
|
||||
choice = int(Prompt.ask("Select Mode", choices=["1", "2", "3", "4"], default="1"))
|
||||
|
||||
if choice == 1:
|
||||
self.user = self._get_or_create_wizard_user()
|
||||
return self.create_new_project()
|
||||
elif choice == 2:
|
||||
self.user = self._get_or_create_wizard_user()
|
||||
if self.open_existing_project(): return True
|
||||
elif choice == 3:
|
||||
# Personas don't need a user context
|
||||
self.manage_personas()
|
||||
else:
|
||||
return False
|
||||
|
||||
def create_new_project(self):
|
||||
self.clear()
|
||||
console.print(Panel("[bold green]🆕 New Project Setup[/bold green]"))
|
||||
|
||||
# 1. Ask for Concept first to guide defaults
|
||||
console.print("Tell me about your story idea (or leave empty to start from scratch).")
|
||||
concept = Prompt.ask("Story Concept")
|
||||
|
||||
suggestions = {}
|
||||
if concept:
|
||||
with console.status("[bold yellow]AI is analyzing your concept...[/bold yellow]"):
|
||||
prompt = f"""
|
||||
Analyze this story concept and suggest metadata for a book or series.
|
||||
CONCEPT: {concept}
|
||||
|
||||
RETURN JSON with these keys:
|
||||
- title: Suggested book title
|
||||
- genre: Genre
|
||||
- target_audience: e.g. Adult, YA
|
||||
- tone: e.g. Dark, Whimsical
|
||||
- length_category: One of ["00", "0", "01", "1", "2", "2b", "3", "4", "5"] based on likely depth.
|
||||
- estimated_chapters: int (suggested chapter count)
|
||||
- estimated_word_count: string (e.g. "75,000")
|
||||
- include_prologue: boolean
|
||||
- include_epilogue: boolean
|
||||
- tropes: list of strings
|
||||
- pov_style: e.g. First Person
|
||||
- time_period: e.g. Modern
|
||||
- spice: e.g. Standard, Explicit
|
||||
- violence: e.g. None, Graphic
|
||||
- is_series: boolean
|
||||
- series_title: string (if series)
|
||||
- narrative_tense: e.g. Past, Present
|
||||
- language_style: e.g. Standard, Flowery
|
||||
- dialogue_style: e.g. Witty, Formal
|
||||
- page_orientation: Portrait, Landscape, or Square
|
||||
- formatting_rules: list of strings
|
||||
"""
|
||||
suggestions = self.ask_gemini_json(prompt)
|
||||
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel("[bold green]🤖 AI Suggestions[/bold green]"))
|
||||
|
||||
grid = Table.grid(padding=(0, 2))
|
||||
grid.add_column(style="bold cyan")
|
||||
grid.add_column()
|
||||
|
||||
def get_str(k):
|
||||
v = suggestions.get(k, 'N/A')
|
||||
if isinstance(v, list): return ", ".join(v)
|
||||
return str(v)
|
||||
|
||||
grid.add_row("Title:", get_str('title'))
|
||||
grid.add_row("Genre:", get_str('genre'))
|
||||
grid.add_row("Audience:", get_str('target_audience'))
|
||||
grid.add_row("Tone:", get_str('tone'))
|
||||
|
||||
len_cat = suggestions.get('length_category', '4')
|
||||
len_label = config.LENGTH_DEFINITIONS.get(len_cat, {}).get('label', 'Novel')
|
||||
grid.add_row("Length:", len_label)
|
||||
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A')))
|
||||
grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
|
||||
|
||||
grid.add_row("Tropes:", get_str('tropes'))
|
||||
grid.add_row("POV:", get_str('pov_style'))
|
||||
grid.add_row("Time:", get_str('time_period'))
|
||||
grid.add_row("Spice:", get_str('spice'))
|
||||
grid.add_row("Violence:", get_str('violence'))
|
||||
grid.add_row("Series:", "Yes" if suggestions.get('is_series') else "No")
|
||||
|
||||
console.print(grid)
|
||||
console.print("\n[dim]These will be the defaults for the next step.[/dim]")
|
||||
|
||||
console.print("\n1. Continue (Manual Step-through)")
|
||||
console.print("2. Refine Suggestions with AI")
|
||||
|
||||
choice = Prompt.ask("Select Option", choices=["1", "2"], default="1")
|
||||
|
||||
if choice == "1":
|
||||
break
|
||||
else:
|
||||
instruction = Prompt.ask("Instruction (e.g. 'Make it darker', 'Change genre to Sci-Fi')")
|
||||
with console.status("[bold yellow]Refining suggestions...[/bold yellow]"):
|
||||
refine_prompt = f"""
|
||||
Update these project suggestions based on the user instruction.
|
||||
CURRENT JSON: {json.dumps(suggestions)}
|
||||
INSTRUCTION: {instruction}
|
||||
RETURN ONLY VALID JSON with the same keys.
|
||||
"""
|
||||
new_sugg = self.ask_gemini_json(refine_prompt)
|
||||
if new_sugg: suggestions = new_sugg
|
||||
|
||||
# 2. Select Type (with AI default)
|
||||
default_type = "2" if suggestions.get('is_series') else "1"
|
||||
|
||||
console.print("1. Standalone Book")
|
||||
console.print("2. Series")
|
||||
|
||||
choice = int(Prompt.ask("Select Type", choices=["1", "2"], default=default_type))
|
||||
is_series = (choice == 2)
|
||||
|
||||
self.configure_details(suggestions, concept, is_series)
|
||||
self.enrich_blueprint()
|
||||
self.refine_blueprint("Review & Edit Bible")
|
||||
self.save_bible()
|
||||
return True
|
||||
|
||||
def open_existing_project(self):
|
||||
# Query projects from the database for the wizard user
|
||||
projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
|
||||
if not projects:
|
||||
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
|
||||
Prompt.ask("Press Enter to continue...")
|
||||
return False
|
||||
|
||||
console.print("\n[bold cyan]📂 Select Project[/bold cyan]")
|
||||
for i, p in enumerate(projects):
|
||||
console.print(f"[{i+1}] {p.name}")
|
||||
|
||||
console.print(f"[{len(projects)+1}] Back")
|
||||
console.print(f"[{len(projects)+2}] Exit")
|
||||
|
||||
choice = int(Prompt.ask("Select Project", choices=[str(i) for i in range(1, len(projects)+3)]))
|
||||
if choice == len(projects) + 1: return False
|
||||
if choice == len(projects) + 2: sys.exit()
|
||||
|
||||
selected_project = projects[choice-1]
|
||||
self.project_name = selected_project.name
|
||||
self.project_path = selected_project.folder_path
|
||||
return True
|
||||
|
||||
def load_bible(self):
|
||||
if not self.project_path:
|
||||
console.print("[red]No project loaded.[/red]")
|
||||
return False
|
||||
|
||||
path = os.path.join(self.project_path, "bible.json")
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r') as f: self.data = json.load(f)
|
||||
return True
|
||||
console.print("[red]Bible not found.[/red]")
|
||||
return False
|
||||
|
||||
def configure_details(self, suggestions=None, concept="", is_series=False):
|
||||
if suggestions is None: suggestions = {}
|
||||
console.print("\n[bold blue]📝 Project Details[/bold blue]")
|
||||
|
||||
# Simplified Persona Selection (Skip creation)
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
author_details = {}
|
||||
if personas:
|
||||
console.print("\n[bold]Select Author Persona[/bold]")
|
||||
opts = list(personas.keys())
|
||||
for i, p in enumerate(opts): console.print(f"[{i+1}] {p}")
|
||||
console.print(f"[{len(opts)+1}] None (AI Default)")
|
||||
|
||||
sel = IntPrompt.ask("Option", choices=[str(i) for i in range(1, len(opts)+2)], default=len(opts)+1)
|
||||
if sel <= len(opts):
|
||||
author_details = personas[opts[sel-1]]
|
||||
if isinstance(author_details, str): author_details = {"bio": author_details}
|
||||
|
||||
default_author = author_details.get('name', "AI Co-Pilot")
|
||||
author = Prompt.ask("Author Name", default=default_author)
|
||||
|
||||
genre = Prompt.ask("Genre", default=suggestions.get('genre', "Fiction"))
|
||||
|
||||
# LENGTH SELECTION
|
||||
table = Table(title="Target Length Options")
|
||||
table.add_column("#"); table.add_column("Type"); table.add_column("Est. Words"); table.add_column("Chapters")
|
||||
for k, v in config.LENGTH_DEFINITIONS.items():
|
||||
table.add_row(k, v['label'], v['words'], str(v['chapters']))
|
||||
console.print(table)
|
||||
|
||||
def_len = suggestions.get('length_category', "4")
|
||||
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4"
|
||||
|
||||
len_choice = Prompt.ask("Select Target Length", choices=list(config.LENGTH_DEFINITIONS.keys()), default=def_len)
|
||||
# Create a copy so we don't modify the global definition
|
||||
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
|
||||
|
||||
# AI Defaults
|
||||
def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
|
||||
def_words = suggestions.get('estimated_word_count', settings['words'])
|
||||
def_prologue = suggestions.get('include_prologue', False)
|
||||
def_epilogue = suggestions.get('include_epilogue', False)
|
||||
|
||||
settings['chapters'] = IntPrompt.ask("Target Chapters", default=int(def_chapters))
|
||||
settings['words'] = Prompt.ask("Target Word Count", default=str(def_words))
|
||||
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
|
||||
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
|
||||
|
||||
# --- GENRE STANDARD CHECK ---
|
||||
# Parse current word count selection
|
||||
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
|
||||
avg_words = 0
|
||||
if '-' in w_str:
|
||||
parts = w_str.split('-')
|
||||
try: avg_words = (int(parts[0].strip()) + int(parts[1].strip().replace('k','000'))) // 2
|
||||
except: pass
|
||||
else:
|
||||
try: avg_words = int(w_str.replace('k', '000'))
|
||||
except: pass
|
||||
|
||||
# Define rough standards
|
||||
std_target = 0
|
||||
g_lower = genre.lower()
|
||||
if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000
|
||||
elif "thriller" in g_lower or "horror" in g_lower or "historical" in g_lower: std_target = 85000
|
||||
elif "romance" in g_lower or "mystery" in g_lower: std_target = 70000
|
||||
elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
|
||||
|
||||
if std_target > 0 and avg_words > 0:
|
||||
# If difference is > 25%, warn user
|
||||
if abs(std_target - avg_words) / std_target > 0.25:
|
||||
console.print(f"\n[bold yellow]⚠️ Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
|
||||
if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
|
||||
settings['words'] = f"{std_target:,}"
|
||||
|
||||
# TROPES
|
||||
console.print("\n[italic]Note: Tropes are recurring themes (e.g. 'Chosen One').[/italic]")
|
||||
def_tropes = ", ".join(suggestions.get('tropes', []))
|
||||
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes)
|
||||
sel_tropes = [x.strip() for x in tropes_input.split(',')] if tropes_input else []
|
||||
|
||||
# TITLE
|
||||
# If series, this is Series Title. If book, Book Title.
|
||||
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
|
||||
|
||||
# PROJECT NAME
|
||||
default_proj = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_") if title else "New_Project"
|
||||
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
|
||||
|
||||
# Create Project in DB and set path
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
|
||||
if not os.path.exists(user_dir): os.makedirs(user_dir)
|
||||
|
||||
self.project_path = os.path.join(user_dir, self.project_name)
|
||||
if os.path.exists(self.project_path):
|
||||
console.print(f"[yellow]Warning: Project folder '{self.project_path}' already exists.[/yellow]")
|
||||
|
||||
new_proj = Project(user_id=self.user.id, name=self.project_name, folder_path=self.project_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
console.print(f"[green]Project '{self.project_name}' created in database.[/green]")
|
||||
|
||||
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]")
|
||||
tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced"))
|
||||
|
||||
# POV SETTINGS
|
||||
pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited"))
|
||||
pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="")
|
||||
pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else []
|
||||
|
||||
# ADVANCED STYLE
|
||||
tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
|
||||
|
||||
console.print("\n[bold]Content Guidelines[/bold]")
|
||||
spice = Prompt.ask("Spice/Romance (e.g. 'Clean', 'Fade-to-Black', 'Explicit')", default=suggestions.get('spice', "Standard"))
|
||||
violence = Prompt.ask("Violence (e.g. 'None', 'Mild', 'Graphic')", default=suggestions.get('violence', "Standard"))
|
||||
language = Prompt.ask("Language (e.g. 'No Swearing', 'Mild', 'Heavy')", default=suggestions.get('language_style', "Standard"))
|
||||
|
||||
dialogue_style = Prompt.ask("Dialogue Style (e.g. 'Witty', 'Formal', 'Slang-heavy')", default=suggestions.get('dialogue_style', "Standard"))
|
||||
|
||||
console.print("\n[bold]Formatting & World Rules[/bold]")
|
||||
time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern"))
|
||||
|
||||
# Visuals
|
||||
orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait"))
|
||||
|
||||
console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
|
||||
def_fmt = ", ".join(suggestions.get('formatting_rules', ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"]))
|
||||
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt)
|
||||
fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
|
||||
|
||||
# Update book_metadata with new fields
|
||||
style_data = {
|
||||
"tone": tone, "tropes": sel_tropes,
|
||||
"pov_style": pov_style, "pov_characters": pov_chars,
|
||||
"tense": tense, "spice": spice, "violence": violence, "language": language,
|
||||
"dialogue_style": dialogue_style, "time_period": time_period,
|
||||
"page_orientation": orientation,
|
||||
"formatting_rules": fmt_rules
|
||||
}
|
||||
|
||||
self.data['project_metadata'] = {
|
||||
"title": title,
|
||||
"author": author,
|
||||
"author_details": author_details,
|
||||
"author_bio": author_details.get('bio', ''),
|
||||
"genre": genre,
|
||||
"target_audience": Prompt.ask("Audience", default=suggestions.get('target_audience', "Adult")),
|
||||
"is_series": is_series,
|
||||
"length_settings": settings,
|
||||
"style": style_data
|
||||
}
|
||||
|
||||
# Initialize Books List
|
||||
self.data['books'] = []
|
||||
if is_series:
|
||||
count = IntPrompt.ask("How many books in the series?", default=3)
|
||||
for i in range(count):
|
||||
self.data['books'].append({
|
||||
"book_number": i+1,
|
||||
"title": f"Book {i+1}",
|
||||
"manual_instruction": concept if i==0 else "",
|
||||
"plot_beats": []
|
||||
})
|
||||
else:
|
||||
self.data['books'].append({
|
||||
"book_number": 1,
|
||||
"title": title,
|
||||
"manual_instruction": concept,
|
||||
"plot_beats": []
|
||||
})
|
||||
|
||||
def enrich_blueprint(self):
|
||||
console.print("\n[bold yellow]✨ Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
|
||||
|
||||
prompt = f"""
|
||||
You are a Creative Director.
|
||||
Create a comprehensive Book Bible for the following project.
|
||||
|
||||
PROJECT METADATA: {json.dumps(self.data['project_metadata'])}
|
||||
EXISTING BOOKS STRUCTURE: {json.dumps(self.data['books'])}
|
||||
|
||||
TASK:
|
||||
1. Create a list of Main Characters (Global for the project).
|
||||
2. For EACH book in the 'books' list:
|
||||
- Generate a catchy Title (if not provided).
|
||||
- Write a 'manual_instruction' (Plot Summary).
|
||||
- Generate 'plot_beats' (10-15 chronological beats).
|
||||
|
||||
RETURN JSON in standard Bible format:
|
||||
{{
|
||||
"characters": [ {{ "name": "...", "role": "...", "description": "..." }} ],
|
||||
"books": [
|
||||
{{ "book_number": 1, "title": "...", "manual_instruction": "...", "plot_beats": ["...", "..."] }},
|
||||
...
|
||||
]
|
||||
}}
|
||||
"""
|
||||
new_data = self.ask_gemini_json(prompt)
|
||||
if new_data:
|
||||
if 'characters' in new_data:
|
||||
self.data['characters'] = new_data['characters']
|
||||
|
||||
if 'books' in new_data:
|
||||
# Merge book data carefully
|
||||
ai_books = {b.get('book_number'): b for b in new_data['books']}
|
||||
for i, book in enumerate(self.data['books']):
|
||||
b_num = book.get('book_number', i+1)
|
||||
if b_num in ai_books:
|
||||
src = ai_books[b_num]
|
||||
book['title'] = src.get('title', book['title'])
|
||||
book['manual_instruction'] = src.get('manual_instruction', book['manual_instruction'])
|
||||
book['plot_beats'] = src.get('plot_beats', [])
|
||||
|
||||
console.print("[green]Blueprint enriched (Missing data filled)![/green]")
|
||||
|
||||
def display_summary(self, data):
|
||||
meta = data.get('project_metadata', {})
|
||||
length = meta.get('length_settings', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
# Metadata Grid
|
||||
grid = Table.grid(padding=(0, 2))
|
||||
grid.add_column(style="bold cyan")
|
||||
grid.add_column()
|
||||
|
||||
grid.add_row("Title:", meta.get('title', 'N/A'))
|
||||
grid.add_row("Author:", meta.get('author', 'N/A'))
|
||||
grid.add_row("Genre:", meta.get('genre', 'N/A'))
|
||||
grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
|
||||
|
||||
# Dynamic Style Display
|
||||
# Define explicit order for common fields
|
||||
ordered_keys = [
|
||||
"tone", "pov_style", "pov_characters",
|
||||
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
|
||||
"tropes"
|
||||
]
|
||||
|
||||
defaults = {
|
||||
"tone": "Balanced",
|
||||
"pov_style": "Third Person Limited",
|
||||
"tense": "Past",
|
||||
"spice": "Standard",
|
||||
"violence": "Standard",
|
||||
"language": "Standard",
|
||||
"dialogue_style": "Standard",
|
||||
"time_period": "Modern",
|
||||
"page_orientation": "Portrait"
|
||||
}
|
||||
|
||||
# 1. Show ordered keys first
|
||||
for k in ordered_keys:
|
||||
val = style.get(k)
|
||||
if val in [None, "", "N/A"]:
|
||||
val = defaults.get(k, 'N/A')
|
||||
|
||||
if isinstance(val, list): val = ", ".join(val)
|
||||
if isinstance(val, bool): val = "Yes" if val else "No"
|
||||
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
|
||||
|
||||
# 2. Show remaining keys
|
||||
for k, v in style.items():
|
||||
if k not in ordered_keys and k != 'formatting_rules':
|
||||
val = ", ".join(v) if isinstance(v, list) else str(v)
|
||||
grid.add_row(f"{k.replace('_', ' ').title()}:", val)
|
||||
|
||||
len_str = f"{length.get('label', 'N/A')} ({length.get('words', 'N/A')} words, {length.get('chapters', 'N/A')} ch)"
|
||||
extras = []
|
||||
if length.get('include_prologue'): extras.append("Prologue")
|
||||
if length.get('include_epilogue'): extras.append("Epilogue")
|
||||
if extras: len_str += f" + {', '.join(extras)}"
|
||||
grid.add_row("Length:", len_str)
|
||||
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No")
|
||||
|
||||
console.print(Panel(grid, title="[bold blue]📖 Project Metadata[/bold blue]", expand=False))
|
||||
|
||||
# Formatting Rules Table
|
||||
fmt_rules = style.get('formatting_rules', [])
|
||||
if fmt_rules:
|
||||
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
|
||||
for i, r in enumerate(fmt_rules):
|
||||
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r))
|
||||
console.print(Panel(fmt_table, title="[bold blue]🎨 Formatting[/bold blue]"))
|
||||
|
||||
# Characters Table
|
||||
char_table = Table(title="👥 Characters", show_header=True, header_style="bold magenta", expand=True)
|
||||
char_table.add_column("Name", style="green")
|
||||
char_table.add_column("Role")
|
||||
char_table.add_column("Description")
|
||||
for c in data.get('characters', []):
|
||||
# Removed truncation to show full description
|
||||
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
|
||||
console.print(char_table)
|
||||
|
||||
# Books List
|
||||
for book in data.get('books', []):
|
||||
console.print(f"\n[bold cyan]📘 Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
|
||||
console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
|
||||
|
||||
beats = book.get('plot_beats', [])
|
||||
if beats:
|
||||
beat_table = Table(show_header=False, box=None, expand=True)
|
||||
for i, b in enumerate(beats):
|
||||
beat_table.add_row(f"[bold]{i+1}.[/bold]", str(b))
|
||||
console.print(beat_table)
|
||||
|
||||
def refine_blueprint(self, title="Refine Blueprint"):
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel(f"[bold blue]🔧 {title}[/bold blue]"))
|
||||
self.display_summary(self.data)
|
||||
console.print("\n[dim](Full JSON loaded)[/dim]")
|
||||
|
||||
change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]")
|
||||
if change.lower() == 'done': break
|
||||
|
||||
# Inner loop for refinement
|
||||
current_data = self.data
|
||||
instruction = change
|
||||
|
||||
while True:
|
||||
with console.status("[bold green]AI is updating blueprint...[/bold green]"):
|
||||
prompt = f"""
|
||||
Act as a Book Editor.
|
||||
CURRENT JSON: {json.dumps(current_data)}
|
||||
USER INSTRUCTION: {instruction}
|
||||
|
||||
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
|
||||
RETURN ONLY THE JSON.
|
||||
"""
|
||||
new_data = self.ask_gemini_json(prompt)
|
||||
|
||||
if not new_data:
|
||||
console.print("[red]AI failed to generate valid JSON.[/red]")
|
||||
break
|
||||
|
||||
self.clear()
|
||||
console.print(Panel("[bold blue]👀 Review AI Changes[/bold blue]"))
|
||||
self.display_summary(new_data)
|
||||
|
||||
feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
|
||||
|
||||
if feedback.lower() == 'yes':
|
||||
self.data = new_data
|
||||
console.print("[green]Changes saved![/green]")
|
||||
break
|
||||
else:
|
||||
current_data = new_data
|
||||
instruction = feedback
|
||||
|
||||
def save_bible(self):
|
||||
if not self.project_path:
|
||||
console.print("[red]Project path not set. Cannot save bible.[/red]")
|
||||
return None
|
||||
|
||||
if not os.path.exists(self.project_path): os.makedirs(self.project_path)
|
||||
filename = os.path.join(self.project_path, "bible.json")
|
||||
|
||||
with open(filename, 'w') as f: json.dump(self.data, f, indent=2)
|
||||
console.print(Panel(f"[bold green]✅ Bible saved to: {filename}[/bold green]"))
|
||||
return filename
|
||||
|
||||
def manage_runs(self, job_filename):
|
||||
job_name = os.path.splitext(job_filename)[0]
|
||||
runs_dir = os.path.join(self.project_path, "runs", job_name)
|
||||
|
||||
if not os.path.exists(runs_dir):
|
||||
console.print("[red]No runs found for this job.[/red]")
|
||||
Prompt.ask("Press Enter...")
|
||||
return
|
||||
|
||||
runs = sorted([d for d in os.listdir(runs_dir) if os.path.isdir(os.path.join(runs_dir, d)) and d.startswith("run_")], key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0, reverse=True)
|
||||
|
||||
if not runs:
|
||||
console.print("[red]No runs found.[/red]")
|
||||
Prompt.ask("Press Enter...")
|
||||
return
|
||||
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel(f"[bold blue]Runs for: {job_name}[/bold blue]"))
|
||||
for i, r in enumerate(runs):
|
||||
console.print(f"[{i+1}] {r}")
|
||||
console.print(f"[{len(runs)+1}] Back")
|
||||
console.print(f"[{len(runs)+2}] Exit")
|
||||
|
||||
choice = int(Prompt.ask("Select Run", choices=[str(i) for i in range(1, len(runs)+3)]))
|
||||
if choice == len(runs) + 1: break
|
||||
elif choice == len(runs) + 2: sys.exit()
|
||||
|
||||
selected_run = runs[choice-1]
|
||||
run_path = os.path.join(runs_dir, selected_run)
|
||||
|
||||
self.manage_specific_run(run_path)
|
||||
|
||||
def manage_specific_run(self, run_path):
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]"))
|
||||
|
||||
# Detect sub-books (Series Run)
|
||||
subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")])
|
||||
|
||||
if subdirs:
|
||||
console.print("[italic]Series Run Detected[/italic]")
|
||||
for i, s in enumerate(subdirs):
|
||||
console.print(f"[{i+1}] Manage {s}")
|
||||
|
||||
idx_open = len(subdirs) + 1
|
||||
idx_back = len(subdirs) + 2
|
||||
idx_exit = len(subdirs) + 3
|
||||
|
||||
console.print(f"[{idx_open}] Open Run Folder")
|
||||
console.print(f"[{idx_back}] Back")
|
||||
console.print(f"[{idx_exit}] Exit")
|
||||
|
||||
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, idx_exit+1)]))
|
||||
|
||||
if choice <= len(subdirs):
|
||||
book_path = os.path.join(run_path, subdirs[choice-1])
|
||||
self.manage_single_book_folder(book_path)
|
||||
elif choice == idx_open:
|
||||
self.open_folder(run_path)
|
||||
elif choice == idx_back:
|
||||
break
|
||||
elif choice == idx_exit:
|
||||
sys.exit()
|
||||
else:
|
||||
# Legacy or Flat Run
|
||||
self.manage_single_book_folder(run_path)
|
||||
break
|
||||
|
||||
def manage_single_book_folder(self, folder_path):
|
||||
while True:
|
||||
self.clear()
|
||||
console.print(Panel(f"[bold blue]Manage: {os.path.basename(folder_path)}[/bold blue]"))
|
||||
console.print("1. Regenerate Cover & Recompile EPUB")
|
||||
console.print("2. Open Folder")
|
||||
console.print("3. Back")
|
||||
|
||||
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
|
||||
|
||||
if choice == 1:
|
||||
import main
|
||||
bp_path = os.path.join(folder_path, "final_blueprint.json")
|
||||
ms_path = os.path.join(folder_path, "manuscript.json")
|
||||
|
||||
if os.path.exists(bp_path) and os.path.exists(ms_path):
|
||||
with console.status("[bold yellow]Regenerating Cover...[/bold yellow]"):
|
||||
with open(bp_path, 'r') as f: bp = json.load(f)
|
||||
with open(ms_path, 'r') as f: ms = json.load(f)
|
||||
|
||||
# Check/Generate Tracking
|
||||
events_path = os.path.join(folder_path, "tracking_events.json")
|
||||
chars_path = os.path.join(folder_path, "tracking_characters.json")
|
||||
tracking = {"events": [], "characters": {}}
|
||||
|
||||
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path)
|
||||
if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path)
|
||||
|
||||
main.ai.init_models()
|
||||
|
||||
if not tracking['events'] and not tracking['characters']:
|
||||
# Fallback: Use Blueprint data
|
||||
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
|
||||
tracking['events'] = bp.get('plot_beats', [])
|
||||
tracking['characters'] = {}
|
||||
for c in bp.get('characters', []):
|
||||
name = c.get('name', 'Unknown')
|
||||
tracking['characters'][name] = {
|
||||
"descriptors": [c.get('description', '')],
|
||||
"likes_dislikes": [],
|
||||
"last_worn": "Unknown"
|
||||
}
|
||||
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2)
|
||||
with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
|
||||
|
||||
main.marketing.generate_cover(bp, folder_path, tracking)
|
||||
main.export.compile_files(bp, ms, folder_path)
|
||||
console.print("[green]Cover updated and EPUB recompiled![/green]")
|
||||
Prompt.ask("Press Enter...")
|
||||
else:
|
||||
console.print("[red]Missing blueprint or manuscript in run folder.[/red]")
|
||||
Prompt.ask("Press Enter...")
|
||||
elif choice == 2:
|
||||
self.open_folder(folder_path)
|
||||
elif choice == 3:
|
||||
break
|
||||
|
||||
def open_folder(self, path):
|
||||
if os.name == 'nt':
|
||||
os.startfile(path)
|
||||
else:
|
||||
os.system(f"open '{path}'")
|
||||
|
||||
if __name__ == "__main__":
|
||||
w = BookWizard()
|
||||
with app.app_context():
|
||||
try:
|
||||
if w.select_mode():
|
||||
while True:
|
||||
w.clear()
|
||||
console.print(Panel(f"[bold blue]📂 Project: {w.project_name}[/bold blue]"))
|
||||
console.print("1. Edit Bible")
|
||||
console.print("2. Run Book Generation")
|
||||
console.print("3. Manage Runs")
|
||||
console.print("4. Exit")
|
||||
|
||||
choice = int(Prompt.ask("Select Option", choices=["1", "2", "3", "4"]))
|
||||
|
||||
if choice == 1:
|
||||
if w.load_bible():
|
||||
w.refine_blueprint("Refine Bible")
|
||||
w.save_bible()
|
||||
elif choice == 2:
|
||||
if w.load_bible():
|
||||
bible_path = os.path.join(w.project_path, "bible.json")
|
||||
import main
|
||||
main.run_generation(bible_path)
|
||||
Prompt.ask("\nGeneration complete. Press Enter...")
|
||||
elif choice == 3:
|
||||
# Manage runs for the bible
|
||||
w.manage_runs("bible.json")
|
||||
else:
|
||||
break
|
||||
else:
|
||||
pass
|
||||
except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")
|
||||
Reference in New Issue
Block a user