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 """

Waiting for Story...

""" # 正常渲染页面 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}

" for p in content.split('\n') if p.strip()] content_html = "".join(paragraphs) if is_start: header_html = f"""
Generated Novel
{display_title}
""" else: header_html = f"""
{chapter_ref} ... continuing
""" html = f"""
{header_html}
{content_html}
""" 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"""
""" # ========================================== # 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("""
LongStory Agent
""") 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("""
Story Premise
""") premise_input = gr.Textbox( label="Premise", show_label=False, lines=5, elem_classes=["input-box"], placeholder="输入故事创意..." ) # Examples gr.HTML('
⚡ 快速开始 (Quick Inspirations)
') example_prompts = [ ["生成一个只有一个Event的校园故事"], # 青春校园 ["高二分班那天,全校第一的高冷学霸竟然主动申请坐到了全是“差生”的最后一排,成了我的同桌,还递给我一本写满笔记的物理书。"], # 玄幻修仙 ["宗门大比前夕,一直被嘲笑为“杂役弟子”的主角,在后山禁地里误吞了一颗会呼吸的金色龙蛋,觉醒了上古血脉。"], # 都市爱情 ["为了应付家里催婚,我和刚认识不到两小时的陌生人领了证,婚后第一天上班,却发现他竟然是公司新来的那个传闻中冷面无情的顶头上司。"] ] 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( '
' '
' '
system.log
' '
' ) log_output = gr.Textbox( label="Log", lines=10, interactive=False, elem_classes=["terminal-log"], show_label=False, value="> System initialized..." ) gr.HTML("
") # === 右侧:内容展示 === 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()