Update app.py
Browse files
app.py
CHANGED
|
@@ -1,406 +1,321 @@
|
|
| 1 |
import os
|
| 2 |
-
import json
|
| 3 |
-
import random
|
| 4 |
import time
|
| 5 |
-
import base64
|
| 6 |
-
import io
|
| 7 |
-
import traceback
|
| 8 |
import gradio as gr
|
| 9 |
-
from
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
PlanningAgent,
|
| 15 |
-
WritingAgent,
|
| 16 |
-
)
|
| 17 |
-
from storywriter.planning_agent import (
|
| 18 |
-
PersonaTrajectoryAgent,
|
| 19 |
-
SubeventDynamicsAligner
|
| 20 |
-
)
|
| 21 |
|
| 22 |
# ==========================================
|
| 23 |
-
#
|
| 24 |
# ==========================================
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
"""
|
| 107 |
-
|
| 108 |
-
MAX_IMAGES = 3
|
| 109 |
-
for idx, p in enumerate(personas_list):
|
| 110 |
-
name = p.get("name", "Unknown")
|
| 111 |
-
basic = p.get("basic", {})
|
| 112 |
-
role, age, gender = basic.get("role", "N/A"), basic.get("age_stage", "Unknown"), basic.get("gender", "")
|
| 113 |
-
appearance, status = basic.get("appearance", "No description."), basic.get("initial_status", "")
|
| 114 |
-
archetypes = basic.get("archetype", []) if isinstance(basic.get("archetype"), list) else []
|
| 115 |
-
tags_html = "".join([f"<span class='card-tag'>#{t}</span>" for t in archetypes[:4]])
|
| 116 |
-
|
| 117 |
-
img_src = image_map.get(name, "") if image_map else ""
|
| 118 |
-
placeholder_text = "No Image" if idx >= MAX_IMAGES else ("Generating Art..." if generation_started else "Waiting...")
|
| 119 |
-
img_html = f"<div class='card-img-wrapper'><img src='{img_src}' class='card-img' alt='{name}'></div>" if img_src else f"<div class='card-img-wrapper'><div class='card-placeholder'>{placeholder_text}</div></div>"
|
| 120 |
-
|
| 121 |
-
html += f"""
|
| 122 |
-
<div class="persona-card">{img_html}<div class="card-content">
|
| 123 |
-
<div class="card-header"><div class="card-name">{name}</div><div class="card-age">{age} {gender}</div></div>
|
| 124 |
-
<div class="card-role-badge">{role}</div><div class="card-tags">{tags_html}</div>
|
| 125 |
-
<div class="card-section"><span class="label">外貌:</span>{appearance[:40]}...</div>
|
| 126 |
-
</div></div>"""
|
| 127 |
-
return html + "</div>"
|
| 128 |
|
| 129 |
# ==========================================
|
| 130 |
-
#
|
| 131 |
# ==========================================
|
| 132 |
def render_book_page(story_data, page_index):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
if not story_data or not isinstance(story_data, list) or len(story_data) == 0:
|
| 134 |
-
return "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
| 137 |
chapter = story_data[page_index]
|
|
|
|
|
|
|
| 138 |
title = chapter.get("title", f"Chapter {page_index + 1}")
|
| 139 |
content = chapter.get("content", "")
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
| 144 |
html = f"""
|
| 145 |
-
<
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
font-size: 2.2em; font-weight: 800; text-align: center; margin-bottom: 40px;
|
| 157 |
-
color: #8b4513; border-bottom: 2px solid rgba(139, 69, 19, 0.2); padding-bottom: 20px;
|
| 158 |
-
}}
|
| 159 |
-
.chapter-content p {{
|
| 160 |
-
font-size: 1.15em; line-height: 1.8; margin-bottom: 1.2em; text-align: justify; text-indent: 2em;
|
| 161 |
-
}}
|
| 162 |
-
.page-number {{ text-align: center; margin-top: 40px; font-size: 0.9em; color: #888; }}
|
| 163 |
-
</style>
|
| 164 |
-
<div class="book-wrapper">
|
| 165 |
-
<div class="chapter-title">{title}</div>
|
| 166 |
-
<div class="chapter-content">{content_html}</div>
|
| 167 |
-
<div class="page-number">- {page_index + 1} / {len(story_data)} -</div>
|
| 168 |
</div>
|
| 169 |
"""
|
| 170 |
return html
|
| 171 |
|
| 172 |
# ==========================================
|
| 173 |
-
#
|
| 174 |
# ==========================================
|
| 175 |
-
def
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
current_log = msg
|
| 179 |
-
|
| 180 |
-
current_outline = None
|
| 181 |
-
current_plan = None
|
| 182 |
-
current_personas = None
|
| 183 |
-
current_story = None # 将存储结构化数据
|
| 184 |
-
persona_images_map = {}
|
| 185 |
-
|
| 186 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 187 |
-
|
| 188 |
-
if not premise or not premise.strip():
|
| 189 |
-
msg = "❌ Error: Premise is empty.\n"
|
| 190 |
-
current_log += msg
|
| 191 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 192 |
return
|
| 193 |
-
|
| 194 |
-
hf_token = os.environ.get("HF_TOKEN")
|
| 195 |
-
img_gen = ImageGenerator(token=hf_token)
|
| 196 |
-
|
| 197 |
-
# --- Phase 1: Outline & Art ---
|
| 198 |
-
msg = f"📝 [1/5] Generating outline & initial profiles...\n"
|
| 199 |
-
print(msg.strip())
|
| 200 |
-
current_log += msg
|
| 201 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
profiles_json_text = outline_outputs.get("profiles_json", "{}")
|
| 212 |
-
personas_raw_data = {}
|
| 213 |
-
try:
|
| 214 |
-
personas_raw_data = json.loads(profiles_json_text)
|
| 215 |
-
current_personas = render_persona_html(personas_raw_data, persona_images_map, generation_started=True)
|
| 216 |
-
except:
|
| 217 |
-
current_personas = f"<pre>{profiles_json_text}</pre>"
|
| 218 |
-
|
| 219 |
-
msg = "✅ [1/5] Outline generated. Starting Art Generation...\n"
|
| 220 |
-
print(msg.strip())
|
| 221 |
-
current_log += msg
|
| 222 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 223 |
-
|
| 224 |
-
# 生成图片 (Phase 1)
|
| 225 |
-
if hf_token and isinstance(personas_raw_data, dict):
|
| 226 |
-
p_list = personas_raw_data.get("personas", [])
|
| 227 |
-
if isinstance(p_list, list):
|
| 228 |
-
for i, p in enumerate(p_list[:3]):
|
| 229 |
-
name = p.get("name", "Unknown")
|
| 230 |
-
basic = p.get("basic", {})
|
| 231 |
-
desc_prompt = f"{basic.get('gender','')}, {basic.get('age_stage','')}, {basic.get('appearance','')}"
|
| 232 |
-
if name not in persona_images_map:
|
| 233 |
-
img_b64 = img_gen.generate_persona_image_base64(name, desc_prompt)
|
| 234 |
-
if img_b64:
|
| 235 |
-
persona_images_map[name] = img_b64
|
| 236 |
-
msg = f" -> Generated: {name}\n"
|
| 237 |
-
print(msg.strip())
|
| 238 |
-
current_log += msg
|
| 239 |
-
current_personas = render_persona_html(personas_raw_data, persona_images_map, generation_started=True)
|
| 240 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 241 |
-
else:
|
| 242 |
-
msg = f" -> Failed to generate: {name}\n"
|
| 243 |
-
print(msg.strip())
|
| 244 |
-
current_log += msg
|
| 245 |
-
elif not hf_token:
|
| 246 |
-
msg = "⚠️ [Art] HF_TOKEN missing. Skipping Art.\n"
|
| 247 |
-
print(msg.strip())
|
| 248 |
-
current_log += msg
|
| 249 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 250 |
-
|
| 251 |
-
except Exception as e:
|
| 252 |
-
msg = f"❌ [Error in Phase 1]: {str(e)}\n"
|
| 253 |
-
print(msg.strip())
|
| 254 |
-
traceback.print_exc()
|
| 255 |
-
current_log += msg
|
| 256 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 257 |
-
return
|
| 258 |
-
|
| 259 |
-
# --- Phase 2: Plan ---
|
| 260 |
-
msg = "📅 [2/5] Splitting events into sub-events...\n"
|
| 261 |
-
print(msg.strip())
|
| 262 |
-
current_log += msg
|
| 263 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 264 |
-
|
| 265 |
-
try:
|
| 266 |
-
events_json_input = current_outline if isinstance(current_outline, dict) else {}
|
| 267 |
-
temp_dir = "/tmp/longstory_cache"
|
| 268 |
-
os.makedirs(temp_dir, exist_ok=True)
|
| 269 |
-
planner = PlanningAgent()
|
| 270 |
-
plan_stats = planner.plan_from_events_json(
|
| 271 |
-
events_json=events_json_input, output_dir=temp_dir, file_id="temp", premise=premise
|
| 272 |
-
)
|
| 273 |
-
current_plan = plan_stats.get("subevents_json", {})
|
| 274 |
-
msg = "✅ [2/5] Planning complete.\n"
|
| 275 |
-
print(msg.strip())
|
| 276 |
-
current_log += msg
|
| 277 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 278 |
-
except Exception as e:
|
| 279 |
-
msg = f"❌ [Error in Phase 2]: {str(e)}\n"
|
| 280 |
-
print(msg.strip())
|
| 281 |
-
traceback.print_exc()
|
| 282 |
-
current_log += msg
|
| 283 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 284 |
-
return
|
| 285 |
-
|
| 286 |
-
# --- Phase 3: Persona Refinement ---
|
| 287 |
-
msg = "👥 [3/5] Refining Personas (Text Only)...\n"
|
| 288 |
-
print(msg.strip())
|
| 289 |
-
current_log += msg
|
| 290 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 291 |
|
| 292 |
try:
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
current_personas = render_persona_html(personas_data, persona_images_map, generation_started=True)
|
| 296 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
current_plan = enriched_subevents
|
| 301 |
-
msg = "✅ [3/5] Personas refined & Plan enriched.\n"
|
| 302 |
-
print(msg.strip())
|
| 303 |
-
current_log += msg
|
| 304 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 305 |
-
|
| 306 |
-
except Exception as e:
|
| 307 |
-
msg = f"❌ [Error in Phase 3]: {str(e)}\n"
|
| 308 |
-
print(msg.strip())
|
| 309 |
-
traceback.print_exc()
|
| 310 |
-
current_log += msg
|
| 311 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 312 |
-
return
|
| 313 |
-
|
| 314 |
-
# --- Phase 4: Writing (结构化 JSON 输出) ---
|
| 315 |
-
msg = "✍️ [4/5] Writing chapters (Stream starting)...\n"
|
| 316 |
-
print(msg.strip())
|
| 317 |
-
current_log += msg
|
| 318 |
-
current_story = [{"title": "系统消息", "content": "正在撰写正文,请稍候... (Writing in progress)"}]
|
| 319 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 320 |
-
|
| 321 |
-
try:
|
| 322 |
-
writer = WritingAgent()
|
| 323 |
-
outputs = writer.write_from_subevents_json(events_json=current_outline, subevents_json=current_plan, personas=personas_data if 'personas_data' in locals() else {}, max_turns=500)
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
|
|
|
| 340 |
|
| 341 |
except Exception as e:
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
current_log += msg
|
| 346 |
-
current_story = [{"title": "Error", "content": f"Generation Failed: {str(e)}"}]
|
| 347 |
-
yield current_log, current_outline, current_plan, current_personas, current_story
|
| 348 |
|
| 349 |
# ==========================================
|
| 350 |
-
#
|
| 351 |
# ==========================================
|
| 352 |
-
with gr.Blocks(title="
|
| 353 |
-
gr.Markdown("## 🔒 LongStory Backend API")
|
| 354 |
-
premise_in = gr.Textbox(label="Input Premise")
|
| 355 |
|
|
|
|
|
|
|
|
|
|
| 356 |
story_state = gr.State([])
|
| 357 |
page_state = gr.State(0)
|
| 358 |
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
)
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
)
|
| 392 |
-
|
| 393 |
-
def go_prev(story, page):
|
| 394 |
-
new_page = max(0, page - 1)
|
| 395 |
-
return new_page, render_book_page(story, new_page)
|
| 396 |
-
|
| 397 |
-
def go_next(story, page):
|
| 398 |
-
if not story: return page, ""
|
| 399 |
-
new_page = min(len(story) - 1, page + 1)
|
| 400 |
-
return new_page, render_book_page(story, new_page)
|
| 401 |
-
|
| 402 |
-
prev_btn.click(fn=go_prev, inputs=[story_state, page_state], outputs=[page_state, story_display])
|
| 403 |
-
next_btn.click(fn=go_next, inputs=[story_state, page_state], outputs=[page_state, story_display])
|
| 404 |
|
| 405 |
if __name__ == "__main__":
|
| 406 |
-
demo.queue(
|
|
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import time
|
|
|
|
|
|
|
|
|
|
| 3 |
import gradio as gr
|
| 4 |
+
from gradio_client import Client
|
| 5 |
|
| 6 |
+
# 请确保这里的 Space ID 是你部署后端的真实 ID (格式: Username/SpaceName)
|
| 7 |
+
PRIVATE_SPACE_ID = "Yoyo2004/Longstory-backend"
|
| 8 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
# ==========================================
|
| 11 |
+
# 1. CSS 样式设计 (电子书风格)
|
| 12 |
# ==========================================
|
| 13 |
+
custom_css = """
|
| 14 |
+
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&family=Noto+Serif+SC:wght@400;700&family=JetBrains+Mono:wght@400&display=swap');
|
| 15 |
+
|
| 16 |
+
:root {
|
| 17 |
+
--primary-color: #4f46e5;
|
| 18 |
+
--paper-bg: #fdf6e3;
|
| 19 |
+
--text-ink: #2c3e50;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body, .gradio-container {
|
| 23 |
+
font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif !important;
|
| 24 |
+
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%) !important;
|
| 25 |
+
background-attachment: fixed !important;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* 通用布局 */
|
| 29 |
+
.header-box { text-align: center; padding: 20px; background: rgba(255,255,255,0.9); border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
|
| 30 |
+
.title-text { font-size: 2rem; font-weight: 800; font-family: 'Noto Serif SC', serif; color: #1f2937; }
|
| 31 |
+
.subtitle-text { color: #6b7280; letter-spacing: 1px; text-transform: uppercase; font-size: 0.9rem; }
|
| 32 |
+
|
| 33 |
+
/* 控制面板 */
|
| 34 |
+
.control-panel { background: rgba(255,255,255,0.9); padding: 20px !important; border-radius: 16px; }
|
| 35 |
+
.generate-btn { background: linear-gradient(90deg, #4f46e5, #7c3aed) !important; color: white !important; font-weight: 600; margin-top: 15px; }
|
| 36 |
+
.terminal-log textarea { font-family: 'JetBrains Mono', monospace !important; background: #1e1e1e !important; color: #a9b7c6 !important; border-radius: 8px; font-size: 12px; }
|
| 37 |
+
|
| 38 |
+
/* --- 📖 电子书阅读器核心样式 --- */
|
| 39 |
+
.book-container {
|
| 40 |
+
background-color: var(--paper-bg); /* 羊皮纸底色 */
|
| 41 |
+
padding: 60px 80px;
|
| 42 |
+
min-height: 700px;
|
| 43 |
+
border-radius: 8px 16px 16px 8px;
|
| 44 |
+
box-shadow:
|
| 45 |
+
inset 20px 0 50px rgba(0,0,0,0.05), /* 书脊阴影 */
|
| 46 |
+
10px 10px 30px rgba(0,0,0,0.1); /* 外部投影 */
|
| 47 |
+
font-family: 'Noto Serif SC', serif; /* 衬线字体,更有文学感 */
|
| 48 |
+
color: var(--text-ink);
|
| 49 |
+
position: relative;
|
| 50 |
+
margin-top: 10px;
|
| 51 |
+
line-height: 2.0; /* 宽松行高 */
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* 书页左侧装饰线 */
|
| 55 |
+
.book-container::before {
|
| 56 |
+
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
|
| 57 |
+
background: linear-gradient(to right, rgba(0,0,0,0.1), transparent);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* 章节标题设计 */
|
| 61 |
+
.chapter-header {
|
| 62 |
+
text-align: center; margin-bottom: 40px; padding-bottom: 20px;
|
| 63 |
+
border-bottom: 2px solid rgba(139, 69, 19, 0.2);
|
| 64 |
+
}
|
| 65 |
+
.chapter-subtitle { font-size: 0.9em; color: #8b4513; opacity: 0.6; font-style: italic; margin-bottom: 5px; }
|
| 66 |
+
.chapter-title {
|
| 67 |
+
font-size: 2.4em;
|
| 68 |
+
font-weight: 700;
|
| 69 |
+
color: #8b4513; /* 深棕色标题 */
|
| 70 |
+
text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* 正文段落 */
|
| 74 |
+
.chapter-content { font-size: 1.2em; text-align: justify; }
|
| 75 |
+
.chapter-content p {
|
| 76 |
+
margin-bottom: 1.5em;
|
| 77 |
+
text-indent: 2em; /* 首行缩进 */
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* 页脚 */
|
| 81 |
+
.page-footer { text-align: center; margin-top: 60px; font-size: 0.9em; color: #a0aec0; font-family: sans-serif; }
|
| 82 |
+
|
| 83 |
+
/* 翻页按钮容器 */
|
| 84 |
+
.nav-row { margin-top: 20px; display: flex; justify-content: center; gap: 20px; }
|
| 85 |
+
.nav-btn { width: 120px !important; border-radius: 30px !important; }
|
| 86 |
+
|
| 87 |
+
/* 移动端适配 */
|
| 88 |
+
@media (max-width: 768px) {
|
| 89 |
+
.book-container { padding: 30px 20px; }
|
| 90 |
+
.chapter-title { font-size: 1.8em; }
|
| 91 |
+
.chapter-content { font-size: 1.1em; }
|
| 92 |
+
}
|
| 93 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
# ==========================================
|
| 96 |
+
# 2. 渲染辅助函数 (Python)
|
| 97 |
# ==========================================
|
| 98 |
def render_book_page(story_data, page_index):
|
| 99 |
+
"""
|
| 100 |
+
将章节数据渲染成 HTML。
|
| 101 |
+
story_data: List[Dict] -> [{"title": "Chapter 1", "content": "..."}]
|
| 102 |
+
"""
|
| 103 |
+
# 1. 空状态处理
|
| 104 |
if not story_data or not isinstance(story_data, list) or len(story_data) == 0:
|
| 105 |
+
return """
|
| 106 |
+
<div class='book-container' style='display:flex;align-items:center;justify-content:center;color:#999'>
|
| 107 |
+
<div style='text-align:center;'>
|
| 108 |
+
<h3>📖 等待故事生成...</h3>
|
| 109 |
+
<p>Wait for content generation...</p>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
# 2. 页码边界检查
|
| 115 |
+
total_pages = len(story_data)
|
| 116 |
+
page_index = max(0, min(page_index, total_pages - 1))
|
| 117 |
|
| 118 |
+
# 3. 获取当前章节数据
|
| 119 |
chapter = story_data[page_index]
|
| 120 |
+
|
| 121 |
+
# 兼容后端可能返回的字典 key (title/content)
|
| 122 |
title = chapter.get("title", f"Chapter {page_index + 1}")
|
| 123 |
content = chapter.get("content", "")
|
| 124 |
|
| 125 |
+
# 4. 格式化正文 (将换行符 \n 转为 HTML 段落 <p>)
|
| 126 |
+
# 过滤掉空行,给每段加上 <p>
|
| 127 |
+
paragraphs = [p.strip() for p in content.split('\n') if p.strip()]
|
| 128 |
+
content_html = "".join([f"<p>{p}</p>" for p in paragraphs])
|
| 129 |
+
|
| 130 |
+
# 5. 组装 HTML
|
| 131 |
html = f"""
|
| 132 |
+
<div class="book-container">
|
| 133 |
+
<div class="chapter-header">
|
| 134 |
+
<div class="chapter-subtitle">Generated Novel</div>
|
| 135 |
+
<div class="chapter-title">{title}</div>
|
| 136 |
+
</div>
|
| 137 |
+
<div class="chapter-content">
|
| 138 |
+
{content_html}
|
| 139 |
+
</div>
|
| 140 |
+
<div class="page-footer">
|
| 141 |
+
- Page {page_index + 1} of {total_pages} -
|
| 142 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
</div>
|
| 144 |
"""
|
| 145 |
return html
|
| 146 |
|
| 147 |
# ==========================================
|
| 148 |
+
# 3. 后端连接逻辑 (Gradio Client)
|
| 149 |
# ==========================================
|
| 150 |
+
def bridge_to_backend(premise):
|
| 151 |
+
if not premise.strip():
|
| 152 |
+
yield "⚠️ 请输入故事梗概...", None, None, None, [], None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
+
# 初始化日志
|
| 156 |
+
log_buffer = "🚀 初始化前端连接...\n"
|
| 157 |
+
# 初始状态:空故事列表,以及渲染好的“等待中”HTML
|
| 158 |
+
empty_story = []
|
| 159 |
+
initial_html = render_book_page([], 0)
|
| 160 |
+
|
| 161 |
+
# Yield 初始状态 [log, outline, plan, personas, story_state, story_view]
|
| 162 |
+
yield log_buffer, None, None, None, empty_story, initial_html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
try:
|
| 165 |
+
log_buffer += f"🔗 连接后端 Space: {PRIVATE_SPACE_ID}...\n"
|
| 166 |
+
client = Client(PRIVATE_SPACE_ID, hf_token=HF_TOKEN)
|
|
|
|
|
|
|
| 167 |
|
| 168 |
+
# 提交任务
|
| 169 |
+
job = client.submit(premise, api_name="/generate_novel")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
start_time = time.time()
|
| 172 |
+
for result in job:
|
| 173 |
+
elapsed = int(time.time() - start_time)
|
| 174 |
+
current_log = log_buffer + f"\n[+{elapsed}s] 后端处理中... (Processing)"
|
| 175 |
+
|
| 176 |
+
# 解析后端返回的 Tuple (对应后端 app.py 的 return)
|
| 177 |
+
# [0]Log, [1]Outline, [2]Plan, [3]Personas(HTML), [4]Story(List[Dict])
|
| 178 |
+
backend_log = result[0]
|
| 179 |
+
outline = result[1]
|
| 180 |
+
plan = result[2]
|
| 181 |
+
personas_html = result[3]
|
| 182 |
+
story_list = result[4]
|
| 183 |
|
| 184 |
+
# 关键:拿到 story_list 后,立即渲染第一页 (page_index=0)
|
| 185 |
+
book_html = render_book_page(story_list, 0)
|
| 186 |
+
|
| 187 |
+
# 更新所有组件
|
| 188 |
+
# story_state 存原始数据,story_display 存渲染后的 HTML
|
| 189 |
+
yield backend_log, outline, plan, personas_html, story_list, book_html
|
| 190 |
|
| 191 |
except Exception as e:
|
| 192 |
+
error_msg = f"❌ 前端连接错误: {str(e)}"
|
| 193 |
+
yield error_msg, None, None, None, [], render_book_page([], 0)
|
| 194 |
+
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
# ==========================================
|
| 197 |
+
# 4. 前端 UI 布局 (Blocks)
|
| 198 |
# ==========================================
|
| 199 |
+
with gr.Blocks(theme=gr.themes.Soft(), css=custom_css, title="LongStory Agent") as demo:
|
|
|
|
|
|
|
| 200 |
|
| 201 |
+
# --- 状态管理 (State) ---
|
| 202 |
+
# story_state: 存储完整的小说章节列表 (List[Dict])
|
| 203 |
+
# page_state: 存储当前阅读的页码 (int)
|
| 204 |
story_state = gr.State([])
|
| 205 |
page_state = gr.State(0)
|
| 206 |
|
| 207 |
+
# --- 顶部标题 ---
|
| 208 |
+
with gr.Row(elem_classes=["header-box"]):
|
| 209 |
+
gr.HTML("""
|
| 210 |
+
<div class="title-wrapper">
|
| 211 |
+
<div class="title-text">LongStory AI</div>
|
| 212 |
+
<div class="title-badge">PRO</div>
|
| 213 |
+
</div>
|
| 214 |
+
<div class="subtitle-text">Deep Persona-Driven Recursive Novel Generation System</div>
|
| 215 |
+
""")
|
| 216 |
+
|
| 217 |
+
with gr.Row(elem_classes=["main-container"]):
|
| 218 |
+
|
| 219 |
+
# --- 左侧:控制面板 ---
|
| 220 |
+
with gr.Column(scale=4, elem_classes=["control-panel-col"]):
|
| 221 |
+
with gr.Column(elem_classes=["control-panel"]):
|
| 222 |
+
gr.Markdown("### 💡 故事梗概 (Story Premise)", elem_id="input_title")
|
| 223 |
+
|
| 224 |
+
premise_input = gr.Textbox(
|
| 225 |
+
label="输入你的创意...", show_label=False,
|
| 226 |
+
placeholder="例如:赛博朋克背景下,一个因旧型号义肢而被歧视的侦探...",
|
| 227 |
+
lines=6, elem_classes=["input-box"]
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
with gr.Group(elem_classes=["examples-table"]):
|
| 231 |
+
gr.Examples(
|
| 232 |
+
examples=[
|
| 233 |
+
["高中时互相看不顺眼的死对头,一个是高冷学霸,一个是调皮体育生。十年后的同学聚会上..."],
|
| 234 |
+
["天生'废灵根'的宗门弃徒,在被逐出师门当晚,意外捡到一个黑色小鼎..."]
|
| 235 |
+
],
|
| 236 |
+
inputs=premise_input,
|
| 237 |
+
label="⚡ 快速灵感"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
submit_btn = gr.Button(
|
| 241 |
+
"🚀 启动生成 (GENERATE)",
|
| 242 |
+
elem_classes=["generate-btn"]
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# 终端日志样式
|
| 246 |
+
gr.HTML("<div style='margin-top:20px;color:#888;font-size:12px;'>TERMINAL OUTPUT</div>")
|
| 247 |
+
log_output = gr.Textbox(
|
| 248 |
+
label="System Log", lines=12, interactive=False,
|
| 249 |
+
elem_classes=["terminal-log"], show_label=False, value="> System Ready."
|
| 250 |
+
)
|
| 251 |
|
| 252 |
+
# --- 右侧:内容展示区 ---
|
| 253 |
+
with gr.Column(scale=8):
|
| 254 |
+
with gr.Tabs(elem_classes=["tabs-container"]):
|
| 255 |
+
|
| 256 |
+
# Tab 1: 电子书阅读器 (重点优化)
|
| 257 |
+
with gr.TabItem("📖 正文 (Reader)", id="tab-story"):
|
| 258 |
+
# 用于显示渲染后的 HTML
|
| 259 |
+
story_display = gr.HTML(label="Book View")
|
| 260 |
+
|
| 261 |
+
# 翻页按钮
|
| 262 |
+
with gr.Row(elem_classes=["nav-row"]):
|
| 263 |
+
prev_btn = gr.Button("← 上一章", elem_classes=["nav-btn"])
|
| 264 |
+
next_btn = gr.Button("下一章 →", elem_classes=["nav-btn"])
|
| 265 |
+
|
| 266 |
+
# Tab 2: 大纲
|
| 267 |
+
with gr.TabItem("🗺️ 大纲 (Outline)", id="tab-outline"):
|
| 268 |
+
outline_output = gr.JSON(label=None, elem_classes=["json-panel"])
|
| 269 |
+
|
| 270 |
+
# Tab 3: 规划
|
| 271 |
+
with gr.TabItem("📅 规划 (Plan)", id="tab-planning"):
|
| 272 |
+
plan_output = gr.JSON(label=None, elem_classes=["json-panel"])
|
| 273 |
+
|
| 274 |
+
# Tab 4: 人设 (HTML 卡片)
|
| 275 |
+
with gr.TabItem("👥 档案 (Personas)", id="tab-persona"):
|
| 276 |
+
persona_output = gr.HTML(label=None, elem_classes=["html-panel"])
|
| 277 |
+
|
| 278 |
+
# ==========================================
|
| 279 |
+
# 5. 事件交互逻辑
|
| 280 |
+
# ==========================================
|
| 281 |
+
|
| 282 |
+
# A. 点击生成按钮
|
| 283 |
+
submit_btn.click(
|
| 284 |
+
fn=bridge_to_backend,
|
| 285 |
+
inputs=[premise_input],
|
| 286 |
+
outputs=[
|
| 287 |
+
log_output, # 1. 日志
|
| 288 |
+
outline_output, # 2. 大纲
|
| 289 |
+
plan_output, # 3. 规划
|
| 290 |
+
persona_output, # 4. 人设(HTML)
|
| 291 |
+
story_state, # 5. 故事数据(Hidden State)
|
| 292 |
+
story_display # 6. 故事显示(HTML)
|
| 293 |
+
],
|
| 294 |
+
concurrency_limit=1
|
| 295 |
)
|
| 296 |
|
| 297 |
+
# B. 翻页逻辑函数 (纯前端交互,不需要请求后端)
|
| 298 |
+
def go_prev(story_data, current_page):
|
| 299 |
+
new_page = max(0, current_page - 1)
|
| 300 |
+
return new_page, render_book_page(story_data, new_page)
|
| 301 |
+
|
| 302 |
+
def go_next(story_data, current_page):
|
| 303 |
+
if not story_data: return current_page, render_book_page([], 0)
|
| 304 |
+
new_page = min(len(story_data) - 1, current_page + 1)
|
| 305 |
+
return new_page, render_book_page(story_data, new_page)
|
| 306 |
+
|
| 307 |
+
# C. 绑定翻页按钮
|
| 308 |
+
prev_btn.click(
|
| 309 |
+
fn=go_prev,
|
| 310 |
+
inputs=[story_state, page_state],
|
| 311 |
+
outputs=[page_state, story_display]
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
next_btn.click(
|
| 315 |
+
fn=go_next,
|
| 316 |
+
inputs=[story_state, page_state],
|
| 317 |
+
outputs=[page_state, story_display]
|
| 318 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
if __name__ == "__main__":
|
| 321 |
+
demo.queue().launch()
|