diff --git a/embedding_adapters.py b/embedding_adapters.py index 8c39f51..a17bb7a 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -69,7 +69,6 @@ class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): """ 调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding """ - # 如果 base_url 中已含 /api/embeddings,可直接用;否则拼上 /api/embeddings url = self.base_url if "api/embeddings" not in url: url = f"{url}/api/embeddings" diff --git a/llm_adapters.py b/llm_adapters.py index 2fa5e37..714e72a 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -28,7 +28,7 @@ class DeepSeekAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -54,7 +54,7 @@ class OpenAIAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -81,7 +81,7 @@ class OllamaAdapter(BaseLLMAdapter): Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -104,7 +104,7 @@ class OllamaAdapter(BaseLLMAdapter): return response.content class MLStudioAdapter(BaseLLMAdapter): - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -131,18 +131,19 @@ def create_llm_adapter( base_url: str, model_name: str, api_key: str, - temperature: float + temperature: float, + max_tokens: int ) -> BaseLLMAdapter: """ 工厂函数:根据 interface_format 返回不同的适配器实例。 """ if interface_format.lower() == "deepseek": - return DeepSeekAdapter(api_key, base_url, model_name, temperature) + return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "openai": - return OpenAIAdapter(api_key, base_url, model_name, temperature) + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "ollama": - return OllamaAdapter(api_key, base_url, model_name, temperature) + return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "ml studio": - return MLStudioAdapter(api_key, base_url, model_name, temperature) + return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/novel_generator.py b/novel_generator.py index 9e6206a..5e2a6ed 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -43,6 +43,7 @@ from embedding_adapters import create_embedding_adapter logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + # ============ 工具函数 ============ def remove_think_tags(text: str) -> str: @@ -67,6 +68,7 @@ def invoke_with_cleaning(llm_adapter, prompt: str) -> str: debug_log(prompt, cleaned_text) return cleaned_text.strip() + # ============ 获取 vectorstore 路径 ============ def get_vectorstore_dir(filepath: str) -> str: @@ -89,6 +91,7 @@ def clear_vector_store(filepath: str) -> bool: traceback.print_exc() return False + # ============ 根据 embedding 接口创建/加载 Chroma ============ def init_vector_store( @@ -103,11 +106,8 @@ def init_vector_store( store_dir = get_vectorstore_dir(filepath) os.makedirs(store_dir, exist_ok=True) - # 将文本封装为 Document documents = [Document(page_content=str(t)) for t in texts] - # 因为我们是自定义的 embeddings,对接Chroma时需包装一个“langchain兼容对象” - # 这里示例:写一个包装函数 from langchain.embeddings.base import Embeddings as LCEmbeddings class LCEmbeddingWrapper(LCEmbeddings): @@ -140,7 +140,6 @@ def load_vector_store( logging.info("Vector store not found. Will return None.") return None - # 同样要包装embedding_adapter from langchain.embeddings.base import Embeddings as LCEmbeddings class LCEmbeddingWrapper(LCEmbeddings): @@ -159,6 +158,7 @@ def load_vector_store( collection_name="novel_collection" ) + # ============ 文本分段工具 ============ def split_by_length(text: str, max_length: int = 500) -> List[str]: @@ -240,7 +240,7 @@ def update_vector_store( 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( @@ -287,6 +287,7 @@ def summarize_recent_chapters( base_url: str, model_name: str, temperature: float, + max_tokens: int, chapters_text_list: List[str] ) -> Tuple[str, str]: """ @@ -297,13 +298,13 @@ def summarize_recent_chapters( if not combined_text: return ("", "") - # 1) 构造 llm_adapter llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) @@ -328,6 +329,7 @@ def summarize_recent_chapters( # ============ 1) 生成总体架构 ============ def Novel_architecture_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, @@ -336,7 +338,8 @@ def Novel_architecture_generate( number_of_chapters: int, word_number: int, filepath: str, - temperature: float = 0.7 + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: """ 依次调用: @@ -348,13 +351,13 @@ def Novel_architecture_generate( """ os.makedirs(filepath, exist_ok=True) - # 通过工厂函数创建 LLM 适配器 llm_adapter = create_llm_adapter( - interface_format="openai", # 或根据你的实际:若你在UI中就是 "OpenAI" 就传递过来 + interface_format=interface_format, base_url=base_url, model_name=llm_model, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) # Step1: 核心种子 @@ -382,7 +385,6 @@ def Novel_architecture_generate( ) plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) - # 合并 final_content = ( "#=== 1) 核心种子 ===\n" f"{core_seed_result}\n\n" @@ -399,14 +401,17 @@ def Novel_architecture_generate( save_string_to_txt(final_content, arch_file) logging.info("Novel_architecture.txt has been generated successfully.") + # ============ 2) 生成章节蓝图 ============ def Chapter_blueprint_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, filepath: str, - temperature: float = 0.7 + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): @@ -432,11 +437,12 @@ def Chapter_blueprint_generate( plot_arch_text = m.group(1).strip() llm_adapter = create_llm_adapter( - interface_format="openai", # 或实际由UI传入 + interface_format=interface_format, base_url=base_url, model_name=llm_model, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = chapter_blueprint_prompt.format( @@ -454,6 +460,7 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") + # ============ 3) 生成章节草稿 ============ def generate_chapter_draft( @@ -473,7 +480,9 @@ def generate_chapter_draft( embedding_url: str, embedding_interface_format: str, embedding_model_name: str, - embedding_retrieval_k: int = 2 + embedding_retrieval_k: int = 2, + interface_format: str = "openai", + max_tokens: int = 2048 ) -> str: arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -487,7 +496,6 @@ def generate_chapter_draft( character_state_file = os.path.join(filepath, "character_state.txt") character_state_text = read_file(character_state_file) - # 解析本章信息 chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] chapter_role = chapter_info["chapter_role"] @@ -500,18 +508,17 @@ def generate_chapter_draft( chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - # 获取最近3章 => (短期摘要, 下一章关键字) recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) short_summary, next_chapter_keywords = summarize_recent_chapters( - interface_format="openai", # 或由UI传进 + interface_format=interface_format, api_key=api_key, base_url=base_url, model_name=model_name, temperature=temperature, + max_tokens=max_tokens, chapters_text_list=recent_3_texts ) - # 上一章片段(末尾1500字) previous_chapter_excerpt = "" for text_block in reversed(recent_3_texts): if text_block.strip(): @@ -521,7 +528,6 @@ def generate_chapter_draft( previous_chapter_excerpt = text_block break - # 使用embedding检索上下文 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -538,7 +544,6 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 组装 Prompt prompt_text = chapter_draft_prompt.format( novel_number=novel_number, chapter_title=chapter_title, @@ -562,19 +567,18 @@ def generate_chapter_draft( context_excerpt=relevant_context ) - # 调用 LLM 生成 llm_adapter = create_llm_adapter( - interface_format="openai", # 或由UI传进 + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 写入 chapter_X.txt chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -595,7 +599,9 @@ def finalize_chapter( embedding_api_key: str, embedding_url: str, embedding_interface_format: str, - embedding_model_name: str + embedding_model_name: str, + interface_format: str, + max_tokens: int ): chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") @@ -604,25 +610,23 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 如果篇幅过短,可以扩写 if len(chapter_text) < 0.6 * word_number: - chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) + chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - # 读取全局摘要、角色状态 global_summary_file = os.path.join(filepath, "global_summary.txt") old_global_summary = read_file(global_summary_file) character_state_file = os.path.join(filepath, "character_state.txt") old_character_state = read_file(character_state_file) - # 调用 LLM 更新全局摘要 llm_adapter = create_llm_adapter( - interface_format="openai", + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt_summary = summary_prompt.format( chapter_text=chapter_text, @@ -632,7 +636,6 @@ def finalize_chapter( if not new_global_summary.strip(): new_global_summary = old_global_summary - # 更新角色状态 prompt_char_state = update_character_state_prompt.format( chapter_text=chapter_text, old_state=old_character_state @@ -641,14 +644,12 @@ def finalize_chapter( if not new_char_state.strip(): new_char_state = old_character_state - # 写回 clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 更新向量库 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -665,14 +666,17 @@ def enrich_chapter_text( api_key: str, base_url: str, model_name: str, - temperature: float + temperature: float, + interface_format: str, + max_tokens: int ) -> str: llm_adapter = create_llm_adapter( - interface_format="openai", + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: 原内容: @@ -681,6 +685,7 @@ def enrich_chapter_text( enriched_text = invoke_with_cleaning(llm_adapter, prompt) return enriched_text if enriched_text else chapter_text + # ============ 导入知识文件到向量库 ============ def advanced_split_content(content: str, diff --git a/tooltips.py b/tooltips.py index f4e8220..062de2f 100644 --- a/tooltips.py +++ b/tooltips.py @@ -4,10 +4,20 @@ tooltips = { "api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。", "base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。", - "interface_format": "指定LLM接口兼容格式,可选OpenAI、Ollama、ML Studio等。", - "model_name": "要使用的模型名称,例如gpt-3.5-turbo、llama2等。如果是Ollama,请填写你下载好的本地模型名。", + "interface_format": "指定LLM接口兼容格式,可选DeepSeek\OpenAI\Ollama\ML Studio等。\n\n注意:"+ + "OpenAI 兼容是指的可以通过该标准请求的任何接口,不是只允许使用api.openai.com接口\n"+ + "例如Ollama接口格式也兼容OpenAI,可以无需修改直接使用\n"+ + "ML Studio接口格式与OpenAI接口格式也一致。", + "model_name": "要使用的模型名称,例如deepseek-reasoner、gpt-4o等。如果是Ollama等,请填写你下载好的本地模型名。", "temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。", - "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。", + "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。\n"+ + "以下是一些常见模型的最大值:\n"+ + "o1:100,000\n"+ + "o1-mini:65,536\n"+ + "gpt-4o:16384\n"+ + "gpt-4o-mini:16384\n"+ + "deepseek-reasoner:8192\n"+ + "deepseek-chat:4096\n", "embedding_api_key": "调用Embedding模型时所需的API Key。", "embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。", "embedding_url": "Embedding模型接口地址。", diff --git a/ui.py b/ui.py index 3c2a267..f5b81cc 100644 --- a/ui.py +++ b/ui.py @@ -20,9 +20,11 @@ from novel_generator import ( clear_vector_store, get_last_n_chapters_text ) - from consistency_checker import check_consistency +# ---- Import the tooltip texts ---- +from tooltips import tooltips + def log_error(message: str): logging.error(f"{message}\n{traceback.format_exc()}") @@ -52,6 +54,7 @@ class NovelGeneratorGUI: 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.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) # Embedding相关 self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) @@ -92,6 +95,11 @@ class NovelGeneratorGUI: self.build_summary_tab() self.build_chapters_tab() + def show_tooltip(self, key: str): + """Display a popup with tooltip text.""" + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + def safe_get_int(self, var, default=1): try: val_str = str(var.get()).strip() @@ -203,6 +211,27 @@ class NovelGeneratorGUI: self.build_ai_config_tab() self.build_embeddings_config_tab() + # 封装一个小工具函数,用来创建「标签 + 问号按钮」的组合 + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + # frame容器:同一格子里存放 label + "?"按钮 + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + # 先放 label + label = ctk.CTkLabel(frame, text=label_text, font=font) + label.pack(side="left") + # 再放问号按钮 + btn = ctk.CTkButton( + frame, + text="?", + width=22, + height=22, + font=("Microsoft YaHei", 10), + command=lambda: self.show_tooltip(tooltip_key) + ) + btn.pack(side="left", padx=3) + return frame + def build_ai_config_tab(self): def on_interface_format_changed(new_value): if new_value == "Ollama": @@ -211,26 +240,49 @@ class NovelGeneratorGUI: self.base_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") - for i in range(5): + for i in range(6): 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) self.ai_config_tab.grid_columnconfigure(2, weight=0) - api_key_label = ctk.CTkLabel(self.ai_config_tab, text="LLM API Key:", font=("Microsoft YaHei", 12)) - api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + # 1) API Key + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="LLM API Key:", + tooltip_key="api_key", + row=0, + column=0, + font=("Microsoft YaHei", 12) + ) 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") + api_key_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - base_url_label = ctk.CTkLabel(self.ai_config_tab, text="LLM Base URL:", font=("Microsoft YaHei", 12)) - base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + # 2) Base URL + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="LLM Base URL:", + tooltip_key="base_url", + row=1, + column=0, + font=("Microsoft YaHei", 12) + ) 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") + base_url_entry.grid(row=1, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - interface_label = ctk.CTkLabel(self.ai_config_tab, text="LLM 接口格式:", font=("Microsoft YaHei", 12)) - interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI", "Ollama", "ML Studio"] + # 3) 接口格式 + label_frame = self.create_label_with_help( + parent=self.ai_config_tab, + label_text="LLM 接口格式:", + tooltip_key="interface_format", + row=2, + column=0, + font=("Microsoft YaHei", 12) + ) + interface_options = ["DeepSeek", "OpenAI", "Ollama", "ML Studio"] interface_dropdown = ctk.CTkOptionMenu( self.ai_config_tab, values=interface_options, @@ -238,15 +290,29 @@ class NovelGeneratorGUI: command=on_interface_format_changed, font=("Microsoft YaHei", 12) ) - interface_dropdown.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") + interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - 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") + # 4) Model Name + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="Model Name:", + tooltip_key="model_name", + row=3, + column=0, + font=("Microsoft YaHei", 12) + ) 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") + model_name_entry.grid(row=3, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - 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") + # 5) Temperature + temp_frame = self.create_label_with_help( + parent=self.ai_config_tab, + label_text="Temperature:", + tooltip_key="temperature", + row=4, + column=0, + font=("Microsoft YaHei", 12) + ) def update_temp_label(value): self.temp_value_label.configure(text=f"{float(value):.2f}") @@ -265,7 +331,37 @@ class NovelGeneratorGUI: 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") + self.temp_value_label.grid(row=4, column=2, padx=5, pady=5, sticky="w") + + # 6) Max Tokens + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="Max Tokens:", + tooltip_key="max_tokens", + row=5, + column=0, + font=("Microsoft YaHei", 12) + ) + + def update_max_tokens_label(value): + self.max_tokens_value_label.configure(text=str(int(float(value)))) + + max_tokens_slider = ctk.CTkSlider( + self.ai_config_tab, + from_=0, + to=102400, + number_of_steps=100, + command=update_max_tokens_label, + variable=self.max_tokens_var + ) + max_tokens_slider.grid(row=5, column=1, padx=5, pady=5, sticky="we") + + self.max_tokens_value_label = ctk.CTkLabel( + self.ai_config_tab, + text=str(self.max_tokens_var.get()), + font=("Microsoft YaHei", 12) + ) + self.max_tokens_value_label.grid(row=5, column=2, padx=5, pady=5, sticky="w") def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): @@ -275,20 +371,37 @@ class NovelGeneratorGUI: self.embedding_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.embedding_url_var.set("https://api.openai.com/v1") - + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + 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) + self.embeddings_config_tab.grid_columnconfigure(2, weight=0) - emb_api_key_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding API Key:", font=("Microsoft YaHei", 12)) - emb_api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + # 1) Embedding API Key + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding API Key:", + tooltip_key="embedding_api_key", + row=0, + column=0, + font=("Microsoft YaHei", 12) + ) emb_api_key_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_api_key_var, font=("Microsoft YaHei", 12)) emb_api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") - emb_interface_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding 接口格式:", font=("Microsoft YaHei", 12)) - emb_interface_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - emb_interface_options = ["OpenAI", "Ollama", "ML Studio"] + # 2) Embedding 接口格式 + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding 接口格式:", + tooltip_key="embedding_interface_format", + row=1, + column=0, + font=("Microsoft YaHei", 12) + ) + emb_interface_options = ["DeepSeek", "OpenAI", "Ollama", "ML Studio"] emb_interface_dropdown = ctk.CTkOptionMenu( self.embeddings_config_tab, values=emb_interface_options, @@ -298,18 +411,39 @@ class NovelGeneratorGUI: ) emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") - emb_url_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Base URL:", font=("Microsoft YaHei", 12)) - emb_url_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + # 3) Embedding Base URL + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding Base URL:", + tooltip_key="embedding_url", + row=2, + column=0, + font=("Microsoft YaHei", 12) + ) emb_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12)) emb_url_entry.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") - emb_model_name_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding Model Name:", font=("Microsoft YaHei", 12)) - emb_model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + # 4) Embedding Model Name + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding Model Name:", + tooltip_key="embedding_model_name", + row=3, + column=0, + font=("Microsoft YaHei", 12) + ) 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=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") + # 5) Retrieval Top-K + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Retrieval Top-K:", + tooltip_key="embedding_retrieval_k", + row=4, + column=0, + font=("Microsoft YaHei", 12) + ) 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") @@ -330,41 +464,72 @@ class NovelGeneratorGUI: self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) self.params_frame.columnconfigure(1, weight=1) - topic_label = ctk.CTkLabel(self.params_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) - topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) + # 1) 主题(Topic) + topic_label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="主题(Topic):", + tooltip_key="topic", + row=0, + column=0, + font=("Microsoft YaHei", 12), + sticky="ne" + ) + self.topic_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") if self.topic_default: self.topic_text.insert("0.0", self.topic_default) - genre_label = ctk.CTkLabel(self.params_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) - genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + # 2) 类型(Genre) + self.create_label_with_help( + parent=self.params_frame, + label_text="类型(Genre):", + tooltip_key="genre", + row=1, + column=0, + font=("Microsoft YaHei", 12) + ) genre_entry = ctk.CTkEntry(self.params_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") + # 3) 章节数 & 每章字数 row_for_chapter_and_word = 2 - num_chapters_label = ctk.CTkLabel(self.params_frame, text="章节数:", font=("Microsoft YaHei", 12)) - num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + chapter_word_frame = ctk.CTkFrame(self.params_frame) + chapter_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") + chapter_word_frame.columnconfigure((0, 1, 2, 3), weight=0) - 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, 1, 2, 3), weight=0) + # 左边标签 + label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="章节数 & 每章字数:", + tooltip_key="num_chapters", + row=row_for_chapter_and_word, + column=0, + font=("Microsoft YaHei", 12) + ) - num_chapters_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12)) - num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + # 输入框 + num_chapters_label = ctk.CTkLabel(chapter_word_frame, text="章节数:", font=("Microsoft YaHei", 12)) + num_chapters_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + num_chapters_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12)) + num_chapters_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w") - word_number_label = ctk.CTkLabel(ch_word_frame, text="每章字数:", font=("Microsoft YaHei", 12)) - word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") - - word_number_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12)) - word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") - - # 保存路径 - filepath_label = ctk.CTkLabel(self.params_frame, text="保存路径:", font=("Microsoft YaHei", 12)) - filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + word_number_label = ctk.CTkLabel(chapter_word_frame, text="每章字数:", font=("Microsoft YaHei", 12)) + word_number_label.grid(row=0, column=2, padx=(15, 5), pady=5, sticky="e") + word_number_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12)) + word_number_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w") + # 4) 保存路径 + row_fp = 3 + self.create_label_with_help( + parent=self.params_frame, + label_text="保存路径:", + tooltip_key="filepath", + row=row_fp, + column=0, + font=("Microsoft YaHei", 12) + ) self.filepath_frame = ctk.CTkFrame(self.params_frame) - self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + self.filepath_frame.grid(row=row_fp, 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)) @@ -372,43 +537,85 @@ class NovelGeneratorGUI: 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.params_frame, text="章节号:", font=("Microsoft YaHei", 12)) - chapter_num_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + # 5) 章节号 + row_chap_num = 4 + self.create_label_with_help( + parent=self.params_frame, + label_text="章节号:", + tooltip_key="chapter_num", + row=row_chap_num, + column=0, + font=("Microsoft YaHei", 12) + ) chapter_num_entry = ctk.CTkEntry(self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12)) - chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w") + chapter_num_entry.grid(row=row_chap_num, column=1, padx=5, pady=5, sticky="w") - # 用户指导 - guide_label = ctk.CTkLabel(self.params_frame, text="本章指导:", font=("Microsoft YaHei", 12)) - guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) - self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + # 6) 本章指导 + row_user_guide = 5 + guide_label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="本章指导:", + tooltip_key="user_guidance", + row=row_user_guide, + column=0, + font=("Microsoft YaHei", 12), + sticky="ne" + ) + self.user_guide_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") - # 新增:四个可选元素 - row_index = 6 - - char_inv_label = ctk.CTkLabel(self.params_frame, text="核心人物:", font=("Microsoft YaHei", 12)) - char_inv_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 7) 可选元素:核心人物/关键道具/空间坐标/时间压力 + row_idx = 6 + # 核心人物 + self.create_label_with_help( + parent=self.params_frame, + label_text="核心人物:", + tooltip_key="characters_involved", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) char_inv_entry = ctk.CTkEntry(self.params_frame, textvariable=self.characters_involved_var, font=("Microsoft YaHei", 12)) - char_inv_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + char_inv_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - key_items_label = ctk.CTkLabel(self.params_frame, text="关键道具:", font=("Microsoft YaHei", 12)) - key_items_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 关键道具 + self.create_label_with_help( + parent=self.params_frame, + label_text="关键道具:", + tooltip_key="key_items", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12)) - key_items_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + key_items_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - scene_loc_label = ctk.CTkLabel(self.params_frame, text="空间坐标:", font=("Microsoft YaHei", 12)) - scene_loc_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 空间坐标 + self.create_label_with_help( + parent=self.params_frame, + label_text="空间坐标:", + tooltip_key="scene_location", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) scene_loc_entry = ctk.CTkEntry(self.params_frame, textvariable=self.scene_location_var, font=("Microsoft YaHei", 12)) - scene_loc_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + scene_loc_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - time_const_label = ctk.CTkLabel(self.params_frame, text="时间压力:", font=("Microsoft YaHei", 12)) - time_const_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 时间压力 + self.create_label_with_help( + parent=self.params_frame, + label_text="时间压力:", + tooltip_key="time_constraint", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) time_const_entry = ctk.CTkEntry(self.params_frame, textvariable=self.time_constraint_var, font=("Microsoft YaHei", 12)) - time_const_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + time_const_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") def build_optional_buttons_area(self, start_row=2): self.optional_btn_frame = ctk.CTkFrame(self.right_frame) @@ -456,11 +663,14 @@ class NovelGeneratorGUI: self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) self.model_name_var.set(cfg.get("model_name", "")) self.temperature_var.set(cfg.get("temperature", 0.7)) + self.max_tokens_var.set(cfg.get("max_tokens", 2048)) + self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) 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", "")) self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) + self.genre_var.set(cfg.get("genre", "")) self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) self.word_number_var.set(str(cfg.get("word_number", 3000))) @@ -481,6 +691,7 @@ class NovelGeneratorGUI: "interface_format": self.interface_format_var.get(), "model_name": self.model_name_var.get(), "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), "embedding_api_key": self.embedding_api_key_var.get(), "embedding_interface_format": self.embedding_interface_format_var.get(), @@ -535,10 +746,12 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_architecture) try: + interface_format = self.interface_format_var.get().strip() 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() + max_tokens = self.max_tokens_var.get() topic = self.topic_text.get("0.0", "end").strip() genre = self.genre_var.get().strip() @@ -547,6 +760,7 @@ class NovelGeneratorGUI: self.safe_log("开始生成小说架构...") Novel_architecture_generate( + interface_format=interface_format, api_key=api_key, base_url=base_url, llm_model=model_name, @@ -555,7 +769,8 @@ class NovelGeneratorGUI: number_of_chapters=num_chapters, word_number=word_number, filepath=filepath, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") except Exception: @@ -575,18 +790,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_directory) try: + interface_format = self.interface_format_var.get().strip() 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() + max_tokens = self.max_tokens_var.get() self.safe_log("开始生成章节蓝图...") Chapter_blueprint_generate( + interface_format=interface_format, api_key=api_key, base_url=base_url, llm_model=model_name, filepath=filepath, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") except Exception: @@ -606,24 +825,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_chapter) try: - # LLM相关 + interface_format = self.interface_format_var.get().strip() 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() + max_tokens = self.max_tokens_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() - # 新增四个可选要素 char_inv = self.characters_involved_var.get().strip() key_items = self.key_items_var.get().strip() scene_loc = self.scene_location_var.get().strip() time_constr = self.time_constraint_var.get().strip() - # Embedding相关 embedding_api_key = self.embedding_api_key_var.get().strip() embedding_url = self.embedding_url_var.get().strip() embedding_interface_format = self.embedding_interface_format_var.get().strip() @@ -648,7 +865,9 @@ class NovelGeneratorGUI: embedding_url=embedding_url, embedding_interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, - embedding_retrieval_k=embedding_k + embedding_retrieval_k=embedding_k, + interface_format=interface_format, + max_tokens=max_tokens ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -678,24 +897,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_finalize_chapter) try: - # LLM相关 + interface_format = self.interface_format_var.get().strip() 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() + max_tokens = self.max_tokens_var.get() - # Embedding相关 embedding_api_key = self.embedding_api_key_var.get().strip() embedding_url = self.embedding_url_var.get().strip() embedding_interface_format = self.embedding_interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() - # 章节参数 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}章...") - # 先保存用户在左侧编辑框中的修改 chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt") @@ -714,7 +931,9 @@ class NovelGeneratorGUI: embedding_api_key=embedding_api_key, embedding_url=embedding_url, embedding_interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name + embedding_model_name=embedding_model_name, + interface_format=interface_format, + max_tokens=max_tokens ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") @@ -846,7 +1065,6 @@ class NovelGeneratorGUI: text_area.configure(state="disabled") # ============ 其余标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ============ - def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -1164,6 +1382,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") + if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app)