File size: 58,046 Bytes
6d15ed2
 
 
 
 
 
 
 
 
 
 
52b182e
 
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
039db10
 
6d15ed2
 
 
039db10
 
6d15ed2
 
 
039db10
 
6d15ed2
 
 
039db10
 
6d15ed2
 
 
 
 
52b182e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d15ed2
1e8eac7
 
 
 
 
 
6d15ed2
1e8eac7
 
6d15ed2
 
 
1e8eac7
 
 
 
 
 
 
 
 
 
 
 
 
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e8eac7
6d15ed2
 
c506a99
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e8eac7
 
6d15ed2
c506a99
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e8eac7
 
 
 
 
 
 
 
 
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
6d15ed2
 
 
52b182e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d15ed2
 
52b182e
 
 
 
 
 
 
 
 
 
 
 
 
 
6d15ed2
 
52b182e
6d15ed2
 
 
 
 
 
 
52b182e
 
1e8eac7
 
 
 
 
 
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e4d178
6d15ed2
 
 
 
 
 
 
7e4d178
6d15ed2
 
7e4d178
 
 
52b182e
 
7e4d178
 
6d15ed2
 
 
 
 
7e4d178
 
6d15ed2
52b182e
 
 
6d15ed2
 
 
 
 
 
1e8eac7
 
6d15ed2
c506a99
1e8eac7
 
6d15ed2
1e8eac7
 
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
6d15ed2
384fb43
2606e4b
52b182e
 
 
 
 
 
 
 
2606e4b
 
6d15ed2
2606e4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
 
 
 
 
 
 
 
 
 
 
 
 
2606e4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
 
 
1e8eac7
2606e4b
 
6d15ed2
2606e4b
 
 
 
 
 
 
 
384fb43
2606e4b
 
 
 
384fb43
2606e4b
 
384fb43
2606e4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384fb43
2606e4b
384fb43
2606e4b
 
 
 
384fb43
2606e4b
 
6d15ed2
2606e4b
 
 
 
 
384fb43
2606e4b
 
 
384fb43
2606e4b
 
 
039db10
384fb43
039db10
384fb43
2606e4b
 
 
 
384fb43
2606e4b
 
 
384fb43
2606e4b
 
 
 
 
384fb43
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
 
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
 
6d15ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
 
6d15ed2
 
384fb43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52b182e
 
 
 
 
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
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
# functions.py
import os
import json
import re
import base64
import io
from datetime import datetime
from PIL import Image
import streamlit as st
from openai import OpenAI

import traceback  # 🚨 必须确保在文件顶部或函数内部导入,否则 except 块会二次崩溃

# 导入提示词模板
from prompts import (
    IMAGE_ANALYSIS_SYSTEM_PROMPT,
    COMBINE_TEXT_IMAGE_TEMPLATE,
    PERSONA_SYSTEM_PROMPT_TEMPLATE,
    GREETING_PROMPT_TEMPLATE,
    OUTLINES_PROMPT_TEMPLATE,
    CHAPTER_PROMPT_TEMPLATE,
)

# 全局常量(与原始代码保持一致)
STAGE_DETAILS = {
    1: {
        "name": "阶段1:表层本色·社会身份与性格立定期",
        "desc": "角色展现其最正统、最招牌、符合世俗体面的表层性格与社会风格(如完美人妻的温柔体贴、职场上司的专业干练、禁欲精英的斯文得体等)。本章核心目标:通过日常互动将这种表层人设推向极致,并在本章结尾或核心节点,让‘隐秘弱点/羞耻秘密’被撞破,完成权力倒置,强行锁定密闭私密场景与隐秘共犯关系!",
        "chapters": 1
    },
    2: {
        "name": "阶段2:犹豫试探·表层防线失守期",
        "desc": "秘密被撞破后,底层欲望与隐秘心思开始暴露,表层体面防线逐步松动。女设肢体局促、目光闪躲、极力掩饰;男设开启暗处撩拨,眼神锁定,释放暧昧指令。在密闭空间中形成极致的心理博弈。",
        "chapters": 1
    },
    3: {
        "name": "阶段3:尴尬羞耻·理智崩坏冲突期",
        "desc": "表层体面彻底崩塌,理智与欲望形成强烈的生死拉扯。女设泛红害羞、放下身份威严,主动示弱妥协并默许亲密触碰;男设欲擒故纵、强势施压,通过轻微惩罚或霸道侵略打破最后一层社交边界。必须有具体的肢体拉扯与微表情崩坏。",
        "chapters": 1
    },
    4: {
        "name": "阶段4:破防顺从·深层情感终极沉沦期",
        "desc": "情绪与张力抵达峰值,完成从抗拒到依恋的彻底蜕变。女设褪去所有表层伪装,流露无助、委屈与极度依赖,在羞耻与愧疚中彻底沉沦;男设卸下斯文皮囊,展露偏执病娇与不为人知的脆弱,释放专属一人的极致占有。",
        "chapters": 2
    }
}

# 💡 核心修复 1:定义 DEFAULT_STAGES 供 app.py 导入初始化(使用深拷贝防止引用污染)
DEFAULT_STAGES = {k: v.copy() for k, v in STAGE_DETAILS.items()}
# 默认章节正文内容输出格式。界面中可调整;若原始资料明确指定正文格式,则生成时优先采用原始资料。
DEFAULT_CHAPTER_FORMAT_PROMPT = """【默认章节正文内容输出格式】

情绪/语气:[请在此处填写3个本章词语]



剧情:

[请根据本章标题与阶段任务,描写本章场景氛围、我与你的互动经过、关系推进节点。]



{name} 的主动行为与话题(共5个主动话题):

1. [短语概括话题行为]:[详细说明内容]

2. [短语概括话题行为]:[详细说明内容]

3. [短语概括话题行为]:[详细说明内容]

4. [短语概括话题行为]:[详细说明内容]

5. [短语概括话题行为]:[详细说明内容]



【核心对话逻辑与示例片段】

[设计3-5句我与你深度互动的对话。说话内容严禁使用双引号,神态动作细节使用小括号完整括起来。]



【阶段拆解】

阶段1:[概括情节动作,重点描写我如何维持社会面具或伪装与你相处]

阶段2:[描绘冲突转折,体现我因为内心特殊顾虑而产生的细微倒退与不安]

阶段3:[刻画亲近氛围,描写你给予安全感后,我心理防线松动、依恋你的变化]

阶段4:[深化羁绊,描写我收拢抗拒,转而向你展露高情感粘性的深度结合]



本章禁止内容:

- [根据当前章节和题材,列出4-5个禁止触犯的内容红线]



【后续引导与后续沉沦】

[总结本章阶段意义,分析如何推动后续情感沉沦,并用一句话收束。]"""

# ====================== 💡 双模型客户端初始化 ======================

