Yoyo2004 commited on
Commit
ab21d03
·
verified ·
1 Parent(s): 6ec656b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +275 -360
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 huggingface_hub import InferenceClient
10
 
11
- # 假设这些是你上传的 storywriter
12
- from storywriter import (
13
- OutlineAgent,
14
- PlanningAgent,
15
- WritingAgent,
16
- )
17
- from storywriter.planning_agent import (
18
- PersonaTrajectoryAgent,
19
- SubeventDynamicsAligner
20
- )
21
 
22
  # ==========================================
23
- # 图像生成辅助类
24
  # ==========================================
25
- class ImageGenerator:
26
- def __init__(self, token=None):
27
- self.token = token
28
- self.model_id = "cagliostrolab/animagine-xl-3.1"
29
- self.client = InferenceClient(model=self.model_id, token=token)
30
-
31
- def _translate_prompt(self, text):
32
- mapping = {
33
- "男": "1boy, male", "女": "1girl, female", "少年": "young boy", "少女": "young girl",
34
- "青年": "young adult", "长发": "long hair", "短发": "short hair", "眼镜": "glasses",
35
- "帅气": "handsome", "美丽": "beautiful", "可爱": "cute", "冷酷": "cool",
36
- "阳光": "cheerful", "校服": "school uniform",
37
- }
38
- for k, v in mapping.items():
39
- text = text.replace(k, v)
40
- return text
41
-
42
- def generate_persona_image_base64(self, name, description):
43
- if not self.token:
44
- print(f"⚠️ No token found for {name}")
45
- return None
46
-
47
- eng_description = self._translate_prompt(description)
48
- prompt = (
49
- f"anime style, character design, masterpiece, best quality, "
50
- f"solo, upper body, portrait, looking at viewer, "
51
- f"{eng_description}, "
52
- f"white background, simple background, flat color"
53
- )
54
- negative_prompt = "lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, background elements, multiple views"
55
-
56
- print(f"🎨 Generating for {name}...")
57
- max_retries = 2
58
- for attempt in range(max_retries + 1):
59
- try:
60
- image = self.client.text_to_image(
61
- prompt,
62
- negative_prompt=negative_prompt,
63
- height=1024,
64
- width=832,
65
- guidance_scale=7.0,
66
- )
67
- buffered = io.BytesIO()
68
- image.save(buffered, format="PNG")
69
- img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
70
- return f"data:image/png;base64,{img_str}"
71
- except Exception as e:
72
- print(f"⚠️ Attempt {attempt+1} failed for {name}: {e}")
73
- if attempt == max_retries:
74
- traceback.print_exc()
75
- return None
76
- time.sleep(2)
77
-
78
- # ==========================================
79
- # UI 渲染辅助函数 (人物卡片)
80
- # ==========================================
81
- def render_persona_html(personas_data, image_map=None, generation_started=False):
82
- if not personas_data: return ""
83
- personas_list = personas_data.get("personas", []) if isinstance(personas_data, dict) else personas_data
84
- if not personas_list: return "<div style='padding:20px; color: #666;'>暂无人物数据</div>"
85
-
86
- html = """
87
- <style>
88
- .persona-container { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; font-family: 'Noto Sans SC', sans-serif; }
89
- .persona-card { width: 300px; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); overflow: hidden; border: 1px solid #f3f4f6; transition: transform 0.2s, box-shadow 0.2s; display: flex; flex-direction: column; }
90
- .persona-card:hover { transform: translateY(-4px); box-shadow: 0 12px 20px rgba(0,0,0,0.12); }
91
- .card-img-wrapper { width: 100%; height: 360px; background: #f9fafb; display: flex; align-items: center; justify-content: center; overflow: hidden; border-bottom: 1px solid #eee; }
92
- .card-img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s ease; }
93
- .card-img:hover { transform: scale(1.03); }
94
- .card-placeholder { color: #9ca3af; font-size: 0.9rem; font-style: italic; text-align: center; padding: 20px; }
95
- .card-content { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; }
96
- .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
97
- .card-name { font-size: 1.3rem; font-weight: 800; color: #111827; }
98
- .card-age { font-size: 0.8rem; color: #6b7280; background: #f3f4f6; padding: 2px 8px; border-radius: 12px; }
99
- .card-role-badge { align-self: flex-start; display: inline-block; padding: 3px 8px; border-radius: 6px; background: #e0e7ff; color: #4f46e5; font-size: 0.75rem; font-weight: 600; margin-bottom: 10px; }
100
- .card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; }
101
- .card-tag { font-size: 0.7rem; background: #f3f4f6; color: #4b5563; padding: 2px 6px; border-radius: 99px; border: 1px solid #e5e7eb; }
102
- .card-section { margin-bottom: 6px; font-size: 0.85rem; color: #374151; line-height: 1.5; }
103
- .label { font-weight: 700; color: #1f2937; margin-right: 4px; }
104
- </style>
105
- <div class='persona-container'>
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
- # UI 渲染辅助函数 (电子书)
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 "<div style='text-align:center; padding: 50px; color: #999;'><i>等待故事生成...</i></div>"
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- page_index = max(0, min(page_index, len(story_data) - 1))
137
  chapter = story_data[page_index]
 
 
138
  title = chapter.get("title", f"Chapter {page_index + 1}")
139
  content = chapter.get("content", "")
140
 
141
- paragraphs = content.split('\n')
142
- content_html = "".join([f"<p>{p.strip()}</p>" for p in paragraphs if p.strip()])
143
-
 
 
 
144
  html = f"""
