diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py index f9fc23b..a2c6311 100644 --- a/chapter_directory_parser.py +++ b/chapter_directory_parser.py @@ -27,11 +27,8 @@ def get_chapter_info_from_directory(novel_directory_content: str, chapter_number if match: chap_num = int(match.group(1)) if chap_num == chapter_number: - # group(2) 可能是标题及简述的混合 full_title = match.group(2).strip() - # 这里假设用 '-' 进一步区分“标题 - 简述”,也可能用户没写“ - ” if ' - ' in full_title: - # 根据你的目录格式自由处理 parts = full_title.split(' - ', 1) return { "chapter_title": parts[0].strip(), diff --git a/main.spec b/main.spec index 2c7b79a..4694d44 100644 --- a/main.spec +++ b/main.spec @@ -44,7 +44,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.3.0', + name='AI_NovelGenerator_V1.3.2', debug=True, bootloader_ignore_signals=False, strip=False, @@ -65,5 +65,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.3.0' + name='AI_NovelGenerator_V1.3.2' ) diff --git a/novel_generator.py b/novel_generator.py index f6115c9..7505948 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -8,7 +8,7 @@ import traceback from typing import List, Optional # langchain 相关 -from langchain_openai import ChatOpenAI,OpenAIEmbeddings +from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_chroma import Chroma from chromadb.config import Settings from langchain.docstore.document import Document @@ -51,6 +51,10 @@ def remove_think_tags(text: str) -> str: """移除 ... 包裹的内容""" return re.sub(r'.*?', '', text, flags=re.DOTALL) +def debug_log(prompt: str, response_content: str): + logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") + logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") + def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: """通用封装:调用模型并移除 ... 文本,记录日志后返回""" response = model.invoke(prompt) @@ -61,11 +65,6 @@ def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: debug_log(prompt, cleaned_text) return cleaned_text.strip() -def debug_log(prompt: str, response_content: str): - logging.info(f"\n[Prompt >>>] {prompt}\n") - logging.info(f"[Response >>>] {response_content}\n") - - def ensure_openai_base_url_has_v1(url: str) -> str: """ 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 @@ -79,7 +78,6 @@ def ensure_openai_base_url_has_v1(url: str) -> str: url = url.rstrip('/') + '/v1' return url - def is_using_ollama_api(interface_format: str) -> bool: return interface_format.lower() == "ollama" @@ -113,6 +111,7 @@ def create_embeddings_object( base_url=fixed_url ) else: + # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 fixed_url = ensure_openai_base_url_has_v1(base_url) return OpenAIEmbeddings( openai_api_key=api_key, @@ -122,38 +121,26 @@ def create_embeddings_object( # ============ 向量库相关操作 ============ -def clear_vector_store(filepath: str): +def clear_vector_store(filepath: str) -> bool: """ - 不删除文件,仅通过 Chroma API 移除集合数据(保留空目录) + 返回值表示是否成功清空向量库。 """ - from chromadb import Client + import shutil store_dir = get_vectorstore_dir(filepath) if not os.path.exists(store_dir): logging.info("No vector store found to clear.") - return + return False try: - client = Client(settings=Settings( - persist_directory=store_dir, - allow_reset=True # 允许重置操作 - )) - print(client.list_collections()) - - if client.list_collections(): - client.delete_collection(name="novel_collection") - logging.info("Collection 'novel_collection' deleted via API.") - - client.reset() - + if os.path.exists(store_dir): + shutil.rmtree(store_dir) + logging.info(f"Vector store directory '{store_dir}' removed.") + return True except Exception as e: - logging.error(f"API-based clear failed: {str(e)}") + logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") traceback.print_exc() - finally: - # 显式关闭客户端释放资源 - if 'client' in locals(): - del client - + return False def init_vector_store( api_key: str, @@ -215,6 +202,65 @@ def load_vector_store( ) +def split_by_length(text: str, max_length: int = 500) -> List[str]: + segments = [] + start_idx = 0 + while start_idx < len(text): + end_idx = min(start_idx + max_length, len(text)) + segment = text[start_idx:end_idx] + segments.append(segment.strip()) + start_idx = end_idx + return segments + + +def split_text_for_vectorstore(chapter_text: str, + max_length: int = 500, + similarity_threshold: float = 0.7) -> List[str]: + """ + 对新的章节文本进行分段后,再用于存入向量库。 + """ + if not chapter_text.strip(): + return [] + + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + sentences = nltk.sent_tokenize(chapter_text) + if not sentences: + return [] + + # 先对相近句子进行合并 + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + + # 再对合并好的段落做 max_length 切分 + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = split_by_length(para, max_length=max_length) + final_segments.extend(sub_segments) + else: + final_segments.append(para) + + return final_segments + + def update_vector_store( api_key: str, base_url: str, @@ -226,6 +272,11 @@ def update_vector_store( """ 将最新章节文本插入到向量库中。若库不存在则初始化。 """ + splitted_texts = split_text_for_vectorstore(new_chapter) + if not splitted_texts: + logging.warning("No valid text to insert into vector store. Skipping.") + return + store = load_vector_store( api_key=api_key, base_url=base_url, @@ -240,14 +291,14 @@ def update_vector_store( base_url=base_url, interface_format=interface_format, embedding_model_name=embedding_model_name, - texts=[new_chapter], + texts=splitted_texts, filepath=filepath ) return - new_doc = Document(page_content=str(new_chapter)) - store.add_documents([new_doc]) - logging.info("Vector store updated with the new chapter.") + docs = [Document(page_content=str(t)) for t in splitted_texts] + store.add_documents(docs) + logging.info("Vector store updated with the new chapter splitted segments.") def get_relevant_context_from_vector_store( @@ -480,37 +531,38 @@ def generate_chapter_draft( filepath: str, interface_format: str, embedding_model_name: str, - embedding_base_url: str + embedding_base_url: str, + embedding_retrieval_k: int = 4 ) -> str: # 1) 根据目录解析标题、简介 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 2) 从向量库检索上下文 - queries = [] + # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) + combined_query_parts = [] if user_guidance.strip(): - queries.append(user_guidance) + combined_query_parts.append(user_guidance) if chapter_brief.strip(): - queries.append(chapter_brief) + combined_query_parts.append(chapter_brief) if recent_chapters_summary.strip(): - queries.append(recent_chapters_summary) - queries.append("回顾剧情") + combined_query_parts.append(recent_chapters_summary) + # 额外加一个关键字 + combined_query_parts.append("回顾剧情") - relevant_context = "" - for q in queries: - partial_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=embedding_base_url if embedding_base_url else base_url, - query=q, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath, - k=2 - ) - if partial_context.strip(): - relevant_context += "\n" + partial_context - if not relevant_context: + merged_query_str = "\n".join(combined_query_parts) + + # 2) 从向量库检索上下文 + relevant_context = get_relevant_context_from_vector_store( + api_key=api_key, + base_url=embedding_base_url if embedding_base_url else base_url, + query=merged_query_str, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath, + k=embedding_retrieval_k + ) + if not relevant_context.strip(): relevant_context = "暂无相关内容。" # 3) 生成本章大纲 @@ -547,6 +599,7 @@ def generate_chapter_draft( global_summary=global_summary, chapter_outline=chapter_outline, word_number=word_number, + novel_number=novel_number, chapter_title=chapter_title, chapter_brief=chapter_brief ) @@ -615,6 +668,7 @@ def finalize_chapter( base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) + def update_global_summary(chapter_text: str, old_summary: str) -> str: prompt = summary_prompt.format( chapter_text=chapter_text, @@ -690,59 +744,13 @@ def enrich_chapter_text( # ============ 导入外部知识文本到向量库 ============ -def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - file_path: str, - embedding_base_url: str, - filepath: str -): - logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") - if not os.path.exists(file_path): - logging.warning(f"知识库文件不存在: {file_path}") - return - - content = read_file(file_path) - if not content.strip(): - logging.warning("知识库文件内容为空。") - return - - nltk.download('punkt', quiet=True) - - paragraphs = advanced_split_content(content) - - # 若向量库不存在则初始化,否则追加 - store = load_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) - if not store: - logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=paragraphs, - filepath=filepath - ) - else: - docs = [Document(page_content=str(p)) for p in paragraphs] - store.add_documents(docs) - logging.info("知识库文件已成功导入至向量库。") - - def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 """ + nltk.download('punkt', quiet=True) sentences = nltk.sent_tokenize(content) if not sentences: return [] @@ -777,13 +785,46 @@ def advanced_split_content(content: str, return final_segments +def import_knowledge_file( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str, + filepath: str +): + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") + if not os.path.exists(file_path): + logging.warning(f"知识库文件不存在: {file_path}") + return -def split_by_length(text: str, max_length: int = 500) -> List[str]: - segments = [] - start_idx = 0 - while start_idx < len(text): - end_idx = min(start_idx + max_length, len(text)) - segment = text[start_idx:end_idx] - segments.append(segment.strip()) - start_idx = end_idx - return segments + content = read_file(file_path) + if not content.strip(): + logging.warning("知识库文件内容为空。") + return + + paragraphs = advanced_split_content(content) + + # 若向量库不存在则初始化,否则追加 + store = load_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) + if not store: + logging.info("Vector store does not exist. Initializing a new one for knowledge import...") + init_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=paragraphs, + filepath=filepath + ) + else: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库。") diff --git a/prompt_definitions.py b/prompt_definitions.py index 60bf818..9e99408 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,249 +1,143 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -小说创作辅助系统的提示词(Prompt)集合。 -这些提示词被设计用于引导AI生成连贯、丰富的小说内容。 +集中存放所有提示词(Prompt),新版本更精确、更具创新性,结合部分雪花写作法等理论。 """ -# =============== 基础设定与规划提示词 =================== +# =============== 1. 整体设定 =================== set_prompt = """\ -基于主题「{topic}」、类型「{genre}」,将创作一部{number_of_chapters}章、每章约{word_number}字的小说。 -请详细规划以下要素: +请根据主题: {topic}、类型: {genre}、章数: {number_of_chapters}、每章字数: {word_number} 来设计小说的总体结构和世界观。 +写作时请参考雪花写作法等创作理论,结合以下要点: -【基本信息】 -• 建议书名(可含副标题) -• 预估总字数区间 -• 主要类型定位(如:奇幻/都市/科幻等) -• 基调与氛围(如:史诗/轻松/黑暗等) +• 小说标题与大致篇幅(总字数范围)。 +• 类型与基调(如:都市、魔幻、战争、轻松、暗黑等)。 +• 主要写作风格(视角、叙述方式、语言风格等)。 +• 世界观设定(背景时间、地理环境、社会结构、科技或魔法水平、重要历史等)。 +• 整体故事框架(可提及常见叙事结构:三幕、英雄之旅等)。 +• 主线与副线的初步构想,以及它们如何交织。 +• 关键角色群像定位与主要冲突关系。 +• 结局的可能方向(圆满、悲剧、开放式等)。 -【创作风格】 -• 叙事视角选择及理由 -• 语言风格特点 -• 节奏把控思路 - -【世界观构筑】 -• 时空背景设定 -• 世界运行规则(社会/科技/魔法体系等) -• 重大历史事件或背景 - -【核心故事】 -• 主线故事框架 -• 重要支线规划 -• 核心冲突设置 -• 结局走向构思 - -请具体阐述以上各点,确保前后呼应、逻辑自洽。 +请以简洁、逻辑清晰的方式输出,保留足够细节以支撑后续创作。 """ +# =============== 2. 角色设定 =================== character_prompt = """\ -基于已确立的小说设定: +基于已生成的小说整体设定: {novel_setting} -请完善以下角色体系: +请进一步扩展角色设置,至少包含三名核心角色。对每位角色说明: +• 角色背景、外貌与主要性格特征。 +• 内在冲突、目标与动机。 +• 暗藏的秘密或潜在弱点(可与世界观或其他角色有关)。 +• 与其他角色的关系或对立点,如何推动或阻碍情节发展。 -【核心角色塑造】(至少3个) -• 角色基本信息(名字/年龄/身份等) -• 外在特征与性格特点 -• 核心价值观与行为模式 -• 个人成长轨迹设想 -• 独特能力或专长 - -【人物关系网络】 -• 角色间的重要关联 -• 潜在矛盾点 -• 关系发展预期 - -【隐藏维度】 -• 每个角色的个人秘密 -• 待揭示的过往经历 -• 性格中的矛盾面 - -重点说明这些特质如何推动故事发展,为情节转折提供基础。 +请重点突出角色的多重面向,为后续情节埋下伏笔。 """ +# =============== 3. 暗线与伏笔 =================== dark_lines_prompt = """\ -根据已设定的角色与背景: +在当前世界观与角色关系中: {character_info} -请构建以下隐藏线索体系: +请构思若干暗线、伏笔或隐藏冲突,结合雪花写作法“细节逐步扩展”的思路。要求: +• 每条暗线给出初始迹象、后续发展与可能的爆发条件。 +• 与角色背景、世界观或关键事件相呼应。 +• 保持合理的悬念设置,与现有设定不冲突。 +• 为后续剧情保留足够展开空间,强调持续影响故事进程。 -【关键暗线设计】 -• 暗线起源与表现形式 -• 发展脉络规划 -• 揭示时机与方式 -• 对整体故事的影响 - -【伏笔布置】 -• 早期暗示点设置 -• 中期发展线索 -• 后期爆发契机 - -【隐藏冲突】 -• 角色间潜在矛盾 -• 阵营对立根源 -• 价值观冲突点 - -确保这些暗线自然融入故事,避免生硬设置。建议提供具体场景建议。 +请避免一次性透漏全部细节,以逐步揭示的方式为后文做铺垫。 """ +# =============== 4. 最终设定整合 =================== finalize_setting_prompt = """\ -请整合以下创作准备内容: - -【现有设定】 +基于以下内容,请整合并输出终稿《小说设定》: +1. 整体设定: {novel_setting_base} - -【角色系统】 +2. 角色设定: {character_setting} - -【暗线规划】 +3. 暗线与伏笔: {dark_lines} -将以上要素整合为完整的创作蓝图: - -1. 总体框架 - • 核心故事脉络 - • 世界观体系 - • 主题表达方式 - -2. 人物系统 - • 角色群像 - • 关系网络 - • 成长轨迹 - -3. 情节编排 - • 主线发展 - • 支线设计 - • 暗线铺陈 - -4. 创作建议 - • 重点场景构思 - • 节奏控制要点 - • 细节描写建议 - -请以流畅的叙述文本呈现,突出要素间的有机联系。 +整合要求: +• 以整体视角整理世界观、角色与暗线,形成一个有机结合的故事框架。 +• 注意角色动机与暗线如何与世界观互相呼应,并兼顾主线与副线节奏。 +• 语言通顺、层次分明。直接输出文本,不使用Markdown格式。 """ +# =============== 5. 小说目录 =================== novel_directory_prompt = """\ -依据最终设定: +根据以下最终《小说设定》: {final_novel_setting} -请规划{number_of_chapters}章的详细目录。每章格式: -第N章:章节名 - 核心内容提示 - -要求: -1. 章节名需简明扼要,富有吸引力 -2. 核心内容提示需点明关键信息,为创作提供指引 -3. 整体节奏要富有张力,符合三幕结构 -4. 适当预留转折与高潮 - -示例: -第n章:黎明前的暗影 - 主角遭遇神秘袭击,接触核心谜题 +请列出共 {number_of_chapters} 章的目录,并保证每章的标题或简述能呼应主要冲突、角色成长及暗线埋设。 +目录格式示例: +第1章 :<标题> - <简要说明> +第2章 :<标题> - <简要说明> ... +第{number_of_chapters}章 :<标题> - <简要说明> + +每章可酌情加入一句简述,引导读者了解本章看点。直接输出文本,不使用Markdown。 """ -# =============== 章节创作辅助提示词 =================== +# =============== 6. 全局摘要更新 =================== summary_prompt = """\ -新增章节内容: +以下是新完成的章节文本: {chapter_text} -当前全局摘要: +这是当前的全局摘要(可为空): {global_summary} -请更新全局摘要(控制在3000字以内): - -【已发生】 -• 关键事件梳理 -• 人物关系变化 -• 重要线索进展 - -【正在进行】 -• 当前危机/冲突 -• 角色动态 -• 悬而未决的问题 - -确保摘要重点突出,为后续创作提供清晰参考。 -不展开未来发展,保持故事悬念。 +请根据本章新增内容,更新全局摘要。保留既有重要信息,同时融入新剧情要点,勿剧透未来。控制在不超过3000字的范围内,语言简练流畅。 """ +# =============== 7. 角色状态更新 =================== update_character_state_prompt = """\ -本章内容: +以下是新完成的章节文本: {chapter_text} -现有角色状态: +这是当前的角色状态文档(可为空): {old_state} -请更新角色状态档案: +请更新角色状态,内容包括: +• 角色的物品、能力或心理状态变化。 +• 角色间关系的最新进展或冲突。 +• 是否触发或加深了某些暗线或关键事件。 +• 任何新增角色或临时出场人物的基本信息。 -【角色发展】 -• 能力/状态变化 -• 重要物品获得/失去 -• 性格/观念的微妙改变 - -【人际关系】 -• 新建立的联系 -• 关系的强化或弱化 -• 潜在矛盾点 - -【事件参与】 -• 正在进行的事件 -• 个人目标进展 -• 新接触的任务 - -请保持简洁明了,便于后续参考。 +请直接在已有文档基础上进行增删,语言尽量简洁、有条理。 """ +# =============== 8. 章节大纲设计 =================== chapter_outline_prompt = """\ -创作参考资料: -- 设定:{novel_setting} +这是当前小说的重要信息: +- 小说设定:{novel_setting} - 角色状态:{character_state} - 全局摘要:{global_summary} -第{novel_number}章:{chapter_title} -章节说明:{chapter_brief} +我们即将写第 {novel_number} 章,标题:{chapter_title},简述(若有):{chapter_brief} -请设计本章节详细大纲: +请按照以下思路设计本章大纲: +1. 本章的主要冲突或情节目标,如何与标题呼应。 +2. 出场角色及其动机,对剧情走向的推动或阻碍。 +3. 暗线或伏笔如何有所进展或被揭示。 +4. 本章结尾的悬念或转折,如何为下一章做铺垫。 -【核心设计】 -• 本章主要冲突/事件 -• 与章节主题的呼应方式 -• 情节推进目标 - -【人物安排】 -• 出场角色及其状态 -• 个人目标与动机 -• 互动关系设计 - -【暗线发展】 -• 已有伏笔的推进 -• 新增悬念的埋设 -• 线索的暗示方式 - -【结构布局】 -• 章节节奏规划 -• 高潮设计 -• 结尾悬念构思 +请以简要分点方式输出,不要使用Markdown格式。 """ +# =============== 9. 章节正文写作 =================== chapter_write_prompt = """\ -创作参考信息: -1. 设定:{novel_setting} +以下信息供你参考: +1. 小说设定:{novel_setting} 2. 角色状态:{character_state} -3. 摘要:{global_summary} -4. 大纲:{chapter_outline} +3. 全局摘要:{global_summary} +4. 本章大纲:{chapter_outline} -第{chapter_title}章 -核心:{chapter_brief} +请写出第 {novel_number} 章的正文,标题为“{chapter_title}”。需要: +• 字数不少于 {word_number} 字,与标题和简述相呼应。 +• 保持连贯叙述,可增加环境、心理、对话等细节描写。 +• 适度呼应前文暗线或角色矛盾,为后续发展留出悬念。 -创作要求: -1. 字数不少于{word_number}字 -2. 紧扣章节主题 -3. 注重细节描写 -4. 深入角色内心 -5. 为下章预留引子 - -建议: -• 通过环境描写渲染氛围 -• 展现人物细微情感变化 -• 适当运用对话推进情节 -• 保持节奏张弛有度 -""" \ No newline at end of file +直接输出正文内容,不使用分章节小标题,章节末模仿正常小说中断或转场。 +""" diff --git a/ui.py b/ui.py index 9a2f53e..73d4d12 100644 --- a/ui.py +++ b/ui.py @@ -28,6 +28,7 @@ def log_error(message: str): """ logging.error(f"{message}\n{traceback.format_exc()}") + # 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") @@ -52,12 +53,15 @@ class NovelGeneratorGUI: self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # ========== 主要的属性变量 ========== + # ========== 主要的属性变量 ========== + # LLM 接口相关 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) + + # 仍然用 DoubleVar,但因为是 Slider,不会让用户手动清空文本,一般不会出现空字符串问题 self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) # Embedding 接口相关 @@ -66,16 +70,23 @@ class NovelGeneratorGUI: self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) + # ### CHANGED:将 IntVar 改为 StringVar,避免用户清空输入时抛错 + self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) + # 小说通用参数 self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - self.num_chapters_var = ctk.IntVar(value=self.loaded_config.get("num_chapters", 10)) - self.word_number_var = ctk.IntVar(value=self.loaded_config.get("word_number", 3000)) + + # ### CHANGED:将章节数、每章字数改为 StringVar + self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) + self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) + self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - self.chapter_num_var = ctk.IntVar(value=1) + # ### CHANGED:章节号也改为 StringVar + self.chapter_num_var = ctk.StringVar(value="1") - # ========== 主容器使用 TabView ========== + # ========== 主容器使用 TabView ========== self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) self.tabview.pack(fill="both", expand=True) @@ -95,13 +106,20 @@ class NovelGeneratorGUI: self.build_summary_tab() self.build_chapters_tab() # 新增 - # ------------------ 统一异常处理方法 ------------------ - def handle_exception(self, context: str): - full_message = f"{context}\n{traceback.format_exc()}" - logging.error(full_message) - self.safe_log(full_message) + # ------------------ 工具方法:安全获取 IntVar (现已兼容 StringVar) ------------------ + def safe_get_int(self, var, default=1): + """ + 尝试把 StringVar 或 IntVar 中的值转换为 int; + 若失败则将其重置为 default 并返回 default。 + """ + try: + val_str = str(var.get()).strip() + return int(val_str) + except: + var.set(str(default)) + return default - # ------------------ 主功能 Tab ------------------ + # ------------------ 主功能 Tab ------------------ def build_main_tab(self): """ 主Tab分为左右两栏: @@ -140,34 +158,34 @@ class NovelGeneratorGUI: self.left_frame.grid_rowconfigure(4, weight=1) self.left_frame.grid_columnconfigure(0, weight=1) - # ========== 本章内容 ========== + # ========== 本章内容 ========== chapter_label = ctk.CTkLabel( self.left_frame, text="本章内容 (可编辑)", - font=("Microsoft YaHei", 14) + font=("Microsoft YaHei", 12) ) chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") # 章节草稿:可编辑 self.chapter_result = ctk.CTkTextbox( - self.left_frame, - wrap="word", + self.left_frame, + wrap="word", font=("Microsoft YaHei", 14) ) self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # ========== Step1~4按钮 ========== + # ========== Step1~4按钮 ========== self.build_step_buttons_area() - # ========== 输出日志 label ========== + # ========== 输出日志 label ========== log_label = ctk.CTkLabel( self.left_frame, text="输出日志 (只读)", - font=("Microsoft YaHei", 14) + font=("Microsoft YaHei", 12) ) log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w") - # ========== 日志:只读 ========== + # ========== 日志:只读 ========== self.log_text = ctk.CTkTextbox( self.left_frame, wrap="word", @@ -248,7 +266,7 @@ class NovelGeneratorGUI: # 3) 可选功能按钮 self.build_optional_buttons_area(start_row=2) - # ------------------ 可选功能按钮区域(右下) ------------------ + # ------------------ 可选功能按钮区域(右下) ------------------ def build_optional_buttons_area(self, start_row=2): """ 放在右侧的最下方:包括 一致性审校、导入知识库、清空向量库、查看剧情要点 @@ -389,7 +407,7 @@ class NovelGeneratorGUI: variable=self.temperature_var ) temp_scale.grid(row=4, column=1, padx=5, pady=5, sticky="we") - + self.temp_value_label = ctk.CTkLabel( self.ai_config_tab, text=f"{self.temperature_var.get():.2f}", @@ -400,13 +418,13 @@ class NovelGeneratorGUI: def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): if new_value == "Ollama": - self.embedding_url_var.set("http://localhost:11434/v1") + self.embedding_url_var.set("http://localhost:11434/api") elif new_value == "ML Studio": self.embedding_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.embedding_url_var.set("https://api.openai.com/v1") - for i in range(3): + for i in range(5): self.embeddings_config_tab.grid_rowconfigure(i, weight=0) self.embeddings_config_tab.grid_columnconfigure(0, weight=0) self.embeddings_config_tab.grid_columnconfigure(1, weight=1) @@ -466,6 +484,19 @@ class NovelGeneratorGUI: ) emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + emb_retrieval_k_label = ctk.CTkLabel( + self.embeddings_config_tab, + text="Retrieval Top-K:", + font=("Microsoft YaHei", 12) + ) + emb_retrieval_k_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + emb_retrieval_k_entry = ctk.CTkEntry( + self.embeddings_config_tab, + textvariable=self.embedding_retrieval_k_var, + font=("Microsoft YaHei", 12) + ) + emb_retrieval_k_entry.grid(row=4, column=1, padx=5, pady=5, sticky="nsew") + # ========== 保存/加载 配置按钮区域 ========== def build_main_buttons_area(self): """ @@ -499,7 +530,7 @@ class NovelGeneratorGUI: """ self.params_frame = ctk.CTkScrollableFrame( self.right_frame, - orientation="vertical" # 默认垂直滚动 + orientation="vertical" ) self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) self.params_frame.columnconfigure(1, weight=1) @@ -536,35 +567,44 @@ class NovelGeneratorGUI: ) genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") - # 章节数 + # 章节数、每章字数 放在同一行 + row_for_chapter_and_word = 2 num_chapters_label = ctk.CTkLabel( self.params_frame, text="章节数:", font=("Microsoft YaHei", 12) ) - num_chapters_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + + ch_word_frame = ctk.CTkFrame(self.params_frame) + ch_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") + ch_word_frame.columnconfigure(0, weight=0) + ch_word_frame.columnconfigure(1, weight=0) + ch_word_frame.columnconfigure(2, weight=0) + ch_word_frame.columnconfigure(3, weight=1) + num_chapters_entry = ctk.CTkEntry( - self.params_frame, + ch_word_frame, textvariable=self.num_chapters_var, - width=80, + width=60, font=("Microsoft YaHei", 12) ) - num_chapters_entry.grid(row=2, column=1, padx=5, pady=5, sticky="w") + num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") - # 每章字数 word_number_label = ctk.CTkLabel( - self.params_frame, + ch_word_frame, text="每章字数:", font=("Microsoft YaHei", 12) ) - word_number_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") + word_number_entry = ctk.CTkEntry( - self.params_frame, + ch_word_frame, textvariable=self.word_number_var, - width=80, + width=60, font=("Microsoft YaHei", 12) ) - word_number_entry.grid(row=3, column=1, padx=5, pady=5, sticky="w") + word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") # 保存路径 filepath_label = ctk.CTkLabel( @@ -572,10 +612,10 @@ class NovelGeneratorGUI: text="保存路径:", font=("Microsoft YaHei", 12) ) - filepath_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - + filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + self.filepath_frame = ctk.CTkFrame(self.params_frame) - self.filepath_frame.grid(row=4, column=1, padx=5, pady=5, sticky="nsew") + self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") self.filepath_frame.columnconfigure(0, weight=1) filepath_entry = ctk.CTkEntry( @@ -599,14 +639,14 @@ class NovelGeneratorGUI: text="章节号:", font=("Microsoft YaHei", 12) ) - chapter_num_label.grid(row=5, column=0, padx=5, pady=5, sticky="e") + chapter_num_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") chapter_num_entry = ctk.CTkEntry( self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12) ) - chapter_num_entry.grid(row=5, column=1, padx=5, pady=5, sticky="w") + chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w") # 用户指导 guide_label = ctk.CTkLabel( @@ -614,7 +654,7 @@ class NovelGeneratorGUI: text="本章指导:", font=("Microsoft YaHei", 12) ) - guide_label.grid(row=6, column=0, padx=5, pady=5, sticky="ne") + guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") self.user_guide_text = ctk.CTkTextbox( self.params_frame, width=200, @@ -622,9 +662,9 @@ class NovelGeneratorGUI: wrap="word", font=("Microsoft YaHei", 12) ) - self.user_guide_text.grid(row=6, column=1, padx=5, pady=5, sticky="nsew") + self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") - # ------------------ 其他Tab的构建 ------------------ + # ------------------ 其他Tab的构建 ------------------ def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -874,7 +914,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") - # ------------------ 配置管理 ------------------ + # ------------------ 配置管理 ------------------ def load_config_btn(self): cfg = load_config(self.config_file) if cfg: @@ -890,16 +930,20 @@ class NovelGeneratorGUI: self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) self.embedding_url_var.set(cfg.get("embedding_url", "")) self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) + # ### CHANGED:用字符串形式设值 + self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) # Novel self.genre_var.set(cfg.get("genre", "")) - self.num_chapters_var.set(cfg.get("num_chapters", 10)) - self.word_number_var.set(cfg.get("word_number", 3000)) + + # ### CHANGED:用字符串形式设值 + self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) + self.word_number_var.set(str(cfg.get("word_number", 3000))) self.filepath_var.set(cfg.get("filepath", "")) - # 主题 + topic_value = cfg.get("topic", "") self.topic_text.delete("0.0", "end") - self.topic_text.insert("0.0", cfg.get("topic", "")) + self.topic_text.insert("0.0", topic_value) self.log("已加载配置。") else: @@ -919,12 +963,13 @@ class NovelGeneratorGUI: "embedding_interface_format": self.embedding_interface_format_var.get(), "embedding_url": self.embedding_url_var.get(), "embedding_model_name": self.embedding_model_name_var.get(), + "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), # Novel "topic": self.topic_text.get("0.0", "end").strip(), "genre": self.genre_var.get(), - "num_chapters": self.num_chapters_var.get(), - "word_number": self.word_number_var.get(), + "num_chapters": self.safe_get_int(self.num_chapters_var, 10), + "word_number": self.safe_get_int(self.word_number_var, 3000), "filepath": self.filepath_var.get() } if save_config(config_data, self.config_file): @@ -938,7 +983,7 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) - # ------------------ 日志输出(主线程安全) ------------------ + # ------------------ 日志输出(主线程安全) ------------------ def log(self, message: str): self.log_text.configure(state="normal") self.log_text.insert("end", message + "\n") @@ -954,7 +999,12 @@ class NovelGeneratorGUI: def enable_button_safe(self, btn): self.master.after(0, lambda: btn.configure(state="normal")) - # ------------------ 分步操作:生成设定、目录、章节草稿、定稿 ------------------ + def handle_exception(self, context: str): + full_message = f"{context}\n{traceback.format_exc()}" + logging.error(full_message) + self.safe_log(full_message) + + # ------------------ 分步操作:生成设定、目录、章节草稿、定稿 ------------------ def generate_novel_setting_ui(self): """Step1. 生成小说设定(Novel_setting.txt)""" filepath = self.filepath_var.get().strip() @@ -968,10 +1018,11 @@ class NovelGeneratorGUI: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() + topic = self.topic_text.get("0.0", "end").strip() genre = self.genre_var.get().strip() - num_chapters = self.num_chapters_var.get() - word_number = self.word_number_var.get() + num_chapters = self.safe_get_int(self.num_chapters_var, 10) + word_number = self.safe_get_int(self.word_number_var, 3000) temperature = self.temperature_var.get() self.safe_log("开始生成小说设定...") @@ -1007,7 +1058,7 @@ class NovelGeneratorGUI: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() - num_chapters = self.num_chapters_var.get() + num_chapters = self.safe_get_int(self.num_chapters_var, 10) temperature = self.temperature_var.get() self.safe_log("开始生成小说目录...") @@ -1041,6 +1092,7 @@ class NovelGeneratorGUI: base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + novel_settings_file = os.path.join(filepath, "Novel_setting.txt") novel_settings = read_file(novel_settings_file) if not novel_settings.strip(): @@ -1054,8 +1106,8 @@ class NovelGeneratorGUI: novel_directory_file = os.path.join(filepath, "Novel_directory.txt") novel_directory = read_file(novel_directory_file) - chap_num = self.chapter_num_var.get() - word_number = self.word_number_var.get() + chap_num = self.safe_get_int(self.chapter_num_var, 1) + word_number = self.safe_get_int(self.word_number_var, 3000) user_guidance = self.user_guide_text.get("0.0", "end").strip() # 获取最近3章文本 @@ -1087,10 +1139,12 @@ class NovelGeneratorGUI: novel_novel_directory=novel_directory, filepath=filepath, - # 传入 Embedding 的专用配置 + # Embedding 配置 interface_format=self.embedding_interface_format_var.get().strip(), embedding_model_name=self.embedding_model_name_var.get().strip(), embedding_base_url=self.embedding_url_var.get().strip(), + # 新增:检索 K 值 + embedding_retrieval_k=self.safe_get_int(self.embedding_retrieval_k_var, 4) ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -1124,12 +1178,14 @@ class NovelGeneratorGUI: base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + interface_format = self.embedding_interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() embedding_base_url = self.embedding_url_var.get().strip() + embedding_api_key = self.embedding_api_key_var.get().strip() - chap_num = self.chapter_num_var.get() - word_number = self.word_number_var.get() + chap_num = self.safe_get_int(self.chapter_num_var, 1) + word_number = self.safe_get_int(self.word_number_var, 3000) self.safe_log(f"开始定稿第{chap_num}章...") finalize_chapter( @@ -1143,7 +1199,7 @@ class NovelGeneratorGUI: temperature=temperature, filepath=filepath, embedding_base_url=embedding_base_url, - embedding_api_key=self.embedding_api_key_var.get().strip() + embedding_api_key=embedding_api_key ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") @@ -1159,7 +1215,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ 一致性审校 ------------------ + # ------------------ 一致性审校 ------------------ def do_consistency_check(self): """使用审校Agent对最新章节进行简单一致性或冲突检查""" filepath = self.filepath_var.get().strip() @@ -1185,7 +1241,7 @@ class NovelGeneratorGUI: global_summary = read_file(global_summary_file) plot_arcs = read_file(plot_arcs_file) - chap_num = self.chapter_num_var.get() + chap_num = self.safe_get_int(self.chapter_num_var, 1) chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") chapter_text = read_file(chap_file) @@ -1215,7 +1271,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------ + # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------ def import_knowledge_handler(self): selected_file = filedialog.askopenfilename( title="选择要导入的知识库文件", @@ -1233,7 +1289,7 @@ class NovelGeneratorGUI: embedding_model_name=self.embedding_model_name_var.get().strip(), file_path=selected_file, embedding_base_url=self.embedding_url_var.get().strip(), - filepath=self.filepath_var.get().strip() # 用于本地化 vectorstore + filepath=self.filepath_var.get().strip() ) self.safe_log("✅ 知识库文件导入完成。") except Exception: @@ -1253,8 +1309,10 @@ class NovelGeneratorGUI: if first_confirm: second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") if second_confirm: - clear_vector_store(filepath) - self.log("已清空向量库。") + if clear_vector_store(filepath): + self.log("已清空向量库。") + else: + self.log(f"未能清空向量库。请关闭程序后前往{filepath}文件夹手动删除vectorstore目录。") def show_plot_arcs_ui(self): filepath = self.filepath_var.get().strip()