Spaces:
Sleeping
Sleeping
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
|