| 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() |
|
|