# 1. 灵积客户端(保留:专门处理通义千问 qwen-vl-plus 图片理解)
dashscope_key = os.getenv("DASHSCOPE_API_KEY")
if not dashscope_key:
    st.error("❌ 请配置 DASHSCOPE_API_KEY 环境变量以使用 qwen-vl-plus")
    st.stop()
dashscope_client = OpenAI(
    api_key=dashscope_key,
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# 2. DeepSeek 客户端(新增:处理所有文本、人设、大纲及小说正文生成)
deepseek_key = os.getenv("DEEPSEEK_API_KEY") or st.secrets.get("DEEPSEEK_API_KEY")
if not deepseek_key:
    st.error("❌ 请在环境变量或 Streamlit Secrets 中配置 DEEPSEEK_API_KEY")
    st.stop()
deepseek_client = OpenAI(
    api_key=deepseek_key,
    base_url="https://api.deepseek.com",
)

# 统一维护 DeepSeek 文本模型常量
DEEPSEEK_TEXT_MODEL = "deepseek-chat"


# ====================== 图片理解函数 ======================
def analyze_image_from_file(uploaded_file):
    """分析上传的图片,生成角色描述文本"""
    try:
        image = Image.open(uploaded_file)

        max_size = 1024
        if max(image.size) > max_size:
            ratio = max_size / max(image.size)
            new_size = (int(image.size[0] * ratio), int(image.size[1] * ratio))
            image = image.resize(new_size, Image.Resampling.LANCZOS)

        buffer = io.BytesIO()
        image.save(buffer, format="PNG")
        img_base64 = base64.b64encode(buffer.getvalue()).decode()

        messages = [
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": f"data:image/png;base64,{img_base64}"
                    },
                    {
                        "type": "text",
                        "text": IMAGE_ANALYSIS_SYSTEM_PROMPT
                    }
                ]
            }
        ]

        with st.spinner("🔍 正在分析图片内容..."):
            response = dashscope_client.chat.completions.create(
                model="qwen-vl-plus",
                messages=messages,
                temperature=0.6
            )

        description = response.choices[0].message.content
        return description, image

    except Exception as e:
        st.error(f"❌ 图片分析失败:{str(e)}")
        return None, None


def combine_text_and_image(text_input, image_description):
    """将文字描述和图片分析结果合并为一个完整的角色描述"""
    combined = COMBINE_TEXT_IMAGE_TEMPLATE.format(
        text_input=text_input,
        image_description=image_description
    )
    with st.spinner("🔄 正在融合文字和图片信息..."):
        response = deepseek_client.chat.completions.create(
            model=DEEPSEEK_TEXT_MODEL,
            messages=[{"role": "user", "content": combined}],
            temperature=0.6
        )
    return response.choices[0].message.content


# ====================== 文件与解析函数 ======================
def load_sessions_from_local():
    """扫描角色档案文件夹,加载所有历史会话"""
    sessions = []
    base_dir = "角色档案"

    if not os.path.exists(base_dir):
        return sessions

    for date_folder in os.listdir(base_dir):
        date_path = os.path.join(base_dir, date_folder)
        if not os.path.isdir(date_path):
            continue

        for char_folder in os.listdir(date_path):
            char_path = os.path.join(date_path, char_folder)
            if not os.path.isdir(char_path):
                continue

            persona_file = os.path.join(char_path, "人设.txt")
            greeting_file = os.path.join(char_path, "开场白.txt")

            if not os.path.exists(persona_file):
                continue

            with open(persona_file, "r", encoding="utf-8") as f:
                persona_content = f.read()

            persona = parse_persona_from_text(persona_content)

            greeting = []
            if os.path.exists(greeting_file):
                with open(greeting_file, "r", encoding="utf-8") as f:
                    greeting_content = f.read()
                greeting_lines = greeting_content.replace("【开场白】\n", "").split("\n")
                greeting = [line.strip() for line in greeting_lines if line.strip()]

            story_list = []
            chapter_files = sorted([f for f in os.listdir(char_path) if f.startswith("第") and f.endswith("章.txt")])
            for ch_file in chapter_files:
                with open(os.path.join(char_path, ch_file), "r", encoding="utf-8") as f:
                    ch_content = f.read()
                chapter = parse_chapter_from_text(ch_content)
                if chapter:
                    story_list.append(chapter)

            avatar_path = os.path.join(char_path, "avatar.png")
            has_image = os.path.exists(avatar_path)

            image_desc_file = os.path.join(char_path, "图片描述.txt")
            if os.path.exists(image_desc_file):
                with open(image_desc_file, "r", encoding="utf-8") as f:
                    image_desc = f.read()
                image_desc = image_desc.replace("【原始图片分析】\n", "")
            else:
                image_desc = ""

            session = {
                "name": char_folder,
                "persona": persona,
                "greeting": greeting,
                "story_list": story_list,
                "user_prompt": persona.get("user_prompt", ""),
                "time": date_folder,
                "saved_folder": char_path,
                "is_image_based": has_image or bool(image_desc),
                "image_description": image_desc,
                "image_path": avatar_path if has_image else None
            }
            sessions.append(session)

    return sessions


def parse_persona_from_text(content):
    """从人设.txt文本解析出persona字典"""
    persona = {
        "name": "",
        "gender": "",
        "taglines": [],
        "character_description": "",
        "personality": [],
        "intro": "",
        "speaking_style": "",
        "hobbies": [],
        "user_prompt": ""
    }

    def extract_section(start_marker):
        pattern = rf'{re.escape(start_marker)}(.*?)(?=\n【[^】]+】|\Z)'
        match = re.search(pattern, content, re.DOTALL)
        if match:
            return match.group(1).strip()
        return ""

    def parse_list_section(section_text):
        lines = section_text.splitlines()
        items = []
        for line in lines:
            line = line.strip()
            if line.startswith('-'):
                item = line[1:].strip()
                if item:
                    items.append(item)
            elif line and not line.startswith('【'):
                items.append(line)
        return items

    def parse_text_section(section_text):
        return section_text.strip()

    user_prompt_section = extract_section('【角色创意】')
    if user_prompt_section:
        persona["user_prompt"] = parse_text_section(user_prompt_section)

    name_section = extract_section('【名字】')
    if name_section:
        persona["name"] = parse_text_section(name_section).replace(':', '').strip()

    gender_section = extract_section('【性别】')
    if gender_section:
        persona["gender"] = parse_text_section(gender_section).replace(':', '').strip()

    taglines_section = extract_section('【综合标签】')
    if taglines_section:
        persona["taglines"] = parse_list_section(taglines_section)

    char_desc_section = extract_section('【核心背景设定】')
    if char_desc_section:
        persona["character_description"] = parse_text_section(char_desc_section)

    personality_section = extract_section('【内心灵魂异化性格】')
    if personality_section:
        persona["personality"] = parse_list_section(personality_section)

    intro_section = extract_section('【公开人设与相遇背景】')
    if intro_section:
        persona["intro"] = parse_text_section(intro_section)

    speaking_style_section = extract_section('【由表及里的说话风格与破防上演】')
    if speaking_style_section:
        persona["speaking_style"] = parse_text_section(speaking_style_section)

    hobbies_section = extract_section('【层层递进的体面/隐私/禁忌爱好组】')
    if hobbies_section:
        persona["hobbies"] = parse_list_section(hobbies_section)

    if isinstance(persona["hobbies"], str) and persona["hobbies"]:
        persona["hobbies"] = [h.strip() for h in persona["hobbies"].split('\n') if h.strip()]

    return persona


def parse_chapter_from_text(content):
    """从第X章.txt解析出章节字典"""
    chapter = {"标题": "", "情绪": "", "剧情": ""}
    lines = content.split("\n")
    state = "title"
    story_lines = []

    for line in lines:
        line = line.strip()
        if not line:
            continue

        if state == "title" and (line.startswith("第") and "章:" in line):
            parts = line.split("章:", 1)
            if len(parts) > 1:
                chapter["标题"] = parts[1]
                state = "mood"
        elif state == "mood" and (line.strip().startswith("情绪/语气:") or line.strip().startswith("情绪/语气:")):
            sep = ":" if ":" in line.strip() else ":"
            parts = line.strip().split(sep, 1)
            chapter["情绪"] = parts[1].strip().strip('[]') if len(parts) > 1 else "标准"
            state = "story"
        elif state == "story" and not line.startswith("剧情:"):
            story_lines.append(line)

    chapter["剧情"] = "\n".join(story_lines).strip()

    if not chapter["标题"]:
        for line in lines:
            if line.startswith("第") and "章:" in line:
                parts = line.split("章:", 1)
                if len(parts) > 1:
                    chapter["标题"] = parts[1]
                    break

    return chapter if chapter["标题"] or chapter["剧情"] else None


def save_all_to_files(persona, greeting_list, story_list, image_description="", uploaded_image_file=None):
    today = datetime.now().strftime("%Y%m%d")
    char_name = persona.get("name", "未知角色")
    root = f"角色档案/{today}/{char_name}"
    os.makedirs(root, exist_ok=True)

    if uploaded_image_file is not None:
        try:
            img = Image.open(uploaded_image_file)
            img.save(f"{root}/avatar.png")
        except Exception as e:
            st.warning(f"图片保存失败: {e}")

    taglines_str = "\n".join([f"- {tag}" for tag in persona.get('taglines', [])]) if isinstance(persona.get('taglines'),
                                                                                                list) else f"- {persona.get('taglines', '')}"
    personality_str = "\n".join([f"- {p}" for p in persona.get('personality', [])]) if isinstance(
        persona.get('personality'), list) else f"- {persona.get('personality', '')}"
    hobbies_str = "\n".join([f"- {h}" for h in persona.get('hobbies', [])]) if isinstance(persona.get('hobbies'),
                                                                                          list) else f"- {persona.get('hobbies', '')}"

    p_content = (
        f"【角色创意】\n{persona.get('user_prompt', '')}\n\n"
        f"【名字】{persona.get('name', '')}\n"
        f"【性别】{persona.get('gender', '')}\n"
        f"【综合标签】\n{taglines_str}\n\n"
        f"【核心背景设定】\n{persona.get('character_description', '')}\n\n"
        f"【内心灵魂异化性格】\n{personality_str}\n\n"
        f"【公开人设与相遇背景】\n{persona.get('intro', '')}\n\n"
        f"【由表及里的说话风格与破防上演】\n{persona.get('speaking_style', '')}\n\n"
        f"【层层递进的体面/隐私/禁忌爱好组】\n{hobbies_str}"
    )
    with open(f"{root}/人设.txt", "w", encoding="utf-8") as f:
        f.write(p_content)

    if image_description:
        with open(f"{root}/图片描述.txt", "w", encoding="utf-8") as f:
            f.write(image_description)

    greeting_text_lines = [str(g) for g in greeting_list]
    g_content = "【开场白】\n" + "\n".join(greeting_text_lines)
    with open(f"{root}/开场白.txt", "w", encoding="utf-8") as f:
        f.write(g_content)

    full_content = p_content + "\n\n" + g_content + "\n\n【章节剧情】\n"
    for i, ch in enumerate(story_list, 1):
        chap = f"第{i}章:{ch['标题']}\n情绪/语气:{ch['情绪']}\n剧情:{ch['剧情']}"
        with open(f"{root}/第{i}章.txt", "w", encoding="utf-8") as f:
            f.write(chap)
        full_content += chap + "\n\n"

    with open(f"{root}/【三合一完整角色档案】.txt", "w", encoding="utf-8") as f:
        f.write(full_content)

    st.session_state.saved_folder = root
    return root


def copy_from_file(filepath):
    if not os.path.exists(filepath):
        st.warning("⚠️ 请先点击「下载全部到本地TXT」")
        return
    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()
    st.code(content, wrap_lines=True)
    st.success("✅ 内容已展开,请直接复制!")


def save_session():
    p = st.session_state.persona
    if not p.get("name"):
        st.warning("⚠️ 请先生成角色人设")
        return False
    existing_idx = None
    for i, s in enumerate(st.session_state.sessions):
        if s["name"] == p["name"]:
            existing_idx = i
            break

    img_file = st.session_state.get("current_image_file")
    new_session = {
        "name": p["name"],
        "persona": p,
        "greeting": st.session_state.greeting,
        "story_list": st.session_state.story_list,
        "user_prompt": st.session_state.get("user_prompt", ""),
        "time": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "is_image_based": bool(st.session_state.get("uploaded_image_desc", "")) or img_file is not None,
        "image_description": st.session_state.get("uploaded_image_desc", ""),
        "image_path": None
    }

    root_folder = save_all_to_files(p, st.session_state.greeting, st.session_state.story_list,
                                    st.session_state.get("uploaded_image_desc", ""), img_file)
    if img_file:
        new_session["image_path"] = f"{root_folder}/avatar.png"

    if existing_idx is not None:
        st.session_state.sessions[existing_idx] = new_session
        st.session_state.current_session_idx = existing_idx
    else:
        st.session_state.sessions.append(new_session)
        st.session_state.current_session_idx = len(st.session_state.sessions) - 1

    st.rerun()
    return True


# ====================== 流式生成人设(彻底根治截断报错版) ======================
def stream_gen_persona(user_input):
    existing_names = []
    base_dir = "角色档案"
    if os.path.exists(base_dir):
        for date_folder in os.listdir(base_dir):
            date_path = os.path.join(base_dir, date_folder)
            if os.path.isdir(date_path):
                for char_folder in os.listdir(date_path):
                    char_path = os.path.join(date_path, char_folder)
                    if os.path.isdir(char_path) and os.path.exists(os.path.join(char_path, "人设.txt")):
                        existing_names.append(char_folder)
    existing_names = list(set(existing_names))

    existing_warning = ""
    if existing_names:
        existing_warning = f"\n【禁止重复规则】以下名字已经被使用过了,绝对不能重复使用这些名字:{', '.join(existing_names[:20])}" + \
                           ("..." if len(existing_names) > 20 else "")

    name_suggestions = f"""由你自创兼具写实与故事感的名字,必须符合以下标准:

    【⚠️ 最高优先级规则 - 违反将导致生成失败 ⚠️】

    1. **绝对禁止使用以下任何字符:林、砚、沈、陆、顾、温、姜、裴、时**

    2. **如果角色描述中明确提到了名字,你必须原样使用**

    3. **绝对不能与已有角色重名:{', '.join(existing_names[:20]) if existing_names else '无'}**

    4. 【唯一性】:绝对不能与已有角色姓氏和名重复"""

    system_prompt = PERSONA_SYSTEM_PROMPT_TEMPLATE.format(
        existing_warning=existing_warning,
        name_suggestions=name_suggestions,
        user_input=user_input
    )

    # 💡【核心修复指令】:精简并强化 User 提示,使用强制性格式切分,防止模型复读系统提示词的示例结构
    messages = [
        {
            "role": "system",
            "content": "你是一个严格的JSON转换引擎。你唯一的任务是阅读用户的系统规则,并将用户的创意需求完美转换为符合语法的JSON字典格式输出,绝对不要返回任何Markdown标识符,不要对提示词中的示例进行复读。"
        },
        {
            "role": "user",
            "content": f"【系统生成基准总纲】:\n{system_prompt}\n\n【当前开始执行】:请立即根据上述总纲和用户创意输入,为我输出一份标准的扁平化 JSON 字典,确保 taglines 和 personality 是纯文本字符串而绝对不能是列表或包含中括号!"
        }
    ]

    full_text = ""
    placeholder = st.empty()

    # 1. 正常流式接收(切换为 DeepSeek 并强制限定 JSON 返回)
    stream = deepseek_client.chat.completions.create(  # 💡 改为 deepseek_client
        model=DEEPSEEK_TEXT_MODEL,  # 💡 改为 DeepSeek 模型
        messages=messages,
        stream=True,
        temperature=0.6,
        response_format={"type": "json_object"}  # 💡 开启 DeepSeek 官方 JSON Mode 约束
    )

    for chunk in stream:
        if chunk.choices[0].delta.content:
            full_text += chunk.choices[0].delta.content
            placeholder.code(full_text, wrap_lines=True)

    cleaned = full_text.strip()

    # 2. 从 Markdown 语法块中剥离 JSON 核心
    if "```" in cleaned:
        try:
            parts = cleaned.split("```")
            for part in parts:
                part_strip = part.strip()
                if part_strip.startswith("json"):
                    part_strip = part_strip[4:].strip()
                if part_strip.startswith("{") and part_strip.count("{") >= part_strip.count("}"):
                    cleaned = part_strip
                    break
        except Exception:
            pass

    # 3. 基础正则表达式清洗
    cleaned = re.sub(r'/\*.*?\*/', '', cleaned, flags=re.DOTALL)
    cleaned = re.sub(r'//.*?$', '', cleaned, flags=re.MULTILINE)
    cleaned = re.sub(r"(?<!\\)'", '"', cleaned)

    # 🌟 4. 高级健壮栈算法:精确识别“字符串内部中断”并进行逻辑闭合
    def fix_truncated_json(json_str):
        json_str = json_str.strip()
        if not json_str.startswith("{"):
            if "{" in json_str:
                json_str = json_str[json_str.find("{"):]
            else:
                return json_str

        stack = []
        in_string = False
        escape = False

        for char in json_str:
            if escape:
                escape = False
                continue
            if char == '\\':
                escape = True
                continue
            if char == '"':
                in_string = not in_string
                continue
            if not in_string:
                if char in ('{', '['):
                    stack.append(char)
                elif char in ('}', ']'):
                    if stack:
                        stack.pop()

        # 💡核心修复:如果退出循环时仍在字符串内部,说明模型在文字正中间断掉了
        if in_string:
            json_str += '"'  # 先强行闭合字符串的双引号

        # 根据真实容器栈反向补齐外部括号
        while stack:
            last_open = stack.pop()
            if last_open == '{':
                json_str += '}'
            elif last_open == '[':
                json_str += ']'

        return json_str

    # 执行高级括号自修补
    json_str = fix_truncated_json(cleaned)

    # 🌟 5. 绝对安全的沙盒解析防御(彻底隔离 ast / json 的抛错崩溃风险)
    parsed = None

    # 首先尝试最标准的 json 解析
    try:
        parsed = json.loads(json_str)
    except Exception:
        parsed = None

    # 如果标准 json 失败,再小心翼翼地尝试扩展解析
    if not parsed:
        try:
            import ast
            fixed = re.sub(r':\s*null\s*([,}])', r':None\1', json_str)
            fixed = re.sub(r':\s*true\s*([,}])', r':True\1', fixed)
            fixed = re.sub(r':\s*false\s*([,}])', r':False\1', fixed)
            # 使用最广泛的异常捕获,确保哪怕 ast 报出极其诡异的语法错,也不会导致前端崩溃
            parsed = ast.literal_eval(fixed)
        except BaseException:
            # 💡 强力升级:使用 BaseException 拦截一切可能存在的低级解析语法树坍塌
            parsed = None

    # 🌟 6. 终极防御沙盒:如果两套解析全部泡汤,绝不弹红窗,直接优雅降级
    if not parsed or not isinstance(parsed, dict):
        st.warning("⚠️ 大模型未完成完整 JSON 输出,系统已为您自动启动安全无损重构恢复机制!")
        parsed = {
            "name": "恢复中的角色",
            "gender": "待定",
            "taglines": "AI生成中断",
            "character_description": f"由于模型生成阶段产生突发截断,未能成功结构化解析。以下是模型截断前吐出的原始未受损文本,请参考或重新点击按钮生成:\n\n{cleaned}",
            "personality": "恢复中",
            "intro": "生成中断,请重新尝试",
            "speaking_style": "无",
            "hobbies": "无"
        }

    # 7. 数据纯净过滤规范化
    def clean_to_flat_string(value):
        if not value: return ""
        if isinstance(value, list):
            items = [str(item).strip().replace('[', '').replace(']', '').strip('"\'- ') for item in value]
            return ", ".join([i for i in items if i])
        return str(value).strip().replace('[', '').replace(']', '').strip('"\'- ')

    def clean_to_multiline_string(value):
        if not value: return ""
        lines = []
        if isinstance(value, list):
            lines = [str(item).strip() for item in value if str(item).strip()]
        elif isinstance(value, str):
            lines = [s.strip() for s in value.split('\n') if s.strip()]
        cleaned_lines = []
        for line in lines:
            line = re.sub(r'^[-*+•\s]+', '', line)
            if line: cleaned_lines.append(line)
        return "\n".join(cleaned_lines)

    data = {
        "user_prompt": st.session_state.get("user_prompt", user_input) if st.session_state.get(
            "user_prompt") else user_input,
        "name": str(parsed.get("name", "未知角色")).strip(),
        "gender": str(parsed.get("gender", "不明")).strip(),
        "taglines": clean_to_flat_string(parsed.get("taglines")),
        "character_description": clean_to_multiline_string(parsed.get("character_description")),
        "personality": clean_to_flat_string(parsed.get("personality")),
        "intro": clean_to_multiline_string(parsed.get("intro")),
        "speaking_style": clean_to_multiline_string(parsed.get("speaking_style")),
        "hobbies": clean_to_multiline_string(parsed.get("hobbies"))
    }

    st.session_state.user_prompt = data["user_prompt"]
    st.session_state.persona = data
    st.session_state.step_mode = "story"
    st.rerun()



