|
|
import os |
|
|
import time |
|
|
import math |
|
|
import gradio as gr |
|
|
import html as py_html |
|
|
|
|
|
from gradio_client import Client |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PRIVATE_SPACE_ID = "Yoyo2004/Longstory-backend" |
|
|
HF_TOKEN = os.environ.get("HF_TOKEN") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; } |
|
|
} |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("") |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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([]) |
|
|
chapter_indices_state = gr.State([]) |
|
|
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="输入故事创意..." |
|
|
) |
|
|
|
|
|
|
|
|
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"]): |
|
|
|
|
|
|
|
|
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"]) |
|
|
|
|
|
|
|
|
with gr.TabItem("🗺️ 故事大纲", id="tab-outline"): |
|
|
outline_output = gr.JSON(label="Structure Data") |
|
|
|
|
|
|
|
|
with gr.TabItem("📅 剧情规划", id="tab-planning"): |
|
|
plan_output = gr.JSON(label="Event Planning") |
|
|
|
|
|
|
|
|
with gr.TabItem("👥 人物档案", id="tab-persona"): |
|
|
persona_output = gr.HTML(label="Character Cards") |
|
|
|
|
|
|
|
|
with gr.TabItem("📄 全文导出", id="tab-fulltext"): |
|
|
full_text_html = gr.HTML(label="Full Story Export") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
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() |
|
|
|