LongStory / app.py
Yoyo2004's picture
Update app.py
e06be5d verified
raw
history blame
21.2 kB
import os
import time
import math
import gradio as gr
from gradio_client import Client
# ==========================================
# 0. 配置
# ==========================================
PRIVATE_SPACE_ID = "Yoyo2004/Longstory-backend"
HF_TOKEN = os.environ.get("HF_TOKEN")
# ==========================================
# 1. CSS
# ==========================================
custom_css = """
@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&family=Inter:wght@400;600&display=swap');
:root {
--primary-color: #4f46e5;
--paper-bg: #fdf6e3;
--glass-bg: rgba(255, 255, 255, 0.95);
}
body, .gradio-container {
font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif !important;
background: linear-gradient(135deg, #eef2f3 0%, #8e9eab 100%) !important;
background-attachment: fixed !important;
}
/* --- 通用容器 --- */
.header-box { text-align: center; padding: 25px; background: var(--glass-bg); border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); backdrop-filter: blur(10px); }
.title-text { font-size: 2.2rem; font-weight: 800; font-family: 'Noto Serif SC', serif; color: #1a202c; letter-spacing: -0.5px; }
.subtitle-text { color: #718096; letter-spacing: 1.5px; text-transform: uppercase; font-size: 0.85rem; font-weight: 600; margin-top: 5px; }
.title-badge { display: inline-block; background: #000; color: #fff; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; vertical-align: super; margin-left: 5px; }
/* --- 左侧控制面板 --- */
.control-panel {
background: var(--glass-bg);
padding: 24px !important;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.6);
box-shadow: 0 8px 32px rgba(0,0,0,0.05);
}
/* 输入框与按钮美化 (保留之前的样式,此处略微精简以节省篇幅,功能不变) */
.input-label { font-weight: 700; color: #4a5568; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
.input-box textarea { background: #f8fafc !important; border: 2px solid #e2e8f0 !important; border-radius: 12px !important; padding: 12px !important; }
.input-box textarea:focus { border-color: var(--primary-color) !important; background: #fff !important; }
.generate-btn {
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%) !important;
color: white !important; font-weight: 600; border-radius: 12px !important; padding: 12px !important; font-size: 1.1rem !important;
box-shadow: 0 4px 15px rgba(79, 70, 229, 0.4); transition: all 0.3s ease;
}
.generate-btn:hover { transform: translateY(-2px); filter: brightness(110%); }
/* Examples */
.examples-container { margin-top: 15px; }
.examples-label { font-size: 0.85rem; color: #a0aec0; font-weight: 600; margin-bottom: 10px; text-transform: uppercase; }
.examples-table table { border-collapse: separate !important; border-spacing: 0 8px !important; background: transparent !important; }
.examples-table thead { display: none; }
.examples-table tbody tr { background: #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.05); border-radius: 8px; cursor: pointer; border: 1px solid #edf2f7; display: block; margin-bottom: 8px; }
.examples-table td { padding: 12px 15px !important; border: none !important; display: block; }
/* 终端日志 */
.terminal-wrapper { margin-top: 25px; background: #1e1e1e; border-radius: 10px; overflow: hidden; }
.terminal-header { background: #2d2d2d; padding: 8px 12px; display: flex; gap: 6px; border-bottom: 1px solid #333; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot-red { background: #ff5f56; } .dot-yellow { background: #ffbd2e; } .dot-green { background: #27c93f; }
.terminal-log textarea { font-family: 'JetBrains Mono', monospace !important; background: #1e1e1e !important; color: #4ade80 !important; border: none !important; padding: 10px !important; }
/* =========================================
Book UI (Fixed Height & Pagination)
========================================= */
/* 1. 书页容器 */
.book-page-container {
background-color: var(--paper-bg);
height: 800px;
width: 100%;
border-radius: 8px 16px 16px 8px;
box-shadow: inset 20px 0 50px rgba(0,0,0,0.05), 10px 10px 30px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
.book-page-container::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
background: linear-gradient(to right, rgba(0,0,0,0.1), transparent);
z-index: 1;
}
/* 2. 内容包装器 */
.book-content-wrapper {
flex: 1; padding: 50px 70px 20px 70px; overflow-y: hidden; position: relative; z-index: 2;
}
.book-page-container.empty-state .book-content-wrapper { display: flex; justify-content: center; align-items: center; }
/* 3. 排版细节 */
.page-top-spacer { height: 20px; }
.chapter-header-block { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 2px solid rgba(139, 69, 19, 0.2); }
.chapter-subtitle { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 2px; color: #8b4513; opacity: 0.6; }
.chapter-title { font-family: 'Noto Serif SC', serif; font-size: 2.2rem; font-weight: 700; color: #2c1810; line-height: 1.2; }
.page-running-header { height: 40px; border-bottom: 1px solid rgba(0,0,0,0.05); margin-bottom: 30px; color: #a0aec0; font-size: 0.75rem; display: flex; justify-content: space-between; align-items: center; text-transform: uppercase; }
.chapter-text-content { font-family: 'Noto Serif SC', serif; font-size: 1.15rem; color: #333; line-height: 1.9; text-align: justify; }
.chapter-text-content p { margin-bottom: 1.2em; text-indent: 2em; }
.page-footer { height: 50px; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; color: #999; border-top: 1px dashed rgba(0,0,0,0.05); z-index: 2; }
/* =========================================
Navigation Buttons (Refined)
========================================= */
/* 1. 侧边箭头 (极简风格) */
.arrow-btn {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: #cbd5e0 !important; /* 默认很浅的灰色 */
font-size: 3rem !important; /* 字体加大 */
font-weight: 200 !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: all 0.2s ease !important;
}
/* Hover 时只变色,不加背景,保持清爽 */
.arrow-btn:hover {
color: var(--primary-color) !important;
transform: scale(1.1);
background: transparent !important;
}
/* 2. 底部章节导航按钮 */
.chapter-nav-btn {
background: white !important;
border: 1px solid #e2e8f0 !important;
color: #718096 !important;
border-radius: 8px !important;
font-size: 0.9rem !important;
padding: 8px 16px !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.chapter-nav-btn:hover {
background: #f7fafc !important;
color: var(--primary-color) !important;
border-color: #cbd5e0 !important;
}
/* 移动端适配 */
@media (max-width: 768px) {
.book-page-container { padding: 0; height: auto; min-height: 500px; }
.book-content-wrapper { padding: 30px 20px; }
.arrow-btn { font-size: 2rem !important; }
}
"""
# ==========================================
# 2. 逻辑处理
# ==========================================
def paginate_story(story_data, chars_per_page=600):
"""
分章 -> 分页。同时记录每一章的起始页码,方便章节跳转。
返回: (flat_pages, chapter_start_indices)
"""
if not story_data:
return [], []
flat_pages = []
chapter_start_indices = [] # 记录 [Chapter 1起始页index, Chapter 2起始页index, ...]
current_global_page_index = 0
for chapter in story_data:
# 记录当前章是从第几页开始的
chapter_start_indices.append(current_global_page_index)
title = chapter.get("title", "")
content = chapter.get("content", "")
paragraphs = content.split('\n')
current_page_content = []
current_char_count = 0
limit = chars_per_page // 1.5
is_start = True
# --- 分页算法 ---
for p in paragraphs:
p = p.strip()
if not p: continue
if current_char_count + len(p) > limit and current_page_content:
flat_pages.append({
"title": title if is_start else "",
"content": "\n".join(current_page_content),
"is_chapter_start": is_start,
"chapter_title": title
})
current_global_page_index += 1 # 全局页码 +1
current_page_content = [p]
current_char_count = len(p)
is_start = False
limit = chars_per_page
else:
current_page_content.append(p)
current_char_count += len(p)
if current_page_content:
flat_pages.append({
"title": title if is_start else "",
"content": "\n".join(current_page_content),
"is_chapter_start": is_start,
"chapter_title": title
})
current_global_page_index += 1
return flat_pages, chapter_start_indices
def render_book_page(flat_pages, page_index):
# 1. 空状态
if not flat_pages or len(flat_pages) == 0:
return """
<div class='book-page-container empty-state'>
<div class='book-content-wrapper'>
<div style='text-align:center; opacity:0.5'>
<div style='font-size:3rem; margin-bottom:20px;'>☕</div>
<h3>Waiting for Story...</h3>
</div>
</div>
</div>
"""
# 2. 渲染
total_pages = len(flat_pages)
page_index = max(0, min(page_index, total_pages - 1))
page_data = flat_pages[page_index]
is_start = page_data.get("is_chapter_start", False)
display_title = page_data.get("title", "")
chapter_ref = page_data.get("chapter_title", "")
content = page_data.get("content", "")
paragraphs = [f"<p>{p}</p>" for p in content.split('\n') if p.strip()]
content_html = "".join(paragraphs)
if is_start:
header_html = f"""
<div class="page-top-spacer"></div>
<div class="chapter-header-block">
<div class="chapter-subtitle">Generated Novel</div>
<div class="chapter-title">{display_title}</div>
</div>
"""
else:
header_html = f"""
<div class="page-running-header">
<span>{chapter_ref}</span>
<span style="float:right">... continuing</span>
</div>
"""
html = f"""
<div class="book-page-container">
<div class="book-content-wrapper">
{header_html}
<div class="chapter-text-content">
{content_html}
</div>
</div>
<div class="page-footer">
Page {page_index + 1} of {total_pages}
</div>
</div>
"""
return html
# ==========================================
# 3. 后端连接
# ==========================================
def bridge_to_backend(premise):
if not premise.strip():
# yield error
yield "⚠️ 请输入故事梗概...", None, None, None, [], [], render_book_page([], 0)
return
log_buffer = "🚀 初始化前端连接...\n"
initial_html = render_book_page([], 0)
# Yield 初始状态 [log, outline, plan, personas, pages, chap_indices, html]
yield log_buffer, None, None, None, [], [], initial_html
try:
log_buffer += f"🔗 连接后端 Space: {PRIVATE_SPACE_ID}...\n"
client = Client(PRIVATE_SPACE_ID, hf_token=HF_TOKEN)
job = client.submit(premise, api_name="/generate_novel")
for result in job:
# result: [0]Log, [1]Outline, [2]Plan, [3]Personas, [4]StoryList
backend_log = result[0]
outline = result[1]
plan = result[2]
personas_html = result[3]
raw_story_list = result[4]
# 分页 + 计算章节索引
flat_pages, chap_indices = paginate_story(raw_story_list, chars_per_page=600)
book_html = render_book_page(flat_pages, 0)
yield backend_log, outline, plan, personas_html, flat_pages, chap_indices, book_html
except Exception as e:
error_msg = f"❌ 前端连接错误: {str(e)}"
yield error_msg, None, None, None, [], [], render_book_page([], 0)
# ==========================================
# 4. 前端 UI 布局
# ==========================================
with gr.Blocks(theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"), css=custom_css, title="LongStory Agent") as demo:
# --- 状态管理 ---
story_pages_state = gr.State([]) # 所有的页面 List[Dict]
chapter_indices_state = gr.State([]) # 每一章起始页的 index List[int]
current_page_state = gr.State(0) # 当前在第几页
# --- 顶部标题 ---
with gr.Row(elem_classes=["header-box"]):
gr.HTML("""
<div style="display:flex; flex-direction:column; align-items:center;">
<div class="title-wrapper">
<span class="title-text">LongStory AI</span>
<span class="title-badge">PRO</span>
</div>
<div class="subtitle-text">Deep Persona-Driven Recursive Novel Generation</div>
</div>
""")
with gr.Row(elem_classes=["main-container"]):
# === 左侧:控制中心 ===
with gr.Column(scale=4, elem_classes=["control-panel-col"]):
with gr.Column(elem_classes=["control-panel"]):
gr.HTML("""
<div class="input-label">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path></svg>
Story Premise
</div>
""")
premise_input = gr.Textbox(label="Premise", show_label=False, lines=5, elem_classes=["input-box"], placeholder="输入故事创意...")
# 2. 灵感卡片
gr.HTML('<div class="examples-container"><div class="examples-label">⚡ 快速开始 (Quick Inspirations)</div></div>')
example_prompts = [
["高中时互相看不顺眼的死对头,十年后在公司并购案谈判桌上重逢,一个是冷血收购方,一个是绝境求生的CEO。"],
["天生'废灵根'的宗门弃徒,在被逐出师门当晚,意外捡到一个能听见万物心声的黑色小鼎。"],
["一觉醒来,人类全部消失,只剩下我和我养的猫。第三天,猫开口对我说话了。"]
]
with gr.Column(elem_classes=["examples-table"]):
gr.Examples(
examples=example_prompts,
inputs=premise_input,
label=None
)
submit_btn = gr.Button("✨ 开始生成 (GENERATE)", elem_classes=["generate-btn"])
gr.HTML('<div class="terminal-wrapper"><div class="terminal-header"><div class="dot dot-red"></div><div class="dot dot-yellow"></div><div class="dot dot-green"></div><div class="terminal-title">system.log</div></div>')
log_output = gr.Textbox(label="Log", lines=10, interactive=False, elem_classes=["terminal-log"], show_label=False, value="> System initialized...")
gr.HTML("</div>")
# === 右侧:内容展示 ===
with gr.Column(scale=8):
with gr.Tabs(elem_classes=["tabs-container"]):
# Tab 1: 电子书 (核心修改区域)
with gr.TabItem("📖 正文阅读", id="tab-story"):
# 1. 阅读区:[左箭] [书] [右箭]
# equal_height=True 保证高度对其
# gap=0 去除列间隙,让箭头紧贴
with gr.Row(elem_classes=["book-reader-row"], equal_height=True):
# 左侧箭头:缩小宽度 (min_width=40),去掉间距
with gr.Column(scale=1, min_width=40, elem_classes=["arrow-col"]):
btn_prev_page = gr.Button("‹", elem_classes=["arrow-btn"])
# 书本核心
with gr.Column(scale=15):
story_display = gr.HTML(label="Book View", value=render_book_page([], 0))
# 右侧箭头
with gr.Column(scale=1, min_width=40, elem_classes=["arrow-col"]):
btn_next_page = gr.Button("›", elem_classes=["arrow-btn"])
# 2. 章节导航区:放在书本下方
with gr.Row(elem_classes=["chapter-nav-row"]):
btn_prev_chap = gr.Button("⏮️ 上一章", elem_classes=["chapter-nav-btn"])
# 占位符,把按钮挤到两边或中间
# gr.Spacer()
btn_next_chap = gr.Button("⏭️ 下一章", elem_classes=["chapter-nav-btn"])
# Tab 2-4
with gr.TabItem("🗺️ 大纲"): outline_output = gr.JSON(label="Structure")
with gr.TabItem("📅 规划"): plan_output = gr.JSON(label="Plan")
with gr.TabItem("👥 人设"): persona_output = gr.HTML(label="Personas")
# ==========================================
# 5. 事件交互
# ==========================================
# A. 生成
submit_btn.click(
fn=bridge_to_backend,
inputs=[premise_input],
outputs=[log_output, outline_output, plan_output, persona_output, story_pages_state, chapter_indices_state, story_display],
concurrency_limit=1
)
# B. 翻页 (上一页/下一页)
def on_prev_page(pages, current_idx):
if not pages: return 0, render_book_page([], 0)
new_idx = max(0, current_idx - 1)
return new_idx, render_book_page(pages, new_idx)
def on_next_page(pages, current_idx):
if not pages: return 0, render_book_page([], 0)
new_idx = min(len(pages) - 1, current_idx + 1)
return new_idx, render_book_page(pages, new_idx)
btn_prev_page.click(fn=on_prev_page, inputs=[story_pages_state, current_page_state], outputs=[current_page_state, story_display])
btn_next_page.click(fn=on_next_page, inputs=[story_pages_state, current_page_state], outputs=[current_page_state, story_display])
# C. 章节跳转 (上一章/下一章)
def on_prev_chap(pages, current_idx, chap_indices):
if not pages or not chap_indices: return current_idx, render_book_page(pages, current_idx)
# 找到当前页属于第几章 (找到最后一个 <= current_idx 的 start_index)
# 例如 chap_indices = [0, 5, 12], current = 6. 属于索引为1的章节(index 5)
# 我们要跳到索引为0的章节(index 0)
target_idx = 0
# 简单遍历寻找目标
for start_idx in reversed(chap_indices):
if start_idx < current_idx:
target_idx = start_idx
break
return target_idx, render_book_page(pages, target_idx)
def on_next_chap(pages, current_idx, chap_indices):
if not pages or not chap_indices: return current_idx, render_book_page(pages, current_idx)
target_idx = current_idx
# 找到第一个 > current_idx 的 start_index
for start_idx in chap_indices:
if start_idx > current_idx:
target_idx = start_idx
break
# 如果没找到(已经是最后一章),保持不变或跳到最后一页,这里保持不变
return target_idx, render_book_page(pages, target_idx)
btn_prev_chap.click(fn=on_prev_chap, inputs=[story_pages_state, current_page_state, chapter_indices_state], outputs=[current_page_state, story_display])
btn_next_chap.click(fn=on_next_chap, inputs=[story_pages_state, current_page_state, chapter_indices_state], outputs=[current_page_state, story_display])
if __name__ == "__main__":
demo.queue().launch()