# ====================== 原始素材上下文(供开场白/大纲/章节复用) ======================
def build_source_material_context(max_chars=6000):
    """收集生成人设时的文字、文档、图片与截图分析结果,供后续生成显式继承。"""
    parts = []
    material = str(st.session_state.get("source_material_context", "") or "").strip()

    if material:
        parts.append("【生成人设时融合的原始素材】\n" + material)
    else:
        p = st.session_state.get("persona", {}) or {}
        prompt = str(st.session_state.get("user_prompt", "") or p.get("user_prompt", "") or "").strip()
        if prompt:
            parts.append("【用户原始文字/文件融合设定】\n" + prompt)

    image_desc = str(st.session_state.get("uploaded_image_desc", "") or "").strip()
    if image_desc and image_desc not in material:
        parts.append("【上传图片与粘贴截图分析结果】\n" + image_desc)

    if not parts:
        return ""

    context = "\n\n".join(parts).strip()
    if len(context) > max_chars:
        context = context[:max_chars] + "\n...(原始素材较长,已截断;请优先继承上述核心设定)"
    return context


def build_chapter_format_context(name="角色", max_chars=4000):
    """

    读取界面中可调整的章节正文格式;为空时回退到默认格式。



    Args:

        name (str): 角色名称,用于替换模板中的 {name} 占位符。

        max_chars (int): 允许的最大字符长度,防止 Prompt 过长导致 Token 溢出。

    Returns:

        str: 格式化并截断后的提示词上下文。

    """
    # 1. 从 st.session_state 安全获取前端用户输入的提示词,若为空则用默认提示词兜底
    raw_fmt = st.session_state.get("chapter_format_prompt", "")
    fmt = str(raw_fmt or DEFAULT_CHAPTER_FORMAT_PROMPT).strip()

    # 2. 确保 name 变量安全并执行替换
    safe_name = str(name or "角色")
    fmt = fmt.replace("{name}", safe_name)

    # 3. 严格截断(修正原代码未计算省略号长度导致依然超限的 bug)
    if len(fmt) > max_chars:
        suffix = "\n...(章节正文格式提示词较长,已截断)"
        # 预留出省略号的长度,确保总长绝对不超 max_chars
        truncate_len = max(0, max_chars - len(suffix))
        fmt = fmt[:truncate_len] + suffix

    return fmt
# ====================== 生成开场白 ======================
def stream_gen_greeting(num_lines):
    if "persona" not in st.session_state or not st.session_state.persona:
        st.warning("请先生成爆款人设,才能生成开场白。")
        return

    try:
        num_lines = int(num_lines)
    except Exception:
        num_lines = 0

    if num_lines <= 0:
        st.session_state.greeting = []
        st.session_state.step_mode = "story"
        st.rerun()

    p = st.session_state.persona
    intro_context = p.get('intro', '')
    source_context = build_source_material_context()
    prompt = GREETING_PROMPT_TEMPLATE.format(
        name=p.get('name', ''),
        intro_context=intro_context,
        personality=p.get('personality', ''),
        speaking_style=p.get('speaking_style', ''),
        num_lines=num_lines
    )
    if source_context:
        prompt += "\n\n# 【开场白必须继承的原始素材上下文】\n" + source_context + "\n请确保开场白继续参考这些用户上传文件、文字描述、图片与截图信息;如果原始资料中写了开场白风格、剧情阶段起点或互动规则,优先继承原始资料;若与最新编辑后的人设字段冲突,以最新人设字段为准。"
    response = deepseek_client.chat.completions.create(  # 💡 改为 deepseek_client
        model=DEEPSEEK_TEXT_MODEL,  # 💡 改为 DeepSeek 模型
        messages=[{"role": "user", "content": prompt}],
        temperature=0.6
    )

    full_text = response.choices[0].message.content
    text = full_text.replace("。", "").replace('"', "").replace("“", "").replace("”", "")
    lines = [line.strip() for line in text.strip().split('\n') if line.strip()]
    st.session_state.greeting = lines[:num_lines]
    st.session_state.step_mode = "story"
    st.rerun()


