diff --git a/README.md b/README.md index fa7fbb7..1240290 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,15 @@ > 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 +2025-03-05 添加角色库功能 + +2025-03-09 添加字数显示 + +2025-03-13 +1、新增闲云修改; +2、把本章指导改成内容指导; +3、在生成架构中的: 2. 角色动力学设定(角色弧光模型)、 3. 世界构建矩阵(三维度交织法)、 4. 情节架构(三幕式悬念)与生成目录的:5. 章节目录生成(悬念节奏曲线)加入引导词内容指导,以方便生成角色动力学时只以核心种子生成,导致生成的内容与实际需求不符。 +4、在终端加回被删除的LLM提示词与LLM返回内容显示,以便复盘,参考修改提示词。 --- ## 📑 目录导航 diff --git a/embedding_adapters.py b/embedding_adapters.py index cb00673..73de078 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -3,11 +3,9 @@ import logging import traceback from typing import List - import requests from langchain_openai import AzureOpenAIEmbeddings, OpenAIEmbeddings - def ensure_openai_base_url_has_v1(url: str) -> str: """ 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 diff --git a/llm_adapters.py b/llm_adapters.py index 30ff1e6..12ddee2 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -9,6 +9,7 @@ from azure.ai.inference import ChatCompletionsClient from azure.core.credentials import AzureKeyCredential from azure.ai.inference.models import SystemMessage, UserMessage from openai import OpenAI +import requests def check_base_url(url: str) -> str: @@ -114,7 +115,8 @@ class GeminiAdapter(BaseLLMAdapter): config = types.GenerateContentConfig( max_output_tokens=self.max_tokens, temperature=self.temperature, - ) + ), + timeout=self.timeout # 添加超时参数 ) if response and response.text: return response.text @@ -212,11 +214,15 @@ class MLStudioAdapter(BaseLLMAdapter): ) def invoke(self, prompt: str) -> str: - response = self._client.invoke(prompt) - if not response: - logging.warning("No response from MLStudioAdapter.") + try: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from MLStudioAdapter.") + return "" + return response.content + except Exception as e: + logging.error(f"ML Studio API 调用超时或失败: {e}") return "" - return response.content class AzureAIAdapter(BaseLLMAdapter): """ @@ -279,24 +285,59 @@ class VolcanoEngineAIAdapter(BaseLLMAdapter): self.timeout = timeout self._client = OpenAI( - # 此为默认路径,您可根据业务所在地域进行配置 base_url=base_url, - # 从环境变量中获取您的 API Key - api_key=api_key + api_key=api_key, + timeout=timeout # 添加超时配置 ) def invoke(self, prompt: str) -> str: - response = self._client.chat.completions.create( - model=self.model_name, # bot-20250223190248-2bq5k 为您当前的智能体的ID,注意此处与Chat API存在差异。差异对比详见 SDK使用指南 - messages=[ - {"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"}, - {"role": "user", "content": prompt}, - ], + try: + response = self._client.chat.completions.create( + model=self.model_name, + messages=[ + {"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"}, + {"role": "user", "content": prompt}, + ], + timeout=self.timeout # 添加超时参数 + ) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.choices[0].message.content + except Exception as e: + logging.error(f"火山引擎API调用超时或失败: {e}") + return "" + +class SiliconFlowAdapter(BaseLLMAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + self.base_url = check_base_url(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = OpenAI( + base_url=base_url, + api_key=api_key, + timeout=timeout # 添加超时配置 ) - # response = self._client.invoke(prompt) - if not response: - logging.warning("No response from DeepSeekAdapter.") + def invoke(self, prompt: str) -> str: + try: + response = self._client.chat.completions.create( + model=self.model_name, + messages=[ + {"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"}, + {"role": "user", "content": prompt}, + ], + timeout=self.timeout # 添加超时参数 + ) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.choices[0].message.content + except Exception as e: + logging.error(f"硅基流动API调用超时或失败: {e}") return "" - return response.choices[0].message.content def create_llm_adapter( interface_format: str, @@ -330,5 +371,7 @@ def create_llm_adapter( return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) elif fmt == "火山引擎": return VolcanoEngineAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "硅基流动": + return SiliconFlowAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/novel_generator/architecture.py b/novel_generator/architecture.py index d6dae79..7e06a07 100644 --- a/novel_generator/architecture.py +++ b/novel_generator/architecture.py @@ -55,6 +55,7 @@ def Novel_architecture_generate( number_of_chapters: int, word_number: int, filepath: str, + user_guidance: str = "", # 新增参数 temperature: float = 0.7, max_tokens: int = 2048, timeout: int = 600 @@ -91,7 +92,8 @@ def Novel_architecture_generate( topic=topic, genre=genre, number_of_chapters=number_of_chapters, - word_number=word_number + word_number=word_number, + user_guidance=user_guidance # 修复:添加内容指导 ) core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) if not core_seed_result.strip(): @@ -105,7 +107,10 @@ def Novel_architecture_generate( # Step2: 角色动力学 if "character_dynamics_result" not in partial_data: logging.info("Step2: Generating character_dynamics_prompt ...") - prompt_character = character_dynamics_prompt.format(core_seed=partial_data["core_seed_result"].strip()) + prompt_character = character_dynamics_prompt.format( + core_seed=partial_data["core_seed_result"].strip(), + user_guidance=user_guidance + ) character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) if not character_dynamics_result.strip(): logging.warning("character_dynamics_prompt generation failed.") @@ -135,7 +140,10 @@ def Novel_architecture_generate( # Step3: 世界观 if "world_building_result" not in partial_data: logging.info("Step3: Generating world_building_prompt ...") - prompt_world = world_building_prompt.format(core_seed=partial_data["core_seed_result"].strip()) + prompt_world = world_building_prompt.format( + core_seed=partial_data["core_seed_result"].strip(), + user_guidance=user_guidance # 修复:添加用户指导 + ) world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) if not world_building_result.strip(): logging.warning("world_building_prompt generation failed.") @@ -151,7 +159,8 @@ def Novel_architecture_generate( prompt_plot = plot_architecture_prompt.format( core_seed=partial_data["core_seed_result"].strip(), character_dynamics=partial_data["character_dynamics_result"].strip(), - world_building=partial_data["world_building_result"].strip() + world_building=partial_data["world_building_result"].strip(), + user_guidance=user_guidance # 修复:添加用户指导 ) plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) if not plot_arch_result.strip(): diff --git a/novel_generator/chapter.py b/novel_generator/chapter.py index 9d1a6a4..7758564 100644 --- a/novel_generator/chapter.py +++ b/novel_generator/chapter.py @@ -5,7 +5,6 @@ """ import os import logging -from nltk import download from llm_adapters import create_llm_adapter from prompt_definitions import first_chapter_draft_prompt, next_chapter_draft_prompt, summarize_recent_chapters_prompt from chapter_directory_parser import get_chapter_info_from_blueprint @@ -59,7 +58,7 @@ def summarize_recent_chapters( short_summary = "" next_chapter_keywords = "" for line in response_text.splitlines(): - line = line.strip() + line = line.strip() if line.startswith("短期摘要:"): short_summary = line.replace("短期摘要:", "").strip() elif line.startswith("下一章关键字:"): @@ -91,7 +90,7 @@ def build_chapter_prompt( timeout: int = 600 ) -> str: """ - 构造当前章节的请求提示词,不调用 LLM,仅返回构造好的提示词字符串。 + 构造当前章节的请求提示词,新增对下一章节元数据的引用 """ arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -102,6 +101,7 @@ def build_chapter_prompt( character_state_file = os.path.join(filepath, "character_state.txt") character_state_text = read_file(character_state_file) + # 获取当前章节信息 chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] chapter_role = chapter_info["chapter_role"] @@ -111,6 +111,17 @@ def build_chapter_prompt( plot_twist_level = chapter_info["plot_twist_level"] chapter_summary = chapter_info["chapter_summary"] + # 新增:获取下一章节信息 + next_chapter_number = novel_number + 1 + next_chapter_info = get_chapter_info_from_blueprint(blueprint_text, next_chapter_number) + next_chapter_title = next_chapter_info.get("chapter_title", "(未命名)") + next_chapter_role = next_chapter_info.get("chapter_role", "过渡章节") + next_chapter_purpose = next_chapter_info.get("chapter_purpose", "承上启下") + next_chapter_suspense = next_chapter_info.get("suspense_level", "中等") + next_chapter_foreshadow = next_chapter_info.get("foreshadowing", "无特殊伏笔") + next_chapter_twist = next_chapter_info.get("plot_twist_level", "★☆☆☆☆") + next_chapter_summary = next_chapter_info.get("chapter_summary", "衔接过渡内容") + chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) @@ -187,14 +198,24 @@ def build_chapter_prompt( global_summary=global_summary_text, character_state=character_state_text, context_excerpt=relevant_context, - previous_chapter_excerpt=previous_chapter_excerpt + previous_chapter_excerpt=previous_chapter_excerpt, + + # 新增下一章节参数 + next_chapter_number=next_chapter_number, + next_chapter_title=next_chapter_title, + next_chapter_role=next_chapter_role, + next_chapter_purpose=next_chapter_purpose, + next_chapter_suspense_level=next_chapter_suspense, + next_chapter_foreshadowing=next_chapter_foreshadow, + next_chapter_plot_twist_level=next_chapter_twist, + next_chapter_summary=next_chapter_summary ) return prompt_text def generate_chapter_draft( api_key: str, base_url: str, - model_name: str, + model_name: str, filepath: str, novel_number: int, word_number: int, @@ -212,16 +233,11 @@ def generate_chapter_draft( interface_format: str = "openai", max_tokens: int = 2048, timeout: int = 600, - custom_prompt_text: str = None # 新增参数,若不为 None,则使用用户编辑后的提示词 + custom_prompt_text: str = None ) -> str: """ - 根据 novel_number 判断是否为第一章。 - - 若是第一章,则使用 first_chapter_draft_prompt - - 否则使用 next_chapter_draft_prompt - 若 custom_prompt_text 提供,则以此作为提示词进行生成。 - 最终将生成文本存入 chapters/chapter_{novel_number}.txt。 + 生成章节草稿,支持自定义提示词 """ - # 构造提示词:若用户提供了编辑后的提示词,则使用之;否则构造默认提示词 if custom_prompt_text is None: prompt_text = build_chapter_prompt( api_key=api_key, diff --git a/novel_generator/common.py b/novel_generator/common.py index f31fed7..f61b665 100644 --- a/novel_generator/common.py +++ b/novel_generator/common.py @@ -42,18 +42,36 @@ def debug_log(prompt: str, response_content: str): f"\n[######################################### Response #########################################]\n{response_content}\n" ) -def invoke_with_cleaning(llm_adapter, prompt: str) -> str: - """ - 调用 LLM,增加重试和清洗逻辑 - 如果多次失败,则返回空字符串以继续流程,而不是中断。 - """ - def _invoke(prompt): - return llm_adapter.invoke(prompt) +def invoke_with_cleaning(llm_adapter, prompt: str, max_retries: int = 3) -> str: + """调用 LLM 并清理返回结果""" + print("\n" + "="*50) + print("发送到 LLM 的提示词:") + print("-"*50) + print(prompt) + print("="*50 + "\n") + + result = "" + retry_count = 0 + + while retry_count < max_retries: + try: + result = llm_adapter.invoke(prompt) + print("\n" + "="*50) + print("LLM 返回的内容:") + print("-"*50) + print(result) + print("="*50 + "\n") + + # 清理结果中的特殊格式标记 + result = result.replace("```", "").strip() + if result: + return result + retry_count += 1 + except Exception as e: + print(f"调用失败 ({retry_count + 1}/{max_retries}): {str(e)}") + retry_count += 1 + if retry_count >= max_retries: + raise e + + return result - response = call_with_retry(func=_invoke, max_retries=3, fallback_return="", prompt=prompt) - if not response: - logging.warning("No response from model after retry. Return empty.") - return "" - cleaned_text = remove_think_tags(response) - debug_log(prompt, cleaned_text) - return cleaned_text.strip() diff --git a/prompt_definitions.py b/prompt_definitions.py index 6ad1d11..0951d2a 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -40,8 +40,9 @@ core_seed_prompt = """\ # =============== 2. 角色动力学设定(角色弧光模型)=================== character_dynamics_prompt = """\ -基于核心种子: -{core_seed} +基于以下元素: +- 内容指导:{user_guidance} +- 核心种子:{core_seed} 请设计3-6个具有动态变化潜力的核心角色,每个角色需包含: 特征: @@ -68,7 +69,11 @@ character_dynamics_prompt = """\ # =============== 3. 世界构建矩阵(三维度交织法)=================== world_building_prompt = """\ -为服务核心冲突"{core_seed}",请构建三维交织的世界观: +基于以下元素: +- 内容指导:{user_guidance} +- 核心冲突:"{core_seed}" + +为服务上述内容,请构建三维交织的世界观: 1. 物理维度: - 空间结构(地理×社会阶层分布图) @@ -92,10 +97,11 @@ world_building_prompt = """\ # =============== 4. 情节架构(三幕式悬念)=================== plot_architecture_prompt = """\ -基于以下元素构建三幕式悬念架构: -核心种子:{core_seed} -角色体系:{character_dynamics} -世界观:{world_building} +基于以下元素: +- 内容指导:{user_guidance} +- 核心种子:{core_seed} +- 角色体系:{character_dynamics} +- 世界观:{world_building} 要求按以下结构设计: 第一幕(触发) @@ -121,7 +127,9 @@ plot_architecture_prompt = """\ # =============== 5. 章节目录生成(悬念节奏曲线)=================== chapter_blueprint_prompt = """\ -根据小说架构:\n +基于以下元素: +- 内容指导:{user_guidance} +- 小说架构: {novel_architecture} 设计{number_of_chapters}章的节奏分布: @@ -163,7 +171,9 @@ chapter_blueprint_prompt = """\ """ chunked_chapter_blueprint_prompt = """\ -根据小说架构:\n +基于以下元素: +- 内容指导:{user_guidance} +- 小说架构: {novel_architecture} 需要生成总共{number_of_chapters}章的节奏分布, @@ -232,40 +242,45 @@ create_character_state_prompt = """\ 依据当前角色动力学设定:{character_dynamics} 请生成一个角色状态文档,内容格式: -角色A属性: +例: +李员外: ├──物品: - ├──物品(若有初始物品则增加,没有则为暂无):描述 - ... +│ ├──青衫:一件破损的青色长袍,带有暗红色的污渍 +│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文 ├──能力 - ├──技能1(若有初始技能则增加,没有则为暂无):描述 - ... +│ ├──技能1:强大的精神感知能力:能够察觉到周围人的心中活动 +│ └──技能2:无形攻击:能够释放一种无法被视觉捕捉的精神攻击 ├──状态 - ├──身体状态: - ├──Buff/Debuff - ├──心理状态:描述 - +│ ├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻 +│ └──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安 ├──主要角色间关系网 - ├──角色B:描述(初始有关联则增加,没有则为暂无关系) - ├──角色C:描述(初始有关联则增加,没有则为暂无关系) - ... +│ ├──林婉儿:李员外从小就与她有关联,对她的成长一直保持关注 +│ └──苏明远:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁 ├──触发或加深的事件 - ├──暂无事件 - ... +│ ├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件 +│ └──林婉儿被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍 -角色B属性: -├──物品 - ├──... +角色名: +├──物品: +│ ├──某物(道具):描述 +│ └──XX长剑(武器):描述 +│ ... ├──能力 - ├──... +│ ├──技能1:描述 +│ └──技能2:描述 +│ ... ├──状态 - ├──... +│ ├──身体状态: +│ └──心理状态:描述 +│ ├──主要角色间关系网 - ├──... +│ ├──角色B:描述 +│ └──角色C:描述 +│ ... ├──触发或加深的事件 - ├──... - -角色C属性: -...... +│ ├──事件1:描述 +│ └──事件2:描述 + ... 新出场角色: - (此处填写未来任何新增角色或临时出场人物的基本信息) @@ -282,42 +297,46 @@ update_character_state_prompt = """\ {old_state} 请更新主要角色状态,内容格式: -角色A属性: +例: +李员外: ├──物品: - ├──某物(道具):描述 - ├──XX长剑(武器):描述 - ... +│ ├──青衫:一件破损的青色长袍,带有暗红色的污渍 +│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文 ├──能力 - ├──技能1:描述 - ├──技能2:描述 - ... +│ ├──技能1:强大的精神感知能力:能够察觉到周围人的心中活动 +│ └──技能2:无形攻击:能够释放一种无法被视觉捕捉的精神攻击 ├──状态 - ├──身体状态: - ├──Buff/Debuff - ├──心理状态:描述 - +│ ├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻 +│ └──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安 ├──主要角色间关系网 - ├──角色B:描述 - ├──角色C:描述 - ... +│ ├──林婉儿:李员外从小就与她有关联,对她的成长一直保持关注 +│ └──苏明远:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁 ├──触发或加深的事件 - ├──事件1:描述 - ├──事件2:描述 +│ ├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件 +│ └──林婉儿被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍 + +角色名: +├──物品: +│ ├──某物(道具):描述 +│ └──XX长剑(武器):描述 +│ ... +├──能力 +│ ├──技能1:描述 +│ └──技能2:描述 +│ ... +├──状态 +│ ├──身体状态: +│ └──心理状态:描述 +│ +├──主要角色间关系网 +│ ├──角色B:描述 +│ └──角色C:描述 +│ ... +├──触发或加深的事件 +│ ├──事件1:描述 +│ └──事件2:描述 ... -角色B属性: -├──物品 - ├──... -├──能力 - ├──... -├──状态 - ├──... -├──主要角色间关系网 - ├──... -├──触发或加深的事件 - ├──... - -角色C属性: ...... 新出场角色: @@ -407,6 +426,16 @@ next_chapter_draft_prompt = """\ 认知颠覆:{plot_twist_level} 本章简述:{chapter_summary} +下一章节介绍: +第 {next_chapter_number} 章《{next_chapter_title}》 +本章定位:{next_chapter_role} +核心作用:{next_chapter_purpose} +悬念密度:{next_chapter_suspense_level} +伏笔操作:{next_chapter_foreshadowing} +认知颠覆:{next_chapter_plot_twist_level} +本章简述:{next_chapter_summary} +参考下一章内容简介,避免情节脱节或冲突。 + 可用元素: - 核心人物(可能未指定):{characters_involved} - 关键道具(可能未指定):{key_items} @@ -448,4 +477,46 @@ next_chapter_draft_prompt = """\ - 不要使用markdown格式。 额外指导(可能未指定):{user_guidance} -""" \ No newline at end of file +""" + + +Character_Import_Prompt = """\ +根据以下文本内容,分析出所有角色及其属性信息,严格按照以下格式要求: + +<<角色状态格式要求>> +1. 必须包含以下五个分类(按顺序): + ● 物品 ● 能力 ● 状态 ● 主要角色间关系网 ● 触发或加深的事件 +2. 每个属性条目必须用【名称: 描述】格式 + 例:├──青衫: 一件破损的青色长袍,带有暗红色的污渍 +3. 状态必须包含: + ● 身体状态: [当前身体状况] + ● 心理状态: [当前心理状况] +4. 关系网格式: + ● [角色名称]: [关系类型,如"竞争对手"/"盟友"] +5. 触发事件格式: + ● [事件名称]: [简要描述及影响] + +<<示例>> +李员外: +├──物品: +│ ├──青衫: 一件破损的青色长袍,带有暗红色污渍 +│ └──寒铁长剑: 剑身有裂痕,刻有「青云」符文 +├──能力: +│ ├──精神感知: 能感知半径30米内的生命体 +│ └──剑气压制: 通过目光释放精神威压 +├──状态: +│ ├──身体状态: 右臂有未愈合的刀伤 +│ └──心理状态: 对苏明远的实力感到忌惮 +├──主要角色间关系网: +│ ├──苏明远: 竞争对手,十年前的同僚 +│ └──林婉儿: 暗中培养的继承人 +├──触发或加深的事件: +│ ├──兵器库遇袭: 丢失三把传家宝剑,影响战力 +│ └──匿名威胁信: 信纸带有檀香味,暗示内部泄密 +│ + +请严格按上述格式分析以下内容: +<<待分析小说文本开始>> +{content} +<<待分析小说文本结束>> +""" diff --git a/ui/chapters_tab.py b/ui/chapters_tab.py index 5310fbf..2566468 100644 --- a/ui/chapters_tab.py +++ b/ui/chapters_tab.py @@ -34,11 +34,22 @@ def build_chapters_tab(self): save_btn.grid(row=0, column=3, padx=5, pady=5, sticky="w") refresh_btn = ctk.CTkButton(top_frame, text="刷新章节列表", command=self.refresh_chapters_list, font=("Microsoft YaHei", 12)) - refresh_btn.grid(row=0, column=4, padx=5, pady=5, sticky="e") + refresh_btn.grid(row=0, column=5, padx=5, pady=5, sticky="e") + + self.chapters_word_count_label = ctk.CTkLabel(top_frame, text="字数:0", font=("Microsoft YaHei", 12)) + self.chapters_word_count_label.grid(row=0, column=4, padx=(0,10), sticky="e") self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12)) + + def update_word_count(event=None): + text = self.chapter_view_text.get("0.0", "end-1c") + text_length = len(text) + self.chapters_word_count_label.configure(text=f"字数:{text_length}") + + self.chapter_view_text.bind("", update_word_count) + self.chapter_view_text.bind("", update_word_count) TextWidgetContextMenu(self.chapter_view_text) - self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=6) self.chapters_list = [] refresh_chapters_list(self) diff --git a/ui/character_tab.py b/ui/character_tab.py index 2a87670..eefd781 100644 --- a/ui/character_tab.py +++ b/ui/character_tab.py @@ -15,12 +15,23 @@ def build_character_tab(self): load_btn = ctk.CTkButton(self.character_tab, text="加载 character_state.txt", command=self.load_character_state, font=("Microsoft YaHei", 12)) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.character_wordcount_label = ctk.CTkLabel(self.character_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.character_wordcount_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + save_btn = ctk.CTkButton(self.character_tab, text="保存修改", command=self.save_character_state, font=("Microsoft YaHei", 12)) - save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12)) + + def update_word_count(event=None): + text = self.character_text.get("0.0", "end-1c") + text_length = len(text) + self.character_wordcount_label.configure(text=f"字数:{text_length}") + + self.character_text.bind("", update_word_count) + self.character_text.bind("", update_word_count) TextWidgetContextMenu(self.character_text) - self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) def load_character_state(self): filepath = self.filepath_var.get().strip() diff --git a/ui/config_tab.py b/ui/config_tab.py index 91f05a1..2840679 100644 --- a/ui/config_tab.py +++ b/ui/config_tab.py @@ -92,6 +92,9 @@ def build_ai_config_tab(self): elif new_value == "阿里云百炼": self.base_url_var.set("https://dashscope.aliyuncs.com/compatible-mode/v1") self.model_name_var.set("qwen-plus") + elif new_value == "硅基流动": + self.base_url_var.set("https://api.siliconflow.cn/v1") + self.model_name_var.set("deepseek-ai/DeepSeek-V3") for i in range(7): self.ai_config_tab.grid_rowconfigure(i, weight=0) @@ -111,7 +114,8 @@ def build_ai_config_tab(self): # 3) 接口格式 create_label_with_help(self, parent=self.ai_config_tab, label_text="LLM 接口格式:", tooltip_key="interface_format", row=2, column=0, font=("Microsoft YaHei", 12)) - interface_options = ["DeepSeek", "阿里云百炼", "OpenAI", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini","火山引擎"] + # 在这里的接口选项列表中添加 "硅基流动" + interface_options = ["DeepSeek", "阿里云百炼", "OpenAI", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini", "火山引擎", "硅基流动"] interface_dropdown = ctk.CTkOptionMenu(self.ai_config_tab, values=interface_options, variable=self.interface_format_var, command=on_interface_format_changed, font=("Microsoft YaHei", 12)) interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") @@ -197,7 +201,9 @@ def build_embeddings_config_tab(self): # 2) Embedding 接口格式 create_label_with_help(self, parent=self.embeddings_config_tab, label_text="Embedding 接口格式:", tooltip_key="embedding_interface_format", row=1, column=0, font=("Microsoft YaHei", 12)) + emb_interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Gemini", "Ollama", "ML Studio","SiliconFlow"] + emb_interface_dropdown = ctk.CTkOptionMenu(self.embeddings_config_tab, values=emb_interface_options, variable=self.embedding_interface_format_var, command=on_embedding_interface_changed, font=("Microsoft YaHei", 12)) emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") diff --git a/ui/directory_tab.py b/ui/directory_tab.py index 09b7aba..2a26c64 100644 --- a/ui/directory_tab.py +++ b/ui/directory_tab.py @@ -15,12 +15,23 @@ def build_directory_tab(self): load_btn = ctk.CTkButton(self.directory_tab, text="加载 Novel_directory.txt", command=self.load_chapter_blueprint, font=("Microsoft YaHei", 12)) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.directory_word_count_label = ctk.CTkLabel(self.directory_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.directory_word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + save_btn = ctk.CTkButton(self.directory_tab, text="保存修改", command=self.save_chapter_blueprint, font=("Microsoft YaHei", 12)) - save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12)) + + def update_word_count(event=None): + text = self.directory_text.get("0.0", "end") + count = len(text) - 1 + self.directory_word_count_label.configure(text=f"字数:{count}") + + self.directory_text.bind("", update_word_count) + self.directory_text.bind("", update_word_count) TextWidgetContextMenu(self.directory_text) - self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) def load_chapter_blueprint(self): filepath = self.filepath_var.get().strip() diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index 681e2e9..d1fbadb 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -25,6 +25,11 @@ def generate_novel_architecture_ui(self): return def task(): + confirm = messagebox.askyesno("确认", "确定要生成小说架构吗?") + if not confirm: + self.enable_button_safe(self.btn_generate_architecture) + return + self.disable_button_safe(self.btn_generate_architecture) try: interface_format = self.interface_format_var.get().strip() @@ -39,6 +44,8 @@ def generate_novel_architecture_ui(self): genre = self.genre_var.get().strip() num_chapters = self.safe_get_int(self.num_chapters_var, 10) word_number = self.safe_get_int(self.word_number_var, 3000) + # 获取内容指导 + user_guidance = self.user_guide_text.get("0.0", "end").strip() self.safe_log("开始生成小说架构...") Novel_architecture_generate( @@ -53,7 +60,8 @@ def generate_novel_architecture_ui(self): filepath=filepath, temperature=temperature, max_tokens=max_tokens, - timeout=timeout_val + timeout=timeout_val, + user_guidance=user_guidance # 添加内容指导参数 ) self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") except Exception: @@ -69,6 +77,9 @@ def generate_chapter_blueprint_ui(self): return def task(): + if not messagebox.askyesno("确认", "确定要生成章节草稿吗?"): + self.enable_button_safe(self.btn_generate_chapter) + return self.disable_button_safe(self.btn_generate_directory) try: interface_format = self.interface_format_var.get().strip() @@ -168,7 +179,64 @@ def generate_chapter_draft_ui(self): dialog.geometry("600x400") text_box = ctk.CTkTextbox(dialog, wrap="word", font=("Microsoft YaHei", 12)) text_box.pack(fill="both", expand=True, padx=10, pady=10) - text_box.insert("0.0", prompt_text) + + # 字数统计标签 + wordcount_label = ctk.CTkLabel(dialog, text="字数:0", font=("Microsoft YaHei", 12)) + wordcount_label.pack(side="left", padx=(10,0), pady=5) + + # 插入角色内容 + final_prompt = prompt_text + role_names = [name.strip() for name in self.char_inv_text.get("0.0", "end").strip().split(',') if name.strip()] + role_lib_path = os.path.join(filepath, "角色库") + role_contents = [] + + if os.path.exists(role_lib_path): + for root, dirs, files in os.walk(role_lib_path): + for file in files: + if file.endswith(".txt") and os.path.splitext(file)[0] in role_names: + file_path = os.path.join(root, file) + try: + with open(file_path, 'r', encoding='utf-8') as f: + role_contents.append(f.read().strip()) # 直接使用文件内容,不添加重复名字 + except Exception as e: + self.safe_log(f"读取角色文件 {file} 失败: {str(e)}") + + if role_contents: + role_content_str = "\n".join(role_contents) + # 更精确的替换逻辑,处理不同情况下的占位符 + placeholder_variations = [ + "核心人物(可能未指定):{characters_involved}", + "核心人物:{characters_involved}", + "核心人物(可能未指定):{characters_involved}", + "核心人物:{characters_involved}" + ] + + for placeholder in placeholder_variations: + if placeholder in final_prompt: + final_prompt = final_prompt.replace( + placeholder, + f"核心人物:\n{role_content_str}" + ) + break + else: # 如果没有找到任何已知占位符变体 + lines = final_prompt.split('\n') + for i, line in enumerate(lines): + if "核心人物" in line and ":" in line: + lines[i] = f"核心人物:\n{role_content_str}" + break + final_prompt = '\n'.join(lines) + + text_box.insert("0.0", final_prompt) + # 更新字数函数 + def update_word_count(event=None): + text = text_box.get("0.0", "end-1c") + text_length = len(text) + wordcount_label.configure(text=f"字数:{text_length}") + + text_box.bind("", update_word_count) + text_box.bind("", update_word_count) + update_word_count() # 初始化统计 + button_frame = ctk.CTkFrame(dialog) button_frame.pack(pady=10) def on_confirm(): @@ -236,6 +304,10 @@ def finalize_chapter_ui(self): return def task(): + if not messagebox.askyesno("确认", "确定要定稿当前章节吗?"): + self.enable_button_safe(self.btn_finalize_chapter) + return + self.disable_button_safe(self.btn_finalize_chapter) try: interface_format = self.interface_format_var.get().strip() @@ -385,7 +457,12 @@ def import_knowledge_handler(self): self.handle_exception("导入知识库时出错") finally: self.enable_button_safe(self.btn_import_knowledge) - threading.Thread(target=task, daemon=True).start() + try: + thread = threading.Thread(target=task, daemon=True) + thread.start() + except Exception as e: + self.enable_button_safe(self.btn_import_knowledge) + messagebox.showerror("错误", f"线程启动失败: {str(e)}") def clear_vectorstore_handler(self): filepath = self.filepath_var.get().strip() diff --git a/ui/main_tab.py b/ui/main_tab.py index 2ca6add..a5f66d4 100644 --- a/ui/main_tab.py +++ b/ui/main_tab.py @@ -33,14 +33,24 @@ def build_left_layout(self): self.left_frame.grid_rowconfigure(4, weight=1) self.left_frame.columnconfigure(0, weight=1) - chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容 (可编辑)", font=("Microsoft YaHei", 12)) - chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") + self.chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容(可编辑) 字数:0", font=("Microsoft YaHei", 12)) + self.chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") # 章节文本编辑框 self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) TextWidgetContextMenu(self.chapter_result) self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + + + def update_word_count(event=None): + text = self.chapter_result.get("0.0", "end") + count = len(text) - 1 # 减去最后一个换行符 + self.chapter_label.configure(text=f"本章内容(可编辑) 字数:{count}") + + self.chapter_result.bind("", update_word_count) + self.chapter_result.bind("", update_word_count) + # Step 按钮区域 self.step_buttons_frame = ctk.CTkFrame(self.left_frame) self.step_buttons_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=5) diff --git a/ui/main_window.py b/ui/main_window.py index 382223e..8f8f75a 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -7,6 +7,8 @@ import traceback import customtkinter as ctk import tkinter as tk from tkinter import filedialog, messagebox +from .role_library import RoleLibrary +from llm_adapters import create_llm_adapter from config_manager import load_config, save_config, test_llm_config, test_embedding_config from utils import read_file, save_string_to_txt, clear_file_content @@ -222,6 +224,120 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) + def show_character_import_window(self): + """显示角色导入窗口""" + import_window = ctk.CTkToplevel(self.master) + import_window.title("导入角色信息") + import_window.geometry("600x500") + import_window.transient(self.master) # 设置为父窗口的临时窗口 + import_window.grab_set() # 保持窗口在顶层 + + # 主容器 + main_frame = ctk.CTkFrame(import_window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 滚动容器 + scroll_frame = ctk.CTkScrollableFrame(main_frame) + scroll_frame.pack(fill="both", expand=True, padx=5, pady=5) + + # 获取角色库路径 + role_lib_path = os.path.join(self.filepath_var.get().strip(), "角色库") + self.selected_roles = [] # 存储选中的角色名称 + + # 动态加载角色分类 + if os.path.exists(role_lib_path): + # 配置网格布局参数 + scroll_frame.columnconfigure(0, weight=1) + max_roles_per_row = 4 + current_row = 0 + + for category in os.listdir(role_lib_path): + category_path = os.path.join(role_lib_path, category) + if os.path.isdir(category_path): + # 创建分类容器 + category_frame = ctk.CTkFrame(scroll_frame) + category_frame.grid(row=current_row, column=0, sticky="w", pady=(10,5), padx=5) + + # 添加分类标签 + category_label = ctk.CTkLabel(category_frame, text=f"【{category}】", + font=("Microsoft YaHei", 12, "bold")) + category_label.grid(row=0, column=0, padx=(0,10), sticky="w") + + # 初始化角色排列参数 + role_count = 0 + row_num = 0 + col_num = 1 # 从第1列开始(第0列是分类标签) + + # 添加角色复选框 + for role_file in os.listdir(category_path): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + if not any(name == role_name for _, name in self.selected_roles): + chk = ctk.CTkCheckBox(category_frame, text=role_name) + chk.grid(row=row_num, column=col_num, padx=5, pady=2, sticky="w") + self.selected_roles.append((chk, role_name)) + + # 更新行列位置 + role_count += 1 + col_num += 1 + if col_num > max_roles_per_row: + col_num = 1 + row_num += 1 + + # 如果没有角色,调整分类标签占满整行 + if role_count == 0: + category_label.grid(columnspan=max_roles_per_row+1, sticky="w") + + # 更新主布局的行号 + current_row += 1 + + # 添加分隔线 + separator = ctk.CTkFrame(scroll_frame, height=1, fg_color="gray") + separator.grid(row=current_row, column=0, sticky="ew", pady=5) + current_row += 1 + + # 底部按钮框架 + btn_frame = ctk.CTkFrame(main_frame) + btn_frame.pack(fill="x", pady=10) + + # 选择按钮 + def confirm_selection(): + selected = [name for chk, name in self.selected_roles if chk.get() == 1] + self.char_inv_text.delete("0.0", "end") + self.char_inv_text.insert("0.0", ", ".join(selected)) + import_window.destroy() + + btn_confirm = ctk.CTkButton(btn_frame, text="选择", command=confirm_selection) + btn_confirm.pack(side="left", padx=20) + + # 取消按钮 + btn_cancel = ctk.CTkButton(btn_frame, text="取消", command=import_window.destroy) + btn_cancel.pack(side="right", padx=20) + + def show_role_library(self): + save_path = self.filepath_var.get().strip() + if not save_path: + messagebox.showwarning("警告", "请先设置保存路径") + return + + # 初始化LLM适配器 + llm_adapter = create_llm_adapter( + interface_format=self.interface_format_var.get(), + base_url=self.base_url_var.get(), + model_name=self.model_name_var.get(), + api_key=self.api_key_var.get(), + temperature=self.temperature_var.get(), + max_tokens=self.max_tokens_var.get(), + timeout=self.timeout_var.get() + ) + + # 传递LLM适配器实例到角色库 + if hasattr(self, '_role_lib'): + if self._role_lib.window and self._role_lib.window.winfo_exists(): + self._role_lib.window.destroy() + + self._role_lib = RoleLibrary(self.master, save_path, llm_adapter) # 新增参数 + # ----------------- 将导入的各模块函数直接赋给类方法 ----------------- generate_novel_architecture_ui = generate_novel_architecture_ui generate_chapter_blueprint_ui = generate_chapter_blueprint_ui diff --git a/ui/novel_params_tab.py b/ui/novel_params_tab.py index 8eb9a9b..84369bb 100644 --- a/ui/novel_params_tab.py +++ b/ui/novel_params_tab.py @@ -55,9 +55,9 @@ def build_novel_params_area(self, start_row=1): chapter_num_entry = ctk.CTkEntry(self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12)) chapter_num_entry.grid(row=row_chap_num, column=1, padx=5, pady=5, sticky="w") - # 6) 本章指导 + # 6) 内容指导 row_user_guide = 5 - create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="本章指导:", tooltip_key="user_guidance", row=row_user_guide, column=0, font=("Microsoft YaHei", 12), sticky="ne") + create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="内容指导:", tooltip_key="user_guidance", row=row_user_guide, column=0, font=("Microsoft YaHei", 12), sticky="ne") self.user_guide_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) TextWidgetContextMenu(self.user_guide_text) self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") @@ -67,8 +67,24 @@ def build_novel_params_area(self, start_row=1): # 7) 可选元素:核心人物/关键道具/空间坐标/时间压力 row_idx = 6 create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="核心人物:", tooltip_key="characters_involved", row=row_idx, column=0, font=("Microsoft YaHei", 12)) - char_inv_entry = ctk.CTkEntry(self.params_frame, textvariable=self.characters_involved_var, font=("Microsoft YaHei", 12)) - char_inv_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + + # 核心人物输入框+按钮容器 + char_inv_frame = ctk.CTkFrame(self.params_frame) + char_inv_frame.grid(row=row_idx, column=1, padx=5, pady=5, sticky="nsew") + char_inv_frame.columnconfigure(0, weight=1) + char_inv_frame.rowconfigure(0, weight=1) + + # 三行文本输入框 + self.char_inv_text = ctk.CTkTextbox(char_inv_frame, height=60, wrap="word", font=("Microsoft YaHei", 12)) + self.char_inv_text.grid(row=0, column=0, padx=(0,5), pady=5, sticky="nsew") + if hasattr(self, 'characters_involved_var'): + self.char_inv_text.insert("0.0", self.characters_involved_var.get()) + + # 导入按钮 + import_btn = ctk.CTkButton(char_inv_frame, text="导入", width=60, + command=self.show_character_import_window, + font=("Microsoft YaHei", 12)) + import_btn.grid(row=0, column=1, padx=(0,5), pady=5, sticky="e") row_idx += 1 create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="关键道具:", tooltip_key="key_items", row=row_idx, column=0, font=("Microsoft YaHei", 12)) key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12)) @@ -85,20 +101,39 @@ def build_novel_params_area(self, start_row=1): def build_optional_buttons_area(self, start_row=2): self.optional_btn_frame = ctk.CTkFrame(self.right_frame) self.optional_btn_frame.grid(row=start_row, column=0, sticky="ew", padx=5, pady=5) - self.optional_btn_frame.columnconfigure((0, 1, 2, 3), weight=1) + self.optional_btn_frame.columnconfigure((0, 1, 2, 3, 4), weight=1) - self.btn_check_consistency = ctk.CTkButton(self.optional_btn_frame, text="一致性审校", command=self.do_consistency_check, font=("Microsoft YaHei", 12)) + self.btn_check_consistency = ctk.CTkButton( + self.optional_btn_frame, text="一致性审校", command=self.do_consistency_check, + font=("Microsoft YaHei", 12), width=100 # 固定宽度 + ) self.btn_check_consistency.grid(row=0, column=0, padx=5, pady=5, sticky="ew") - self.btn_import_knowledge = ctk.CTkButton(self.optional_btn_frame, text="导入知识库", command=self.import_knowledge_handler, font=("Microsoft YaHei", 12)) + self.btn_import_knowledge = ctk.CTkButton( + self.optional_btn_frame, text="导入知识库", command=self.import_knowledge_handler, + font=("Microsoft YaHei", 12), width=100 + ) self.btn_import_knowledge.grid(row=0, column=1, padx=5, pady=5, sticky="ew") - self.btn_clear_vectorstore = ctk.CTkButton(self.optional_btn_frame, text="清空向量库", fg_color="red", command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12)) + self.btn_clear_vectorstore = ctk.CTkButton( + self.optional_btn_frame, text="清空向量库", fg_color="red", + command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12), width=100 + ) self.btn_clear_vectorstore.grid(row=0, column=2, padx=5, pady=5, sticky="ew") - self.plot_arcs_btn = ctk.CTkButton(self.optional_btn_frame, text="查看剧情要点", command=self.show_plot_arcs_ui, font=("Microsoft YaHei", 12)) + self.plot_arcs_btn = ctk.CTkButton( + self.optional_btn_frame, text="查看剧情要点", command=self.show_plot_arcs_ui, + font=("Microsoft YaHei", 12), width=100 + ) self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") + # 新增角色库按钮 + self.role_library_btn = ctk.CTkButton( + self.optional_btn_frame, text="角色库", command=self.show_role_library, + font=("Microsoft YaHei", 12), width=100 + ) + self.role_library_btn.grid(row=0, column=4, padx=5, pady=5, sticky="ew") + def create_label_with_help_for_novel_params(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): frame = ctk.CTkFrame(parent) frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) diff --git a/ui/role_library.py b/ui/role_library.py new file mode 100644 index 0000000..0f53d58 --- /dev/null +++ b/ui/role_library.py @@ -0,0 +1,1528 @@ +# ui/role_library.py +import os +import tkinter as tk +from tkinter import filedialog +import shutil +import re +import customtkinter as ctk +from tkinter import messagebox, BooleanVar +from customtkinter import CTkScrollableFrame, CTkTextbox, END +from utils import read_file, save_string_to_txt # 导入 utils 中的函数 +from novel_generator.common import invoke_with_cleaning # 新增导入 +from prompt_definitions import Character_Import_Prompt + +class RoleLibrary: + def __init__(self, master, save_path, llm_adapter): # 新增llm_adapter参数 + self.master = master + self.save_path = os.path.join(save_path, "角色库") + self.selected_category = None + self.current_roles = [] + self.selected_del = [] + self.llm_adapter = llm_adapter # 保存LLM适配器实例 + + # 初始化窗口 + self.window = ctk.CTkToplevel(master) + self.window.title("角色库管理") + self.window.geometry("1200x800") + self.window.protocol("WM_DELETE_WINDOW", self.on_close) + + # 创建目录结构 + self.create_library_structure() + # 构建UI + self.create_ui() + # 窗口居中 + self.center_window() + # 窗口模态设置 + self.window.grab_set() + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + + def create_library_structure(self): + """创建必要的目录结构""" + os.makedirs(self.save_path, exist_ok=True) + all_dir = os.path.join(self.save_path, "全部") + os.makedirs(all_dir, exist_ok=True) + + def create_ui(self): + """创建主界面""" + # 分类按钮区 + self.create_category_bar() + + # 主内容区 + main_frame = ctk.CTkFrame(self.window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左侧面板(保持不变) + left_panel = ctk.CTkFrame(main_frame, width=300) + left_panel.pack(side="left", fill="both", padx=5, pady=5) + + # 上部角色列表区(保持不变) + role_list_container = ctk.CTkFrame(left_panel) + role_list_container.pack(fill="both", expand=True, pady=(0, 5)) + + self.role_list_frame = ctk.CTkScrollableFrame(role_list_container) + self.role_list_frame.pack(fill="both", expand=True) + + # 下部内容预览区(保持不变) + preview_container = ctk.CTkFrame(left_panel) + preview_container.pack(fill="both", expand=True, pady=(5, 0)) + + self.preview_text = ctk.CTkTextbox(preview_container, wrap="word", + font=("Microsoft YaHei", 12)) + scrollbar = ctk.CTkScrollbar( + preview_container, command=self.preview_text.yview) + self.preview_text.configure(yscrollcommand=scrollbar.set) + + self.preview_text.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # 右侧面板(信息编辑区) + right_panel = ctk.CTkFrame(main_frame) + right_panel.pack(side="right", fill="both", expand=True, padx=5, pady=5) + + # 分类选择行 + category_frame = ctk.CTkFrame(right_panel) + category_frame.pack(fill="x", padx=5, pady=5) + + # 分类选择标签 + ctk.CTkLabel(category_frame, text="分类选择").pack(side="left", padx=(0, 5)) + + # 分类选择框 + self.category_combobox = ctk.CTkComboBox( + category_frame, + values=self._get_all_categories(), + width=200 + ) + self.category_combobox.pack(side="left", padx=0) + + # 分类保存按钮 + self.save_category_btn = ctk.CTkButton( + category_frame, + text="保存分类", + width=80, + command=self._move_to_category + ) + self.save_category_btn.pack(side="left", padx=(0, 5)) + + # 打开文件夹按钮 + ctk.CTkButton( + category_frame, + text="打开文件夹", + width=80, + command=lambda: os.startfile( + os.path.join(self.save_path, self.category_combobox.get())) + ).pack(side="left", padx=0) + + # 角色名编辑行 + name_frame = ctk.CTkFrame(right_panel) + name_frame.pack(fill="x", padx=5, pady=5) + + # 角色名称标签 + ctk.CTkLabel(name_frame, text="角色名称").pack(side="left", padx=(0, 5)) + + self.role_name_var = tk.StringVar() + self.role_name_entry = ctk.CTkEntry( + name_frame, + textvariable=self.role_name_var, + placeholder_text="角色名称", + width=200 + ) + self.role_name_entry.pack(side="left", padx=0) + + ctk.CTkButton( + name_frame, + text="修改", + width=60, + command=self._rename_role_file + ).pack(side="left", padx=(0, 5)) + + ctk.CTkButton( + name_frame, + text="新增", + width=60, + command=lambda: self._create_new_role("全部") + ).pack(side="left", padx=0) + + # 属性编辑区(基础框架) + self.attributes_frame = ctk.CTkScrollableFrame(right_panel) + self.attributes_frame.pack(fill="both", expand=True, padx=5, pady=5) + # 设置统一的列权重 + self.attributes_frame.grid_columnconfigure(1, weight=1) + + button_frame = ctk.CTkFrame(right_panel) + button_frame.pack(fill="x", padx=5, pady=5) + + ctk.CTkButton(button_frame, text="导入角色", + command=self.import_roles).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="删除", + command=self.delete_current_role).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="保存", + command=self.save_current_role).pack(side="left", padx=5) + + def _get_all_categories(self): + """获取所有有效分类(包括动态更新)""" + categories = ["全部"] + for d in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部": + categories.append(d) + return categories + + def _move_to_category(self): + """分类转移功能""" + if not hasattr(self, 'current_role') or not self.current_role: + messagebox.showwarning("警告", "请先选择一个角色", parent=self.window) + return + + new_category = self.category_combobox.get() + + # 如果当前在"全部"分类下,需要找到角色实际所在分类 + if self.selected_category == "全部": + # 遍历所有分类查找实际存储位置(包含全部目录) + actual_category = None + for category in os.listdir(self.save_path): + test_path = os.path.join( + self.save_path, category, f"{self.current_role}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + msg = messagebox.showerror("错误", f"找不到角色 {self.current_role} 的实际存储位置", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + old_path = os.path.join( + self.save_path, actual_category, f"{self.current_role}.txt") + else: + old_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + + # 如果目标分类是"全部",则实际移动到"全部"分类 + if new_category == "全部": + new_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + else: + new_path = os.path.join( + self.save_path, new_category, f"{self.current_role}.txt") + + # 检查是否已经在目标分类 + if os.path.exists(new_path): + msg = messagebox.showinfo("提示", "角色已在目标分类中", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + confirm = messagebox.askyesno( + "确认", f"确定要将角色 {self.current_role} 移动到 {new_category} 分类吗?", parent=self.window) + if not confirm: + return + + try: + # 确保目标目录存在 + os.makedirs(os.path.dirname(new_path), exist_ok=True) + + try: + # 执行移动操作 + shutil.move(old_path, new_path) + + # 更新显示 + self.selected_category = new_category if new_category != "全部" else "全部" + self.show_category(self.selected_category) + self.category_combobox.set(new_category) + + # 成功提示 + messagebox.showinfo("成功", "分类已更新", parent=self.window) + return # 成功时直接返回 + + except Exception as e: + # 失败时恢复原分类显示 + self.category_combobox.set(self.selected_category) + raise e + except Exception as e: + msg = messagebox.showerror("错误", f"分类转移失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + self.category_combobox.set(self.selected_category) + + def import_roles(self): + """导入角色窗口""" + import_window = ctk.CTkToplevel(self.window) + import_window.title("角色导入") + import_window.geometry("800x600") + import_window.transient(self.window) # 设置为子窗口 + import_window.grab_set() # 模态窗口 + import_window.lift() # 置于父窗口前面 + + # 窗口居中计算 + import_window.update_idletasks() + i_width = import_window.winfo_width() + i_height = import_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - i_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - i_height) // 2 + import_window.geometry(f"+{x}+{y}") + + # 主内容区 + main_frame = ctk.CTkFrame(import_window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左右面板容器 + content_frame = ctk.CTkFrame(main_frame) + content_frame.pack(fill="both", expand=True, pady=(0, 10)) + content_frame.grid_columnconfigure(0, weight=1) # 左侧面板权重 + content_frame.grid_columnconfigure(1, weight=1) # 右侧面板权重 + + # 左侧面板 - 使用权重让控件占满空间 + left_panel = ctk.CTkFrame(content_frame) + left_panel.grid(row=0, column=0, sticky="nsew", padx=(0, 5), pady=5) + left_panel.grid_rowconfigure(0, weight=1) + left_panel.grid_columnconfigure(0, weight=1) + left_panel.grid_propagate(False) # 防止子控件改变父容器大小 + + # 右侧面板(2份宽度) - 添加初始可编辑文本框 + right_panel = ctk.CTkFrame(content_frame) + right_panel.grid(row=0, column=1, sticky="nsew", padx=(5, 0), pady=5) + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + # 创建初始可编辑文本框 + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + text_box.configure(state="normal") # 保持可编辑状态 + + # 初始化角色列表 + self.import_roles_list = [] + + # 底部按钮区 + btn_frame = ctk.CTkFrame(main_frame) + btn_frame.pack(fill="x", pady=(0, 10)) + + # 导入按钮 + ctk.CTkButton( + btn_frame, + text="导入临时角色库", + width=120, + command=lambda: self.confirm_import(import_window) + ).pack(side="left", padx=10) + + # 分析文件按钮 + ctk.CTkButton( + btn_frame, + text="分析文件", + width=100, + command=lambda: self.analyze_character_state(right_panel, left_panel) + ).pack(side="left", padx=10) + + # 加载character_state.txt按钮 + ctk.CTkButton( + btn_frame, + text="加载character_state.txt", + width=160, + command=lambda: self.load_default_character_state(right_panel) + ).pack(side="right", padx=10) + + # 从文件导入按钮 + ctk.CTkButton( + btn_frame, + text="从文件导入", + width=100, + command=lambda: self.import_from_file(right_panel) + ).pack(side="right", padx=10) + + # 设置内容区权重 + content_frame.grid_rowconfigure(0, weight=1) + + def analyze_character_state(self, right_panel, left_panel): + """分析角色状态文件,使用LLM提取角色信息并保存到临时角色库""" + content = "" + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + content = widget.get("1.0", "end").strip() + break + + if not content: + messagebox.showwarning("警告", "未找到可分析的内容", parent=self.window) + return + + try: + # 创建临时角色库目录 + target_dir = os.path.join(self.save_path, "临时角色库") + # 清空现有临时角色库 + if os.path.exists(target_dir): + for filename in os.listdir(target_dir): + file_path = os.path.join(target_dir, filename) + try: + if os.path.isfile(file_path): + os.unlink(file_path) + except Exception as e: + print(f"删除文件{file_path}时出错: {e}") + os.makedirs(target_dir, exist_ok=True) + + # 调用LLM进行分析 + prompt = f"{Character_Import_Prompt}\n<<待分析小说文本开始>>\n{content}\n<<待分析小说文本结束>>" + response = invoke_with_cleaning( + self.llm_adapter, + prompt + ) + + # 解析LLM响应 + roles = self._parse_llm_response(response) + + if not roles: + messagebox.showwarning("警告", "未解析到有效角色信息", parent=self.window) + return + + # 直接显示分析结果而不保存到文件 + self._display_analyzed_roles(left_panel, roles) + + except Exception as e: + messagebox.showerror("分析失败", f"LLM分析出错:{str(e)}", parent=self.window) + + def _display_temp_roles(self, parent, temp_dir): + """显示临时角色库中的角色""" + # 清空左侧面板 + for widget in parent.winfo_children(): + widget.destroy() + + # 创建滚动容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True) + + # 读取所有临时角色文件 + self.character_checkboxes = {} + for file_name in os.listdir(temp_dir): + if file_name.endswith(".txt"): + role_name = os.path.splitext(file_name)[0] + file_path = os.path.join(temp_dir, file_name) + + # 解析角色属性 + attributes = self._parse_temp_role_file(file_path) + + # 创建带勾选框的条目 + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称 + lbl = ctk.CTkLabel(frame, text=role_name, + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in attributes.items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role_name] = { + 'var': var, + 'data': {'name': role_name, 'attributes': attributes} + } + + # 添加操作按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="取消选择", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _parse_temp_role_file(self, file_path): + """解析临时角色文件""" + attributes = {} + current_attr = None + try: + with open(file_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # 统一解析├──和└──两种前缀 + if any(prefix in line for prefix in ['├──', '└──']) and ':' in line: + prefix = '├──' if '├──' in line else '└──' + current_attr = line.split(prefix)[1].split(':')[0].strip() + attributes[current_attr] = [] + elif any(prefix in line for prefix in ['│ ├──', '│ └──']): + prefix = '│ ├──' if '│ ├──' in line else '│ └──' + if current_attr: + item = line.split(prefix)[1].strip() + attributes[current_attr].append(item) + except Exception as e: + messagebox.showerror("解析错误", f"解析临时文件失败:{str(e)}", parent=self.window) + return attributes + + def _parse_llm_response(self, response): + """解析LLM返回的角色数据""" + roles = [] + current_role = None + current_attr = None + current_subattr = None + + attribute_pattern = re.compile(r'^([├└]──)([\w\u4e00-\u9fa5]+)\s*[::]') + item_pattern = re.compile(r'^│\s+([├└]──)\s*(.*)') + + for line in response.split('\n'): + line = line.rstrip() + + # 检测角色名称行(兼容中英文冒号和前后空格) + role_match = re.match(r'^\s*([\u4e00-\u9fa5a-zA-Z0-9]+)\s*[::]\s*$', line) + if role_match: + current_role = role_match.group(1).strip() + roles.append({'name': current_role, 'attributes': {}}) + continue + + if not current_role: + continue + + # 解析属性(支持子属性) + attr_match = attribute_pattern.match(line) + if attr_match: + prefix, attr_name = attr_match.groups() + current_attr = attr_name.strip() + roles[-1]['attributes'][current_attr] = [] + current_subattr = None + continue + + # 解析属性条目(支持多级结构) + item_match = item_pattern.match(line) + if item_match and current_attr: + prefix, content = item_match.groups() + content = content.strip() + + # 解析子属性(例如"身体状态: xxx") + if ':' in content or ':' in content: + subattr_match = re.split(r'[::]', content, 1) + if len(subattr_match) > 1: + current_subattr = subattr_match[0].strip() + value = subattr_match[1].strip() + if value: # 值不为空时才添加 + roles[-1]['attributes'][current_attr].append( + f"{current_subattr}: {value}" + ) + continue + + # 普通条目处理 + if content: + if current_subattr: + # 子属性的延续条目 + roles[-1]['attributes'][current_attr][-1] += f",{content}" + else: + roles[-1]['attributes'][current_attr].append(content) + return roles + + def _display_analyzed_roles(self, parent, roles): + """显示分析后的角色列表""" + self.character_checkboxes = {} + + # 创建带滚动条的容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True, padx=5, pady=5) + scroll_frame.grid_rowconfigure(0, weight=1) + scroll_frame.grid_columnconfigure(0, weight=1) + + # 为每个角色创建带勾选框的条目 + for role in roles: + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称标签 + lbl = ctk.CTkLabel(frame, text=role['name'], + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in role['attributes'].items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role['name']] = { + 'var': var, + 'data': role + } + + # 添加全选/反选按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="反选", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _toggle_all(self, select): + """全选/反选操作""" + for role in self.character_checkboxes.values(): + current_state = role['var'].get() + # 如果是反选操作,则设置相反状态 + if isinstance(select, bool): + role['var'].set(select) + else: + role['var'].set(not current_state) + + + def import_from_file(self, right_panel): + """从文件导入内容到右侧窗口""" + filetypes = ( + ('文本文件', '*.txt'), + ('Word文档', '*.docx'), + ('所有文件', '*.*') + ) + + file_path = filedialog.askopenfilename( + title="选择要导入的文件", + initialdir=os.path.expanduser("~"), + filetypes=filetypes + ) + + if not file_path: + return + + try: + content = "" + if file_path.endswith('.docx'): + # 处理Word文档 + from docx import Document + doc = Document(file_path) + content = "\n".join([para.text for para in doc.paragraphs]) + else: + # 处理普通文本文件 + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 更新右侧文本框中 + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + widget.delete("1.0", "end") + widget.insert("1.0", content) + break + + except Exception as e: + messagebox.showerror("导入失败", f"无法读取文件:{str(e)}", parent=self.window) + + def load_default_character_state(self, right_panel): + """加载character_state.txt文件到右侧窗口""" + # 获取保存路径 + save_path = os.path.dirname(self.save_path) + file_path = os.path.join(save_path, "character_state.txt") + + if not os.path.exists(file_path): + messagebox.showwarning("警告", f"未找到文件: {file_path}", parent=self.window) + return + + try: + # 读取文件内容 + content = read_file(file_path) + + # 清空右侧面板中可能存在的旧控件 + for widget in right_panel.winfo_children(): + widget.destroy() + + # 查找或创建文本框 + text_box = None + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + text_box = widget + break + + if not text_box: + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + text_box.configure(state="normal") + text_box.delete("1.0", "end") + text_box.insert("1.0", content) + + # 设置右边面板的布局权重 + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + except Exception as e: + messagebox.showerror("错误", f"加载文件失败: {str(e)}", parent=self.window) + + def confirm_import(self, import_window): + """从临时角色库导入选中的角色""" + # 创建必要的目录 + target_dir = os.path.join(self.save_path, "临时角色库") + os.makedirs(target_dir, exist_ok=True) + + try: + # 获取选中的角色 + selected_roles = [role_data['data'] for role_data in self.character_checkboxes.values() + if role_data['var'].get()] + + if not selected_roles: + # 创建错误提示窗口 + error_window = ctk.CTkToplevel(import_window) + error_window.title("错误") + error_window.transient(import_window) + error_window.grab_set() + + # 窗口内容 + ctk.CTkLabel(error_window, text="请至少选择一个角色").pack(padx=20, pady=10) + ctk.CTkButton(error_window, text="确定", command=error_window.destroy).pack(pady=10) + + # 窗口居中 + error_window.update_idletasks() + e_width = error_window.winfo_width() + e_height = error_window.winfo_height() + x = import_window.winfo_x() + (import_window.winfo_width() - e_width) // 2 + y = import_window.winfo_y() + (import_window.winfo_height() - e_height) // 2 + error_window.geometry(f"+{x}+{y}") + error_window.attributes('-topmost', 1) + return + + # 从内存数据直接保存角色 + for role in selected_roles: + dest_path = os.path.join(target_dir, f"{role['name']}.txt") + + # 构建角色内容 + content_lines = [f"{role['name']}:"] + for attr, items in role['attributes'].items(): + content_lines.append(f"├──{attr}:") + for i, item in enumerate(items): + prefix = "├──" if i < len(items)-1 else "└──" + content_lines.append(f"│ {prefix}{item}") + + # 直接写入文件,覆盖已存在的文件 + with open(dest_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content_lines)) + + # 刷新分类显示 + self.load_categories() + import_window.destroy() + + except Exception as e: + # 静默处理错误 + import_window.destroy() + + + + def delete_current_role(self): + """删除当前角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + confirm = messagebox.askyesno( + "确认删除", f"确定要删除角色 {self.current_role} 吗?", parent=self.window) + if not confirm: + return + + role_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + try: + os.remove(role_path) + # 从"全部"分类也删除 + all_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + if os.path.exists(all_path): + os.remove(all_path) + self.show_category(self.selected_category) + self.preview_text.delete("1.0", "end") + msg = messagebox.showinfo("成功", "角色已删除", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + except Exception as e: + msg = messagebox.showerror("错误", f"删除失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _build_role_content(self): + """构建角色文件内容""" + content = [f"{self.role_name_var.get()}:"] + attributes_order = ["物品", "能力", "状态", "主要角色间关系网", "触发或加深的事件"] + + for attr_name in attributes_order: + content.append(f"├──{attr_name}:") + # 找到对应的 attribute_block + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + # 遍历该 block 中的所有 CTkEntry + for child in block.winfo_children(): + if isinstance(child, ctk.CTkFrame): # 条目行 + for item in child.winfo_children(): + if isinstance(item, ctk.CTkEntry): + entry_text = item.get().strip() + if entry_text: # 只添加非空条目 + content.append(f"│ ├──{entry_text}") + break # 找到对应属性后跳出循环 + return content + + def _save_role_file(self, content, save_path): + """保存角色文件""" + with open(save_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content)) + + def _check_role_name_conflict(self, new_name): + """检查角色名是否重复,遍历整个角色文件夹""" + conflicts = [] + # 遍历所有分类目录 + for category in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, category)): + # 检查该分类下是否有同名角色 + role_path = os.path.join( + self.save_path, category, f"{new_name}.txt") + if os.path.exists(role_path): + # 如果是"全部"分类,需要进一步检查是否是实际文件 + if category == "全部": + # 检查"全部"目录下的文件是否是实际文件 + all_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + if os.path.isfile(all_path): + # 如果是实际文件,则认为是冲突 + conflicts.append(category) + else: + # 普通分类直接记录冲突 + conflicts.append(category) + return conflicts + + def save_current_role(self): + """保存当前编辑的角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + new_name = self.role_name_var.get().strip() + if not new_name: + msg = messagebox.showwarning("警告", "角色名称不能为空", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 检查角色名是否重复 + if new_name != self.current_role: + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + content = self._build_role_content() + save_path = os.path.join(self.save_path, self.selected_category, + f"{new_name}.txt") + + try: + self._save_role_file(content, save_path) + # 如果修改了角色名,更新文件名 + if new_name != self.current_role: + old_path = os.path.join(self.save_path, self.selected_category, + f"{self.current_role}.txt") + os.rename(old_path, save_path) + + # 更新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.show_role(new_name) # 刷新角色显示 + messagebox.showinfo("成功", "角色已保存", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"保存失败:{str(e)}", parent=self.window) + + def _rename_role_file(self): + """修改角色名称""" + old_name = self.current_role + new_name = self.role_name_var.get().strip() + + if not old_name or not new_name: + return + + # 处理中英文冒号 + for colon in [":", ":"]: + old_name = old_name.split(colon)[0] + new_name = new_name.split(colon)[0] + + # 如果角色名没有改变,直接返回 + if new_name == old_name: + return + + # 检查角色名是否重复 + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + try: + # 如果是"全部"分类,需要找到实际存储的分类 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + if os.path.exists(all_path): + # 如果"全部"目录下有文件,则直接操作 + actual_category = "全部" + else: + # 遍历所有分类查找实际存储位置 + actual_category = None + for category in os.listdir(self.save_path): + if category == "全部": + continue + test_path = os.path.join( + self.save_path, category, f"{old_name}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + raise FileNotFoundError( + f"找不到角色 {old_name} 的实际存储位置") + else: + actual_category = self.selected_category + + # 读取旧文件内容并更新角色名 + old_path = os.path.join( + self.save_path, actual_category, f"{old_name}.txt") + with open(old_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 获取第一行内容 + first_line = content.split('\n')[0].strip() + # 提取内容中的角色名 + content_role_name = first_line.split(':')[0].split(':')[0].strip() + # 如果内容中的角色名与旧文件名不同,更新内容 + if content_role_name != old_name: + content = content.replace( + f"{content_role_name}:", f"{new_name}:", 1) + else: + content = content.replace(f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + new_path = os.path.join( + self.save_path, actual_category, f"{new_name}.txt") + with open(new_path, 'w', encoding='utf-8') as f: + f.write(content) + + # 删除旧文件 + os.remove(old_path) + + # 处理"全部"目录 + all_old_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + all_new_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + + # 如果"全部"目录存在旧文件 + if os.path.exists(all_old_path): + try: + # 更新"全部"目录中的文件内容 + with open(all_old_path, 'r', encoding='utf-8') as f: + all_content = f.read() + updated_all_content = all_content.replace( + f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + with open(all_new_path, 'w', encoding='utf-8') as f: + f.write(updated_all_content) + + # 删除旧文件 + os.remove(all_old_path) + except Exception as e: + messagebox.showerror("错误", f"更新全部目录失败: {str(e)}", parent=self.window) + # 回滚重命名操作 + os.rename(new_path, old_path) + return + + # 刷新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.role_name_var.set(new_name) + self.show_role(new_name) # 刷新角色显示区域 + + except Exception as e: + msg = messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _create_new_role(self, category): + """在指定分类创建新角色""" + role_dir = os.path.join(self.save_path, category) + base_name = "未命名" + counter = 1 + + # 生成唯一文件名 + while os.path.exists(os.path.join(role_dir, f"{base_name}.txt")): + base_name = f"未命名{counter}" + counter += 1 + + # 创建基础文件结构(包含初始条目) + content = f"{base_name}:\n" + "\n".join([ + "├──物品:", + "│ └──待补充", + "├──能力:", + "│ └──待补充", + "├──状态:", + "│ └──待补充", + "├──主要角色间关系网:", + "│ └──待补充", + "├──触发或加深的事件:", + "│ └──待补充" + ]) + + with open(os.path.join(role_dir, f"{base_name}.txt"), "w", encoding="utf-8") as f: + f.write(content) + + # 刷新显示 + self.show_category(category) + self.role_name_var.set(base_name) + self.current_role = base_name + + def create_category_bar(self): + """创建分类按钮区""" + category_frame = ctk.CTkFrame(self.window) + category_frame.pack(fill="x", padx=10, pady=5) + + # 操作提示 + ctk.CTkLabel(category_frame, + text="右键分类名即可重命名", + font=("Microsoft YaHei", 10), + text_color="gray").pack(side="top", anchor="w", padx=5) + + # 固定按钮 + ctk.CTkButton(category_frame, text="全部", width=50, + command=lambda: self.show_category("全部")).pack(side="left", padx=2) + + # 滚动分类区 + self.scroll_frame = ctk.CTkScrollableFrame( + category_frame, orientation="horizontal", height=30) + self.scroll_frame.pack(side="left", fill="x", expand=True, padx=5) + + # 操作按钮 + ctk.CTkButton(category_frame, text="新增", width=50, + command=self.add_category).pack(side="right", padx=2) + ctk.CTkButton(category_frame, text="删除", width=50, + command=self.delete_category).pack(side="right", padx=2) + + self.load_categories() + + def center_window(self): + """窗口居中""" + self.window.update_idletasks() + parent_x = self.master.winfo_x() + parent_y = self.master.winfo_y() + parent_width = self.master.winfo_width() + parent_height = self.master.winfo_height() + win_width = 1200 + win_height = 800 + x = parent_x + (parent_width - win_width) // 2 + y = parent_y + (parent_height - win_height) // 2 + self.window.geometry(f"{win_width}x{win_height}+{x}+{y}") + + def load_categories(self): + """加载分类按钮""" + for widget in self.scroll_frame.winfo_children(): + widget.destroy() + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + + for category in categories: + btn = ctk.CTkButton(self.scroll_frame, text=category, width=80) + btn.bind("", lambda e, c=category: self.show_category(c)) + btn.bind("", lambda e, c=category: self.rename_category(c)) + btn.pack(side="left", padx=2) + + def _create_category_directory(self, category_name): + """创建分类目录""" + new_dir = os.path.join(self.save_path, category_name) + if not os.path.exists(new_dir): + os.makedirs(new_dir) + return new_dir + + def add_category(self): + """新增分类""" + self._create_category_directory("未命名") + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + + def delete_category(self): + """删除分类对话框""" + if not self.window.winfo_exists(): + return + + del_window = ctk.CTkToplevel(self.window) + del_window.title("删除分类") + del_window.transient(self.window) + del_window.grab_set() + del_window.attributes('-topmost', 1) + + # 居中计算 + parent_x = self.window.winfo_x() + parent_y = self.window.winfo_y() + parent_width = self.window.winfo_width() + parent_height = self.window.winfo_height() + del_window.geometry( + f"300x400+{parent_x + (parent_width-300)//2}+{parent_y + (parent_height-400)//2}") + + scroll_frame = ctk.CTkScrollableFrame(del_window) + scroll_frame.pack(fill="both", expand=True) + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + self.selected_del = [] + + for cat in categories: + var = tk.BooleanVar() + chk = ctk.CTkCheckBox(scroll_frame, text=cat, variable=var) + chk.pack(anchor="w") + self.selected_del.append((cat, var)) + + # 操作按钮 + btn_frame = ctk.CTkFrame(del_window) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="删除选中", + command=lambda: self.confirm_delete(del_window)).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="取消", + command=del_window.destroy).pack(side="right", padx=5) + + self.category_combobox.configure(values=self._get_all_categories()) + self.category_combobox.set("全部") + + def confirm_delete(self, original_window): + """确认删除操作""" + selected = [item[0] for item in self.selected_del if item[1].get()] + if not selected: + msg = messagebox.showwarning("警告", "请至少选择一个分类", parent=self.window) + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + return + + # 创建选择窗口时添加前置设置 + choice_window = ctk.CTkToplevel(self.window) + choice_window.transient(self.window) # 设置为子窗口 + choice_window.grab_set() # 模态窗口 + choice_window.lift() # 置顶 + choice_window.attributes('-topmost', 1) # 强制置顶 + + # 添加居中计算 + choice_window.update_idletasks() + c_width = choice_window.winfo_width() + c_height = choice_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - c_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - c_height) // 2 + choice_window.geometry(f"+{x}+{y}") + + ctk.CTkLabel(choice_window, text="请选择删除方式:").pack(pady=10) + btn_frame = ctk.CTkFrame(choice_window) + btn_frame.pack(pady=10) + + def perform_delete(mode): + all_dir = os.path.join(self.save_path, "全部") + for cat in selected: + cat_path = os.path.join(self.save_path, cat) + if mode == "move": + for role_file in os.listdir(cat_path): + if role_file.endswith(".txt"): + src = os.path.join(cat_path, role_file) + dst = os.path.join(all_dir, role_file) + try: + shutil.move(src, dst) + except: + os.remove(dst) + shutil.move(src, dst) + shutil.rmtree(cat_path) + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + original_window.destroy() + choice_window.destroy() + + ctk.CTkButton(btn_frame, text="全部删除", + command=lambda: perform_delete("all")).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="移动角色", + command=lambda: perform_delete("move")).pack(side="left", padx=5) + + def count_roles(self, categories): + """统计角色数量""" + count = 0 + for cat in categories: + cat_path = os.path.join(self.save_path, cat) + count += len([f for f in os.listdir(cat_path) if f.endswith(".txt")]) + return count + + def show_category(self, category): + """显示分类内容""" + self.selected_category = category + self.category_combobox.set(category) + for widget in self.role_list_frame.winfo_children(): + widget.destroy() + + # 如果是"全部"分类,显示所有角色 + if category == "全部": + # 获取所有分类目录 + categories = [d for d in os.listdir(self.save_path) + + if os.path.isdir(os.path.join(self.save_path, d))] + # 用于去重的角色集合 + unique_roles = set() + + for cat in categories: + role_dir = os.path.join(self.save_path, cat) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + # 去重 + if role_name not in unique_roles: + unique_roles.add(role_name) + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + continue + else: + # 普通分类显示 + role_dir = os.path.join(self.save_path, category) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + messagebox.showerror("错误", "分类目录不存在", parent=self.window) + + def show_role(self, role_name): + """显示角色详细信息(支持UTF-8/ANSI编码)""" + try: + # 清空现有属性控件 + self.preview_text.delete('1.0', tk.END) + for widget in self.attributes_frame.winfo_children(): + widget.destroy() + + # 更新角色名称显示 + self.current_role = role_name.split(":")[0].split(":")[0] + self.role_name_var.set(self.current_role) + + # 查找角色实际所在目录 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{role_name}.txt") + if os.path.exists(all_path): + file_path = all_path + actual_category = "全部" + else: + # 如果"全部"目录下没有,则遍历其他分类查找 + file_path = None + for cat in os.listdir(self.save_path): + if cat == "全部": + continue + test_path = os.path.join( + self.save_path, cat, f"{role_name}.txt") + if os.path.exists(test_path): + file_path = test_path + actual_category = cat + # 保存实际分类 + self.actual_category = cat + break + if file_path is None: + raise FileNotFoundError(f"找不到角色文件:{role_name}") + + # 只更新分类选择框的显示值,不改变当前选中的分类 + self.category_combobox.set(actual_category) + else: + # 普通分类直接使用当前路径 + file_path = os.path.join( + self.save_path, self.selected_category, f"{role_name}.txt") + + content, _ = self._read_file_with_fallback_encoding(file_path) + + # 解析属性结构 + attributes = { + "物品": [], + "能力": [], + "状态": [], + "主要角色间关系网": [], + "触发或加深的事件": [] + } + current_attribute = None + for line in content[1:]: + # 改进属性名称识别 + if line.startswith(("├──", "├──")): + # 提取属性名称(兼容冒号和空格) + attr_part = line.split("──")[1].strip() + attr_name = re.split(r'[::]', attr_part, 1)[0].strip() + + # 匹配预设属性 + for preset_attr in attributes: + if attr_name == preset_attr: + current_attribute = preset_attr + indent_level = line.find( + "├") if "├" in line else line.find("├") + break + else: + current_attribute = None + + # 改进条目内容提取 + elif current_attribute and line.startswith(("│ ", " ")): + # 提取整个条目内容 + item_content = line.strip() + # 去掉前面的符号和空格 + item_content = re.sub(r'^[│├└─\s]*', '', item_content) + attributes[current_attribute].append(item_content) + + # 显示原始文件内容 + self.preview_text.insert(tk.END, '\n'.join(content)) + + # 重构属性编辑区 + for attr_name, items in attributes.items(): + self._create_attribute_section(attr_name, items) + + except FileNotFoundError as e: + messagebox.showerror("错误", f"文件不存在:{str(e)}", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"读取文件失败:{str(e)}", parent=self.window) + + def _create_attribute_section(self, attr_name, items): + """创建单个属性的编辑区域""" + + # 属性块 (attribute_block) + attribute_block = ctk.CTkFrame(self.attributes_frame) + attribute_block.pack(fill="x", pady=5) + attribute_block.attribute_name = attr_name # 存储属性名称 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + + # 属性名称标签 + label = ctk.CTkLabel(attribute_block, text=attr_name) + label.grid(row=0, column=0, sticky="w", padx=(5, 10), pady=2) + + # 第一个条目和“增加”按钮的容器 + first_item_frame = ctk.CTkFrame(attribute_block) + first_item_frame.grid(row=0, column=1, sticky="ew", padx=5, pady=2) + first_item_frame.grid_columnconfigure(0, weight=1) + + # 第一个条目输入框 + first_entry = ctk.CTkEntry(first_item_frame) + first_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + if items: + first_entry.insert(0, items[0]) # 填充第一个条目的内容 + + # “增加”按钮容器 + add_button_frame = ctk.CTkFrame(first_item_frame, fg_color="transparent") + add_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + + # “增加”按钮 + add_button = ctk.CTkButton( + add_button_frame, + text="+", + width=30, + command=lambda: self._add_item(attr_name) + ) + add_button.grid(row=0, column=0) + + # 创建剩余的条目(如果有) + for i, item_text in enumerate(items[1:]): + self._add_item(attr_name, item_text) # 传入初始文本 + + def _add_item(self, attr_name, initial_text=""): + """为指定属性添加一个新条目""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 计算新条目的行号 + row_number = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + row_number += 1 + + # 条目容器 + item_frame = ctk.CTkFrame(attribute_block) + item_frame.grid(row=row_number, column=1, sticky="ew", padx=5, pady=2) + item_frame.grid_columnconfigure(0, weight=1) + + # 条目输入框 + new_entry = ctk.CTkEntry(item_frame) + new_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + new_entry.insert(0, initial_text) # 设置初始文本 + + # 删除按钮容器 + del_button_frame = ctk.CTkFrame(item_frame, fg_color="transparent") + del_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + # “删除”按钮 + del_button = ctk.CTkButton( + del_button_frame, + text="-", + width=30, + command=lambda f=item_frame: self._remove_item(f, attr_name) + ) + del_button.grid(row=0, column=0) + + + def _remove_item(self, item_frame, attr_name): + """移除指定的条目,并重新调整布局""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 确认不是删除带"+"号的原始条目 + for child in item_frame.winfo_children(): + if isinstance(child, ctk.CTkFrame): + for btn in child.winfo_children(): + if isinstance(btn, ctk.CTkButton) and btn.cget("text") == "+": + msg = messagebox.showinfo("提示", "不能删除带'+'号的原始条目", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 移除条目 + item_frame.destroy() + + # 重新调整剩余条目的行号 + current_row = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + if current_row == 0: # 找到属性标签 + current_row += 1 + continue + ctk.CTkFrame.grid_configure(child, row=current_row) + current_row += 1 + + def _read_file_with_fallback_encoding(self, file_path): + """带编码回退的文件读取,支持UTF-8、GBK(ANSI)和BOM""" + encodings = ['utf-8-sig', 'utf-8', 'gbk', 'latin1'] # 增加更多编码支持 + + for encoding in encodings: + try: + with open(file_path, "r", encoding=encoding) as f: + content = f.read() + # 检查内容是否包含乱码 + if any(ord(char) > 127 and not char.isprintable() for char in content): + continue # 如果包含乱码,尝试下一个编码 + return content.splitlines(), encoding + except UnicodeDecodeError: + continue + except Exception as e: + raise + + # 如果所有编码尝试都失败,尝试二进制读取 + try: + with open(file_path, "rb") as f: + raw_data = f.read() + # 尝试UTF-8解码 + try: + return raw_data.decode('utf-8').splitlines(), 'utf-8' + except UnicodeDecodeError: + # 尝试GBK解码 + try: + return raw_data.decode('gbk').splitlines(), 'gbk' + except UnicodeDecodeError: + # 最后尝试latin1解码 + return raw_data.decode('latin1').splitlines(), 'latin1' + except Exception as e: + raise ValueError(f"无法识别的文件编码:{file_path}") + + def rename_category(self, old_name): + """分类重命名(带居中功能)""" + new_name = None # 初始化变量 + + # 创建对话框窗口 + dialog = ctk.CTkToplevel(self.window) + dialog.title("重命名分类") + dialog.transient(self.window) + dialog.grab_set() + + # 窗口内容 + content_frame = ctk.CTkFrame(dialog) + content_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 顶部提示 + ctk.CTkLabel(content_frame, text=f"当前分类:{old_name}").pack(pady=(10, 5)) + + # 输入框 + input_frame = ctk.CTkFrame(content_frame) + input_frame.pack(fill="x", pady=5) + ctk.CTkLabel(input_frame, text="新名称:").pack(side="left", padx=5) + name_var = tk.StringVar() + name_entry = ctk.CTkEntry(input_frame, textvariable=name_var, width=150) + name_entry.pack(side="left", padx=5) + + # 按钮区 + button_frame = ctk.CTkFrame(content_frame) + button_frame.pack(fill="x", pady=(10, 5)) + + def confirm_rename(): + nonlocal new_name # 引用外部变量 + new_name = name_var.get().strip() + if not new_name: + messagebox.showwarning("警告", "分类名称不能为空", parent=self.window) + return + if new_name == old_name: + dialog.destroy() + return + if os.path.exists(os.path.join(self.save_path, new_name)): + messagebox.showerror("错误", "分类名称已存在", parent=self.window) + return + + try: + os.rename(os.path.join(self.save_path, old_name), + os.path.join(self.save_path, new_name)) + self.load_categories() + # 更新分类选择框 + self.category_combobox.configure( + values=self._get_all_categories()) + self.category_combobox.set(new_name) + dialog.destroy() + except Exception as e: + messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + + ctk.CTkButton(button_frame, text="确认", + command=confirm_rename).pack(side="left", padx=10) + ctk.CTkButton(button_frame, text="取消", + command=dialog.destroy).pack(side="right", padx=10) + + # 窗口居中 + dialog.update_idletasks() + d_width = dialog.winfo_width() + d_height = dialog.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - d_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - d_height) // 2 + dialog.geometry(f"+{x}+{y}") + dialog.attributes('-topmost', 1) + + def on_close(self): + """关闭窗口""" + self.window.destroy() + + diff --git a/ui/setting_tab.py b/ui/setting_tab.py index 11599c1..a5b6548 100644 --- a/ui/setting_tab.py +++ b/ui/setting_tab.py @@ -15,12 +15,23 @@ def build_setting_tab(self): load_btn = ctk.CTkButton(self.setting_tab, text="加载 Novel_architecture.txt", command=self.load_novel_architecture, font=("Microsoft YaHei", 12)) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.setting_word_count_label = ctk.CTkLabel(self.setting_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.setting_word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + save_btn = ctk.CTkButton(self.setting_tab, text="保存修改", command=self.save_novel_architecture, font=("Microsoft YaHei", 12)) - save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12)) TextWidgetContextMenu(self.setting_text) - self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) + + def update_word_count(event=None): + text = self.setting_text.get("0.0", "end") + count = len(text) - 1 + self.setting_word_count_label.configure(text=f"字数:{count}") + + self.setting_text.bind("", update_word_count) + self.setting_text.bind("", update_word_count) def load_novel_architecture(self): filepath = self.filepath_var.get().strip() diff --git a/ui/summary_tab.py b/ui/summary_tab.py index 3f7a999..9a66d7c 100644 --- a/ui/summary_tab.py +++ b/ui/summary_tab.py @@ -11,17 +11,29 @@ def build_summary_tab(self): self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(1, weight=1) self.summary_tab.columnconfigure(0, weight=1) + self.summary_tab.columnconfigure(1, weight=0) + self.summary_tab.columnconfigure(2, weight=0) load_btn = ctk.CTkButton(self.summary_tab, text="加载 global_summary.txt", command=self.load_global_summary, font=("Microsoft YaHei", 12)) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.word_count_label = ctk.CTkLabel(self.summary_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + save_btn = ctk.CTkButton(self.summary_tab, text="保存修改", command=self.save_global_summary, font=("Microsoft YaHei", 12)) - save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12)) TextWidgetContextMenu(self.summary_text) - self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) + def update_word_count(event=None): + text = self.summary_text.get("0.0", "end") + count = len(text) - 1 + self.word_count_label.configure(text=f"字数:{count}") + + self.summary_text.bind("", update_word_count) + self.summary_text.bind("", update_word_count) def load_global_summary(self): filepath = self.filepath_var.get().strip() if not filepath: