diff --git a/embedding_ollama.py b/embedding_ollama.py new file mode 100644 index 0000000..c7979be --- /dev/null +++ b/embedding_ollama.py @@ -0,0 +1,55 @@ +# embedding_ollama.py +import requests +import traceback +from typing import List + +class OllamaEmbeddings: + """ + Ollama 本地服务提供的 Embedding 接口, + 最终拼出形如: http://localhost:11434/api/embed + 即 base_url + "/embed" + """ + + def __init__(self, model_name: str, base_url: str): + self.model_name = model_name + self.base_url = base_url + + def embed(self, texts: List[str]) -> List[List[float]]: + """ + 批量将多段文本转换为embedding向量 + """ + embeddings = [] + for text in texts: + embeddings.append(self.embed_single_document(text)) + return embeddings + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """ + 兼容langchain的接口写法 + """ + return self.embed(texts) + + def embed_query(self, query: str) -> List[float]: + """ + 将单条 query 转换为 embedding 向量 + """ + return self.embed_single_document(query) + + def embed_single_document(self, text: str) -> List[float]: + """ + 调用 Ollama 本地服务接口,获取文本的 embedding。 + """ + url = f"{self.base_url}/embed" + data = { + "model": self.model_name, + "prompt": text + } + try: + response = requests.post(url, json=data) + response.raise_for_status() + result = response.json() + if "embedding" not in result: + raise ValueError("No 'embedding' field in Ollama response.") + return result["embedding"] + except requests.exceptions.RequestException as e: + raise Exception(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}") diff --git a/main.spec b/main.spec index 93ae127..cf52c4c 100644 --- a/main.spec +++ b/main.spec @@ -40,7 +40,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.2.3', + name='AI_NovelGenerator_V1.2.4', debug=True, bootloader_ignore_signals=False, strip=False, @@ -60,5 +60,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.2.3' + name='AI_NovelGenerator_V1.2.4' ) diff --git a/novel_generator.py b/novel_generator.py index a5d62bf..3c4722d 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -3,11 +3,9 @@ import os import logging import re +import traceback from typing import Dict, List, Optional -try: - from typing import TypedDict # Python 3.8+ 直接可用;若是3.7可改用 typing_extensions -except ImportError: - from typing_extensions import TypedDict +from typing import TypedDict from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, START, END @@ -30,20 +28,79 @@ from prompt_definitions import ( summary_prompt, update_character_state_prompt, chapter_outline_prompt, chapter_write_prompt ) - -# ============ 新增:导入 chapter_directory_parser ============ +from embedding_ollama import OllamaEmbeddings from chapter_directory_parser import get_chapter_info_from_directory # ============ 日志配置 ============ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") def debug_log(prompt: str, response_content: str): - """在控制台打印或记录下每次Prompt与Response,[调试]""" + """ + 打印prompt和response的辅助函数 + """ logging.info(f"\n[Prompt >>>] {prompt}\n") logging.info(f"[Response >>>] {response_content}\n") -# ============ 向量检索相关 ============ +def remove_think_tags(text: str) -> str: + """ + 移除 ... 包裹的内容 + """ + return re.sub(r'.*?', '', text, flags=re.DOTALL) +def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: + """ + 通用封装:调用模型并移除 ... 文本,记录日志后返回 + """ + response = model.invoke(prompt) + if not response: + logging.warning("No response from model.") + return "" + cleaned_text = remove_think_tags(response.content) + debug_log(prompt, cleaned_text) + return cleaned_text.strip() + +# ============ 判断接口格式相关 ============ +def is_using_ollama_api(interface_format: str, base_url: str) -> bool: + """ + 当 interface_format == "Ollama" 时返回 True + """ + return interface_format.lower() == "ollama" + +def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool: + """ + 如果用户在下拉里选择了 ML Studio + """ + return interface_format.lower() == "ml studio" + +# ============ 创建 Embeddings 对象 ============ +def create_embeddings_object( + api_key: str, + base_url: str, + embed_url: str, + interface_format: str, + embedding_model_name: str +): + """ + 根据用户在UI中配置的参数,返回对应的 embeddings 对象。 + - 当 interface_format = "Ollama" => OllamaEmbeddings(...) + - 当 interface_format = "OpenAI"/"ML Studio" => OpenAIEmbeddings(...) + - 其它情况可扩展 + """ + if is_using_ollama_api(interface_format, embed_url): + fixed_url = embed_url.rstrip("/") + # Ollama embedding接口通常是 /api/embed + fixed_url = fixed_url.replace("/v1", "/api") + return OllamaEmbeddings( + model_name=embedding_model_name, + base_url=fixed_url + ) + elif is_using_ml_studio_api(interface_format, base_url): + return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) + else: + # 默认使用 OpenAIEmbeddings + return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) + +# ============ 向量库相关 ============ VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") if not os.path.exists(VECTOR_STORE_DIR): os.makedirs(VECTOR_STORE_DIR) @@ -51,11 +108,10 @@ if not os.path.exists(VECTOR_STORE_DIR): def clear_vector_store(): """ 清空本地向量库(删除 vectorstore 文件夹内的内容)。 - 需要在UI中加一个二次确认弹窗,防止误删。 """ if os.path.exists(VECTOR_STORE_DIR): + import shutil try: - import shutil for filename in os.listdir(VECTOR_STORE_DIR): file_path = os.path.join(VECTOR_STORE_DIR, filename) if os.path.isfile(file_path) or os.path.islink(file_path): @@ -63,19 +119,29 @@ def clear_vector_store(): elif os.path.isdir(file_path): shutil.rmtree(file_path) logging.info("Local vector store has been cleared.") - except Exception as e: - logging.warning(f"Failed to clear vector store: {e}") + except Exception: + logging.warning(f"Failed to clear vector store:\n{traceback.format_exc()}") else: logging.info("No vector store found to clear.") -def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: +def init_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + texts: List[str], + embedding_base_url: str = "" +) -> Chroma: """ 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 - 如果不存在该目录,会自动创建。 """ - embeddings = OpenAIEmbeddings( - openai_api_key=api_key, - openai_api_base=base_url + embed_url = embedding_base_url if embedding_base_url else base_url + embeddings = create_embeddings_object( + api_key=api_key, + base_url=base_url, + embed_url=embed_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name ) documents = [Document(page_content=t) for t in texts] vectorstore = Chroma.from_documents( @@ -86,43 +152,101 @@ def init_vector_store(api_key: str, base_url: str, texts: List[str]) -> Chroma: vectorstore.persist() return vectorstore -def load_vector_store(api_key: str, base_url: str) -> Optional[Chroma]: - """读取已存在的向量库。若不存在则返回 None。""" +def load_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str = "" +) -> Optional[Chroma]: + """ + 读取已存在的向量库。若不存在则返回 None。 + """ if not os.path.exists(VECTOR_STORE_DIR): + logging.info("Vector store not found. Initializing a new one...") return None - embeddings = OpenAIEmbeddings( - openai_api_key=api_key, - openai_api_base=base_url + embed_url = embedding_base_url if embedding_base_url else base_url + embeddings = create_embeddings_object( + api_key=api_key, + base_url=base_url, + embed_url=embed_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name ) return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings) -def update_vector_store(api_key: str, base_url: str, new_chapter: str) -> None: - """将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。""" - store = load_vector_store(api_key, base_url) +def update_vector_store( + api_key: str, + base_url: str, + new_chapter: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str = "" +) -> None: + """ + 将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。 + """ + store = load_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + embedding_base_url=embedding_base_url + ) + + # 如果向量库不存在,初始化它 if not store: - logging.info("Vector store does not exist. Initializing a new one...") - init_vector_store(api_key, base_url, [new_chapter]) + logging.info("Vector store does not exist. Initializing a new one for new chapter...") + init_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=[new_chapter], + embedding_base_url=embedding_base_url + ) return new_doc = Document(page_content=new_chapter) store.add_documents([new_doc]) store.persist() + logging.info("Vector store updated with the new chapter.") -def get_relevant_context_from_vector_store(api_key: str, base_url: str, query: str, k: int = 2) -> str: +def get_relevant_context_from_vector_store( + api_key: str, + base_url: str, + query: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str = "", + k: int = 2 +) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 - 若向量库不存在则返回空字符串。 + 若向量库不存在或没有足够的内容,则返回空字符串。 """ - store = load_vector_store(api_key, base_url) + store = load_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + embedding_base_url=embedding_base_url + ) + + # 如果向量库为空,直接返回空字符串 if not store: - logging.warning("Vector store not found. Returning empty context.") + logging.info("No vector store found. Returning empty context.") return "" + docs = store.similarity_search(query, k=k) + if not docs: + logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") + return "" + combined = "\n".join([d.page_content for d in docs]) return combined # ============ 多步生成:设置 & 目录 ============ - class OverallState(TypedDict): topic: str genre: str @@ -148,7 +272,6 @@ def Novel_novel_directory_generate( """ 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 """ - # 确保文件夹存在 os.makedirs(filepath, exist_ok=True) model = ChatOpenAI( @@ -165,67 +288,46 @@ def Novel_novel_directory_generate( number_of_chapters=state["number_of_chapters"], word_number=state["word_number"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_base_setting: No response.") - return {"novel_setting_base": ""} - debug_log(prompt, response.content) - return {"novel_setting_base": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"novel_setting_base": result_text} def generate_character_setting(state: OverallState) -> Dict[str, str]: prompt = character_prompt.format( novel_setting=state["novel_setting_base"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_character_setting: No response.") - return {"character_setting": ""} - debug_log(prompt, response.content) - return {"character_setting": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"character_setting": result_text} def generate_dark_lines(state: OverallState) -> Dict[str, str]: prompt = dark_lines_prompt.format( character_info=state["character_setting"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_dark_lines: No response.") - return {"dark_lines": ""} - debug_log(prompt, response.content) - return {"dark_lines": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"dark_lines": result_text} - def finalize_novel_setting(state: OverallState) -> Dict[str, str]: + def finalize_novel_setting_func(state: OverallState) -> Dict[str, str]: prompt = finalize_setting_prompt.format( novel_setting_base=state["novel_setting_base"], character_setting=state["character_setting"], dark_lines=state["dark_lines"] ) - response = model.invoke(prompt) - if not response: - logging.warning("finalize_novel_setting: No response.") - return {"final_novel_setting": ""} - debug_log(prompt, response.content) - return {"final_novel_setting": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"final_novel_setting": result_text} - def generate_novel_directory(state: OverallState) -> Dict[str, str]: + def generate_novel_directory_func(state: OverallState) -> Dict[str, str]: prompt = novel_directory_prompt.format( final_novel_setting=state["final_novel_setting"], number_of_chapters=state["number_of_chapters"] ) - response = model.invoke(prompt) - if not response: - logging.warning("generate_novel_directory: No response.") - return {"novel_directory": ""} - debug_log(prompt, response.content) - return {"novel_directory": response.content.strip()} + result_text = invoke_with_cleaning(model, prompt) + return {"novel_directory": result_text} - # 构建状态图 graph = StateGraph(OverallState) graph.add_node("generate_base_setting", generate_base_setting) graph.add_node("generate_character_setting", generate_character_setting) graph.add_node("generate_dark_lines", generate_dark_lines) - graph.add_node("finalize_novel_setting", finalize_novel_setting) - graph.add_node("generate_novel_directory", generate_novel_directory) + graph.add_node("finalize_novel_setting", finalize_novel_setting_func) + graph.add_node("generate_novel_directory", generate_novel_directory_func) graph.add_edge(START, "generate_base_setting") graph.add_edge("generate_base_setting", "generate_character_setting") @@ -255,24 +357,25 @@ def Novel_novel_directory_generate( logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。") return - # 写入文件 filename_set = os.path.join(filepath, "Novel_setting.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") - # 清理文本(可根据需要去除多余字符) def clean_text(txt: str) -> str: return txt.replace('#', '').replace('*', '') final_novel_setting_cleaned = clean_text(final_novel_setting) final_novel_directory_cleaned = clean_text(final_novel_directory) - append_text_to_file(final_novel_setting_cleaned, filename_set) - append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) + # 改进:写文件时先清空再写入 + clear_file_content(filename_set) + save_string_to_txt(final_novel_setting_cleaned, filename_set) + + clear_file_content(filename_novel_directory) + save_string_to_txt(final_novel_directory_cleaned, filename_novel_directory) logging.info("Novel settings and directory generated successfully.") # ============ 获取最近N章内容,生成短期摘要 ============ - def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: """ 从指定文件夹中,读取最近 n 章的内容(如果存在),并按从旧到新的顺序返回文本列表。 @@ -286,30 +389,45 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int text = read_file(chap_file).strip() if text: texts.append(text) + if len(texts) < n: + texts = [''] * (n - len(texts)) + texts return texts -def summarize_recent_chapters(model: ChatOpenAI, chapters_text_list: List[str]) -> str: +def summarize_recent_chapters( + llm_model: str, + api_key: str, + base_url: str, + temperature: float, + chapters_text_list: List[str] +) -> str: """ - 将最近几章的文本拼接后,通过模型生成一个相对详细的“短期内容摘要”。 + 将最近几章文本拼接,通过模型生成相对简要的“短期内容摘要”。 """ if not chapters_text_list: return "" + if all(not txt.strip() for txt in chapters_text_list): + return "暂无摘要。" + + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=base_url, + temperature=temperature + ) combined_text = "\n".join(chapters_text_list) - prompt = f"""\ -这是最近几章的故事内容,请生成一份详细的短期内容摘要(不少于一章篇幅的细节),用于帮助后续创作时回顾细节。 -请着重强调发生的事件、角色的心理和关系变化、冲突或悬念等。 - + prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: {combined_text} -""" - response = model.invoke(prompt) - if not response: - return "" - debug_log(prompt, response.content) - return response.content.strip() -# ============ 新增1:记录剧情要点/未解决冲突 ============ +请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" + summary_text = invoke_with_cleaning(model, prompt) + if not summary_text: + # 若模型无响应,就截取一段作为“备选” + return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text + return summary_text + +# ============ 新增:剧情要点/未解决冲突 ============ PLOT_ARCS_PROMPT = """\ 下面是新生成的章节内容: {chapter_text} @@ -317,9 +435,9 @@ PLOT_ARCS_PROMPT = """\ 这里是已记录的剧情要点/未解决冲突(可能为空): {old_plot_arcs} -请基于新的章节内容,提炼出本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 +请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出一份更新后的剧情要点列表,以帮助后续保持故事的整体一致性和悬念延续。 +最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 """ def update_plot_arcs( @@ -330,10 +448,6 @@ def update_plot_arcs( model_name: str, temperature: float ) -> str: - """ - 利用模型分析最新章节文本,提炼或更新“未解决冲突或剧情要点”。 - 并返回更新后的字符串。 - """ model = ChatOpenAI( model=model_name, api_key=api_key, @@ -344,15 +458,13 @@ def update_plot_arcs( chapter_text=chapter_text, old_plot_arcs=old_plot_arcs ) - response = model.invoke(prompt) - if not response: - logging.warning("update_plot_arcs: No response.") + arcs_text = invoke_with_cleaning(model, prompt) + if not arcs_text: + logging.warning("update_plot_arcs: No response or empty result.") return old_plot_arcs - debug_log(prompt, response.content) - return response.content.strip() + return arcs_text # ============ 生成章节草稿 & 定稿 ============ - def generate_chapter_draft( novel_settings: str, global_summary: str, @@ -366,24 +478,43 @@ def generate_chapter_draft( word_number: int, temperature: float, novel_novel_directory: str, - filepath: str + filepath: str, + interface_format: str, + embedding_model_name: str, + embedding_base_url: str ) -> str: """ - 仅生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 - 并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。 - 同时生成 "outline_{novel_number}.txt" 存储大纲内容。 + 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 """ - # 0) 根据 novel_number 从 novel_novel_directory 中获取本章标题及简述 + # 根据目录信息获取本章标题、简介 chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_title = chapter_info["chapter_title"] chapter_brief = chapter_info["chapter_brief"] - # 1) 从向量库检索往期上下文 - relevant_context = get_relevant_context_from_vector_store( - api_key, base_url, "回顾剧情", k=2 - ) + # 从向量库检索上下文 + queries = [] + if user_guidance.strip(): + queries.append(user_guidance) + if chapter_brief.strip(): + queries.append(chapter_brief) + queries.append("回顾剧情") + + relevant_context = "" + for q in queries: + partial_context = get_relevant_context_from_vector_store( + api_key=api_key, + base_url=base_url, + query=q, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + embedding_base_url=embedding_base_url, + k=2 + ) + if partial_context.strip(): + relevant_context += "\n" + partial_context + if not relevant_context: + relevant_context = "暂无相关内容。" - # 2) 生成大纲 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -391,26 +522,19 @@ def generate_chapter_draft( temperature=temperature ) + # 1) 生成本章大纲 outline_prompt_text = chapter_outline_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, global_summary=global_summary, novel_number=novel_number, chapter_title=chapter_title, chapter_brief=chapter_brief ) - - outline_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" - outline_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" + outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - response_outline = model.invoke(outline_prompt_text) - if not response_outline: - logging.warning("generate_chapter_draft: outline no response.") - chapter_outline = "" - else: - debug_log(outline_prompt_text, response_outline.content) - chapter_outline = response_outline.content.strip() + chapter_outline = invoke_with_cleaning(model, outline_prompt_text) outlines_dir = os.path.join(filepath, "outlines") os.makedirs(outlines_dir, exist_ok=True) @@ -418,28 +542,20 @@ def generate_chapter_draft( clear_file_content(outline_file) save_string_to_txt(chapter_outline, outline_file) - # 3) 生成正文草稿 + # 2) 生成正文草稿 writing_prompt_text = chapter_write_prompt.format( novel_setting=novel_settings, - character_state=character_state + "\n\n【历史上下文】\n" + relevant_context, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, global_summary=global_summary, chapter_outline=chapter_outline, word_number=word_number, chapter_title=chapter_title, chapter_brief=chapter_brief ) - - writing_prompt_text += f"\n\n【本章目录标题与简述】\n标题:{chapter_title}\n简述:{chapter_brief}\n" - writing_prompt_text += f"\n【最近几章摘要】\n{recent_chapters_summary}" + writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - response_chapter = model.invoke(writing_prompt_text) - if not response_chapter: - logging.warning("generate_chapter_draft: writing no response.") - chapter_content = "" - else: - debug_log(writing_prompt_text, response_chapter.content) - chapter_content = response_chapter.content.strip() + chapter_content = invoke_with_cleaning(model, writing_prompt_text) chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) @@ -455,19 +571,20 @@ def finalize_chapter( word_number: int, api_key: str, base_url: str, + interface_format: str, + embedding_model_name: str, model_name: str, temperature: float, filepath: str ): """ 对当前章节进行定稿: - 1. 读取 chapter_{novel_number}.txt 的最终内容; - 2. 更新全局摘要、角色状态文件; - 3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; - 4. 更新向量库; - 5. 新增:更新剧情要点/未解决冲突 -> plot_arcs.txt + 1. 读取草稿文本 + 2. 若字数太短则再次扩写 + 3. 更新全局摘要、角色状态 + 4. 更新剧情要点 + 5. 更新向量库 """ - # 读取当前章节内容 chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_text = read_file(chapter_file).strip() @@ -475,18 +592,17 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 读取角色状态 & 全局摘要 & 剧情要点 character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") # 新增文件 + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") old_char_state = read_file(character_state_file) old_global_summary = read_file(global_summary_file) old_plot_arcs = read_file(plot_arcs_file) - # 1) 先检查字数是否过少,若少于 80% 则调用 enrich 逻辑 + # 若篇幅过短,二次扩写 if len(chapter_text) < 0.8 * word_number: - logging.info("Chapter text seems shorter than 80% of desired length. Attempting to enrich content...") + logging.info("Chapter text is shorter than 80% of desired length. Enriching...") chapter_text = enrich_chapter_text( chapter_text=chapter_text, word_number=word_number, @@ -495,12 +611,10 @@ def finalize_chapter( model_name=model_name, temperature=temperature ) - # 覆盖写回文件 clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - logging.info("Chapter text has been enriched and updated.") - # 2) 更新全局摘要 + # 更新全局摘要 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -513,31 +627,21 @@ def finalize_chapter( chapter_text=chapter_text, global_summary=old_summary ) - response = model.invoke(prompt) - if not response: - logging.warning("update_global_summary: No response.") - return old_summary - debug_log(prompt, response.content) - return response.content.strip() + return invoke_with_cleaning(model, prompt) or old_summary new_global_summary = update_global_summary(chapter_text, old_global_summary) - # 3) 更新角色状态 + # 更新角色状态 def update_character_state(chapter_text: str, old_state: str) -> str: prompt = update_character_state_prompt.format( chapter_text=chapter_text, old_state=old_state ) - response = model.invoke(prompt) - if not response: - logging.warning("update_character_state: No response.") - return old_state - debug_log(prompt, response.content) - return response.content.strip() + return invoke_with_cleaning(model, prompt) or old_state new_char_state = update_character_state(chapter_text, old_char_state) - # ============ 新增2: 更新剧情要点 ============= + # 更新剧情要点 new_plot_arcs = update_plot_arcs( chapter_text=chapter_text, old_plot_arcs=old_plot_arcs, @@ -547,7 +651,7 @@ def finalize_chapter( temperature=temperature ) - # 4) 覆盖写入角色状态文件、全局摘要文件、剧情要点文件 + # 写回文件 clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) @@ -557,10 +661,16 @@ def finalize_chapter( clear_file_content(plot_arcs_file) save_string_to_txt(new_plot_arcs, plot_arcs_file) - # 5) 更新向量检索库 - update_vector_store(api_key, base_url, chapter_text) + # 更新向量库 + update_vector_store( + api_key=api_key, + base_url=base_url, + new_chapter=chapter_text, + interface_format=interface_format, + embedding_model_name=embedding_model_name + ) - logging.info(f"Chapter {novel_number} has been finalized (summary & state updated, plot arcs updated, vector store updated).") + logging.info(f"Chapter {novel_number} has been finalized.") def enrich_chapter_text( chapter_text: str, @@ -572,7 +682,6 @@ def enrich_chapter_text( ) -> str: """ 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。 - 可以让模型补充场景描写、角色心理等,保证与现有文本风格一致。 """ model = ChatOpenAI( model=model_name, @@ -580,25 +689,26 @@ def enrich_chapter_text( base_url=base_url, temperature=temperature ) - prompt = f"""\ -以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 + prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 原章节内容: -{chapter_text} -""" - response = model.invoke(prompt) - if not response: - logging.warning("enrich_chapter_text: No response.") - return chapter_text # 无响应时就返回原文 - debug_log(prompt, response.content) - return response.content.strip() +{chapter_text}""" + enriched_text = invoke_with_cleaning(model, prompt) + return enriched_text if enriched_text else chapter_text # ============ 导入外部知识文本 ============ - -def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: +def import_knowledge_file( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + file_path: str, + embedding_base_url: str = "" +) -> None: """ 将用户选定的文本文件导入到向量库,以便在写作时检索。 """ + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -608,12 +718,22 @@ def import_knowledge_file(api_key: str, base_url: str, file_path: str) -> None: logging.warning("知识库文件内容为空。") return + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + paragraphs = advanced_split_content(content) - store = load_vector_store(api_key, base_url) + store = load_vector_store(api_key, base_url, interface_format, embedding_model_name, embedding_base_url) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store(api_key, base_url, paragraphs) + init_vector_store( + api_key, + base_url, + interface_format, + embedding_model_name, + paragraphs, + embedding_base_url + ) return docs = [Document(page_content=p) for p in paragraphs] @@ -625,11 +745,10 @@ def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 + 将文本先按句子切分,然后根据语义相似度进行合并,最后按max_length二次切分。 + 可根据需要微调此逻辑。 """ - nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(content) - if not sentences: return [] diff --git a/ui.py b/ui.py index d5d79ca..cbd371f 100644 --- a/ui.py +++ b/ui.py @@ -1,12 +1,14 @@ # ui.py # -*- coding: utf-8 -*- -import os -import tkinter as tk -from tkinter import ttk, filedialog, scrolledtext, messagebox -import threading +import logging +import os +import threading +import customtkinter as ctk +from tkinter import filedialog, messagebox +import traceback from config_manager import load_config, save_config -from utils import read_file +from utils import read_file, save_string_to_txt, clear_file_content from novel_generator import ( Novel_novel_directory_generate, generate_chapter_draft, @@ -18,181 +20,526 @@ from novel_generator import ( ) from consistency_checker import check_consistency +def log_error(message: str): + """ + 用于打印详细的错误信息和堆栈信息。 + """ + logging.error(f"{message}\n{traceback.format_exc()}") + +# 设置全局主题和颜色 +ctk.set_appearance_mode("System") +ctk.set_default_color_theme("blue") + class NovelGeneratorGUI: def __init__(self, master): self.master = master self.master.title("Novel Generator GUI") + # 防止因 icon.ico 不存在导致程序崩溃 + try: + if os.path.exists("icon.ico"): + self.master.iconbitmap("icon.ico") + except Exception: + pass + + # 窗口最大化 + self.master.state("zoomed") + # 配置窗口大小 + self.master.geometry("1428x960") + # 配置持久化 self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # 使用 PanedWindow 实现左右布局 - main_pane = ttk.PanedWindow(master, orient="horizontal") - main_pane.pack(fill="both", expand=True) + # ========== 主要的属性变量 ========== + 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")) - # 左侧:显示区(上下分区) - self.left_frame = ttk.Frame(main_pane) - main_pane.add(self.left_frame, weight=3) + 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", "")) - # 右侧:参数输入区 - self.right_frame = ttk.Frame(main_pane, padding="10 10 10 10") - main_pane.add(self.right_frame, weight=1) + self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + 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)) + self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - # 左侧布局:日志区 + 章节内容 + self.chapter_num_var = ctk.IntVar(value=1) + + # ========== 主容器使用 TabView ========== + self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) + self.tabview.pack(fill="both", expand=True) + + # 创建各个Tab + self.main_tab = self.tabview.add("Main Functions") + self.setting_tab = self.tabview.add("Novel Settings") + self.directory_tab = self.tabview.add("Novel Directory") + self.character_tab = self.tabview.add("Character State") + self.summary_tab = self.tabview.add("Global Summary") + + # 构建主功能Tab的布局 + self.build_main_tab() + + # 构建“Novel Settings”编辑Tab + self.build_setting_tab() + + # 构建“Novel Directory”编辑Tab + self.build_directory_tab() + + # 角色状态 Tab + self.build_character_tab() + + # 全局摘要 Tab + self.build_summary_tab() + + # ------------------ 统一异常处理方法 ------------------ + def handle_exception(self, context: str): + full_message = f"{context}\n{traceback.format_exc()}" + logging.error(full_message) + self.safe_log(full_message) + + # ------------------ 主功能 Tab ------------------ + def build_main_tab(self): + self.main_tab.rowconfigure(0, weight=1) + self.main_tab.columnconfigure(0, weight=1) + self.main_tab.columnconfigure(1, weight=0) + + # 左侧Frame + self.left_frame = ctk.CTkFrame(self.main_tab) + self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) + + # 右侧Frame + self.right_frame = ctk.CTkFrame(self.main_tab) + self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2) + + # 左侧布局 self.build_left_layout() - # 右侧布局:参数输入区 + # 右侧布局 self.build_right_layout() def build_left_layout(self): - self.left_frame.rowconfigure(0, weight=1) - self.left_frame.rowconfigure(1, weight=1) - self.left_frame.columnconfigure(0, weight=1) + self.left_frame.grid_rowconfigure(0, weight=3) + self.left_frame.grid_rowconfigure(1, weight=1) + self.left_frame.grid_columnconfigure(0, weight=1) - # 日志区 - log_frame = ttk.LabelFrame(self.left_frame, text="输出日志") - log_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - log_frame.rowconfigure(0, weight=1) - log_frame.columnconfigure(0, weight=1) + # 本章内容 + chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容", font=("Microsoft YaHei", 14)) + chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") - self.log_text = scrolledtext.ScrolledText(log_frame, width=80, height=10) - self.log_text.grid(row=0, column=0, sticky="nsew") + # 章节草稿:可编辑 + self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) + self.chapter_result.grid(row=0, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # 章节内容区 - chapter_frame = ttk.LabelFrame(self.left_frame, text="本章内容") - chapter_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - chapter_frame.rowconfigure(0, weight=1) - chapter_frame.columnconfigure(0, weight=1) + # 输出日志 + log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 14)) + log_label.grid(row=1, column=0, padx=5, pady=(5, 0), sticky="w") - self.chapter_result = scrolledtext.ScrolledText(chapter_frame, width=80, height=10, foreground="blue") - self.chapter_result.grid(row=0, column=0, sticky="nsew") + # 日志:只读 + self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) + self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + self.log_text.configure(state="disabled") def build_right_layout(self): - # 行列配置 - for i in range(20): - self.right_frame.rowconfigure(i, weight=0) + self.right_frame.grid_rowconfigure(0, weight=0) + self.right_frame.grid_rowconfigure(1, weight=0) + self.right_frame.grid_rowconfigure(2, weight=1) + self.right_frame.columnconfigure(0, weight=0) self.right_frame.columnconfigure(1, weight=1) + # ========== 1. 配置项 TabView(AI接口配置 & Embedding配置) ========== + self.config_tabview = ctk.CTkTabview(self.right_frame) + self.config_tabview.grid(row=0, column=0, columnspan=2, sticky="we", padx=5, pady=5) + + self.ai_config_tab = self.config_tabview.add("LLM Model settings") + self.embeddings_config_tab = self.config_tabview.add("Embedding settings") + + # 构建这两个 Tab 的布局 + self.build_ai_config_tab() + self.build_embeddings_config_tab() + + # ========== 2. 与小说相关的参数区 ========== + self.build_novel_params_area(start_row=1) + + # ========== 3. 放主要功能按钮 & “保存/加载配置”按钮区 ========== + self.build_main_buttons_area(start_row=10) + + def build_ai_config_tab(self): + def on_interface_format_changed(new_value): + # 如果用户切换接口格式,可根据需要修改BaseURL为默认值 + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.base_url_var.set("https://api.agicto.cn/v1") + + for i in range(5): + self.ai_config_tab.grid_rowconfigure(i, weight=0) + self.ai_config_tab.grid_columnconfigure(0, weight=0) + self.ai_config_tab.grid_columnconfigure(1, weight=1) + # 1. API Key - ttk.Label(self.right_frame, text="API Key:").grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.api_key_var = tk.StringVar(value=self.loaded_config.get("api_key", "")) - ttk.Entry(self.right_frame, textvariable=self.api_key_var, width=32).grid(row=0, column=1, padx=5, pady=5, sticky="w") + api_key_label = ctk.CTkLabel(self.ai_config_tab, text="API Key:", font=("Microsoft YaHei", 12)) + api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var, font=("Microsoft YaHei", 12)) + api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") # 2. Base URL - ttk.Label(self.right_frame, text="Base URL:").grid(row=1, column=0, padx=5, pady=5, sticky="e") - self.base_url_var = tk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) - ttk.Entry(self.right_frame, textvariable=self.base_url_var, width=32).grid(row=1, column=1, padx=5, pady=5, sticky="w") + base_url_label = ctk.CTkLabel(self.ai_config_tab, text="Base URL:", font=("Microsoft YaHei", 12)) + base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var, font=("Microsoft YaHei", 12)) + base_url_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") - # 3. Model Name - ttk.Label(self.right_frame, text="Model Name:").grid(row=2, column=0, padx=5, pady=5, sticky="e") - self.model_name_var = tk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - ttk.Entry(self.right_frame, textvariable=self.model_name_var, width=32).grid(row=2, column=1, padx=5, pady=5, sticky="w") - - # 4. Temperature - ttk.Label(self.right_frame, text="Temperature:").grid(row=3, column=0, padx=5, pady=5, sticky="e") - self.temperature_var = tk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) - self.temp_value_label = ttk.Label(self.right_frame, text=f"{self.temperature_var.get():.2f}") - self.temp_value_label.grid(row=3, column=2, padx=5, pady=5, sticky="w") - - temp_scale = ttk.Scale(self.right_frame, from_=0.0, to=1.0, orient=tk.HORIZONTAL, variable=self.temperature_var) - temp_scale.grid(row=3, column=1, padx=5, pady=5, sticky="we") - def update_temp_label(*args): - self.temp_value_label.config(text=f"{self.temperature_var.get():.2f}") - self.temperature_var.trace("w", update_temp_label) - - # 5. 主题(Topic) 多行输入 - ttk.Label(self.right_frame, text="主题(Topic):").grid(row=4, column=0, padx=5, pady=5, sticky="ne") - self.topic_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) - self.topic_text.grid(row=4, column=1, padx=5, pady=5, sticky="w") - topic_default = self.loaded_config.get("topic", "") - if topic_default: - self.topic_text.insert(tk.END, topic_default) - - # 6. 类型(Genre) - ttk.Label(self.right_frame, text="类型(Genre):").grid(row=5, column=0, padx=5, pady=5, sticky="e") - self.genre_var = tk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - ttk.Entry(self.right_frame, textvariable=self.genre_var, width=32).grid(row=5, column=1, padx=5, pady=5, sticky="w") - - # 7. 章节数 - ttk.Label(self.right_frame, text="章节数:").grid(row=6, column=0, padx=5, pady=5, sticky="e") - self.num_chapters_var = tk.IntVar(value=self.loaded_config.get("num_chapters", 10)) - ttk.Entry(self.right_frame, textvariable=self.num_chapters_var, width=8).grid(row=6, column=1, padx=5, pady=5, sticky="w") - - # 8. 每章字数 - ttk.Label(self.right_frame, text="每章字数:").grid(row=7, column=0, padx=5, pady=5, sticky="e") - self.word_number_var = tk.IntVar(value=self.loaded_config.get("word_number", 3000)) - ttk.Entry(self.right_frame, textvariable=self.word_number_var, width=8).grid(row=7, column=1, padx=5, pady=5, sticky="w") - - # 9. 文件保存路径 - ttk.Label(self.right_frame, text="保存路径:").grid(row=8, column=0, padx=5, pady=5, sticky="e") - self.filepath_var = tk.StringVar(value=self.loaded_config.get("filepath", "")) - ttk.Entry(self.right_frame, textvariable=self.filepath_var, width=32).grid(row=8, column=1, padx=5, pady=5, sticky="w") - ttk.Button(self.right_frame, text="浏览...", command=self.browse_folder).grid(row=8, column=2, padx=5, pady=5, sticky="w") - - # 保存/加载配置按钮 - config_frame = ttk.Frame(self.right_frame) - config_frame.grid(row=9, column=1, sticky="w") - ttk.Button(config_frame, text="保存配置", command=self.save_config_btn).grid(row=0, column=0, padx=5) - ttk.Button(config_frame, text="加载配置", command=self.load_config_btn).grid(row=0, column=1, padx=5) - - # 10. 章节号 - ttk.Label(self.right_frame, text="章节号:").grid(row=10, column=0, sticky="e") - self.chapter_num_var = tk.IntVar(value=1) - ttk.Entry(self.right_frame, textvariable=self.chapter_num_var, width=6).grid(row=10, column=1, padx=5, pady=5, sticky="w") - - # 11. “用户指导” 多行输入 - ttk.Label(self.right_frame, text="本章指导:").grid(row=11, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = scrolledtext.ScrolledText(self.right_frame, width=32, height=4) - self.user_guide_text.grid(row=11, column=1, padx=5, pady=5, sticky="w") - - row_base = 12 - # ============ 功能按钮 ============ - - # (1) 生成设定 & 目录 - self.btn_generate_full = ttk.Button(self.right_frame, text="Step1. 生成设定 & 目录", command=self.generate_full_novel) - self.btn_generate_full.grid(row=row_base, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (2) 生成章节草稿 - self.btn_generate_chapter = ttk.Button(self.right_frame, text="Step2. 生成章节草稿", command=self.generate_chapter_draft_ui) - self.btn_generate_chapter.grid(row=row_base+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (3) 定稿当前章节 - self.btn_finalize_chapter = ttk.Button(self.right_frame, text="Step3. 定稿当前章节", command=self.finalize_chapter_ui) - self.btn_finalize_chapter.grid(row=row_base+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (4) 一致性审校 - self.btn_check_consistency = ttk.Button(self.right_frame, text="[可选]一致性审校", command=self.do_consistency_check) - self.btn_check_consistency.grid(row=row_base+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (5) 导入知识库文件 - self.btn_import_knowledge = ttk.Button(self.right_frame, text="[可选]导入知识库", command=self.import_knowledge_handler) - self.btn_import_knowledge.grid(row=row_base+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (6) 清空向量库 - self.btn_clear_vectorstore = ttk.Button(self.right_frame, text="清空向量库", command=self.clear_vectorstore_handler) - self.btn_clear_vectorstore.grid(row=row_base+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # (7) 查看剧情要点 - ttk.Button(self.right_frame, text="[查看] 剧情要点", command=self.show_plot_arcs_ui).grid( - row=row_base+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew" + # 3. 接口格式 + interface_label = ctk.CTkLabel(self.ai_config_tab, text="接口格式:", font=("Microsoft YaHei", 12)) + interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + interface_options = ["OpenAI", "Ollama", "ML Studio", "Local"] + 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, sticky="nsew") - # -------------- 配置管理 -------------- + # 4. 模型名称 + model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:", font=("Microsoft YaHei", 12)) + model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var, font=("Microsoft YaHei", 12)) + model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + + # 5. Temperature + temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:", font=("Microsoft YaHei", 12)) + temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + + def update_temp_label(value): + self.temp_value_label.configure(text=f"{float(value):.2f}") + + temp_scale = ctk.CTkSlider( + self.ai_config_tab, + from_=0.0, to=1.0, + number_of_steps=100, + command=update_temp_label, + 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}", font=("Microsoft YaHei", 12)) + self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") + + def build_embeddings_config_tab(self): + for i in range(2): + 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) + + # 1. Embedding URL + embedding_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding URL:", font=("Microsoft YaHei", 12)) + embedding_url_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + embedding_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12)) + embedding_url_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + + # 2. Embedding 模型名称 + emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:", font=("Microsoft YaHei", 12)) + emb_model_name_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + emb_model_name_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_model_name_var, font=("Microsoft YaHei", 12)) + emb_model_name_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + + def build_novel_params_area(self, start_row=2): + # 主题(Topic) + topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) + topic_label.grid(row=start_row, column=0, padx=5, pady=5, sticky="e") + self.topic_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.topic_text.grid(row=start_row, column=1, padx=5, pady=5, sticky="nsew") + if self.topic_default: + self.topic_text.insert("0.0", self.topic_default) + + # 类型(Genre) + genre_label = ctk.CTkLabel(self.right_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) + genre_label.grid(row=start_row+1, column=0, padx=5, pady=5, sticky="e") + genre_entry = ctk.CTkEntry(self.right_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) + genre_entry.grid(row=start_row+1, column=1, padx=5, pady=5, sticky="ew") + + # 章节数 + num_chapters_label = ctk.CTkLabel(self.right_frame, text="章节数:", font=("Microsoft YaHei", 12)) + num_chapters_label.grid(row=start_row+2, column=0, padx=5, pady=5, sticky="e") + num_chapters_entry = ctk.CTkEntry(self.right_frame, textvariable=self.num_chapters_var, width=80, font=("Microsoft YaHei", 12)) + num_chapters_entry.grid(row=start_row+2, column=1, padx=5, pady=5, sticky="w") + + # 每章字数 + word_number_label = ctk.CTkLabel(self.right_frame, text="每章字数:", font=("Microsoft YaHei", 12)) + word_number_label.grid(row=start_row+3, column=0, padx=5, pady=5, sticky="e") + word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12)) + word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") + + # 保存路径 + filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) + filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") + + self.filepath_frame = ctk.CTkFrame(self.right_frame) + self.filepath_frame.grid(row=start_row+4, column=1, padx=5, pady=5, sticky="nsew") + self.filepath_frame.columnconfigure(0, weight=1) + + filepath_entry = ctk.CTkEntry(self.filepath_frame, textvariable=self.filepath_var, font=("Microsoft YaHei", 12)) + filepath_entry.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + browse_btn = ctk.CTkButton(self.filepath_frame, text="浏览...", command=self.browse_folder, width=60, font=("Microsoft YaHei", 12)) + browse_btn.grid(row=0, column=1, padx=5, pady=5, sticky="e") + + # 章节号 + chapter_num_label = ctk.CTkLabel(self.right_frame, text="章节号:", font=("Microsoft YaHei", 12)) + chapter_num_label.grid(row=start_row+5, column=0, padx=5, pady=5, sticky="e") + chapter_num_entry = ctk.CTkEntry(self.right_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12)) + chapter_num_entry.grid(row=start_row+5, column=1, padx=5, pady=5, sticky="w") + + # 用户指导 + guide_label = ctk.CTkLabel(self.right_frame, text="本章指导:", font=("Microsoft YaHei", 12)) + guide_label.grid(row=start_row+6, column=0, padx=5, pady=5, sticky="ne") + self.user_guide_text = ctk.CTkTextbox(self.right_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text.grid(row=start_row+6, column=1, padx=5, pady=5, sticky="nsew") + + def build_main_buttons_area(self, start_row=10): + self.right_frame.grid_rowconfigure(start_row, weight=0) + + self.btn_frame_config = ctk.CTkFrame(self.right_frame) + self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + self.btn_frame_config.columnconfigure(0, weight=1) + self.btn_frame_config.columnconfigure(1, weight=1) + + save_config_btn = ctk.CTkButton(self.btn_frame_config, text="保存配置", command=self.save_config_btn, font=("Microsoft YaHei", 12)) + save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + load_config_btn = ctk.CTkButton(self.btn_frame_config, text="加载配置", command=self.load_config_btn, font=("Microsoft YaHei", 12)) + load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + self.btn_generate_full = ctk.CTkButton( + self.right_frame, text="Step1. 生成设定 & 目录", + command=self.generate_full_novel, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_full.grid(row=start_row+1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + self.btn_generate_chapter = ctk.CTkButton( + self.right_frame, text="Step2. 生成章节草稿", + command=self.generate_chapter_draft_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_chapter.grid(row=start_row+2, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + self.btn_finalize_chapter = ctk.CTkButton( + self.right_frame, text="Step3. 定稿当前章节", + command=self.finalize_chapter_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_finalize_chapter.grid(row=start_row+3, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + self.btn_check_consistency = ctk.CTkButton( + self.right_frame, text="[可选]一致性审校", + command=self.do_consistency_check, + font=("Microsoft YaHei", 12) + ) + self.btn_check_consistency.grid(row=start_row+4, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + self.btn_import_knowledge = ctk.CTkButton( + self.right_frame, text="[可选]导入知识库", + command=self.import_knowledge_handler, + font=("Microsoft YaHei", 12) + ) + self.btn_import_knowledge.grid(row=start_row+5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + self.btn_clear_vectorstore = ctk.CTkButton( + self.right_frame, text="清空向量库", + fg_color="red", + command=self.clear_vectorstore_handler, + font=("Microsoft YaHei", 12) + ) + self.btn_clear_vectorstore.grid(row=start_row+6, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + plot_arcs_btn = ctk.CTkButton( + self.right_frame, text="[查看] 剧情要点", + command=self.show_plot_arcs_ui, + font=("Microsoft YaHei", 12) + ) + plot_arcs_btn.grid(row=start_row+7, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + # ------------------ Novel Settings Tab ------------------ + def build_setting_tab(self): + self.setting_tab.rowconfigure(0, weight=0) + self.setting_tab.rowconfigure(1, weight=1) + self.setting_tab.columnconfigure(0, weight=1) + + load_btn = ctk.CTkButton(self.setting_tab, text="加载 Novel_setting.txt", command=self.load_novel_setting, font=("Microsoft YaHei", 12)) + load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(self.setting_tab, text="保存修改", command=self.save_novel_setting, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") + + self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12)) + self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_novel_setting(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + setting_file = os.path.join(filepath, "Novel_setting.txt") + content = read_file(setting_file) + self.setting_text.delete("0.0", "end") + self.setting_text.insert("0.0", content) + self.log("已加载 Novel_setting.txt 内容到编辑区。") + + def save_novel_setting(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.setting_text.get("0.0", "end").strip() + setting_file = os.path.join(filepath, "Novel_setting.txt") + clear_file_content(setting_file) + save_string_to_txt(content, setting_file) + self.log("已保存对 Novel_setting.txt 的修改。") + + # ------------------ Novel Directory Tab ------------------ + def build_directory_tab(self): + self.directory_tab.rowconfigure(0, weight=0) + self.directory_tab.rowconfigure(1, weight=1) + self.directory_tab.columnconfigure(0, weight=1) + + load_btn = ctk.CTkButton(self.directory_tab, text="加载 Novel_directory.txt", command=self.load_novel_directory, font=("Microsoft YaHei", 12)) + load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(self.directory_tab, text="保存修改", command=self.save_novel_directory, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") + + self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12)) + self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_novel_directory(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + directory_file = os.path.join(filepath, "Novel_directory.txt") + content = read_file(directory_file) + self.directory_text.delete("0.0", "end") + self.directory_text.insert("0.0", content) + self.log("已加载 Novel_directory.txt 内容到编辑区。") + + def save_novel_directory(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.directory_text.get("0.0", "end").strip() + directory_file = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(directory_file) + save_string_to_txt(content, directory_file) + self.log("已保存对 Novel_directory.txt 的修改。") + + # ------------------ Character State Tab ------------------ + def build_character_tab(self): + self.character_tab.rowconfigure(0, weight=0) + self.character_tab.rowconfigure(1, weight=1) + self.character_tab.columnconfigure(0, weight=1) + + 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") + + 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") + + self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12)) + self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_character_state(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + char_file = os.path.join(filepath, "character_state.txt") + content = read_file(char_file) + self.character_text.delete("0.0", "end") + self.character_text.insert("0.0", content) + self.log("已加载 character_state.txt 内容到编辑区。") + + def save_character_state(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.character_text.get("0.0", "end").strip() + char_file = os.path.join(filepath, "character_state.txt") + clear_file_content(char_file) + save_string_to_txt(content, char_file) + self.log("已保存对 character_state.txt 的修改。") + + # ------------------ Global Summary Tab ------------------ + 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) + + 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") + + 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") + + self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12)) + self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_global_summary(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + summary_file = os.path.join(filepath, "global_summary.txt") + content = read_file(summary_file) + self.summary_text.delete("0.0", "end") + self.summary_text.insert("0.0", content) + self.log("已加载 global_summary.txt 内容到编辑区。") + + def save_global_summary(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + content = self.summary_text.get("0.0", "end").strip() + summary_file = os.path.join(filepath, "global_summary.txt") + clear_file_content(summary_file) + save_string_to_txt(content, summary_file) + self.log("已保存对 global_summary.txt 的修改。") + + # ------------------ 配置管理 ------------------ def load_config_btn(self): cfg = load_config(self.config_file) if cfg: self.api_key_var.set(cfg.get("api_key", "")) self.base_url_var.set(cfg.get("base_url", "")) + self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) self.model_name_var.set(cfg.get("model_name", "")) + self.embedding_url_var.set(cfg.get("embedding_url", "")) + self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) self.temperature_var.set(cfg.get("temperature", 0.7)) 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)) self.filepath_var.set(cfg.get("filepath", "")) - # 多行文本 - self.topic_text.delete("1.0", tk.END) - self.topic_text.insert(tk.END, cfg.get("topic", "")) + # 主题 + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", cfg.get("topic", "")) self.log("已加载配置。") else: @@ -202,9 +549,12 @@ class NovelGeneratorGUI: config_data = { "api_key": self.api_key_var.get(), "base_url": self.base_url_var.get(), + "interface_format": self.interface_format_var.get(), "model_name": self.model_name_var.get(), + "embedding_url": self.embedding_url_var.get(), + "embedding_model_name": self.embedding_model_name_var.get(), "temperature": self.temperature_var.get(), - "topic": self.topic_text.get("1.0", tk.END).strip(), + "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(), @@ -221,38 +571,46 @@ class NovelGeneratorGUI: if selected_dir: self.filepath_var.set(selected_dir) - # -------------- 日志输出 -------------- + # ------------------ 日志输出(主线程安全) ------------------ def log(self, message: str): - self.log_text.insert(tk.END, message + "\n") - self.log_text.see(tk.END) + # 这里是主线程下安全的文本输出方法 + self.log_text.configure(state="normal") + self.log_text.insert("end", message + "\n") + self.log_text.see("end") + self.log_text.configure(state="disabled") - # -------------- 功能 -------------- - def disable_button(self, btn): - btn.config(state=tk.DISABLED) + def safe_log(self, message: str): + # 子线程中调用:把真正的日志操作切换回主线程执行 + self.master.after(0, lambda: self.log(message)) - def enable_button(self, btn): - btn.config(state=tk.NORMAL) + # 由于按钮更新也属于UI操作,也需要安全方式 + def disable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="disabled")) + def enable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="normal")) + + # ------------------ 功能区(带线程处理) -------------------- def generate_full_novel(self): """生成小说设定 & 目录""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + def task(): - self.disable_button(self.btn_generate_full) + self.disable_button_safe(self.btn_generate_full) try: 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("1.0", tk.END).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() - filepath = self.filepath_var.get().strip() temperature = self.temperature_var.get() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - self.log("开始生成小说设定和目录...") + self.safe_log("开始生成小说设定和目录...") Novel_novel_directory_generate( api_key=api_key, base_url=base_url, @@ -264,30 +622,32 @@ class NovelGeneratorGUI: filepath=filepath, temperature=temperature ) - self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") - except Exception as e: - self.log(f"❌ 生成小说设定 & 目录时出错: {e}") + self.safe_log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") + except Exception: + self.handle_exception("生成小说设定 & 目录时出错") finally: - self.enable_button(self.btn_generate_full) + self.enable_button_safe(self.btn_generate_full) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def generate_chapter_draft_ui(self): """生成当前章节的草稿""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + def task(): - self.disable_button(self.btn_generate_chapter) + self.disable_button_safe(self.btn_generate_chapter) try: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() - filepath = self.filepath_var.get().strip() - novel_settings_file = os.path.join(filepath, "Novel_setting.txt") novel_settings = read_file(novel_settings_file) if not novel_settings.strip(): - self.log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") + self.safe_log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") return character_state_file = os.path.join(filepath, "character_state.txt") @@ -299,17 +659,22 @@ class NovelGeneratorGUI: chap_num = self.chapter_num_var.get() word_number = self.word_number_var.get() - user_guidance = self.user_guide_text.get("1.0", tk.END).strip() + user_guidance = self.user_guide_text.get("0.0", "end").strip() - # 获取最近3章文本,生成短期摘要 + # 获取最近3章文本 chapters_dir = os.path.join(filepath, "chapters") recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - # 用当前模型生成一个较为详细的最近剧情摘要 - model_obj = self.get_llm_model(model_name, api_key, base_url, temperature) - recent_chapters_summary = summarize_recent_chapters(model_obj, recent_3_texts) + # 生成最近章节摘要 + recent_chapters_summary = summarize_recent_chapters( + llm_model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature, + chapters_text_list=recent_3_texts + ) - self.log(f"开始生成第{chap_num}章草稿...") + self.safe_log(f"开始生成第{chap_num}章草稿...") draft_text = generate_chapter_draft( novel_settings=novel_settings, global_summary=global_summary, @@ -323,97 +688,110 @@ class NovelGeneratorGUI: word_number=word_number, temperature=temperature, novel_novel_directory=novel_directory, - filepath=filepath + filepath=filepath, + interface_format=self.interface_format_var.get().strip(), + embedding_model_name=self.embedding_model_name_var.get().strip(), + embedding_base_url=self.embedding_url_var.get().strip() ) if draft_text: - self.log(f"✅ 第{chap_num}章草稿生成完成。请在下方查看。") - self.chapter_result.delete("1.0", tk.END) - self.chapter_result.insert(tk.END, draft_text) - self.chapter_result.see(tk.END) + self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") + # 更新UI中的章节草稿 + self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) else: - self.log("⚠️ 本章草稿生成失败或无内容。") + self.safe_log("⚠️ 本章草稿生成失败或无内容。") - except Exception as e: - self.log(f"❌ 生成章节草稿时出错: {e}") + except Exception: + self.handle_exception("生成章节草稿时出错") finally: - self.enable_button(self.btn_generate_chapter) + self.enable_button_safe(self.btn_generate_chapter) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() + + def show_chapter_in_textbox(self, text: str): + self.chapter_result.delete("0.0", "end") + self.chapter_result.insert("0.0", text) + self.chapter_result.see("end") def finalize_chapter_ui(self): """定稿当前章节:更新全局摘要、角色状态、向量库等""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + def task(): - self.disable_button(self.btn_finalize_chapter) + self.disable_button_safe(self.btn_finalize_chapter) try: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() - filepath = self.filepath_var.get().strip() + interface_format = self.interface_format_var.get().strip() + embedding_model_name = self.embedding_model_name_var.get().strip() chap_num = self.chapter_num_var.get() word_number = self.word_number_var.get() - self.log(f"开始定稿第{chap_num}章...") + self.safe_log(f"开始定稿第{chap_num}章...") finalize_chapter( novel_number=chap_num, word_number=word_number, api_key=api_key, base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, model_name=model_name, temperature=temperature, filepath=filepath ) - self.log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") + self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") # 读取定稿后的文本显示 chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") final_text = read_file(chap_file) - self.chapter_result.delete("1.0", tk.END) - self.chapter_result.insert(tk.END, final_text) - self.chapter_result.see(tk.END) + self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) - except Exception as e: - self.log(f"❌ 定稿章节时出错: {e}") + except Exception: + self.handle_exception("定稿章节时出错") finally: - self.enable_button(self.btn_finalize_chapter) + self.enable_button_safe(self.btn_finalize_chapter) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def do_consistency_check(self): """使用审校Agent对最新章节进行简单一致性或冲突检查""" + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + def task(): - self.disable_button(self.btn_check_consistency) + self.disable_button_safe(self.btn_check_consistency) try: api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() - filepath = self.filepath_var.get().strip() - # 读取关键文件 novel_settings_file = os.path.join(filepath, "Novel_setting.txt") character_state_file = os.path.join(filepath, "character_state.txt") global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") # 新增 + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") novel_setting = read_file(novel_settings_file) character_state = read_file(character_state_file) global_summary = read_file(global_summary_file) - plot_arcs = read_file(plot_arcs_file) # 新增 + plot_arcs = read_file(plot_arcs_file) - # 获取当前章节文本 chap_num = self.chapter_num_var.get() chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") chapter_text = read_file(chap_file) if not chapter_text.strip(): - self.log("⚠️ 当前章节文件为空或不存在,无法审校。") + self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。") return - self.log("开始一致性审校...") + self.safe_log("开始一致性审校...") result = check_consistency( novel_setting=novel_setting, character_state=character_state, @@ -423,18 +801,17 @@ class NovelGeneratorGUI: base_url=base_url, model_name=model_name, temperature=temperature, - plot_arcs=plot_arcs # 新增传入 + plot_arcs=plot_arcs ) - self.log("审校结果:") - self.log(result) + self.safe_log("审校结果:") + self.safe_log(result) - except Exception as e: - self.log(f"❌ 审校时出错: {e}") + except Exception: + self.handle_exception("审校时出错") finally: - self.enable_button(self.btn_check_consistency) + self.enable_button_safe(self.btn_check_consistency) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def import_knowledge_handler(self): """处理导入知识库文件。""" @@ -444,60 +821,62 @@ class NovelGeneratorGUI: ) if selected_file: def task(): - self.disable_button(self.btn_import_knowledge) + self.disable_button_safe(self.btn_import_knowledge) try: - self.log(f"开始导入知识库文件: {selected_file}") + self.safe_log(f"开始导入知识库文件: {selected_file}") import_knowledge_file( api_key=self.api_key_var.get().strip(), base_url=self.base_url_var.get().strip(), - file_path=selected_file + interface_format=self.interface_format_var.get().strip(), + embedding_model_name=self.embedding_model_name_var.get().strip(), + file_path=selected_file, + embedding_base_url=self.embedding_url_var.get().strip() ) - self.log("✅ 知识库文件导入完成。") - except Exception as e: - self.log(f"❌ 导入知识库时出错: {e}") + self.safe_log("✅ 知识库文件导入完成。") + except Exception: + self.handle_exception("导入知识库时出错") finally: - self.enable_button(self.btn_import_knowledge) + self.enable_button_safe(self.btn_import_knowledge) - thread = threading.Thread(target=task) - thread.start() + threading.Thread(target=task, daemon=True).start() def clear_vectorstore_handler(self): - """ - 清空向量库按钮:弹出二次确认,若确认则执行 clear_vector_store()。 - """ - def confirmed_clear(): + """清空向量库按钮:弹出二次确认。""" + first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") + if first_confirm: second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") if second_confirm: clear_vector_store() self.log("已清空向量库。") - first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") - if first_confirm: - confirmed_clear() - - # =========== 新增:在 UI 中查看当前剧情要点 ============= def show_plot_arcs_ui(self): + """[查看]当前剧情要点""" filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") if not os.path.exists(plot_arcs_file): messagebox.showinfo("剧情要点", "当前还未生成任何剧情要点或未解决冲突。") return + arcs_text = read_file(plot_arcs_file).strip() if not arcs_text: arcs_text = "当前没有记录的剧情要点或冲突。" - # 弹出一个简单的弹窗显示 - top = tk.Toplevel(self.master) - top.title("剧情要点/未解决冲突") - text_area = scrolledtext.ScrolledText(top, width=60, height=20) - text_area.pack(fill="both", expand=True) - text_area.insert(tk.END, arcs_text) - text_area.config(state=tk.DISABLED) - def get_llm_model(self, model_name, api_key, base_url, temperature): - from langchain_openai import ChatOpenAI - return ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=base_url, - temperature=temperature - ) + top = ctk.CTkToplevel(self.master) + top.title("剧情要点/未解决冲突") + top.geometry("600x400") + + text_area = ctk.CTkTextbox(top, wrap="word", font=("Microsoft YaHei", 12)) + text_area.pack(fill="both", expand=True, padx=10, pady=10) + + text_area.insert("0.0", arcs_text) + text_area.configure(state="disabled") + + +if __name__ == "__main__": + app = ctk.CTk() + gui = NovelGeneratorGUI(app) + app.mainloop()