145
- <style>
146
- .book-wrapper {{
147
- background-color: #fdf6e3; padding: 60px 80px; border-radius: 8px;
148
- box-shadow: 0 10px 30px rgba(0,0,0,0.1); font-family: 'Noto Serif SC', serif;
149
- color: #2c3e50; min-height: 600px; max-width: 800px; margin: 0 auto; position: relative;
150
- }}
151
- .book-wrapper::before {{
152
- content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
153
- background: linear-gradient(to right, rgba(0,0,0,0.1), transparent);
154
- }}
155
- .chapter-title {{
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 generate_novel(premise):
176
- msg = "🚀 [Backend] Process initialized...\n"
177
- print(msg.strip())
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
- try:
204
- outline_agent = OutlineAgent()
205
- outline_outputs = outline_agent.run_full_outline(premise=premise, max_retries=5)
206
-
207
- refined_events_text = outline_outputs["refined_events_text"]
208
- try: current_outline = json.loads(refined_events_text)
209
- except: current_outline = {"raw_text": refined_events_text}
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
- persona_agent = PersonaTrajectoryAgent()
294
- personas_data = persona_agent.build_personas(premise=premise, events_json=current_outline, subevents_json=current_plan)
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
- aligner = SubeventDynamicsAligner()
299
- enriched_subevents = aligner.align(premise=premise, events_json=current_outline, subevents_json=current_plan, personas=personas_data)
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
- structured_story = []
326
- if isinstance(outputs, dict):
327
- for title, content in outputs.items():
328
- structured_story.append({"title": str(title), "content": str(content)})
329
- elif isinstance(outputs, list):
330
- for i, content in enumerate(outputs):
331
- structured_story.append({"title": f"Chapter {i+1}", "content": str(content)})
332
- else:
333
- structured_story.append({"title": "全文", "content": str(outputs)})
 
 
 
334
 
335
- current_story = structured_story
336
- msg = "🎉 [Done] Generation complete!\n"
337
- print(msg.strip())
338
- current_log += msg
339
- yield current_log, current_outline, current_plan, current_personas, current_story
 
340
 
341
  except Exception as e:
342
- msg = f"❌ [Error in Phase 4]: {str(e)}\n"
343
- print(msg.strip())
344
- traceback.print_exc()
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
- # Debug UI
351
  # ==========================================
352
- with gr.Blocks(title="Backend Service") as demo:
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
- with gr.Row():
360
- with gr.Column(scale=1):
361
- log_box = gr.Textbox(label="Logs", lines=10)
362
- outline_box = gr.JSON(label="Outline", visible=False)
363
- plan_box = gr.JSON(label="Plan", visible=False)
364
- persona_box = gr.HTML(label="Personas Card")
365
-
366
- with gr.Column(scale=2):
367
- gr.Markdown("### 📖 故事预览 (E-Reader Mode)")
368
- story_display = gr.HTML(label="Story Reader", min_height=600)
369
- with gr.Row():
370
- prev_btn = gr.Button("← 上一章")
371
- next_btn = gr.Button("下一章 →")
372
-
373
- story_json_connector = gr.JSON(visible=False, label="Story Data Connector")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
- btn = gr.Button("Run Generation", variant="primary")
376
-
377
- btn.click(
378
- fn=generate_novel,
379
- inputs=premise_in,
380
- outputs=[log_box, outline_box, plan_box, persona_box, story_json_connector],
381
- api_name="generate_novel"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  )
383
 
384
- def update_reader(story_data, page):
385
- return render_book_page(story_data, page)
386
-
387
- story_json_connector.change(
388
- fn=lambda x: (x, 0, render_book_page(x, 0)),
389
- inputs=[story_json_connector],
390
- outputs=[story_state, page_state, story_display]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(default_concurrency_limit=1, max_size=5).launch()
 
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()