Update app.py
Browse files
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import os
|
| 2 |
import time
|
| 3 |
import math
|
|
|
|
| 4 |
import gradio as gr
|
| 5 |
from gradio_client import Client
|
| 6 |
|
|
@@ -139,7 +140,6 @@ body, .gradio-container {
|
|
| 139 |
justify-content: center !important;
|
| 140 |
transition: all 0.2s ease !important;
|
| 141 |
}
|
| 142 |
-
/* Hover */
|
| 143 |
.arrow-btn:hover {
|
| 144 |
color: var(--primary-color) !important;
|
| 145 |
transform: scale(1.1);
|
|
@@ -162,6 +162,32 @@ body, .gradio-container {
|
|
| 162 |
border-color: #cbd5e0 !important;
|
| 163 |
}
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
/* 移动端适配 */
|
| 166 |
@media (max-width: 768px) {
|
| 167 |
.book-page-container { padding: 0; height: auto; min-height: 500px; }
|
|
@@ -174,17 +200,18 @@ body, .gradio-container {
|
|
| 174 |
# 2. 逻辑处理
|
| 175 |
# ==========================================
|
| 176 |
|
| 177 |
-
def paginate_story(story_data, chars_per_page=
|
| 178 |
"""
|
| 179 |
-
分章 -> 分页。
|
|
|
|
|
|
|
| 180 |
返回: (flat_pages, chapter_start_indices)
|
| 181 |
"""
|
| 182 |
if not story_data:
|
| 183 |
return [], []
|
| 184 |
|
| 185 |
flat_pages = []
|
| 186 |
-
chapter_start_indices = []
|
| 187 |
-
|
| 188 |
current_global_page_index = 0
|
| 189 |
|
| 190 |
for chapter in story_data:
|
|
@@ -193,17 +220,20 @@ def paginate_story(story_data, chars_per_page=600):
|
|
| 193 |
title = chapter.get("title", "")
|
| 194 |
content = chapter.get("content", "")
|
| 195 |
paragraphs = content.split('\n')
|
| 196 |
-
|
| 197 |
current_page_content = []
|
| 198 |
current_char_count = 0
|
| 199 |
-
limit = chars_per_page // 1.5
|
| 200 |
is_start = True
|
| 201 |
-
|
| 202 |
-
#
|
|
|
|
|
|
|
| 203 |
for p in paragraphs:
|
| 204 |
p = p.strip()
|
| 205 |
-
if not p:
|
| 206 |
-
|
|
|
|
|
|
|
| 207 |
if current_char_count + len(p) > limit and current_page_content:
|
| 208 |
flat_pages.append({
|
| 209 |
"title": title if is_start else "",
|
|
@@ -211,16 +241,18 @@ def paginate_story(story_data, chars_per_page=600):
|
|
| 211 |
"is_chapter_start": is_start,
|
| 212 |
"chapter_title": title
|
| 213 |
})
|
| 214 |
-
current_global_page_index += 1
|
| 215 |
|
|
|
|
| 216 |
current_page_content = [p]
|
| 217 |
current_char_count = len(p)
|
| 218 |
is_start = False
|
| 219 |
-
limit = chars_per_page
|
| 220 |
else:
|
| 221 |
current_page_content.append(p)
|
| 222 |
current_char_count += len(p)
|
| 223 |
-
|
|
|
|
| 224 |
if current_page_content:
|
| 225 |
flat_pages.append({
|
| 226 |
"title": title if is_start else "",
|
|
@@ -232,6 +264,7 @@ def paginate_story(story_data, chars_per_page=600):
|
|
| 232 |
|
| 233 |
return flat_pages, chapter_start_indices
|
| 234 |
|
|
|
|
| 235 |
def render_book_page(flat_pages, page_index):
|
| 236 |
# 1. 空状态
|
| 237 |
if not flat_pages or len(flat_pages) == 0:
|
|
@@ -245,20 +278,20 @@ def render_book_page(flat_pages, page_index):
|
|
| 245 |
</div>
|
| 246 |
</div>
|
| 247 |
"""
|
| 248 |
-
|
| 249 |
-
# 2. 渲染
|
| 250 |
total_pages = len(flat_pages)
|
| 251 |
page_index = max(0, min(page_index, total_pages - 1))
|
| 252 |
page_data = flat_pages[page_index]
|
| 253 |
-
|
| 254 |
is_start = page_data.get("is_chapter_start", False)
|
| 255 |
display_title = page_data.get("title", "")
|
| 256 |
chapter_ref = page_data.get("chapter_title", "")
|
| 257 |
content = page_data.get("content", "")
|
| 258 |
-
|
| 259 |
paragraphs = [f"<p>{p}</p>" for p in content.split('\n') if p.strip()]
|
| 260 |
content_html = "".join(paragraphs)
|
| 261 |
-
|
| 262 |
if is_start:
|
| 263 |
header_html = f"""
|
| 264 |
<div class="page-top-spacer"></div>
|
|
@@ -290,47 +323,105 @@ def render_book_page(flat_pages, page_index):
|
|
| 290 |
"""
|
| 291 |
return html
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
# ==========================================
|
| 294 |
# 3. 后端连接
|
| 295 |
# ==========================================
|
| 296 |
def bridge_to_backend(premise):
|
| 297 |
if not premise.strip():
|
| 298 |
-
|
|
|
|
| 299 |
return
|
| 300 |
|
| 301 |
log_buffer = "🚀 初始化前端连接...\n"
|
| 302 |
initial_html = render_book_page([], 0)
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
| 306 |
|
| 307 |
try:
|
| 308 |
log_buffer += f"🔗 连接后端 Space: {PRIVATE_SPACE_ID}...\n"
|
| 309 |
client = Client(PRIVATE_SPACE_ID, hf_token=HF_TOKEN)
|
| 310 |
job = client.submit(premise, api_name="/generate_novel")
|
| 311 |
-
|
| 312 |
for result in job:
|
| 313 |
-
# result: [0]Log, [1]Outline, [2]Plan, [3]Personas, [4]StoryList
|
| 314 |
backend_log = result[0]
|
| 315 |
outline = result[1]
|
| 316 |
plan = result[2]
|
| 317 |
personas_html = result[3]
|
| 318 |
-
raw_story_list = result[4]
|
| 319 |
-
|
| 320 |
-
# 分页 + 计算章节索引
|
| 321 |
-
flat_pages, chap_indices = paginate_story(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
book_html = render_book_page(flat_pages, 0)
|
| 323 |
-
|
| 324 |
-
yield backend_log, outline, plan, personas_html, flat_pages, chap_indices, book_html
|
| 325 |
|
| 326 |
except Exception as e:
|
| 327 |
error_msg = f"❌ 前端连接错误: {str(e)}"
|
| 328 |
-
|
|
|
|
| 329 |
|
| 330 |
# ==========================================
|
| 331 |
# 4. 前端 UI 布局
|
| 332 |
# ==========================================
|
| 333 |
-
with gr.Blocks(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
# --- 状态管理 ---
|
| 336 |
story_pages_state = gr.State([]) # 所有的页面 List[Dict]
|
|
@@ -359,7 +450,13 @@ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
|
|
| 359 |
Story Premise
|
| 360 |
</div>
|
| 361 |
""")
|
| 362 |
-
premise_input = gr.Textbox(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
# Examples
|
| 365 |
gr.HTML('<div class="examples-container"><div class="examples-label">⚡ 快速开始 (Quick Inspirations)</div></div>')
|
|
@@ -379,8 +476,20 @@ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
|
|
| 379 |
|
| 380 |
submit_btn = gr.Button("✨ 开始生成 (GENERATE)", elem_classes=["generate-btn"])
|
| 381 |
|
| 382 |
-
gr.HTML(
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
gr.HTML("</div>")
|
| 385 |
|
| 386 |
# === 右侧:内容展示 ===
|
|
@@ -397,17 +506,18 @@ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
|
|
| 397 |
|
| 398 |
# 核心内容
|
| 399 |
with gr.Column(scale=15):
|
| 400 |
-
story_display = gr.HTML(
|
|
|
|
|
|
|
|
|
|
| 401 |
|
| 402 |
# 右侧箭头
|
| 403 |
with gr.Column(scale=1, min_width=40, elem_classes=["arrow-col"]):
|
| 404 |
btn_next_page = gr.Button("›", elem_classes=["arrow-btn"])
|
| 405 |
|
| 406 |
-
#
|
| 407 |
with gr.Row(elem_classes=["chapter-nav-row"]):
|
| 408 |
btn_prev_chap = gr.Button("⏮️ 上一章", elem_classes=["chapter-nav-btn"])
|
| 409 |
-
# 占位符,把按钮挤到两边或中间
|
| 410 |
-
# gr.Spacer()
|
| 411 |
btn_next_chap = gr.Button("⏭️ 下一章", elem_classes=["chapter-nav-btn"])
|
| 412 |
|
| 413 |
# Tab 2: 大纲
|
|
@@ -422,6 +532,10 @@ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
|
|
| 422 |
with gr.TabItem("👥 人物档案", id="tab-persona"):
|
| 423 |
persona_output = gr.HTML(label="Character Cards")
|
| 424 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
# ==========================================
|
| 426 |
# 5. 事件交互
|
| 427 |
# ==========================================
|
|
@@ -430,56 +544,78 @@ with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
|
|
| 430 |
submit_btn.click(
|
| 431 |
fn=bridge_to_backend,
|
| 432 |
inputs=[premise_input],
|
| 433 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
concurrency_limit=1
|
| 435 |
)
|
| 436 |
|
| 437 |
# B. 翻页 (上一页/下一页)
|
| 438 |
def on_prev_page(pages, current_idx):
|
| 439 |
-
if not pages:
|
|
|
|
| 440 |
new_idx = max(0, current_idx - 1)
|
| 441 |
return new_idx, render_book_page(pages, new_idx)
|
| 442 |
|
| 443 |
def on_next_page(pages, current_idx):
|
| 444 |
-
if not pages:
|
|
|
|
| 445 |
new_idx = min(len(pages) - 1, current_idx + 1)
|
| 446 |
return new_idx, render_book_page(pages, new_idx)
|
| 447 |
|
| 448 |
-
btn_prev_page.click(
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
# C. 章节跳转 (上��章/下一章)
|
| 452 |
def on_prev_chap(pages, current_idx, chap_indices):
|
| 453 |
-
if not pages or not chap_indices:
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
# 例如 chap_indices = [0, 5, 12], current = 6. 属于索引为1的章节(index 5)
|
| 457 |
-
# 我们要跳到索引为0的章节(index 0)
|
| 458 |
-
|
| 459 |
target_idx = 0
|
| 460 |
-
# 简单遍历寻找目标
|
| 461 |
for start_idx in reversed(chap_indices):
|
| 462 |
if start_idx < current_idx:
|
| 463 |
target_idx = start_idx
|
| 464 |
break
|
| 465 |
-
|
| 466 |
return target_idx, render_book_page(pages, target_idx)
|
| 467 |
|
| 468 |
def on_next_chap(pages, current_idx, chap_indices):
|
| 469 |
-
if not pages or not chap_indices:
|
| 470 |
-
|
|
|
|
| 471 |
target_idx = current_idx
|
| 472 |
-
# 找到第一个 > current_idx 的 start_index
|
| 473 |
for start_idx in chap_indices:
|
| 474 |
if start_idx > current_idx:
|
| 475 |
target_idx = start_idx
|
| 476 |
break
|
| 477 |
-
|
| 478 |
-
# 如果没找到(已经是最后一章),保持不变或跳到最后一页,这里保持不变
|
| 479 |
return target_idx, render_book_page(pages, target_idx)
|
| 480 |
|
| 481 |
-
btn_prev_chap.click(
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
|
| 484 |
if __name__ == "__main__":
|
| 485 |
-
demo.queue().launch()
|
|
|
|
| 1 |
import os
|
| 2 |
import time
|
| 3 |
import math
|
| 4 |
+
import html as py_html # ✅ 为了转义全文文本
|
| 5 |
import gradio as gr
|
| 6 |
from gradio_client import Client
|
| 7 |
|
|
|
|
| 140 |
justify-content: center !important;
|
| 141 |
transition: all 0.2s ease !important;
|
| 142 |
}
|
|
|
|
| 143 |
.arrow-btn:hover {
|
| 144 |
color: var(--primary-color) !important;
|
| 145 |
transform: scale(1.1);
|
|
|
|
| 162 |
border-color: #cbd5e0 !important;
|
| 163 |
}
|
| 164 |
|
| 165 |
+
/* 全文导出区域 */
|
| 166 |
+
.fulltext-wrapper { padding: 10px 0; }
|
| 167 |
+
.fulltext-copy-btn {
|
| 168 |
+
background: #4f46e5;
|
| 169 |
+
color: #fff;
|
| 170 |
+
border: none;
|
| 171 |
+
border-radius: 8px;
|
| 172 |
+
padding: 6px 12px;
|
| 173 |
+
font-size: 0.9rem;
|
| 174 |
+
cursor: pointer;
|
| 175 |
+
margin-bottom: 10px;
|
| 176 |
+
}
|
| 177 |
+
.fulltext-copy-btn:hover {
|
| 178 |
+
filter: brightness(1.08);
|
| 179 |
+
}
|
| 180 |
+
.fulltext-area {
|
| 181 |
+
width: 100%;
|
| 182 |
+
min-height: 500px;
|
| 183 |
+
border-radius: 8px;
|
| 184 |
+
border: 1px solid #e2e8f0;
|
| 185 |
+
padding: 10px;
|
| 186 |
+
font-family: 'JetBrains Mono', monospace;
|
| 187 |
+
font-size: 0.9rem;
|
| 188 |
+
resize: vertical;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
/* 移动端适配 */
|
| 192 |
@media (max-width: 768px) {
|
| 193 |
.book-page-container { padding: 0; height: auto; min-height: 500px; }
|
|
|
|
| 200 |
# 2. 逻辑处理
|
| 201 |
# ==========================================
|
| 202 |
|
| 203 |
+
def paginate_story(story_data, chars_per_page=1200, first_page_ratio=0.8):
|
| 204 |
"""
|
| 205 |
+
分章 -> 分页。
|
| 206 |
+
- 首章首页可用字符数 = chars_per_page * first_page_ratio(因为有大标题和空白)
|
| 207 |
+
- 之后的每一页都用 chars_per_page,尽量“填满”固定高度。
|
| 208 |
返回: (flat_pages, chapter_start_indices)
|
| 209 |
"""
|
| 210 |
if not story_data:
|
| 211 |
return [], []
|
| 212 |
|
| 213 |
flat_pages = []
|
| 214 |
+
chapter_start_indices = []
|
|
|
|
| 215 |
current_global_page_index = 0
|
| 216 |
|
| 217 |
for chapter in story_data:
|
|
|
|
| 220 |
title = chapter.get("title", "")
|
| 221 |
content = chapter.get("content", "")
|
| 222 |
paragraphs = content.split('\n')
|
| 223 |
+
|
| 224 |
current_page_content = []
|
| 225 |
current_char_count = 0
|
|
|
|
| 226 |
is_start = True
|
| 227 |
+
|
| 228 |
+
# 首页稍微少一点,为标题留空间
|
| 229 |
+
limit = int(chars_per_page * first_page_ratio)
|
| 230 |
+
|
| 231 |
for p in paragraphs:
|
| 232 |
p = p.strip()
|
| 233 |
+
if not p:
|
| 234 |
+
continue
|
| 235 |
+
|
| 236 |
+
# 如果当前段落加进去会超过限制,并且当前页已有内容 -> 换页
|
| 237 |
if current_char_count + len(p) > limit and current_page_content:
|
| 238 |
flat_pages.append({
|
| 239 |
"title": title if is_start else "",
|
|
|
|
| 241 |
"is_chapter_start": is_start,
|
| 242 |
"chapter_title": title
|
| 243 |
})
|
| 244 |
+
current_global_page_index += 1
|
| 245 |
|
| 246 |
+
# 新的一页
|
| 247 |
current_page_content = [p]
|
| 248 |
current_char_count = len(p)
|
| 249 |
is_start = False
|
| 250 |
+
limit = chars_per_page # 之后的页按照标准容量
|
| 251 |
else:
|
| 252 |
current_page_content.append(p)
|
| 253 |
current_char_count += len(p)
|
| 254 |
+
|
| 255 |
+
# 收尾
|
| 256 |
if current_page_content:
|
| 257 |
flat_pages.append({
|
| 258 |
"title": title if is_start else "",
|
|
|
|
| 264 |
|
| 265 |
return flat_pages, chapter_start_indices
|
| 266 |
|
| 267 |
+
|
| 268 |
def render_book_page(flat_pages, page_index):
|
| 269 |
# 1. 空状态
|
| 270 |
if not flat_pages or len(flat_pages) == 0:
|
|
|
|
| 278 |
</div>
|
| 279 |
</div>
|
| 280 |
"""
|
| 281 |
+
|
| 282 |
+
# 2. 渲染正常页面
|
| 283 |
total_pages = len(flat_pages)
|
| 284 |
page_index = max(0, min(page_index, total_pages - 1))
|
| 285 |
page_data = flat_pages[page_index]
|
| 286 |
+
|
| 287 |
is_start = page_data.get("is_chapter_start", False)
|
| 288 |
display_title = page_data.get("title", "")
|
| 289 |
chapter_ref = page_data.get("chapter_title", "")
|
| 290 |
content = page_data.get("content", "")
|
| 291 |
+
|
| 292 |
paragraphs = [f"<p>{p}</p>" for p in content.split('\n') if p.strip()]
|
| 293 |
content_html = "".join(paragraphs)
|
| 294 |
+
|
| 295 |
if is_start:
|
| 296 |
header_html = f"""
|
| 297 |
<div class="page-top-spacer"></div>
|
|
|
|
| 323 |
"""
|
| 324 |
return html
|
| 325 |
|
| 326 |
+
|
| 327 |
+
def build_full_text(story_data):
|
| 328 |
+
"""
|
| 329 |
+
把章节列表拼成一整本小说文本,供导出/复制。
|
| 330 |
+
story_data: List[{"title": ..., "content": ...}, ...]
|
| 331 |
+
"""
|
| 332 |
+
if not story_data:
|
| 333 |
+
return ""
|
| 334 |
+
|
| 335 |
+
blocks = []
|
| 336 |
+
for ch in story_data:
|
| 337 |
+
title = ch.get("title", "").strip()
|
| 338 |
+
content = ch.get("content", "").strip()
|
| 339 |
+
if title:
|
| 340 |
+
blocks.append(title)
|
| 341 |
+
if content:
|
| 342 |
+
blocks.append(content)
|
| 343 |
+
return "\n\n".join(blocks).strip()
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def build_full_text_html(full_text: str) -> str:
|
| 347 |
+
"""
|
| 348 |
+
生成带「一键复制」按钮的 HTML。
|
| 349 |
+
使用 textarea + JS 调用 navigator.clipboard.writeText。
|
| 350 |
+
"""
|
| 351 |
+
escaped = py_html.escape(full_text or "")
|
| 352 |
+
return f"""
|
| 353 |
+
<div class="fulltext-wrapper">
|
| 354 |
+
<button class="fulltext-copy-btn" onclick="
|
| 355 |
+
const ta = document.getElementById('full-story-area');
|
| 356 |
+
if (ta) {{
|
| 357 |
+
ta.select();
|
| 358 |
+
ta.setSelectionRange(0, 999999);
|
| 359 |
+
navigator.clipboard && navigator.clipboard.writeText(ta.value);
|
| 360 |
+
}}
|
| 361 |
+
">
|
| 362 |
+
📋 一键复制全文
|
| 363 |
+
</button>
|
| 364 |
+
<textarea id="full-story-area" class="fulltext-area">{escaped}</textarea>
|
| 365 |
+
</div>
|
| 366 |
+
"""
|
| 367 |
+
|
| 368 |
# ==========================================
|
| 369 |
# 3. 后端连接
|
| 370 |
# ==========================================
|
| 371 |
def bridge_to_backend(premise):
|
| 372 |
if not premise.strip():
|
| 373 |
+
empty_full_html = build_full_text_html("")
|
| 374 |
+
yield "⚠️ 请输入故事梗概...", None, None, None, [], [], empty_full_html, render_book_page([], 0)
|
| 375 |
return
|
| 376 |
|
| 377 |
log_buffer = "🚀 初始化前端连接...\n"
|
| 378 |
initial_html = render_book_page([], 0)
|
| 379 |
+
empty_full_html = build_full_text_html("")
|
| 380 |
+
|
| 381 |
+
# 初始状态 [log, outline, plan, personas, pages, chap_indices, full_text_html, book_html]
|
| 382 |
+
yield log_buffer, None, None, None, [], [], empty_full_html, initial_html
|
| 383 |
|
| 384 |
try:
|
| 385 |
log_buffer += f"🔗 连接后端 Space: {PRIVATE_SPACE_ID}...\n"
|
| 386 |
client = Client(PRIVATE_SPACE_ID, hf_token=HF_TOKEN)
|
| 387 |
job = client.submit(premise, api_name="/generate_novel")
|
| 388 |
+
|
| 389 |
for result in job:
|
| 390 |
+
# result: [0]Log, [1]Outline, [2]Plan, [3]Personas, [4]StoryList(章节列表)
|
| 391 |
backend_log = result[0]
|
| 392 |
outline = result[1]
|
| 393 |
plan = result[2]
|
| 394 |
personas_html = result[3]
|
| 395 |
+
raw_story_list = result[4]
|
| 396 |
+
|
| 397 |
+
# 分页 + 计算章节索引(让页面尽量填满)
|
| 398 |
+
flat_pages, chap_indices = paginate_story(
|
| 399 |
+
raw_story_list,
|
| 400 |
+
chars_per_page=1200,
|
| 401 |
+
first_page_ratio=0.8,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
# 一键复制全文的文本 + HTML
|
| 405 |
+
full_text = build_full_text(raw_story_list)
|
| 406 |
+
full_text_html = build_full_text_html(full_text)
|
| 407 |
+
|
| 408 |
book_html = render_book_page(flat_pages, 0)
|
| 409 |
+
|
| 410 |
+
yield backend_log, outline, plan, personas_html, flat_pages, chap_indices, full_text_html, book_html
|
| 411 |
|
| 412 |
except Exception as e:
|
| 413 |
error_msg = f"❌ 前端连接错误: {str(e)}"
|
| 414 |
+
error_full_html = build_full_text_html(str(e))
|
| 415 |
+
yield error_msg, None, None, None, [], [], error_full_html, render_book_page([], 0)
|
| 416 |
|
| 417 |
# ==========================================
|
| 418 |
# 4. 前端 UI 布局
|
| 419 |
# ==========================================
|
| 420 |
+
with gr.Blocks(
|
| 421 |
+
theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
|
| 422 |
+
css=custom_css,
|
| 423 |
+
title="LongStory Agent"
|
| 424 |
+
) as demo:
|
| 425 |
|
| 426 |
# --- 状态管理 ---
|
| 427 |
story_pages_state = gr.State([]) # 所有的页面 List[Dict]
|
|
|
|
| 450 |
Story Premise
|
| 451 |
</div>
|
| 452 |
""")
|
| 453 |
+
premise_input = gr.Textbox(
|
| 454 |
+
label="Premise",
|
| 455 |
+
show_label=False,
|
| 456 |
+
lines=5,
|
| 457 |
+
elem_classes=["input-box"],
|
| 458 |
+
placeholder="输入故事创意..."
|
| 459 |
+
)
|
| 460 |
|
| 461 |
# Examples
|
| 462 |
gr.HTML('<div class="examples-container"><div class="examples-label">⚡ 快速开始 (Quick Inspirations)</div></div>')
|
|
|
|
| 476 |
|
| 477 |
submit_btn = gr.Button("✨ 开始生成 (GENERATE)", elem_classes=["generate-btn"])
|
| 478 |
|
| 479 |
+
gr.HTML(
|
| 480 |
+
'<div class="terminal-wrapper"><div class="terminal-header">'
|
| 481 |
+
'<div class="dot dot-red"></div><div class="dot dot-yellow"></div>'
|
| 482 |
+
'<div class="dot dot-green"></div><div class="terminal-title">system.log</div>'
|
| 483 |
+
'</div>'
|
| 484 |
+
)
|
| 485 |
+
log_output = gr.Textbox(
|
| 486 |
+
label="Log",
|
| 487 |
+
lines=10,
|
| 488 |
+
interactive=False,
|
| 489 |
+
elem_classes=["terminal-log"],
|
| 490 |
+
show_label=False,
|
| 491 |
+
value="> System initialized..."
|
| 492 |
+
)
|
| 493 |
gr.HTML("</div>")
|
| 494 |
|
| 495 |
# === 右侧:内容展示 ===
|
|
|
|
| 506 |
|
| 507 |
# 核心内容
|
| 508 |
with gr.Column(scale=15):
|
| 509 |
+
story_display = gr.HTML(
|
| 510 |
+
label="Book View",
|
| 511 |
+
value=render_book_page([], 0)
|
| 512 |
+
)
|
| 513 |
|
| 514 |
# 右侧箭头
|
| 515 |
with gr.Column(scale=1, min_width=40, elem_classes=["arrow-col"]):
|
| 516 |
btn_next_page = gr.Button("›", elem_classes=["arrow-btn"])
|
| 517 |
|
| 518 |
+
# 章节导航
|
| 519 |
with gr.Row(elem_classes=["chapter-nav-row"]):
|
| 520 |
btn_prev_chap = gr.Button("⏮️ 上一章", elem_classes=["chapter-nav-btn"])
|
|
|
|
|
|
|
| 521 |
btn_next_chap = gr.Button("⏭️ 下一章", elem_classes=["chapter-nav-btn"])
|
| 522 |
|
| 523 |
# Tab 2: 大纲
|
|
|
|
| 532 |
with gr.TabItem("👥 人物档案", id="tab-persona"):
|
| 533 |
persona_output = gr.HTML(label="Character Cards")
|
| 534 |
|
| 535 |
+
# Tab 5: 全文导出
|
| 536 |
+
with gr.TabItem("📄 全文导出", id="tab-fulltext"):
|
| 537 |
+
full_text_html = gr.HTML(label="Full Story Export")
|
| 538 |
+
|
| 539 |
# ==========================================
|
| 540 |
# 5. 事件交互
|
| 541 |
# ==========================================
|
|
|
|
| 544 |
submit_btn.click(
|
| 545 |
fn=bridge_to_backend,
|
| 546 |
inputs=[premise_input],
|
| 547 |
+
outputs=[
|
| 548 |
+
log_output,
|
| 549 |
+
outline_output,
|
| 550 |
+
plan_output,
|
| 551 |
+
persona_output,
|
| 552 |
+
story_pages_state,
|
| 553 |
+
chapter_indices_state,
|
| 554 |
+
full_text_html, # ✅ 新增:全文导出 HTML
|
| 555 |
+
story_display
|
| 556 |
+
],
|
| 557 |
concurrency_limit=1
|
| 558 |
)
|
| 559 |
|
| 560 |
# B. 翻页 (上一页/下一页)
|
| 561 |
def on_prev_page(pages, current_idx):
|
| 562 |
+
if not pages:
|
| 563 |
+
return 0, render_book_page([], 0)
|
| 564 |
new_idx = max(0, current_idx - 1)
|
| 565 |
return new_idx, render_book_page(pages, new_idx)
|
| 566 |
|
| 567 |
def on_next_page(pages, current_idx):
|
| 568 |
+
if not pages:
|
| 569 |
+
return 0, render_book_page([], 0)
|
| 570 |
new_idx = min(len(pages) - 1, current_idx + 1)
|
| 571 |
return new_idx, render_book_page(pages, new_idx)
|
| 572 |
|
| 573 |
+
btn_prev_page.click(
|
| 574 |
+
fn=on_prev_page,
|
| 575 |
+
inputs=[story_pages_state, current_page_state],
|
| 576 |
+
outputs=[current_page_state, story_display]
|
| 577 |
+
)
|
| 578 |
+
btn_next_page.click(
|
| 579 |
+
fn=on_next_page,
|
| 580 |
+
inputs=[story_pages_state, current_page_state],
|
| 581 |
+
outputs=[current_page_state, story_display]
|
| 582 |
+
)
|
| 583 |
|
| 584 |
# C. 章节跳转 (上��章/下一章)
|
| 585 |
def on_prev_chap(pages, current_idx, chap_indices):
|
| 586 |
+
if not pages or not chap_indices:
|
| 587 |
+
return current_idx, render_book_page(pages, current_idx)
|
| 588 |
+
|
|
|
|
|
|
|
|
|
|
| 589 |
target_idx = 0
|
|
|
|
| 590 |
for start_idx in reversed(chap_indices):
|
| 591 |
if start_idx < current_idx:
|
| 592 |
target_idx = start_idx
|
| 593 |
break
|
| 594 |
+
|
| 595 |
return target_idx, render_book_page(pages, target_idx)
|
| 596 |
|
| 597 |
def on_next_chap(pages, current_idx, chap_indices):
|
| 598 |
+
if not pages or not chap_indices:
|
| 599 |
+
return current_idx, render_book_page(pages, current_idx)
|
| 600 |
+
|
| 601 |
target_idx = current_idx
|
|
|
|
| 602 |
for start_idx in chap_indices:
|
| 603 |
if start_idx > current_idx:
|
| 604 |
target_idx = start_idx
|
| 605 |
break
|
| 606 |
+
|
|
|
|
| 607 |
return target_idx, render_book_page(pages, target_idx)
|
| 608 |
|
| 609 |
+
btn_prev_chap.click(
|
| 610 |
+
fn=on_prev_chap,
|
| 611 |
+
inputs=[story_pages_state, current_page_state, chapter_indices_state],
|
| 612 |
+
outputs=[current_page_state, story_display]
|
| 613 |
+
)
|
| 614 |
+
btn_next_chap.click(
|
| 615 |
+
fn=on_next_chap,
|
| 616 |
+
inputs=[story_pages_state, current_page_state, chapter_indices_state],
|
| 617 |
+
outputs=[current_page_state, story_display]
|
| 618 |
+
)
|
| 619 |
|
| 620 |
if __name__ == "__main__":
|
| 621 |
+
demo.queue().launch()
|