LongStory / app.py
Yoyo2004's picture
Update app.py
712d21c verified
import os
import time
import math
import gradio as gr
import html as py_html
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; /* indigo 靛蓝色 */
--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; }
/* --- 左侧控制面板 --- */
.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;
transition: transform 0.2s, box-shadow 0.2s;
border: 1px solid #edf2f7;
display: block;
margin-bottom: 8px;
}
.examples-table tbody tr:hover {
transform: translateY(-2px);
box-shadow: 0 5px 12px rgba(0,0,0,0.1);
border-color: var(--primary-color);
}
.examples-table td { padding: 12px 15px !important; font-size: 13px !important; color: #4a5568 !important; line-height: 1.5 !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; }
/* 1. 书页容器 */
.book-page-container {
background-color: var(--paper-bg);
height: 900px;
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; }
/* 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;
}
.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;
}
/* 全文导出区域 */
.fulltext-wrapper { padding: 10px 0; }
.fulltext-copy-btn {
background: #4f46e5;
color: #fff;
border: none;
border-radius: 8px;
padding: 6px 12px;
font-size: 0.9rem;
cursor: pointer;
margin-bottom: 10px;
}
.fulltext-copy-btn:hover {
filter: brightness(1.08);
}
.fulltext-area {
width: 100%;
min-height: 500px;
height: 900px;
border-radius: 8px;
border: 1px solid #e2e8f0;
padding: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
resize: vertical;
}
/* 移动端适配 */
@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=450, first_page_ratio=0.8):
"""
分章 -> 分页
story_data: List[{"title": ..., "content": ...}, ...]
"""
if not story_data:
return [], []
flat_pages = []
chapter_start_indices = []
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
is_start = True
# 首页为标题预留空间
limit = int(chars_per_page * first_page_ratio)
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
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):
# 空状态
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>
"""
# 正常渲染页面
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
def build_full_text(story_data):
"""
把章节列表拼成一整本小说文本,供导出/复制。
story_data: List[{"title": ..., "content": ...}, ...]
"""
if not story_data:
return ""
blocks = []
for ch in story_data:
title = ch.get("title", "").strip()
content = ch.get("content", "").strip()
if title:
blocks.append(title)
if content:
blocks.append(content)
return "\n\n".join(blocks).strip()
def build_full_text_html(full_text: str) -> str:
"""
生成「一键复制」按钮
"""
escaped = py_html.escape(full_text or "")
return f"""
<div class="fulltext-wrapper">
<button class="fulltext-copy-btn" onclick="
const ta = document.getElementById('full-story-area');
if (ta) {{
ta.select();
ta.setSelectionRange(0, 999999);
navigator.clipboard && navigator.clipboard.writeText(ta.value);
}}
">
📋 一键复制全文
</button>
<textarea id="full-story-area" class="fulltext-area">{escaped}</textarea>
</div>
"""
# ==========================================
# 3. 后端连接
# ==========================================
def bridge_to_backend(premise):
if not premise.strip():
empty_full_html = build_full_text_html("")
yield "⚠️ 请输入故事梗概...", None, None, None, [], [], empty_full_html, render_book_page([], 0)
return
log_buffer = "🚀 初始化前端连接...\n"
initial_html = render_book_page([], 0)
empty_full_html = build_full_text_html("")
# 初始状态 [log, outline, plan, personas, pages, chap_indices, full_text_html, book_html]
yield log_buffer, None, None, None, [], [], empty_full_html, 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=500,
first_page_ratio=0.8,
)
# 一键复制全文文本
full_text = build_full_text(raw_story_list)
full_text_html = build_full_text_html(full_text)
book_html = render_book_page(flat_pages, 0)
# flat_pages: List[Dict]
yield backend_log, outline, plan, personas_html, flat_pages, chap_indices, full_text_html, book_html
except Exception as e:
error_msg = f"❌ 前端连接错误: {str(e)}"
error_full_html = build_full_text_html(str(e))
yield error_msg, None, None, None, [], [], error_full_html, 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 Agent</span>
</div>
<div class="subtitle-text"></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="输入故事创意..."
)
# Examples
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"):
with gr.Row(elem_classes=["book-reader-row"], equal_height=True):
# 左侧箭头
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"])
# 章节导航
with gr.Row(elem_classes=["chapter-nav-row"]):
btn_prev_chap = gr.Button("⏮️ 上一章", elem_classes=["chapter-nav-btn"])
btn_next_chap = gr.Button("⏭️ 下一章", elem_classes=["chapter-nav-btn"])
# Tab 2: 大纲
with gr.TabItem("🗺️ 故事大纲", id="tab-outline"):
outline_output = gr.JSON(label="Structure Data")
# Tab 3: 规划
with gr.TabItem("📅 剧情规划", id="tab-planning"):
plan_output = gr.JSON(label="Event Planning")
# Tab 4: 人设
with gr.TabItem("👥 人物档案", id="tab-persona"):
persona_output = gr.HTML(label="Character Cards")
# Tab 5: 全文导出
with gr.TabItem("📄 全文导出", id="tab-fulltext"):
full_text_html = gr.HTML(label="Full Story Export")
# ==========================================
# 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,
full_text_html,
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)
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
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()