File size: 21,154 Bytes
43e19b8
c36065e
f396014
dabaa03
ab21d03
c36065e
0e89edd
e06be5d
0e89edd
ab21d03
 
e39a5b8
 
e06be5d
e39a5b8
ab21d03
9cbc975
ab21d03
 
 
 
9cbc975
ab21d03
 
 
 
9cbc975
ab21d03
 
 
9cbc975
 
 
 
 
ab21d03
9cbc975
 
 
 
 
 
 
 
ab21d03
3b93023
9cbc975
3b93023
 
 
 
 
 
 
ab21d03
3b93023
ab21d03
3b93023
9cbc975
 
1708a78
f396014
3b93023
 
ab21d03
3b93023
 
 
9cbc975
3b93023
 
ab21d03
f396014
3b93023
f396014
 
3b93023
f396014
9cbc975
f396014
 
9cbc975
 
 
f396014
 
3b93023
9cbc975
f396014
9cbc975
 
f396014
 
 
3b93023
f396014
3b93023
f396014
3b93023
f396014
3b93023
f396014
3b93023
 
 
f396014
3b93023
f396014
3b93023
 
f396014
3b93023
f396014
 
3b93023
f396014
3b93023
 
f396014
 
 
 
3b93023
 
 
f396014
3b93023
 
f396014
 
 
 
 
3b93023
f396014
3b93023
f396014
3b93023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e89edd
ab21d03
 
 
3b93023
f396014
3b93023
ab21d03
 
e39a5b8
6ec656b
3b93023
6ec656b
f396014
 
ab21d03
3b93023
 
ab21d03
f396014
3b93023
f396014
 
3b93023
f396014
3b93023
 
f396014
3b93023
 
 
f396014
 
 
 
 
 
 
 
 
3b93023
f396014
 
 
 
 
 
3b93023
f396014
 
3b93023
f396014
3b93023
 
f396014
 
 
3b93023
f396014
 
 
 
 
 
 
 
 
 
 
3b93023
 
 
f396014
 
 
 
ab21d03
f396014
 
 
 
 
 
ab21d03
 
 
 
3b93023
f396014
ab21d03
f396014
e39a5b8
f396014
 
 
 
e39a5b8
f396014
 
ab21d03
f396014
 
 
 
ab21d03
f396014
ab21d03
f396014
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab21d03
 
f396014
ab21d03
e39a5b8
 
 
 
 
7fc598d
e39a5b8
ab21d03
 
3b93023
 
132536a
c36065e
ab21d03
 
 
3b93023
 
e39a5b8
 
ab21d03
 
 
132536a
ab21d03
3b93023
ab21d03
 
 
 
f396014
 
3b93023
 
f396014
ab21d03
3b93023
132536a
 
ab21d03
3b93023
ab21d03
e39a5b8
7fc598d
e39a5b8
9cbc975
3b93023
9cbc975
3b93023
 
 
e39a5b8
ab21d03
 
 
9cbc975
 
 
 
 
 
ab21d03
 
 
 
 
9cbc975
7fc598d
ab21d03
9cbc975
 
 
 
 
 
3b93023
ab21d03
e06be5d
 
 
 
 
 
 
 
 
1708a78
 
e06be5d
1708a78
 
 
ab21d03
3b93023
 
 
 
 
e39a5b8
7fc598d
 
ab21d03
 
3b93023
9cbc975
f396014
3b93023
 
 
f396014
 
3b93023
 
 
f396014
3b93023
 
f396014
 
3b93023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab21d03
 
3b93023
ab21d03
0e89edd
3b93023
ab21d03
 
 
3b93023
ab21d03
e39a5b8
43e19b8
3b93023
 
 
f396014
 
 
3b93023
 
f396014
 
 
3b93023
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e39a5b8
132536a
f396014
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
import os
import time
import math
import gradio as gr
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;
    --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; }
}
"""

# ==========================================
# 2. 逻辑处理
# ==========================================

def paginate_story(story_data, chars_per_page=600):
    """
    分章 -> 分页。同时记录每一章的起始页码,方便章节跳转。
    返回: (flat_pages, chapter_start_indices)
    """
    if not story_data:
        return [], []

    flat_pages = []
    chapter_start_indices = [] # 记录 [Chapter 1起始页index, Chapter 2起始页index, ...]
    
    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 # 全局页码 +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):
    # 1. 空状态
    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>
        """
    
    # 2. 渲染
    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

# ==========================================
# 3. 后端连接
# ==========================================
def bridge_to_backend(premise):
    if not premise.strip():
        # yield error
        yield "⚠️ 请输入故事梗概...", None, None, None, [], [], render_book_page([], 0)
        return

    log_buffer = "🚀 初始化前端连接...\n"
    initial_html = render_book_page([], 0)
    
    # Yield 初始状态 [log, outline, plan, personas, pages, chap_indices, html]
    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:
            # 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=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)

# ==========================================
# 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 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="输入故事创意...")
                
                # 2. 灵感卡片
                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"):
                    
                    # 1. 阅读区:[左箭] [书] [右箭]
                    # equal_height=True 保证高度对其
                    # gap=0 去除列间隙,让箭头紧贴
                    with gr.Row(elem_classes=["book-reader-row"], equal_height=True):
                        
                        # 左侧箭头:缩小宽度 (min_width=40),去掉间距
                        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"])
                    
                    # 2. 章节导航区:放在书本下方
                    with gr.Row(elem_classes=["chapter-nav-row"]):
                        btn_prev_chap = gr.Button("⏮️ 上一章", elem_classes=["chapter-nav-btn"])
                        # 占位符,把按钮挤到两边或中间
                        # gr.Spacer() 
                        btn_next_chap = gr.Button("⏭️ 下一章", elem_classes=["chapter-nav-btn"])

                # Tab 2-4
                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")

    # ==========================================
    # 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, 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)
        
        # 找到当前页属于第几章 (找到最后一个 <= current_idx 的 start_index)
        # 例如 chap_indices = [0, 5, 12], current = 6. 属于索引为1的章节(index 5)
        # 我们要跳到索引为0的章节(index 0)
        
        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
        # 找到第一个 > current_idx 的 start_index
        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()