Adding files.

This commit is contained in:
2026-02-03 10:13:33 -05:00
parent fc44a7834a
commit 9dec4a472f
34 changed files with 5984 additions and 0 deletions

23
.dockerignore Normal file
View 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
View File

@@ -0,0 +1,6 @@
.env
__pycache__/
run_*/
*.docx
*.epub
data/

27
Dockerfile Normal file
View 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
View File

@@ -0,0 +1 @@
# BookApp Modules

61
config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

47
modules/web_db.py Normal file
View 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
View 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
View File

@@ -0,0 +1,10 @@
google-generativeai
google-cloud-aiplatform
google-auth-oauthlib
python-docx
EbookLib
python-dotenv
rich
Pillow
requests
markdown

View 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 %}

View 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
View 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
View 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
View 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
View 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
View 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
View 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 %}

View 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 %}

View 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
View 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
View 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 %}

View 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
View 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
View 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]")