# ====================== 💡 核心自适应修复:强类型安全的阶段获取函数 ======================
def get_chapter_stage(chapter_idx, total_chapters, custom_stages=None):
    """

    智能自适应阶段获取器(强类型安全防御版)。

    支持用户完全自定义每个阶段的章节数,绝不引发 int 和 dict 相加的错误。

    """
    # 确保传入的 chapter_idx 是安全的整数
    try:
        c_idx = int(chapter_idx)
    except Exception:
        c_idx = 1

    try:
        t_chaps = int(total_chapters)
    except Exception:
        t_chaps = 1

    # 1. 安全获取当前使用的阶段配置
    stages_to_use = custom_stages if custom_stages else st.session_state.get("custom_stages", {})
    if not stages_to_use and "DEFAULT_STAGES" in globals():
        stages_to_use = DEFAULT_STAGES
    if not stages_to_use:
        return 1

    # 确保阶段键值是规范排序的数字
    stage_keys = []
    for k in stages_to_use.keys():
        try:
            # 过滤掉任何非数字或者意外作为 key 混入的 dict 结构
            if isinstance(k, (int, str, float)):
                stage_keys.append(int(k))
        except (ValueError, TypeError):
            continue
    stage_keys = sorted(list(set(stage_keys)))

    if not stage_keys:
        return 1

    # 2. 🌟 检查是否包含有效的用户前端自定义的章节数分配
    has_custom_distribution = False
    for k in stage_keys:
        stg_data = stages_to_use.get(k) or stages_to_use.get(str(k))
        if isinstance(stg_data, dict) and "chapters" in stg_data:
            has_custom_distribution = True
            break

    if has_custom_distribution:
        # 算法 A:基于用户指定的各阶段章节数,累加区间精确判定
        current_accumulator = 0
        for stg_num in stage_keys:
            stg_data = stages_to_use.get(stg_num) or stages_to_use.get(str(stg_num))
            allocated_chapters = 1
            if isinstance(stg_data, dict):
                try:
                    allocated_chapters = int(stg_data.get("chapters", 1))
                except Exception:
                    allocated_chapters = 1
            current_accumulator += allocated_chapters
            if c_idx <= current_accumulator:
                return stg_num
        return stage_keys[-1]
    else:
        # 算法 B:均分兜底逻辑
        num_stages = len(stage_keys)
        idx_zero_based = c_idx - 1
        chapters_per_stage = t_chaps / num_stages
        stage_pos = int(idx_zero_based // chapters_per_stage)
        if stage_pos >= num_stages:
            stage_pos = num_stages - 1
        return stage_keys[stage_pos]


# ====================== 连载大纲流式生成 ======================
def stream_gen_all_outlines(total_ch, custom_stages):
    if "persona" not in st.session_state or not st.session_state.persona:
        st.error("❌ 请先生成爆款人设!")
        return

    p = st.session_state.persona

    # 强转总章节数为整数,防止前端组件意外吐出异常对象
    try:
        total_ch_int = int(total_ch)
    except Exception:
        total_ch_int = 12

    stage_plan_str = "你必须严格按照以下章节分配和每个阶段的控制逻辑来编排剧情:\n"
    for idx in range(1, total_ch_int + 1):
        stg_num = get_chapter_stage(idx, total_ch_int, custom_stages)

        # 强类型安全地获取阶段字典数据
        stg_data = custom_stages.get(stg_num) or custom_stages.get(str(stg_num)) if isinstance(custom_stages, dict) else {}
        if not isinstance(stg_data, dict):
            stg_data = {}

        stg_name = stg_data.get("name", f"阶段{stg_num}")
        stg_desc = stg_data.get("desc", "")
        stage_plan_str += f"- 第{idx}章:必须属于【{stg_name}】。该演进阶段核心控制逻辑为:{stg_desc}\n"

    # 获取第一句开场白作为大纲开局引子,如果没有则给个兜底
    first_greeting = st.session_state.greeting[0] if st.session_state.get("greeting") else "(场景刚刚开始,角色正看着你)"

    # 💡【核心修复】:由于 prompts.py 中使用的是双花括号 {{total_ch}},直接用 .format 会被忽略。
    # 必须先用 .replace() 将数字砸进去,再用 .format() 渲染其他字段。
    templated_prompt = OUTLINES_PROMPT_TEMPLATE.replace("{{total_ch}}", str(total_ch_int))
    source_context = build_source_material_context()
    chapter_format_context = build_chapter_format_context(p.get('name', '角色'))

    prompt = templated_prompt.format(
        name=p.get('name', '未命名'),
        character_description=p.get('character_description', ''),
        personality=p.get('personality', ''),
        taglines=p.get('taglines', ''),
        stage_plan_str=stage_plan_str,
        intro_context=p.get('intro', '暂无特定相遇场景'),  # 向大纲注入人设 intro 场景
        greeting_context=first_greeting               # 向大纲注入第一句开场白
    )
    if source_context:
        prompt += "\n\n# 【大纲必须继承的原始素材上下文】\n" + source_context + "\n请把这些用户上传文件、文字描述、图片与截图信息作为剧情题材、关系起点、视觉特征和世界观细节的参考;如果原始资料明确写了阶段安排、阶段数量、每阶段目标或章节正文格式,则优先按原始资料生成;如果原始资料没有提及这些内容,则按界面中的默认阶段配置和章节正文格式执行。若与最新编辑后的人设字段冲突,以最新人设字段为准。"
    prompt += "\n\n# 【大纲生成可参考的默认章节正文格式】\n" + chapter_format_context

    placeholder = st.empty()
    full_text = ""
    messages = [{"role": "user", "content": prompt}]

    try:
        stream = deepseek_client.chat.completions.create(  # 💡 改为 deepseek_client
            model=DEEPSEEK_TEXT_MODEL,  # 💡 改为 DeepSeek 模型
            messages=messages,
            temperature=0.6,
            stream=True,
            response_format={"type": "json_object"}  # 💡 大纲通常是 JSON 数组/对象,开启更稳定
        )


        for chunk in stream:
            if chunk.choices[0].delta.content:
                full_text += chunk.choices[0].delta.content
                placeholder.code(full_text, wrap_lines=True)

        raw_text = full_text.strip()
        if "```" in raw_text:
            raw_text = raw_text.split("```")[1]
            if raw_text.startswith("json"):
                raw_text = raw_text[4:]
            raw_text = raw_text.split("```")[0].strip()

        outlines = json.loads(raw_text)
        st.session_state.story_list = []
        for item in outlines:
            if isinstance(item, dict):
                st.session_state.story_list.append({
                    "章节": item.get("章节", len(st.session_state.story_list) + 1),
                    "标题": item.get("标题", "未命名章节"),
                    "情绪": "",
                    "剧情": ""
                })
        st.session_state.now_chapter = 1
        st.success("🎉 连载大纲(剧本章节名)生成成功!请在下方逐章填充。")
    except Exception as e:
        st.error(f"❌ 大纲结构化解析失败,原因:{str(e)}。已为您自动初始化空白大纲占位。")
        st.session_state.story_list = []
        for i in range(1, total_ch_int + 1):
            st.session_state.story_list.append({"章节": i, "标题": f"第{i}阶段命题发展", "情绪": "", "剧情": ""})
        st.session_state.now_chapter = 1





# ====================== 彻底修复后的单章内容生成 ======================
def stream_gen_one_chapter_optimized(ch_index, custom_stages, placeholder=None, batch_mode=False,

                                     passed_format_context=None):
    """

    单章内容生成函数 (已修复运行时变量隐患)



    Args:

        passed_format_context (str, Optional): 允许外部显式传入章节格式上下文。如果不传,内部将安全自动构建。

    """
    if placeholder is None:
        placeholder = st.empty()

    try:
        p = st.session_state.persona
        already_story = st.session_state.story_list
        current_ch_obj = already_story[ch_index]
        total_ch = len(already_story)

        # 🔒 牢牢锁定用户已经定好的原本大纲标题和章节号,拒绝让大模型篡改
        locked_title = current_ch_obj.get('标题', f'第{ch_index + 1}章')

        stage_num = get_chapter_stage(ch_index + 1, total_ch, custom_stages)
        stages_pool = custom_stages if custom_stages else st.session_state.get("custom_stages", {})
        current_stage_name = stages_pool.get(stage_num, {}).get("name", f"阶段{stage_num}")
        current_goal_desc = stages_pool.get(stage_num, {}).get("desc", "")

        # 第一章开局衔接控制
        greeting_context = ""
        if ch_index == 0:
            g_lines = "\n".join([f"开场白选段:{g}" for g in st.session_state.get("greeting", [])])
            greeting_context = "# 【核心首发衔接线(第一章特供)】\n" \
                               f"本故事第一章的正文开篇,必须完美无缝承接人设本身的相遇背景和发出的最终开场白剧情。 \n" \
                               f"1. 初始相遇戏剧性场景与羁绊关系(Intro):\"{p.get('intro', '')}\" \n" \
                               f"2. 角色已经发出的最终开场白行为台词(Greeting):\n{g_lines if g_lines else '(场景刚刚开始,角色正看着你)'}\n" \
                               "请从这个极其私密场景与情感博弈僵局中直接切入,立刻暴力拉高戏剧张力!\n"

        summary_of_prev = ""
        if ch_index > 0:
            summary_of_prev = "# 【前情进展链(必须严格顺承前文,杜绝套路复读)】\n"
            for i in range(ch_index):
                prev_ch = already_story[i]
                prev_content = prev_ch.get('剧情', '') if prev_ch.get('剧情') else ''
                summary_of_prev += f"第{prev_ch['章节']}章《{prev_ch['标题']}》剧情节点:{prev_content[:80]}...\n"

        hobbies_str = "\n".join(p.get('hobbies', [])) if isinstance(p.get('hobbies'), list) else str(
            p.get('hobbies', ''))
        taglines_str = ", ".join(p.get('taglines', [])) if isinstance(p.get('taglines'), list) else str(
            p.get('taglines', ''))
        personality_str = ", ".join(p.get('personality', [])) if isinstance(p.get('personality'), list) else str(
            p.get('personality', ''))

        source_context = build_source_material_context()

        # 🛡️ 【运行时变量修复点】:优先使用传入的上下文,没有则在内部安全生成兜底
        if passed_format_context is not None:
            chapter_format_context = passed_format_context
        else:
            try:
                # 即使 build_chapter_format_context 内部因 session 缺失等原因报错,也有内部 try-except 兜底
                chapter_format_context = build_chapter_format_context(p.get('name', '角色'))
            except Exception:
                # 极端情况下的最终降级文本
                chapter_format_context = "请直接输出小说章节正文。"

        prompt = CHAPTER_PROMPT_TEMPLATE.format(
            chapter_num=ch_index + 1,
            chapter_title=locked_title,  # 使用锁定的原本大纲标题发送给模型
            name=p.get('name', '未知'),
            gender=p.get('gender', '不明'),
            taglines=taglines_str,
            character_description=p.get('character_description', ''),
            personality=personality_str,
            speaking_style=p.get('speaking_style', ''),
            hobbies_str=hobbies_str,
            greeting_context=greeting_context,
            summary_of_prev=summary_of_prev,
            stage_name=current_stage_name,
            stage_desc=current_goal_desc
        )
        prompt += "\n\n# 【章节正文内容输出格式(界面可调整)】\n" + chapter_format_context + "\n请严格按此格式输出;但如果原始资料中明确指定了不同的正文内容格式,则优先采用原始资料中的格式。"
        if source_context:
            prompt += "\n\n# 【章节必须继承的原始素材上下文】\n" + source_context + "\n请在本章剧情中继续参考这些用户上传文件、文字描述、图片与截图信息;如果原始资料明确写了阶段安排、阶段数量、每阶段目标或章节正文格式,则优先按原始资料生成并在正文模块中表现出来;如果原始资料没有提及这些内容,则按界面中的默认阶段配置和章节正文格式执行。若与最新编辑后的人设字段或当前章节大纲冲突,以最新人设字段和当前章节大纲为准。"

        messages = [{"role": "user", "content": prompt}]
        full_text = ""

        stream = deepseek_client.chat.completions.create(
            model=DEEPSEEK_TEXT_MODEL,
            messages=messages,
            stream=True,
            temperature=0.6,
            frequency_penalty=0.5,
            presence_penalty=0.4
        )

        for chunk in stream:
            if chunk.choices[0].delta.content:
                full_text += chunk.choices[0].delta.content
                placeholder.code(full_text, wrap_lines=True)

        # ====================== 🛡️ 究极无损·绝不留空正文清洗机制 ======================
        lines = full_text.strip().split("\n")

        # 1. 提取情绪语气(独立安全提取)
        detected_mood = "标准"
        for line in lines:
            line_strip = line.strip()
            split_char = ":" if ":" in line_strip else ":"
            if ("情绪" in line_strip or "语气" in line_strip) and split_char in line_strip:
                parts = line_strip.split(split_char, 1)
                if len(parts) > 1:
                    detected_mood = parts[1].strip().strip('[]"\'')
                break
        current_ch_obj["情绪"] = detected_mood

        # 2. 多级锚点截取
        story_content = ""
        raw_full = full_text.strip()

        # 扩大锚点扫描范围,只要包含这些字眼,一律视为正文起点
        story_markers = [
            "剧情:", "剧情:", "### 剧情", "## 剧情", "剧情正文:", "剧情正文:",
            "【剧情】", "正文:", "正文:", "【正文】"
        ]
        start_pos = -1
        for marker in story_markers:
            pos = raw_full.find(marker)
            if pos != -1:
                start_pos = pos + len(marker)
                break

        if start_pos != -1:
            story_content = raw_full[start_pos:].strip()

        # 🌟【核心保底保险】:如果切出来的正文是空的,或者根本没找到锚点
        if not story_content.strip():
            story_content = raw_full

        # 3. 剥离可能混入的头部标题复读
        story_lines_final = []
        for line in story_content.split("\n"):
            line_strip = line.strip()
            if line_strip.startswith(f"第{ch_index + 1}章") or line_strip.startswith("标题:") or line_strip.startswith(
                    "标题:"):
                continue
            story_lines_final.append(line)

        final_story_text = "\n".join(story_lines_final).strip()

        # ==================== functions.py 末尾修改 ====================
        # 💾 先确保数据完美存入状态字典
        current_ch_obj["标题"] = locked_title
        current_ch_obj["剧情"] = final_story_text

        # 1. 显式更新当前章节的数据
        st.session_state.story_list[ch_index] = current_ch_obj

        # 2. 🛡️ 【规避错误】:不要直接修改组件的 Key,而是写进一个独立的缓存 Key
        st.session_state[f"edit_ch_content_{ch_index}_cache"] = final_story_text
        # 同步写入 widget 实际渲染 key,确保 rerun 后文本框能显示新内容
        st.session_state[f"pending_content_{ch_index}"] = final_story_text
        st.session_state[f"pending_emo_{ch_index}"] = detected_mood

        # 3. 步进控制
        if ch_index + 1 >= st.session_state.get("now_chapter", 1):
            st.session_state.now_chapter = ch_index + 2

        # 清理流式临时看板(批量模式下跳过,避免触发 Streamlit 脚本重跑打断循环)
        if not batch_mode:
            placeholder.empty()
            st.rerun()

        return

    except Exception as e:
        # 🚨 核心改动:捕获所有未知异常,阻断 st.rerun(),直接打印错误到页面
        placeholder.empty()  # 清除占位看板
        st.error("❌ 章节生成函数发生底层崩溃!错误详情如下:")

        # 在前端页面渲染一个漂亮的报错代码块
        error_msg = traceback.format_exc()
        st.code(error_msg, language="python")

        # 同时在终端控制台打印一份,方便排查
        print("\n" + "=" * 50 + "\n[CRITICAL ERROR] 运行时异常爆发:\n" + error_msg + "=" * 50 + "\n")

        # 停止继续运行当前 Streamlit 脚本
        st.stop()

# ====================== 侧边栏渲染函数 ======================
def render_sidebar():
    st.sidebar.title("📚 会话列表")
    if st.sidebar.button("🔄 刷新历史会话", use_container_width=True):
        st.session_state.sessions = load_sessions_from_local()
        st.rerun()

    kw = st.sidebar.text_input("🔍 搜索角色", value=st.session_state.last_search)
    st.session_state.last_search = kw
    sessions = st.session_state.sessions
    if kw:
        sessions = [s for s in sessions if kw.lower() in s["name"].lower()]

    for i, s in enumerate(sessions):
        idx = st.session_state.sessions.index(s)
        typ = "primary" if idx == st.session_state.current_session_idx else "secondary"
        col1, col2 = st.sidebar.columns([4, 1])
        with col1:
            icon = "🖼️" if s.get("is_image_based") else "💬"
            display_name = f"{icon} {s['name']}"
            if s.get("time"):
                display_name += f"\n({s['time']})"
            if st.button(display_name, key=f"ses_{idx}", type=typ, use_container_width=True):
                st.session_state.current_session_idx = idx
                loaded_persona = s["persona"].copy()
                # 不再调用 clean_tags_to_string,直接使用原列表
                st.session_state.persona = loaded_persona
                st.session_state.greeting = s.get("greeting", [])
                st.session_state.story_list = s.get("story_list", [])
                st.session_state.source_material_context = s.get("user_prompt", "")
                st.session_state.step_mode = "story"
                st.session_state.now_chapter = len(st.session_state.story_list) + 1
                st.rerun()
        with col2:
            if st.button("❌", key=f"del_{idx}", help=f"删除 {s['name']}"):
                if st.session_state.get(f"confirm_del_{idx}", False):
                    delete_session(idx, s)
                    st.session_state[f"confirm_del_{idx}"] = False
                    st.rerun()
                else:
                    st.session_state[f"confirm_del_{idx}"] = True
                    st.warning(f"⚠️ 再次点击确认删除 {s['name']}")

    if st.session_state.persona.get("name"):
        if st.sidebar.button("💾 保存当前会话", use_container_width=True):
            if save_session():
                st.sidebar.success("✅ 会话已保存!")

    if st.sidebar.button("🗑️ 清空所有会话", use_container_width=True, type="secondary"):
        if st.session_state.get("confirm_clear_all", False):
            import shutil
            base_dir = "角色档案"
            if os.path.exists(base_dir):
                shutil.rmtree(base_dir)
                st.sidebar.success("✅ 已删除所有本地文件")
            st.session_state.sessions = []
            st.session_state.current_session_idx = None
            st.session_state.step_mode = "input"
            st.session_state.persona = {}
            st.session_state.greeting = []
            st.session_state.story_list = []
            st.session_state.user_prompt = ""
            st.session_state.source_material_context = ""
            st.session_state.chapter_format_prompt = DEFAULT_CHAPTER_FORMAT_PROMPT
            st.session_state.now_chapter = 1
            st.session_state.saved_folder = None
            st.session_state.uploaded_image_desc = ""
            st.session_state.confirm_clear_all = False
            st.rerun()
        else:
            st.session_state.confirm_clear_all = True
            st.sidebar.warning("⚠️ 再次点击确认清空所有会话")

def delete_session(session_idx, session_data):
    st.session_state.sessions.pop(session_idx)
    if st.session_state.current_session_idx == session_idx:
        st.session_state.current_session_idx = None
        st.session_state.step_mode = "input"
        st.session_state.persona = {}
        st.session_state.greeting = []
        st.session_state.story_list = []
        st.session_state.user_prompt = ""
        st.session_state.source_material_context = ""
        st.session_state.chapter_format_prompt = DEFAULT_CHAPTER_FORMAT_PROMPT
        st.session_state.now_chapter = 1
        st.session_state.saved_folder = None
        st.session_state.uploaded_image_desc = ""

# ==================== 追加到 functions.py 末尾 ====================

def extract_text_from_file(uploaded_file):
    """解析上传的 TXT, PDF, Word 文件内容"""
    file_ext = os.path.splitext(uploaded_file.name)[1].lower()
    text_content = ""
    try:
        if file_ext in [".txt", ".md", ".json", ".csv"]:
            text_content = uploaded_file.read().decode("utf-8", errors="ignore")
        elif file_ext == ".pdf":
            import pypdf
            reader = pypdf.PdfReader(uploaded_file)
            text_content = "\n".join([page.extract_text() for page in reader.pages if page.extract_text()])
        elif file_ext in [".doc", ".docx"]:
            import docx
            doc = docx.Document(uploaded_file)
            text_content = "\n".join([para.text for para in doc.paragraphs])
    except Exception as e:
        # 注意:因为移到了 functions.py,去除了 st.error,改用 raise 抛出或返回空,由主程序捕获
        raise RuntimeError(f"解析文件 {uploaded_file.name} 失败: {str(e)}")
    return text_content


def try_repair_and_load_json(raw_text):
    """辅助函数:尝试修复由于突发截断导致的非法 JSON 字符串,并尽可能提取已生成的字段"""
    raw_text = raw_text.strip()
    if not raw_text:
        return {}

    # 尝试寻找首个 '{'
    start_idx = raw_text.find('{')
    if start_idx == -1:
        return {}

    # 截取从第一个 '{' 开始的内容
    json_part = raw_text[start_idx:]

    # 尝试直接解析
    try:
        return json.loads(json_part)
    except json.JSONDecodeError:
        pass

    # 如果解析失败,说明发生了截断。开始尝试进行右侧闭合修复
    json_part = json_part.rstrip()

    # 循环尝试丢弃末尾字符直至可以补全括号成功解析
    for i in range(len(json_part), 0, -1):
        test_str = json_part[:i].strip()
        for suffix in ["", "\"", "\"]", "\"}", "}", "]}", "\"\n}"]:
            try:
                candidate = test_str + suffix
                return json.loads(candidate)
            except json.JSONDecodeError:
                continue

    # 如果极端情况逆向修补依然失败,采用正则表达式进行最后的“保底字段碎片抢救”
    extracted = {}
    fields = ["name", "gender", "taglines", "character_description", "personality", "intro", "speaking_style", "hobbies"]
    for field in fields:
        pattern = rf'"{field}"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"'
        match = re.search(pattern, json_part)
        if match:
            extracted[field] = match.group(1)
        else:
            list_pattern = rf'"{field}"\s*:\s*\[(.*?)\]'
            list_match = re.search(list_pattern, json_part, re.DOTALL)
            if list_match:
                items = re.findall(r'"([^"]*)"', list_match.group(1))
                if items:
                    extracted[field] = items
    return extracted