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