|
|
import os |
|
|
import time |
|
|
import math |
|
|
import gradio as gr |
|
|
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; |
|
|
--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; } |
|
|
} |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def paginate_story(story_data, chars_per_page=600): |
|
|
""" |
|
|
分章 -> 分页。同时记录每一章的起始页码,方便章节跳转。 |
|
|
返回: (flat_pages, chapter_start_indices) |
|
|
""" |
|
|
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 |
|
|
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 |
|
|
|
|
|
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 bridge_to_backend(premise): |
|
|
if not premise.strip(): |
|
|
|
|
|
yield "⚠️ 请输入故事梗概...", None, None, None, [], [], render_book_page([], 0) |
|
|
return |
|
|
|
|
|
log_buffer = "🚀 初始化前端连接...\n" |
|
|
initial_html = render_book_page([], 0) |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 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="输入故事创意...") |
|
|
|
|
|
|
|
|
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("🗺️ 大纲"): outline_output = gr.JSON(label="Structure") |
|
|
with gr.TabItem("📅 规划"): plan_output = gr.JSON(label="Plan") |
|
|
with gr.TabItem("👥 人设"): persona_output = gr.HTML(label="Personas") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
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() |