From ba10e6dd66f3f3c0623a4aa38dc0a4acb52115c1 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Sun, 16 Feb 2025 22:32:32 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E6=96=87=E4=BB=B6=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初步对ui.py以及novel_generator.py进行了拆分 --- .gitignore | 2 + novel_generator/__init__.py | 7 + novel_generator/architecture.py | 192 + novel_generator/blueprint.py | 169 + novel_generator/chapter.py | 216 + novel_generator/common.py | 59 + novel_generator/finalization.py | 121 + novel_generator/knowledge.py | 93 + novel_generator/vectorstore_utils.py | 228 ++ ...generator.py => novel_generator_backup.txt | 0 ui/__init__.py | 2 + ui/chapters_tab.py | 129 + ui/character_tab.py | 45 + ui/config_tab.py | 302 ++ ui/context_menu.py | 54 + ui/directory_tab.py | 45 + ui/generation_handlers.py | 362 ++ ui/helpers.py | 7 + ui/main_tab.py | 103 + ui/main_window.py | 251 ++ ui/novel_params_tab.py | 110 + ui/setting_tab.py | 45 + ui/summary_tab.py | 45 + ui.py => ui_backup.txt | 3646 ++++++++--------- 24 files changed, 4410 insertions(+), 1823 deletions(-) create mode 100644 novel_generator/__init__.py create mode 100644 novel_generator/architecture.py create mode 100644 novel_generator/blueprint.py create mode 100644 novel_generator/chapter.py create mode 100644 novel_generator/common.py create mode 100644 novel_generator/finalization.py create mode 100644 novel_generator/knowledge.py create mode 100644 novel_generator/vectorstore_utils.py rename novel_generator.py => novel_generator_backup.txt (100%) create mode 100644 ui/__init__.py create mode 100644 ui/chapters_tab.py create mode 100644 ui/character_tab.py create mode 100644 ui/config_tab.py create mode 100644 ui/context_menu.py create mode 100644 ui/directory_tab.py create mode 100644 ui/generation_handlers.py create mode 100644 ui/helpers.py create mode 100644 ui/main_tab.py create mode 100644 ui/main_window.py create mode 100644 ui/novel_params_tab.py create mode 100644 ui/setting_tab.py create mode 100644 ui/summary_tab.py rename ui.py => ui_backup.txt (97%) diff --git a/.gitignore b/.gitignore index c1b2214..b239dd5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ /example config.json config_test.json +/novel_generator/__pycache__ +/ui/__pycache__ diff --git a/novel_generator/__init__.py b/novel_generator/__init__.py new file mode 100644 index 0000000..a19f65b --- /dev/null +++ b/novel_generator/__init__.py @@ -0,0 +1,7 @@ +#novel_generator/__init__.py +from .architecture import Novel_architecture_generate +from .blueprint import Chapter_blueprint_generate +from .chapter import generate_chapter_draft, get_last_n_chapters_text +from .finalization import finalize_chapter, enrich_chapter_text +from .knowledge import import_knowledge_file +from .vectorstore_utils import clear_vector_store \ No newline at end of file diff --git a/novel_generator/architecture.py b/novel_generator/architecture.py new file mode 100644 index 0000000..d6dae79 --- /dev/null +++ b/novel_generator/architecture.py @@ -0,0 +1,192 @@ +#novel_generator/architecture.py +# -*- coding: utf-8 -*- +""" +小说总体架构生成(Novel_architecture_generate 及相关辅助函数) +""" +import os +import json +import logging +import traceback +from novel_generator.common import invoke_with_cleaning +from llm_adapters import create_llm_adapter +from prompt_definitions import ( + core_seed_prompt, + character_dynamics_prompt, + world_building_prompt, + plot_architecture_prompt, + create_character_state_prompt +) +from utils import clear_file_content, save_string_to_txt + +def load_partial_architecture_data(filepath: str) -> dict: + """ + 从 filepath 下的 partial_architecture.json 读取已有的阶段性数据。 + 如果文件不存在或无法解析,返回空 dict。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + if not os.path.exists(partial_file): + return {} + try: + with open(partial_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data + except Exception as e: + logging.warning(f"Failed to load partial_architecture.json: {e}") + return {} + +def save_partial_architecture_data(filepath: str, data: dict): + """ + 将阶段性数据写入 partial_architecture.json。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + try: + with open(partial_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + logging.warning(f"Failed to save partial_architecture.json: {e}") + +def Novel_architecture_generate( + interface_format: str, + api_key: str, + base_url: str, + llm_model: str, + topic: str, + genre: str, + number_of_chapters: int, + word_number: int, + filepath: str, + temperature: float = 0.7, + max_tokens: int = 2048, + timeout: int = 600 +) -> None: + """ + 依次调用: + 1. core_seed_prompt + 2. character_dynamics_prompt + 3. world_building_prompt + 4. plot_architecture_prompt + 若在中间任何一步报错且重试多次失败,则将已经生成的内容写入 partial_architecture.json 并退出; + 下次调用时可从该步骤继续。 + 最终输出 Novel_architecture.txt + + 新增: + - 在完成角色动力学设定后,依据该角色体系,使用 create_character_state_prompt 生成初始角色状态表, + 并存储到 character_state.txt,后续维护更新。 + """ + os.makedirs(filepath, exist_ok=True) + partial_data = load_partial_architecture_data(filepath) + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=llm_model, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + # Step1: 核心种子 + if "core_seed_result" not in partial_data: + logging.info("Step1: Generating core_seed_prompt (核心种子) ...") + prompt_core = core_seed_prompt.format( + topic=topic, + genre=genre, + number_of_chapters=number_of_chapters, + word_number=word_number + ) + core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) + if not core_seed_result.strip(): + logging.warning("core_seed_prompt generation failed and returned empty.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["core_seed_result"] = core_seed_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step1 already done. Skipping...") + # Step2: 角色动力学 + if "character_dynamics_result" not in partial_data: + logging.info("Step2: Generating character_dynamics_prompt ...") + prompt_character = character_dynamics_prompt.format(core_seed=partial_data["core_seed_result"].strip()) + character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) + if not character_dynamics_result.strip(): + logging.warning("character_dynamics_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["character_dynamics_result"] = character_dynamics_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step2 already done. Skipping...") + # 生成初始角色状态 + if "character_dynamics_result" in partial_data and "character_state_result" not in partial_data: + logging.info("Generating initial character state from character dynamics ...") + prompt_char_state_init = create_character_state_prompt.format( + character_dynamics=partial_data["character_dynamics_result"].strip() + ) + character_state_init = invoke_with_cleaning(llm_adapter, prompt_char_state_init) + if not character_state_init.strip(): + logging.warning("create_character_state_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["character_state_result"] = character_state_init + character_state_file = os.path.join(filepath, "character_state.txt") + clear_file_content(character_state_file) + save_string_to_txt(character_state_init, character_state_file) + save_partial_architecture_data(filepath, partial_data) + logging.info("Initial character state created and saved.") + # Step3: 世界观 + if "world_building_result" not in partial_data: + logging.info("Step3: Generating world_building_prompt ...") + prompt_world = world_building_prompt.format(core_seed=partial_data["core_seed_result"].strip()) + world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) + if not world_building_result.strip(): + logging.warning("world_building_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["world_building_result"] = world_building_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step3 already done. Skipping...") + # Step4: 三幕式情节 + if "plot_arch_result" not in partial_data: + logging.info("Step4: Generating plot_architecture_prompt ...") + prompt_plot = plot_architecture_prompt.format( + core_seed=partial_data["core_seed_result"].strip(), + character_dynamics=partial_data["character_dynamics_result"].strip(), + world_building=partial_data["world_building_result"].strip() + ) + plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) + if not plot_arch_result.strip(): + logging.warning("plot_architecture_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["plot_arch_result"] = plot_arch_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step4 already done. Skipping...") + + core_seed_result = partial_data["core_seed_result"] + character_dynamics_result = partial_data["character_dynamics_result"] + world_building_result = partial_data["world_building_result"] + plot_arch_result = partial_data["plot_arch_result"] + + final_content = ( + "#=== 0) 小说设定 ===\n" + f"主题:{topic},类型:{genre},篇幅:约{number_of_chapters}章(每章{word_number}字)\n\n" + "#=== 1) 核心种子 ===\n" + f"{core_seed_result}\n\n" + "#=== 2) 角色动力学 ===\n" + f"{character_dynamics_result}\n\n" + "#=== 3) 世界观 ===\n" + f"{world_building_result}\n\n" + "#=== 4) 三幕式情节架构 ===\n" + f"{plot_arch_result}\n" + ) + + arch_file = os.path.join(filepath, "Novel_architecture.txt") + clear_file_content(arch_file) + save_string_to_txt(final_content, arch_file) + logging.info("Novel_architecture.txt has been generated successfully.") + + partial_arch_file = os.path.join(filepath, "partial_architecture.json") + if os.path.exists(partial_arch_file): + os.remove(partial_arch_file) + logging.info("partial_architecture.json removed (all steps completed).") diff --git a/novel_generator/blueprint.py b/novel_generator/blueprint.py new file mode 100644 index 0000000..a0425df --- /dev/null +++ b/novel_generator/blueprint.py @@ -0,0 +1,169 @@ +#novel_generator/blueprint.py +# -*- coding: utf-8 -*- +""" +章节蓝图生成(Chapter_blueprint_generate 及辅助函数) +""" +import os +import re +import logging +from novel_generator.common import invoke_with_cleaning +from llm_adapters import create_llm_adapter +from prompt_definitions import chapter_blueprint_prompt, chunked_chapter_blueprint_prompt +from utils import read_file, clear_file_content, save_string_to_txt + +def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: + """ + 基于“每章约100 tokens”的粗略估算, + 再结合当前max_tokens,计算分块大小: + chunk_size = (floor(max_tokens/100/10)*10) - 10 + 并确保 chunk_size 不会小于1或大于实际章节数。 + """ + tokens_per_chapter = 100.0 + ratio = max_tokens / tokens_per_chapter + ratio_rounded_to_10 = int(ratio // 10) * 10 + chunk_size = ratio_rounded_to_10 - 10 + if chunk_size < 1: + chunk_size = 1 + if chunk_size > number_of_chapters: + chunk_size = number_of_chapters + return chunk_size + +def limit_chapter_blueprint(blueprint_text: str, limit_chapters: int = 100) -> str: + """ + 从已有章节目录中只取最近的 limit_chapters 章,以避免 prompt 超长。 + """ + pattern = r"(第\s*\d+\s*章.*?)(?=第\s*\d+\s*章|$)" + chapters = re.findall(pattern, blueprint_text, flags=re.DOTALL) + if not chapters: + return blueprint_text + if len(chapters) <= limit_chapters: + return blueprint_text + selected = chapters[-limit_chapters:] + return "\n\n".join(selected).strip() + +def Chapter_blueprint_generate( + interface_format: str, + api_key: str, + base_url: str, + llm_model: str, + filepath: str, + number_of_chapters: int, + temperature: float = 0.7, + max_tokens: int = 4096, + timeout: int = 600 +) -> None: + """ + 若 Novel_directory.txt 已存在且内容非空,则表示可能是之前的部分生成结果; + 解析其中已有的章节数,从下一个章节继续分块生成; + 对于已有章节目录,传入时仅保留最近100章目录,避免prompt过长。 + 否则: + - 若章节数 <= chunk_size,直接一次性生成 + - 若章节数 > chunk_size,进行分块生成 + 生成完成后输出至 Novel_directory.txt。 + """ + arch_file = os.path.join(filepath, "Novel_architecture.txt") + if not os.path.exists(arch_file): + logging.warning("Novel_architecture.txt not found. Please generate architecture first.") + return + + architecture_text = read_file(arch_file).strip() + if not architecture_text: + logging.warning("Novel_architecture.txt is empty.") + return + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=llm_model, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + if not os.path.exists(filename_dir): + open(filename_dir, "w", encoding="utf-8").close() + + existing_blueprint = read_file(filename_dir).strip() + chunk_size = compute_chunk_size(number_of_chapters, max_tokens) + logging.info(f"Number of chapters = {number_of_chapters}, computed chunk_size = {chunk_size}.") + + if existing_blueprint: + logging.info("Detected existing blueprint content. Will resume chunked generation from that point.") + pattern = r"第\s*(\d+)\s*章" + existing_chapter_numbers = re.findall(pattern, existing_blueprint) + existing_chapter_numbers = [int(x) for x in existing_chapter_numbers if x.isdigit()] + max_existing_chap = max(existing_chapter_numbers) if existing_chapter_numbers else 0 + logging.info(f"Existing blueprint indicates up to chapter {max_existing_chap} has been generated.") + final_blueprint = existing_blueprint + current_start = max_existing_chap + 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=limited_blueprint, + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return + final_blueprint += "\n\n" + chunk_result.strip() + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + current_start = current_end + 1 + + logging.info("All chapters blueprint have been generated (resumed chunked).") + return + + if chunk_size >= number_of_chapters: + prompt = chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + number_of_chapters=number_of_chapters + ) + blueprint_text = invoke_with_cleaning(llm_adapter, prompt) + if not blueprint_text.strip(): + logging.warning("Chapter blueprint generation result is empty.") + return + + clear_file_content(filename_dir) + save_string_to_txt(blueprint_text, filename_dir) + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).") + return + + logging.info("Will generate chapter blueprint in chunked mode from scratch.") + final_blueprint = "" + current_start = 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=limited_blueprint, + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return + if final_blueprint.strip(): + final_blueprint += "\n\n" + chunk_result.strip() + else: + final_blueprint = chunk_result.strip() + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + current_start = current_end + 1 + + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") diff --git a/novel_generator/chapter.py b/novel_generator/chapter.py new file mode 100644 index 0000000..b3eddae --- /dev/null +++ b/novel_generator/chapter.py @@ -0,0 +1,216 @@ +#novel_generator/chapter.py +# -*- coding: utf-8 -*- +""" +章节草稿生成及获取历史章节文本、短期摘要等 +""" +import os +import logging +from nltk import download +from llm_adapters import create_llm_adapter +from prompt_definitions import first_chapter_draft_prompt, next_chapter_draft_prompt, summarize_recent_chapters_prompt +from chapter_directory_parser import get_chapter_info_from_blueprint +from novel_generator.common import invoke_with_cleaning +from utils import read_file, clear_file_content, save_string_to_txt +from novel_generator.vectorstore_utils import get_relevant_context_from_vector_store + +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> list: + """ + 从目录 chapters_dir 中获取最近 n 章的文本内容,返回文本列表。 + """ + texts = [] + start_chap = max(1, current_chapter_num - n) + for c in range(start_chap, current_chapter_num): + chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") + if os.path.exists(chap_file): + text = read_file(chap_file).strip() + texts.append(text) + else: + texts.append("") + return texts + +def summarize_recent_chapters( + interface_format: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + max_tokens: int, + chapters_text_list: list, + timeout: int = 600 +) -> tuple: + """ + 生成 (short_summary, next_chapter_keywords) + 如果解析失败,则返回 (合并文本, "") + """ + combined_text = "\n".join(chapters_text_list).strip() + if not combined_text: + return ("", "") + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) + response_text = invoke_with_cleaning(llm_adapter, prompt) + short_summary = "" + next_chapter_keywords = "" + for line in response_text.splitlines(): + line = line.strip() + if line.startswith("短期摘要:"): + short_summary = line.replace("短期摘要:", "").strip() + elif line.startswith("下一章关键字:"): + next_chapter_keywords = line.replace("下一章关键字:", "").strip() + if not short_summary and not next_chapter_keywords: + short_summary = response_text + return (short_summary, next_chapter_keywords) + +def generate_chapter_draft( + api_key: str, + base_url: str, + model_name: str, + filepath: str, + novel_number: int, + word_number: int, + temperature: float, + user_guidance: str, + characters_involved: str, + key_items: str, + scene_location: str, + time_constraint: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + embedding_retrieval_k: int = 2, + interface_format: str = "openai", + max_tokens: int = 2048, + timeout: int = 600 +) -> str: + """ + 根据 novel_number 判断是否为第一章。 + - 若是第一章,则使用 first_chapter_draft_prompt + - 否则使用 next_chapter_draft_prompt + 最终将生成文本存入 chapters/chapter_{novel_number}.txt。 + """ + arch_file = os.path.join(filepath, "Novel_architecture.txt") + novel_architecture_text = read_file(arch_file) + directory_file = os.path.join(filepath, "Novel_directory.txt") + blueprint_text = read_file(directory_file) + global_summary_file = os.path.join(filepath, "global_summary.txt") + global_summary_text = read_file(global_summary_file) + 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"] + chapter_purpose = chapter_info["chapter_purpose"] + suspense_level = chapter_info["suspense_level"] + foreshadowing = chapter_info["foreshadowing"] + plot_twist_level = chapter_info["plot_twist_level"] + chapter_summary = chapter_info["chapter_summary"] + + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + + if novel_number == 1: + prompt_text = first_chapter_draft_prompt.format( + novel_number=novel_number, + word_number=word_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, + novel_setting=novel_architecture_text + ) + else: + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + short_summary, next_chapter_keywords = summarize_recent_chapters( + 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, + timeout=timeout + ) + previous_chapter_excerpt = "" + for text_block in reversed(recent_3_texts): + if text_block.strip(): + if len(text_block) > 1500: + previous_chapter_excerpt = text_block[-1500:] + else: + previous_chapter_excerpt = text_block + break + from llm_adapters import create_llm_adapter # 避免循环依赖 + embedding_adapter = create_llm_adapter( + interface_format=embedding_interface_format, + base_url=embedding_url, + model_name=embedding_model_name, + api_key=embedding_api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + retrieval_query = short_summary + " " + next_chapter_keywords + relevant_context = get_relevant_context_from_vector_store( + embedding_adapter=embedding_adapter, + query=retrieval_query, + filepath=filepath, + k=embedding_retrieval_k + ) + if not relevant_context.strip(): + relevant_context = "(无检索到的上下文)" + prompt_text = next_chapter_draft_prompt.format( + novel_number=novel_number, + word_number=word_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, + novel_setting=novel_architecture_text, + global_summary=global_summary_text, + character_state=character_state_text, + context_excerpt=relevant_context, + previous_chapter_excerpt=previous_chapter_excerpt + ) + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) + if not chapter_content.strip(): + logging.warning("Generated chapter draft is empty.") + 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) + logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") + return chapter_content diff --git a/novel_generator/common.py b/novel_generator/common.py new file mode 100644 index 0000000..f31fed7 --- /dev/null +++ b/novel_generator/common.py @@ -0,0 +1,59 @@ +#novel_generator/common.py +# -*- coding: utf-8 -*- +""" +通用重试、清洗、日志工具 +""" +import logging +import re +import time +import traceback + +def call_with_retry(func, max_retries=3, sleep_time=2, fallback_return=None, **kwargs): + """ + 通用的重试机制封装。 + :param func: 要执行的函数 + :param max_retries: 最大重试次数 + :param sleep_time: 重试前的等待秒数 + :param fallback_return: 如果多次重试仍失败时的返回值 + :param kwargs: 传给func的命名参数 + :return: func的结果,若失败则返回 fallback_return + """ + for attempt in range(1, max_retries + 1): + try: + return func(**kwargs) + except Exception as e: + logging.warning(f"[call_with_retry] Attempt {attempt} failed with error: {e}") + traceback.print_exc() + if attempt < max_retries: + time.sleep(sleep_time) + else: + logging.error("Max retries reached, returning fallback_return.") + return fallback_return + +def remove_think_tags(text: str) -> str: + """移除 ... 包裹的内容""" + return re.sub(r'.*?', '', text, flags=re.DOTALL) + +def debug_log(prompt: str, response_content: str): + logging.info( + f"\n[######################################### Prompt #########################################]\n{prompt}\n" + ) + logging.info( + f"\n[######################################### Response #########################################]\n{response_content}\n" + ) + +def invoke_with_cleaning(llm_adapter, prompt: str) -> str: + """ + 调用 LLM,增加重试和清洗逻辑 + 如果多次失败,则返回空字符串以继续流程,而不是中断。 + """ + def _invoke(prompt): + return llm_adapter.invoke(prompt) + + response = call_with_retry(func=_invoke, max_retries=3, fallback_return="", prompt=prompt) + if not response: + logging.warning("No response from model after retry. Return empty.") + return "" + cleaned_text = remove_think_tags(response) + debug_log(prompt, cleaned_text) + return cleaned_text.strip() diff --git a/novel_generator/finalization.py b/novel_generator/finalization.py new file mode 100644 index 0000000..0e6a362 --- /dev/null +++ b/novel_generator/finalization.py @@ -0,0 +1,121 @@ +#novel_generator/finalization.py +# -*- coding: utf-8 -*- +""" +定稿章节和扩写章节(finalize_chapter、enrich_chapter_text) +""" +import os +import logging +from llm_adapters import create_llm_adapter +from prompt_definitions import summary_prompt, update_character_state_prompt +from novel_generator.common import invoke_with_cleaning +from utils import read_file, clear_file_content, save_string_to_txt +from novel_generator.vectorstore_utils import update_vector_store + +def finalize_chapter( + novel_number: int, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + filepath: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + interface_format: str, + max_tokens: int, + timeout: int = 600 +): + """ + 对指定章节做最终处理:更新全局摘要、更新角色状态、插入向量库等。 + 默认无需再做扩写操作,若有需要可在外部调用 enrich_chapter_text 处理后再定稿。 + """ + 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() + if not chapter_text: + logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") + return + + 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_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + prompt_summary = summary_prompt.format( + chapter_text=chapter_text, + global_summary=old_global_summary + ) + new_global_summary = invoke_with_cleaning(llm_adapter, prompt_summary) + 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 + ) + new_char_state = invoke_with_cleaning(llm_adapter, prompt_char_state) + 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) + + update_vector_store( + embedding_adapter=create_llm_adapter( + interface_format=embedding_interface_format, + base_url=embedding_url, + model_name=embedding_model_name, + api_key=embedding_api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ), + new_chapter=chapter_text, + filepath=filepath + ) + + logging.info(f"Chapter {novel_number} has been finalized.") + +def enrich_chapter_text( + chapter_text: str, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + interface_format: str, + max_tokens: int, + timeout: int=600 +) -> str: + """ + 对章节文本进行扩写,使其更接近 word_number 字数,保持剧情连贯。 + """ + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: +原内容: +{chapter_text} +""" + enriched_text = invoke_with_cleaning(llm_adapter, prompt) + return enriched_text if enriched_text else chapter_text diff --git a/novel_generator/knowledge.py b/novel_generator/knowledge.py new file mode 100644 index 0000000..b883455 --- /dev/null +++ b/novel_generator/knowledge.py @@ -0,0 +1,93 @@ +#novel_generator/knowledge.py +# -*- coding: utf-8 -*- +""" +知识文件导入至向量库(advanced_split_content、import_knowledge_file) +""" +import os +import logging +import re +import traceback +import nltk +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity +from utils import read_file +from novel_generator.vectorstore_utils import load_vector_store, init_vector_store +from langchain.docstore.document import Document + +def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> list: + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + sentences = nltk.sent_tokenize(content) + if not sentences: + return [] + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = [] + start_idx = 0 + while start_idx < len(para): + end_idx = min(start_idx + max_length, len(para)) + segment = para[start_idx:end_idx].strip() + sub_segments.append(segment) + start_idx = end_idx + final_segments.extend(sub_segments) + else: + final_segments.append(para) + return final_segments + +def import_knowledge_file( + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + file_path: str, + filepath: str +): + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_interface_format}, 模型: {embedding_model_name}") + if not os.path.exists(file_path): + logging.warning(f"知识库文件不存在: {file_path}") + return + content = read_file(file_path) + if not content.strip(): + logging.warning("知识库文件内容为空。") + return + paragraphs = advanced_split_content(content) + from llm_adapters import create_embedding_adapter + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url if embedding_url else "http://localhost:11434/api", + embedding_model_name + ) + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("Vector store does not exist or load failed. Initializing a new one for knowledge import...") + store = init_vector_store(embedding_adapter, paragraphs, filepath) + if store: + logging.info("知识库文件已成功导入至向量库(新初始化)。") + else: + logging.warning("知识库导入失败,跳过。") + else: + try: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库(追加模式)。") + except Exception as e: + logging.warning(f"知识库导入失败: {e}") + traceback.print_exc() diff --git a/novel_generator/vectorstore_utils.py b/novel_generator/vectorstore_utils.py new file mode 100644 index 0000000..5de3a97 --- /dev/null +++ b/novel_generator/vectorstore_utils.py @@ -0,0 +1,228 @@ +#novel_generator/vectorstore_utils.py +# -*- coding: utf-8 -*- +""" +向量库相关操作(初始化、更新、检索、清空、文本切分等) +""" +import os +import logging +import traceback +import nltk +from langchain_chroma import Chroma +from chromadb.config import Settings +from langchain.docstore.document import Document +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity +from .common import call_with_retry + +def get_vectorstore_dir(filepath: str) -> str: + """获取 vectorstore 路径""" + return os.path.join(filepath, "vectorstore") + +def clear_vector_store(filepath: str) -> bool: + """清空 清空向量库""" + import shutil + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("No vector store found to clear.") + return False + try: + shutil.rmtree(store_dir) + logging.info(f"Vector store directory '{store_dir}' removed.") + return True + except Exception as e: + logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}") + traceback.print_exc() + return False + +def init_vector_store(embedding_adapter, texts, filepath: str): + """ + 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + 如果Embedding失败,则返回 None,不中断任务。 + """ + from langchain.embeddings.base import Embeddings as LCEmbeddings + + store_dir = get_vectorstore_dir(filepath) + os.makedirs(store_dir, exist_ok=True) + documents = [Document(page_content=str(t)) for t in texts] + + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, texts): + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + texts=texts + ) + def embed_query(self, query: str): + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query=query + ) + return res + + chroma_embedding = LCEmbeddingWrapper() + vectorstore = Chroma.from_documents( + documents, + embedding=chroma_embedding, + persist_directory=store_dir, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + return vectorstore + except Exception as e: + logging.warning(f"Init vector store failed: {e}") + traceback.print_exc() + return None + +def load_vector_store(embedding_adapter, filepath: str): + """ + 读取已存在的 Chroma 向量库。若不存在则返回 None。 + 如果加载失败(embedding 或IO问题),则返回 None。 + """ + from langchain.embeddings.base import Embeddings as LCEmbeddings + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("Vector store not found. Will return None.") + return None + + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, texts): + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + texts=texts + ) + def embed_query(self, query: str): + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query=query + ) + return res + + chroma_embedding = LCEmbeddingWrapper() + return Chroma( + persist_directory=store_dir, + embedding_function=chroma_embedding, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + except Exception as e: + logging.warning(f"Failed to load vector store: {e}") + traceback.print_exc() + return None + +def split_by_length(text: str, max_length: int = 500): + """按照 max_length 切分文本""" + segments = [] + start_idx = 0 + while start_idx < len(text): + end_idx = min(start_idx + max_length, len(text)) + segment = text[start_idx:end_idx] + segments.append(segment.strip()) + start_idx = end_idx + return segments + +def split_text_for_vectorstore(chapter_text: str, max_length: int = 500, similarity_threshold: float = 0.7): + """ + 对新的章节文本进行分段后,再用于存入向量库。 + 先句子切分 -> 语义相似度合并 -> 再按 max_length 切分。 + """ + if not chapter_text.strip(): + return [] + + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + sentences = nltk.sent_tokenize(chapter_text) + if not sentences: + return [] + + model = SentenceTransformer('paraphrase-MiniLM-L6-v2') + embeddings = model.encode(sentences) + + merged_paragraphs = [] + current_sentences = [sentences[0]] + current_embedding = embeddings[0] + + for i in range(1, len(sentences)): + sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0] + if sim >= similarity_threshold: + current_sentences.append(sentences[i]) + current_embedding = (current_embedding + embeddings[i]) / 2.0 + else: + merged_paragraphs.append(" ".join(current_sentences)) + current_sentences = [sentences[i]] + current_embedding = embeddings[i] + + if current_sentences: + merged_paragraphs.append(" ".join(current_sentences)) + + final_segments = [] + for para in merged_paragraphs: + if len(para) > max_length: + sub_segments = split_by_length(para, max_length=max_length) + final_segments.extend(sub_segments) + else: + final_segments.append(para) + + return final_segments + +def update_vector_store(embedding_adapter, new_chapter: str, filepath: str): + """ + 将最新章节文本插入到向量库中。 + 若库不存在则初始化;若初始化/更新失败,则跳过。 + """ + from utils import read_file, clear_file_content, save_string_to_txt + splitted_texts = split_text_for_vectorstore(new_chapter) + if not splitted_texts: + logging.warning("No valid text to insert into vector store. Skipping.") + return + + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("Vector store does not exist or failed to load. Initializing a new one for new chapter...") + store = init_vector_store(embedding_adapter, splitted_texts, filepath) + if not store: + logging.warning("Init vector store failed, skip embedding.") + else: + logging.info("New vector store created successfully.") + return + + try: + 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.") + except Exception as e: + logging.warning(f"Failed to update vector store: {e}") + traceback.print_exc() + +def get_relevant_context_from_vector_store(embedding_adapter, query: str, filepath: str, k: int = 2) -> str: + """ + 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 + 如果向量库加载/检索失败,则返回空字符串。 + 最终只返回最多2000字符的检索片段。 + """ + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("No vector store found or load failed. Returning empty context.") + return "" + + try: + 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]) + if len(combined) > 2000: + combined = combined[:2000] + return combined + except Exception as e: + logging.warning(f"Similarity search failed: {e}") + traceback.print_exc() + return "" diff --git a/novel_generator.py b/novel_generator_backup.txt similarity index 100% rename from novel_generator.py rename to novel_generator_backup.txt diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..bd11ca3 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,2 @@ +# ui/__init__.py +from .main_window import NovelGeneratorGUI diff --git a/ui/chapters_tab.py b/ui/chapters_tab.py new file mode 100644 index 0000000..5310fbf --- /dev/null +++ b/ui/chapters_tab.py @@ -0,0 +1,129 @@ +# ui/chapters_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from ui.context_menu import TextWidgetContextMenu +from utils import read_file, save_string_to_txt, clear_file_content + +def build_chapters_tab(self): + self.chapters_view_tab = self.tabview.add("Chapters Manage") + self.chapters_view_tab.rowconfigure(0, weight=0) + self.chapters_view_tab.rowconfigure(1, weight=1) + self.chapters_view_tab.columnconfigure(0, weight=1) + + top_frame = ctk.CTkFrame(self.chapters_view_tab) + top_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + top_frame.columnconfigure(0, weight=0) + top_frame.columnconfigure(1, weight=0) + top_frame.columnconfigure(2, weight=0) + top_frame.columnconfigure(3, weight=0) + top_frame.columnconfigure(4, weight=1) + + prev_btn = ctk.CTkButton(top_frame, text="<< 上一章", command=self.prev_chapter, font=("Microsoft YaHei", 12)) + prev_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + next_btn = ctk.CTkButton(top_frame, text="下一章 >>", command=self.next_chapter, font=("Microsoft YaHei", 12)) + next_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + self.chapter_select_var = ctk.StringVar(value="") + self.chapter_select_menu = ctk.CTkOptionMenu(top_frame, values=[], variable=self.chapter_select_var, command=self.on_chapter_selected, font=("Microsoft YaHei", 12)) + self.chapter_select_menu.grid(row=0, column=2, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(top_frame, text="保存修改", command=self.save_current_chapter, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=3, padx=5, pady=5, sticky="w") + + refresh_btn = ctk.CTkButton(top_frame, text="刷新章节列表", command=self.refresh_chapters_list, font=("Microsoft YaHei", 12)) + refresh_btn.grid(row=0, column=4, padx=5, pady=5, sticky="e") + + self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12)) + TextWidgetContextMenu(self.chapter_view_text) + self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + self.chapters_list = [] + refresh_chapters_list(self) + +def refresh_chapters_list(self): + filepath = self.filepath_var.get().strip() + chapters_dir = os.path.join(filepath, "chapters") + if not os.path.exists(chapters_dir): + self.safe_log("尚未找到 chapters 文件夹,请先生成章节或检查保存路径。") + self.chapter_select_menu.configure(values=[]) + return + + all_files = os.listdir(chapters_dir) + chapter_nums = [] + for f in all_files: + if f.startswith("chapter_") and f.endswith(".txt"): + number_part = f.replace("chapter_", "").replace(".txt", "") + if number_part.isdigit(): + chapter_nums.append(number_part) + chapter_nums.sort(key=lambda x: int(x)) + self.chapters_list = chapter_nums + self.chapter_select_menu.configure(values=self.chapters_list) + current_selected = self.chapter_select_var.get() + if current_selected not in self.chapters_list: + if self.chapters_list: + self.chapter_select_var.set(self.chapters_list[0]) + load_chapter_content(self, self.chapters_list[0]) + else: + self.chapter_select_var.set("") + self.chapter_view_text.delete("0.0", "end") + +def on_chapter_selected(self, value): + load_chapter_content(self, value) + +def load_chapter_content(self, chapter_number_str): + if not chapter_number_str: + return + filepath = self.filepath_var.get().strip() + chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") + if not os.path.exists(chapter_file): + self.safe_log(f"章节文件 {chapter_file} 不存在!") + return + content = read_file(chapter_file) + self.chapter_view_text.delete("0.0", "end") + self.chapter_view_text.insert("0.0", content) + +def save_current_chapter(self): + chapter_number_str = self.chapter_select_var.get() + if not chapter_number_str: + messagebox.showwarning("警告", "尚未选择章节,无法保存。") + return + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径") + return + chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") + content = self.chapter_view_text.get("0.0", "end").strip() + clear_file_content(chapter_file) + save_string_to_txt(content, chapter_file) + self.safe_log(f"已保存对第 {chapter_number_str} 章的修改。") + +def prev_chapter(self): + if not self.chapters_list: + return + current = self.chapter_select_var.get() + if current not in self.chapters_list: + return + idx = self.chapters_list.index(current) + if idx > 0: + new_idx = idx - 1 + self.chapter_select_var.set(self.chapters_list[new_idx]) + load_chapter_content(self, self.chapters_list[new_idx]) + else: + messagebox.showinfo("提示", "已经是第一章了。") + +def next_chapter(self): + if not self.chapters_list: + return + current = self.chapter_select_var.get() + if current not in self.chapters_list: + return + idx = self.chapters_list.index(current) + if idx < len(self.chapters_list) - 1: + new_idx = idx + 1 + self.chapter_select_var.set(self.chapters_list[new_idx]) + load_chapter_content(self, self.chapters_list[new_idx]) + else: + messagebox.showinfo("提示", "已经是最后一章了。") diff --git a/ui/character_tab.py b/ui/character_tab.py new file mode 100644 index 0000000..2a87670 --- /dev/null +++ b/ui/character_tab.py @@ -0,0 +1,45 @@ +# ui/character_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_character_tab(self): + self.character_tab = self.tabview.add("Character State") + 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)) + TextWidgetContextMenu(self.character_text) + 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("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "character_state.txt") + content = read_file(filename) + 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("警告", "请先设置保存文件路径") + return + content = self.character_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "character_state.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 character_state.txt 的修改。") diff --git a/ui/config_tab.py b/ui/config_tab.py new file mode 100644 index 0000000..3d724ac --- /dev/null +++ b/ui/config_tab.py @@ -0,0 +1,302 @@ +# ui/config_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import messagebox +from config_manager import load_config, save_config +from tooltips import tooltips + +def create_label_with_help(self, parent, label_text, tooltip_key, row, column, + font=None, sticky="e", padx=5, pady=5): + """ + 封装一个带"?"按钮的Label,用于展示提示信息。 + """ + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + + 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: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明")) + ) + btn.pack(side="left", padx=3) + + return frame + +def build_config_tabview(self): + """ + 创建包含 LLM Model settings 和 Embedding settings 的选项卡。 + """ + self.config_tabview = ctk.CTkTabview(self.config_frame) + self.config_tabview.grid(row=0, column=0, 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") + + build_ai_config_tab(self) + build_embeddings_config_tab(self) + + # 底部的"保存配置"和"加载配置"按钮 + self.btn_frame_config = ctk.CTkFrame(self.config_frame) + self.btn_frame_config.grid(row=1, column=0, 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") + +def build_ai_config_tab(self): + def on_interface_format_changed(new_value): + self.interface_format_var.set(new_value) + config_data = load_config(self.config_file) + if config_data: + config_data["last_interface_format"] = new_value + save_config(config_data, self.config_file) + if self.loaded_config and "llm_configs" in self.loaded_config and new_value in self.loaded_config["llm_configs"]: + llm_conf = self.loaded_config["llm_configs"][new_value] + self.api_key_var.set(llm_conf.get("api_key", "")) + self.base_url_var.set(llm_conf.get("base_url", self.base_url_var.get())) + self.model_name_var.set(llm_conf.get("model_name", "")) + self.temperature_var.set(llm_conf.get("temperature", 0.7)) + self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) + self.timeout_var.set(llm_conf.get("timeout", 600)) + else: + 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.openai.com/v1") + elif new_value == "Azure OpenAI": + self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") + elif new_value == "Gemini": + self.base_url_var.set("") + elif new_value == "Azure AI": + self.base_url_var.set("https://.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview") + + for i in range(7): + 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) + + # 1) API Key + create_label_with_help(self, 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, columnspan=2, sticky="nsew") + + # 2) Base URL + create_label_with_help(self, 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, columnspan=2, sticky="nsew") + + # 3) 接口格式 + create_label_with_help(self, parent=self.ai_config_tab, label_text="LLM 接口格式:", tooltip_key="interface_format", row=2, column=0, font=("Microsoft YaHei", 12)) + interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini"] + interface_dropdown = ctk.CTkOptionMenu(self.ai_config_tab, values=interface_options, variable=self.interface_format_var, command=on_interface_format_changed, font=("Microsoft YaHei", 12)) + interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") + + # 4) Model Name + create_label_with_help(self, 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, columnspan=2, sticky="nsew") + + # 5) Temperature + create_label_with_help(self, 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}") + temp_scale = ctk.CTkSlider(self.ai_config_tab, from_=0.0, to=2.0, number_of_steps=200, 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=5, pady=5, sticky="w") + + # 6) Max Tokens + create_label_with_help(self, 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") + + # 7) Timeout (sec) + create_label_with_help(self, parent=self.ai_config_tab, label_text="Timeout (sec):", tooltip_key="timeout", row=6, column=0, font=("Microsoft YaHei", 12)) + def update_timeout_label(value): + integer_val = int(float(value)) + self.timeout_value_label.configure(text=str(integer_val)) + timeout_slider = ctk.CTkSlider(self.ai_config_tab, from_=0, to=3600, number_of_steps=3600, command=update_timeout_label, variable=self.timeout_var) + timeout_slider.grid(row=6, column=1, padx=5, pady=5, sticky="we") + self.timeout_value_label = ctk.CTkLabel(self.ai_config_tab, text=str(self.timeout_var.get()), font=("Microsoft YaHei", 12)) + self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w") + + # 添加测试按钮 + test_btn = ctk.CTkButton(self.ai_config_tab, text="测试配置", command=self.test_llm_config, font=("Microsoft YaHei", 12)) + test_btn.grid(row=7, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + +def build_embeddings_config_tab(self): + def on_embedding_interface_changed(new_value): + self.embedding_interface_format_var.set(new_value) + config_data = load_config(self.config_file) + if config_data: + config_data["last_embedding_interface_format"] = new_value + save_config(config_data, self.config_file) + if self.loaded_config and "embedding_configs" in self.loaded_config and new_value in self.loaded_config["embedding_configs"]: + emb_conf = self.loaded_config["embedding_configs"][new_value] + self.embedding_api_key_var.set(emb_conf.get("api_key", "")) + self.embedding_url_var.set(emb_conf.get("base_url", self.embedding_url_var.get())) + self.embedding_model_name_var.set(emb_conf.get("model_name", "")) + self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) + else: + if new_value == "Ollama": + self.embedding_url_var.set("http://localhost:11434/api") + elif new_value == "ML Studio": + self.embedding_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.embedding_url_var.set("https://api.openai.com/v1") + self.embedding_model_name_var.set("text-embedding-ada-002") + elif new_value == "Azure OpenAI": + self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + elif new_value == "Gemini": + self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") + self.embedding_model_name_var.set("models/text-embedding-004") + + 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) + + # 1) Embedding API Key + create_label_with_help(self, 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") + + # 2) Embedding 接口格式 + create_label_with_help(self, parent=self.embeddings_config_tab, label_text="Embedding 接口格式:", tooltip_key="embedding_interface_format", row=1, column=0, font=("Microsoft YaHei", 12)) + emb_interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Gemini", "Ollama", "ML Studio"] + emb_interface_dropdown = ctk.CTkOptionMenu(self.embeddings_config_tab, values=emb_interface_options, variable=self.embedding_interface_format_var, command=on_embedding_interface_changed, font=("Microsoft YaHei", 12)) + emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + + # 3) Embedding Base URL + create_label_with_help(self, 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") + + # 4) Embedding Model Name + create_label_with_help(self, 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") + + # 5) Retrieval Top-K + create_label_with_help(self, 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") + + # 添加测试按钮 + test_btn = ctk.CTkButton(self.embeddings_config_tab, text="测试配置", command=self.test_embedding_config, font=("Microsoft YaHei", 12)) + test_btn.grid(row=5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + +def load_config_btn(self): + cfg = load_config(self.config_file) + if cfg: + last_llm = cfg.get("last_interface_format", "OpenAI") + last_embedding = cfg.get("last_embedding_interface_format", "OpenAI") + self.interface_format_var.set(last_llm) + self.embedding_interface_format_var.set(last_embedding) + llm_configs = cfg.get("llm_configs", {}) + if last_llm in llm_configs: + llm_conf = llm_configs[last_llm] + self.api_key_var.set(llm_conf.get("api_key", "")) + self.base_url_var.set(llm_conf.get("base_url", "https://api.openai.com/v1")) + self.model_name_var.set(llm_conf.get("model_name", "gpt-4o-mini")) + self.temperature_var.set(llm_conf.get("temperature", 0.7)) + self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) + self.timeout_var.set(llm_conf.get("timeout", 600)) + embedding_configs = cfg.get("embedding_configs", {}) + if last_embedding in embedding_configs: + emb_conf = embedding_configs[last_embedding] + self.embedding_api_key_var.set(emb_conf.get("api_key", "")) + self.embedding_url_var.set(emb_conf.get("base_url", "https://api.openai.com/v1")) + self.embedding_model_name_var.set(emb_conf.get("model_name", "text-embedding-ada-002")) + self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) + other_params = cfg.get("other_params", {}) + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", other_params.get("topic", "")) + self.genre_var.set(other_params.get("genre", "玄幻")) + self.num_chapters_var.set(str(other_params.get("num_chapters", 10))) + self.word_number_var.set(str(other_params.get("word_number", 3000))) + self.filepath_var.set(other_params.get("filepath", "")) + self.chapter_num_var.set(str(other_params.get("chapter_num", "1"))) + self.user_guide_text.delete("0.0", "end") + self.user_guide_text.insert("0.0", other_params.get("user_guidance", "")) + self.characters_involved_var.set(other_params.get("characters_involved", "")) + self.key_items_var.set(other_params.get("key_items", "")) + self.scene_location_var.set(other_params.get("scene_location", "")) + self.time_constraint_var.set(other_params.get("time_constraint", "")) + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") + +def save_config_btn(self): + current_llm_interface = self.interface_format_var.get().strip() + current_embedding_interface = self.embedding_interface_format_var.get().strip() + llm_config = { + "api_key": self.api_key_var.get(), + "base_url": self.base_url_var.get(), + "model_name": self.model_name_var.get(), + "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), + "timeout": self.safe_get_int(self.timeout_var, 600) + } + embedding_config = { + "api_key": self.embedding_api_key_var.get(), + "base_url": self.embedding_url_var.get(), + "model_name": self.embedding_model_name_var.get(), + "retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4) + } + other_params = { + "topic": self.topic_text.get("0.0", "end").strip(), + "genre": self.genre_var.get(), + "num_chapters": self.safe_get_int(self.num_chapters_var, 10), + "word_number": self.safe_get_int(self.word_number_var, 3000), + "filepath": self.filepath_var.get(), + "chapter_num": self.chapter_num_var.get(), + "user_guidance": self.user_guide_text.get("0.0", "end").strip(), + "characters_involved": self.characters_involved_var.get(), + "key_items": self.key_items_var.get(), + "scene_location": self.scene_location_var.get(), + "time_constraint": self.time_constraint_var.get() + } + existing_config = load_config(self.config_file) + if not existing_config: + existing_config = {} + existing_config["last_interface_format"] = current_llm_interface + existing_config["last_embedding_interface_format"] = current_embedding_interface + if "llm_configs" not in existing_config: + existing_config["llm_configs"] = {} + existing_config["llm_configs"][current_llm_interface] = llm_config + + if "embedding_configs" not in existing_config: + existing_config["embedding_configs"] = {} + existing_config["embedding_configs"][current_embedding_interface] = embedding_config + + existing_config["other_params"] = other_params + + if save_config(existing_config, self.config_file): + messagebox.showinfo("提示", "配置已保存至 config.json") + self.log("配置已保存。") + else: + messagebox.showerror("错误", "保存配置失败。") diff --git a/ui/context_menu.py b/ui/context_menu.py new file mode 100644 index 0000000..438081c --- /dev/null +++ b/ui/context_menu.py @@ -0,0 +1,54 @@ +# ui/context_menu.py +# -*- coding: utf-8 -*- +import tkinter as tk +import customtkinter as ctk + +class TextWidgetContextMenu: + """ + 为 customtkinter.TextBox 或 tkinter.Text 提供右键复制/剪切/粘贴/全选的功能。 + """ + def __init__(self, widget): + self.widget = widget + self.menu = tk.Menu(widget, tearoff=0) + self.menu.add_command(label="复制", command=self.copy) + self.menu.add_command(label="粘贴", command=self.paste) + self.menu.add_command(label="剪切", command=self.cut) + self.menu.add_separator() + self.menu.add_command(label="全选", command=self.select_all) + + # 绑定右键事件 + self.widget.bind("", self.show_menu) + + def show_menu(self, event): + if isinstance(self.widget, ctk.CTkTextbox): + try: + self.menu.tk_popup(event.x_root, event.y_root) + finally: + self.menu.grab_release() + + def copy(self): + try: + text = self.widget.get("sel.first", "sel.last") + self.widget.clipboard_clear() + self.widget.clipboard_append(text) + except tk.TclError: + pass # 没有选中文本时忽略错误 + + def paste(self): + try: + text = self.widget.clipboard_get() + self.widget.insert("insert", text) + except tk.TclError: + pass # 剪贴板为空时忽略错误 + + def cut(self): + try: + text = self.widget.get("sel.first", "sel.last") + self.widget.delete("sel.first", "sel.last") + self.widget.clipboard_clear() + self.widget.clipboard_append(text) + except tk.TclError: + pass # 没有选中文本时忽略错误 + + def select_all(self): + self.widget.tag_add("sel", "1.0", "end") diff --git a/ui/directory_tab.py b/ui/directory_tab.py new file mode 100644 index 0000000..09b7aba --- /dev/null +++ b/ui/directory_tab.py @@ -0,0 +1,45 @@ +# ui/directory_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_directory_tab(self): + self.directory_tab = self.tabview.add("Chapter Blueprint") + 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_chapter_blueprint, 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_chapter_blueprint, 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)) + TextWidgetContextMenu(self.directory_text) + self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + +def load_chapter_blueprint(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "Novel_directory.txt") + content = read_file(filename) + self.directory_text.delete("0.0", "end") + self.directory_text.insert("0.0", content) + self.log("已加载 Novel_directory.txt 内容到编辑区。") + +def save_chapter_blueprint(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + content = self.directory_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 Novel_directory.txt 的修改。") diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py new file mode 100644 index 0000000..c798aae --- /dev/null +++ b/ui/generation_handlers.py @@ -0,0 +1,362 @@ +# ui/generation_handlers.py +# -*- coding: utf-8 -*- +import os +import threading +import tkinter as tk +from tkinter import messagebox +import customtkinter as ctk +import traceback +from utils import read_file, save_string_to_txt, clear_file_content +from novel_generator import ( + Novel_architecture_generate, + Chapter_blueprint_generate, + generate_chapter_draft, + finalize_chapter, + import_knowledge_file, + clear_vector_store, + enrich_chapter_text +) +from consistency_checker import check_consistency + +def generate_novel_architecture_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + topic = self.topic_text.get("0.0", "end").strip() + genre = self.genre_var.get().strip() + num_chapters = self.safe_get_int(self.num_chapters_var, 10) + word_number = self.safe_get_int(self.word_number_var, 3000) + + self.safe_log("开始生成小说架构...") + Novel_architecture_generate( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + llm_model=model_name, + topic=topic, + genre=genre, + number_of_chapters=num_chapters, + word_number=word_number, + filepath=filepath, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout_val + ) + self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成小说架构时出错") + finally: + self.enable_button_safe(self.btn_generate_architecture) + threading.Thread(target=task, daemon=True).start() + +def generate_chapter_blueprint_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + 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() + number_of_chapters = self.safe_get_int(self.num_chapters_var, 10) + temperature = self.temperature_var.get() + max_tokens = self.max_tokens_var.get() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + self.safe_log("开始生成章节蓝图...") + Chapter_blueprint_generate( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + llm_model=model_name, + number_of_chapters=number_of_chapters, + filepath=filepath, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout_val + ) + self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成章节蓝图时出错") + finally: + self.enable_button_safe(self.btn_generate_directory) + 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_safe(self.btn_generate_chapter) + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + 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_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() + embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4) + + self.safe_log(f"开始生成第{chap_num}章草稿...") + draft_text = generate_chapter_draft( + api_key=api_key, + base_url=base_url, + model_name=model_name, + filepath=filepath, + novel_number=chap_num, + word_number=word_number, + temperature=temperature, + user_guidance=user_guidance, + characters_involved=char_inv, + key_items=key_items, + scene_location=scene_loc, + time_constraint=time_constr, + embedding_api_key=embedding_api_key, + embedding_url=embedding_url, + embedding_interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, + embedding_retrieval_k=embedding_k, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + if draft_text: + self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") + self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) + else: + self.safe_log("⚠️ 本章草稿生成失败或无内容。") + except Exception: + self.handle_exception("生成章节草稿时出错") + finally: + self.enable_button_safe(self.btn_generate_chapter) + threading.Thread(target=task, daemon=True).start() + +def finalize_chapter_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + self.disable_button_safe(self.btn_finalize_chapter) + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + 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") + + edited_text = self.chapter_result.get("0.0", "end").strip() + + if len(edited_text) < 0.7 * word_number: + ask = messagebox.askyesno("字数不足", f"当前章节字数 ({len(edited_text)}) 低于目标字数({word_number})的70%,是否要尝试扩写?") + if ask: + self.safe_log("正在扩写章节内容...") + enriched = enrich_chapter_text( + chapter_text=edited_text, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + edited_text = enriched + self.master.after(0, lambda: self.chapter_result.delete("0.0", "end")) + self.master.after(0, lambda: self.chapter_result.insert("0.0", edited_text)) + clear_file_content(chapter_file) + save_string_to_txt(edited_text, chapter_file) + + finalize_chapter( + novel_number=chap_num, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + filepath=filepath, + embedding_api_key=embedding_api_key, + embedding_url=embedding_url, + embedding_interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") + + final_text = read_file(chapter_file) + self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) + except Exception: + self.handle_exception("定稿章节时出错") + finally: + self.enable_button_safe(self.btn_finalize_chapter) + threading.Thread(target=task, daemon=True).start() + +def do_consistency_check(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + 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() + interface_format = self.interface_format_var.get() + max_tokens = self.max_tokens_var.get() + timeout = self.timeout_var.get() + + chap_num = self.safe_get_int(self.chapter_num_var, 1) + chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") + chapter_text = read_file(chap_file) + + if not chapter_text.strip(): + self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。") + return + + self.safe_log("开始一致性审校...") + result = check_consistency( + novel_setting="", + character_state=read_file(os.path.join(filepath, "character_state.txt")), + global_summary=read_file(os.path.join(filepath, "global_summary.txt")), + chapter_text=chapter_text, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout, + plot_arcs="" + ) + self.safe_log("审校结果:") + self.safe_log(result) + except Exception: + self.handle_exception("审校时出错") + finally: + self.enable_button_safe(self.btn_check_consistency) + threading.Thread(target=task, daemon=True).start() + +def import_knowledge_handler(self): + selected_file = tk.filedialog.askopenfilename( + title="选择要导入的知识库文件", + filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] + ) + if selected_file: + def task(): + self.disable_button_safe(self.btn_import_knowledge) + try: + emb_api_key = self.embedding_api_key_var.get().strip() + emb_url = self.embedding_url_var.get().strip() + emb_format = self.embedding_interface_format_var.get().strip() + emb_model = self.embedding_model_name_var.get().strip() + + self.safe_log(f"开始导入知识库文件: {selected_file}") + import_knowledge_file( + embedding_api_key=emb_api_key, + embedding_url=emb_url, + embedding_interface_format=emb_format, + embedding_model_name=emb_model, + file_path=selected_file, + filepath=self.filepath_var.get().strip() + ) + self.safe_log("✅ 知识库文件导入完成。") + except Exception: + self.handle_exception("导入知识库时出错") + finally: + self.enable_button_safe(self.btn_import_knowledge) + threading.Thread(target=task, daemon=True).start() + +def clear_vectorstore_handler(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") + if first_confirm: + second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") + if second_confirm: + if clear_vector_store(filepath): + self.log("已清空向量库。") + else: + self.log(f"未能清空向量库,请关闭程序后手动删除 {filepath} 下的 vectorstore 文件夹。") + +def show_plot_arcs_ui(self): + filepath = self.filepath_var.get().strip() + 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 = 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") diff --git a/ui/helpers.py b/ui/helpers.py new file mode 100644 index 0000000..870549e --- /dev/null +++ b/ui/helpers.py @@ -0,0 +1,7 @@ +# ui/helpers.py +# -*- coding: utf-8 -*- +import logging +import traceback + +def log_error(message: str): + logging.error(f"{message}\n{traceback.format_exc()}") diff --git a/ui/main_tab.py b/ui/main_tab.py new file mode 100644 index 0000000..2ca6add --- /dev/null +++ b/ui/main_tab.py @@ -0,0 +1,103 @@ +# ui/main_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import messagebox +from ui.context_menu import TextWidgetContextMenu + +def build_main_tab(self): + """ + 主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区 + """ + self.main_tab = self.tabview.add("Main Functions") + self.main_tab.rowconfigure(0, weight=1) + self.main_tab.columnconfigure(0, weight=1) + self.main_tab.columnconfigure(1, weight=0) + + self.left_frame = ctk.CTkFrame(self.main_tab) + self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) + + self.right_frame = ctk.CTkFrame(self.main_tab) + self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2) + + build_left_layout(self) + build_right_layout(self) + +def build_left_layout(self): + """ + 左侧区域:本章内容(可编辑) + Step流程按钮 + 输出日志(只读) + """ + self.left_frame.grid_rowconfigure(0, weight=0) + self.left_frame.grid_rowconfigure(1, weight=2) + self.left_frame.grid_rowconfigure(2, weight=0) + self.left_frame.grid_rowconfigure(3, weight=0) + self.left_frame.grid_rowconfigure(4, weight=1) + self.left_frame.columnconfigure(0, weight=1) + + chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容 (可编辑)", font=("Microsoft YaHei", 12)) + chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") + + # 章节文本编辑框 + self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) + TextWidgetContextMenu(self.chapter_result) + self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + + # Step 按钮区域 + self.step_buttons_frame = ctk.CTkFrame(self.left_frame) + self.step_buttons_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=5) + self.step_buttons_frame.columnconfigure((0, 1, 2, 3), weight=1) + + self.btn_generate_architecture = ctk.CTkButton( + self.step_buttons_frame, + text="Step1. 生成架构", + command=self.generate_novel_architecture_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_architecture.grid(row=0, column=0, padx=5, pady=2, sticky="ew") + + self.btn_generate_directory = ctk.CTkButton( + self.step_buttons_frame, + text="Step2. 生成目录", + command=self.generate_chapter_blueprint_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_directory.grid(row=0, column=1, padx=5, pady=2, sticky="ew") + + self.btn_generate_chapter = ctk.CTkButton( + self.step_buttons_frame, + text="Step3. 生成草稿", + command=self.generate_chapter_draft_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_chapter.grid(row=0, column=2, padx=5, pady=2, sticky="ew") + + self.btn_finalize_chapter = ctk.CTkButton( + self.step_buttons_frame, + text="Step4. 定稿章节", + command=self.finalize_chapter_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_finalize_chapter.grid(row=0, column=3, padx=5, pady=2, sticky="ew") + + # 日志文本框 + log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 12)) + log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w") + + self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) + TextWidgetContextMenu(self.log_text) + self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) + self.log_text.configure(state="disabled") + +def build_right_layout(self): + """ + 右侧区域:配置区(tabview) + 小说主参数 + 可选功能按钮 + """ + self.right_frame.grid_rowconfigure(0, weight=0) + self.right_frame.grid_rowconfigure(1, weight=1) + self.right_frame.grid_rowconfigure(2, weight=0) + self.right_frame.columnconfigure(0, weight=1) + + # 配置区(AI/Embedding) + self.config_frame = ctk.CTkFrame(self.right_frame, corner_radius=10, border_width=2, border_color="gray") + self.config_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + self.config_frame.columnconfigure(0, weight=1) + # 其余部分将在 config_tab.py 与 novel_params_tab.py 中构建 diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..382223e --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,251 @@ +# ui/main_window.py +# -*- coding: utf-8 -*- +import os +import threading +import logging +import traceback +import customtkinter as ctk +import tkinter as tk +from tkinter import filedialog, messagebox + +from config_manager import load_config, save_config, test_llm_config, test_embedding_config +from utils import read_file, save_string_to_txt, clear_file_content +from tooltips import tooltips + +from ui.context_menu import TextWidgetContextMenu +from ui.main_tab import build_main_tab, build_left_layout, build_right_layout +from ui.config_tab import build_config_tabview, load_config_btn, save_config_btn +from ui.novel_params_tab import build_novel_params_area, build_optional_buttons_area +from ui.generation_handlers import ( + generate_novel_architecture_ui, + generate_chapter_blueprint_ui, + generate_chapter_draft_ui, + finalize_chapter_ui, + do_consistency_check, + import_knowledge_handler, + clear_vectorstore_handler, + show_plot_arcs_ui +) +from ui.setting_tab import build_setting_tab, load_novel_architecture, save_novel_architecture +from ui.directory_tab import build_directory_tab, load_chapter_blueprint, save_chapter_blueprint +from ui.character_tab import build_character_tab, load_character_state, save_character_state +from ui.summary_tab import build_summary_tab, load_global_summary, save_global_summary +from ui.chapters_tab import build_chapters_tab, refresh_chapters_list, on_chapter_selected, load_chapter_content, save_current_chapter, prev_chapter, next_chapter + +class NovelGeneratorGUI: + """ + 小说生成器的主GUI类,包含所有的界面布局、事件处理、与后端逻辑的交互等。 + """ + def __init__(self, master): + self.master = master + self.master.title("Novel Generator GUI") + try: + if os.path.exists("icon.ico"): + self.master.iconbitmap("icon.ico") + except Exception: + pass + self.master.geometry("1350x840") + + # --------------- 配置文件路径 --------------- + self.config_file = "config.json" + self.loaded_config = load_config(self.config_file) + + if self.loaded_config: + last_llm = self.loaded_config.get("last_interface_format", "OpenAI") + last_embedding = self.loaded_config.get("last_embedding_interface_format", "OpenAI") + else: + last_llm = "OpenAI" + last_embedding = "OpenAI" + + if self.loaded_config and "llm_configs" in self.loaded_config and last_llm in self.loaded_config["llm_configs"]: + llm_conf = self.loaded_config["llm_configs"][last_llm] + else: + llm_conf = { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model_name": "gpt-4o-mini", + "temperature": 0.7, + "max_tokens": 8192, + "timeout": 600 + } + + if self.loaded_config and "embedding_configs" in self.loaded_config and last_embedding in self.loaded_config["embedding_configs"]: + emb_conf = self.loaded_config["embedding_configs"][last_embedding] + else: + emb_conf = { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model_name": "text-embedding-ada-002", + "retrieval_k": 4 + } + + # -- LLM通用参数 -- + self.api_key_var = ctk.StringVar(value=llm_conf.get("api_key", "")) + self.base_url_var = ctk.StringVar(value=llm_conf.get("base_url", "https://api.openai.com/v1")) + self.interface_format_var = ctk.StringVar(value=last_llm) + self.model_name_var = ctk.StringVar(value=llm_conf.get("model_name", "gpt-4o-mini")) + self.temperature_var = ctk.DoubleVar(value=llm_conf.get("temperature", 0.7)) + self.max_tokens_var = ctk.IntVar(value=llm_conf.get("max_tokens", 8192)) + self.timeout_var = ctk.IntVar(value=llm_conf.get("timeout", 600)) + + # -- Embedding相关 -- + self.embedding_interface_format_var = ctk.StringVar(value=last_embedding) + self.embedding_api_key_var = ctk.StringVar(value=emb_conf.get("api_key", "")) + self.embedding_url_var = ctk.StringVar(value=emb_conf.get("base_url", "https://api.openai.com/v1")) + self.embedding_model_name_var = ctk.StringVar(value=emb_conf.get("model_name", "text-embedding-ada-002")) + self.embedding_retrieval_k_var = ctk.StringVar(value=str(emb_conf.get("retrieval_k", 4))) + + # -- 小说参数相关 -- + if self.loaded_config and "other_params" in self.loaded_config: + op = self.loaded_config["other_params"] + self.topic_default = op.get("topic", "") + self.genre_var = ctk.StringVar(value=op.get("genre", "玄幻")) + self.num_chapters_var = ctk.StringVar(value=str(op.get("num_chapters", 10))) + self.word_number_var = ctk.StringVar(value=str(op.get("word_number", 3000))) + self.filepath_var = ctk.StringVar(value=op.get("filepath", "")) + self.chapter_num_var = ctk.StringVar(value=str(op.get("chapter_num", "1"))) + self.characters_involved_var = ctk.StringVar(value=op.get("characters_involved", "")) + self.key_items_var = ctk.StringVar(value=op.get("key_items", "")) + self.scene_location_var = ctk.StringVar(value=op.get("scene_location", "")) + self.time_constraint_var = ctk.StringVar(value=op.get("time_constraint", "")) + self.user_guidance_default = op.get("user_guidance", "") + else: + self.topic_default = "" + self.genre_var = ctk.StringVar(value="玄幻") + self.num_chapters_var = ctk.StringVar(value="10") + self.word_number_var = ctk.StringVar(value="3000") + self.filepath_var = ctk.StringVar(value="") + self.chapter_num_var = ctk.StringVar(value="1") + self.characters_involved_var = ctk.StringVar(value="") + self.key_items_var = ctk.StringVar(value="") + self.scene_location_var = ctk.StringVar(value="") + self.time_constraint_var = ctk.StringVar(value="") + self.user_guidance_default = "" + + # --------------- 整体Tab布局 --------------- + self.tabview = ctk.CTkTabview(self.master) + self.tabview.pack(fill="both", expand=True) + + # 创建各个标签页 + build_main_tab(self) + build_config_tabview(self) + build_novel_params_area(self, start_row=1) + build_optional_buttons_area(self, start_row=2) + build_setting_tab(self) + build_directory_tab(self) + build_character_tab(self) + build_summary_tab(self) + build_chapters_tab(self) + + # ----------------- 通用辅助函数 ----------------- + def show_tooltip(self, key: str): + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + + def safe_get_int(self, var, default=1): + try: + val_str = str(var.get()).strip() + return int(val_str) + except: + var.set(str(default)) + return default + + def log(self, message: str): + 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 safe_log(self, message: str): + self.master.after(0, lambda: self.log(message)) + + 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 handle_exception(self, context: str): + full_message = f"{context}\n{traceback.format_exc()}" + logging.error(full_message) + self.safe_log(full_message) + + 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 test_llm_config(self): + """ + 测试当前的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() + timeout = self.timeout_var.get() + + test_llm_config( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + log_func=self.safe_log, + handle_exception_func=self.handle_exception + ) + + def test_embedding_config(self): + """ + 测试当前的Embedding配置是否可用 + """ + api_key = self.embedding_api_key_var.get().strip() + base_url = self.embedding_url_var.get().strip() + interface_format = self.embedding_interface_format_var.get().strip() + model_name = self.embedding_model_name_var.get().strip() + + test_embedding_config( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + model_name=model_name, + log_func=self.safe_log, + handle_exception_func=self.handle_exception + ) + + def browse_folder(self): + selected_dir = filedialog.askdirectory() + if selected_dir: + self.filepath_var.set(selected_dir) + + # ----------------- 将导入的各模块函数直接赋给类方法 ----------------- + generate_novel_architecture_ui = generate_novel_architecture_ui + generate_chapter_blueprint_ui = generate_chapter_blueprint_ui + generate_chapter_draft_ui = generate_chapter_draft_ui + finalize_chapter_ui = finalize_chapter_ui + do_consistency_check = do_consistency_check + import_knowledge_handler = import_knowledge_handler + clear_vectorstore_handler = clear_vectorstore_handler + show_plot_arcs_ui = show_plot_arcs_ui + load_config_btn = load_config_btn + save_config_btn = save_config_btn + load_novel_architecture = load_novel_architecture + save_novel_architecture = save_novel_architecture + load_chapter_blueprint = load_chapter_blueprint + save_chapter_blueprint = save_chapter_blueprint + load_character_state = load_character_state + save_character_state = save_character_state + load_global_summary = load_global_summary + save_global_summary = save_global_summary + refresh_chapters_list = refresh_chapters_list + on_chapter_selected = on_chapter_selected + save_current_chapter = save_current_chapter + prev_chapter = prev_chapter + next_chapter = next_chapter + test_llm_config = test_llm_config + test_embedding_config = test_embedding_config + browse_folder = browse_folder diff --git a/ui/novel_params_tab.py b/ui/novel_params_tab.py new file mode 100644 index 0000000..28f57f3 --- /dev/null +++ b/ui/novel_params_tab.py @@ -0,0 +1,110 @@ +# ui/novel_params_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import filedialog, messagebox +from ui.context_menu import TextWidgetContextMenu + +def build_novel_params_area(self, start_row=1): + self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical") + self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) + self.params_frame.columnconfigure(1, weight=1) + + # 1) 主题(Topic) + create_label_with_help_for_novel_params(self, 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)) + TextWidgetContextMenu(self.topic_text) + self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + if hasattr(self, 'topic_default') and self.topic_default: + self.topic_text.insert("0.0", self.topic_default) + + # 2) 类型(Genre) + create_label_with_help_for_novel_params(self, 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 + create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="章节数 & 每章字数:", tooltip_key="num_chapters", row=row_for_chapter_and_word, column=0, font=("Microsoft YaHei", 12)) + 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) + 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(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 + create_label_with_help_for_novel_params(self, 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=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)) + 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") + + # 5) 章节号 + row_chap_num = 4 + create_label_with_help_for_novel_params(self, 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=row_chap_num, column=1, padx=5, pady=5, sticky="w") + + # 6) 本章指导 + row_user_guide = 5 + create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="本章指导:", tooltip_key="user_guidance", row=row_user_guide, column=0, font=("Microsoft YaHei", 12), sticky="ne") + self.user_guide_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) + TextWidgetContextMenu(self.user_guide_text) + self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") + if hasattr(self, 'user_guidance_default') and self.user_guidance_default: + self.user_guide_text.insert("0.0", self.user_guidance_default) + + # 7) 可选元素:核心人物/关键道具/空间坐标/时间压力 + row_idx = 6 + create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="核心人物:", tooltip_key="characters_involved", row=row_idx, column=0, font=("Microsoft YaHei", 12)) + char_inv_entry = ctk.CTkEntry(self.params_frame, textvariable=self.characters_involved_var, font=("Microsoft YaHei", 12)) + char_inv_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="关键道具:", tooltip_key="key_items", row=row_idx, column=0, font=("Microsoft YaHei", 12)) + key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12)) + key_items_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + create_label_with_help_for_novel_params(self, 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_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + create_label_with_help_for_novel_params(self, 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_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) + self.optional_btn_frame.grid(row=start_row, column=0, sticky="ew", padx=5, pady=5) + self.optional_btn_frame.columnconfigure((0, 1, 2, 3), weight=1) + + self.btn_check_consistency = ctk.CTkButton(self.optional_btn_frame, text="一致性审校", command=self.do_consistency_check, font=("Microsoft YaHei", 12)) + self.btn_check_consistency.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + self.btn_import_knowledge = ctk.CTkButton(self.optional_btn_frame, text="导入知识库", command=self.import_knowledge_handler, font=("Microsoft YaHei", 12)) + self.btn_import_knowledge.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + self.btn_clear_vectorstore = ctk.CTkButton(self.optional_btn_frame, text="清空向量库", fg_color="red", command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12)) + self.btn_clear_vectorstore.grid(row=0, column=2, padx=5, pady=5, sticky="ew") + + self.plot_arcs_btn = ctk.CTkButton(self.optional_btn_frame, text="查看剧情要点", command=self.show_plot_arcs_ui, font=("Microsoft YaHei", 12)) + self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") + +def create_label_with_help_for_novel_params(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + 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: messagebox.showinfo("参数说明", "暂无说明")) + btn.pack(side="left", padx=3) + return frame diff --git a/ui/setting_tab.py b/ui/setting_tab.py new file mode 100644 index 0000000..11599c1 --- /dev/null +++ b/ui/setting_tab.py @@ -0,0 +1,45 @@ +# ui/setting_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_setting_tab(self): + self.setting_tab = self.tabview.add("Novel Architecture") + 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_architecture.txt", command=self.load_novel_architecture, 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_architecture, 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)) + TextWidgetContextMenu(self.setting_text) + self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + +def load_novel_architecture(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "Novel_architecture.txt") + content = read_file(filename) + self.setting_text.delete("0.0", "end") + self.setting_text.insert("0.0", content) + self.log("已加载 Novel_architecture.txt 内容到编辑区。") + +def save_novel_architecture(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径。") + return + content = self.setting_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "Novel_architecture.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 Novel_architecture.txt 的修改。") diff --git a/ui/summary_tab.py b/ui/summary_tab.py new file mode 100644 index 0000000..3f7a999 --- /dev/null +++ b/ui/summary_tab.py @@ -0,0 +1,45 @@ +# ui/summary_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_summary_tab(self): + self.summary_tab = self.tabview.add("Global Summary") + 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)) + TextWidgetContextMenu(self.summary_text) + 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("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "global_summary.txt") + content = read_file(filename) + 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("警告", "请先设置保存文件路径") + return + content = self.summary_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "global_summary.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 global_summary.txt 的修改。") diff --git a/ui.py b/ui_backup.txt similarity index 97% rename from ui.py rename to ui_backup.txt index 6818005..80836f4 100644 --- a/ui.py +++ b/ui_backup.txt @@ -1,1823 +1,1823 @@ -# ui.py -# -*- coding: utf-8 -*- - -import logging -import os -import threading -import customtkinter as ctk -from tkinter import filedialog, messagebox -import tkinter as tk -import traceback - -from config_manager import load_config, save_config, test_llm_config, test_embedding_config -from utils import read_file, save_string_to_txt, clear_file_content - -from novel_generator import ( - Novel_architecture_generate, - Chapter_blueprint_generate, - generate_chapter_draft, - finalize_chapter, - import_knowledge_file, - clear_vector_store, - get_last_n_chapters_text, - enrich_chapter_text -) -from consistency_checker import check_consistency - -# ---- Import the tooltip texts ---- -from tooltips import tooltips - - -# ----------------- 右键菜单功能封装 ----------------- -class TextWidgetContextMenu: - """ - 为 customtkinter.TextBox 或 tkinter.Text 提供右键复制/剪切/粘贴/全选的功能。 - """ - def __init__(self, widget): - self.widget = widget - self.menu = tk.Menu(widget, tearoff=0) - self.menu.add_command(label="复制", command=self.copy) - self.menu.add_command(label="粘贴", command=self.paste) - self.menu.add_command(label="剪切", command=self.cut) - self.menu.add_separator() - self.menu.add_command(label="全选", command=self.select_all) - - # 绑定右键事件 - self.widget.bind("", self.show_menu) - - def show_menu(self, event): - if isinstance(self.widget, ctk.CTkTextbox): - try: - self.menu.tk_popup(event.x_root, event.y_root) - finally: - self.menu.grab_release() - - def copy(self): - try: - text = self.widget.get("sel.first", "sel.last") - self.widget.clipboard_clear() - self.widget.clipboard_append(text) - except tk.TclError: - pass # 没有选中文本时忽略错误 - - def paste(self): - try: - text = self.widget.clipboard_get() - self.widget.insert("insert", text) - except tk.TclError: - pass # 剪贴板为空时忽略错误 - - def cut(self): - try: - text = self.widget.get("sel.first", "sel.last") - self.widget.delete("sel.first", "sel.last") - self.widget.clipboard_clear() - self.widget.clipboard_append(text) - except tk.TclError: - pass # 没有选中文本时忽略错误 - - def select_all(self): - self.widget.tag_add("sel", "1.0", "end") - - -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: - """ - 小说生成器的主GUI类,包含所有的界面布局、事件处理、与后端逻辑的交互等。 - """ - def __init__(self, master): - self.master = master - self.master.title("Novel Generator GUI") - - # 设置窗口图标 - try: - if os.path.exists("icon.ico"): - self.master.iconbitmap("icon.ico") - except Exception: - pass - - self.master.geometry("1350x840") - - # --------------- 配置文件路径 --------------- - self.config_file = "config.json" - self.loaded_config = load_config(self.config_file) - - # 根据配置加载上一次使用的接口类型及对应配置 - if self.loaded_config: - last_llm = self.loaded_config.get("last_interface_format", "OpenAI") - last_embedding = self.loaded_config.get("last_embedding_interface_format", "OpenAI") - else: - last_llm = "OpenAI" - last_embedding = "OpenAI" - - if self.loaded_config and "llm_configs" in self.loaded_config and last_llm in self.loaded_config["llm_configs"]: - llm_conf = self.loaded_config["llm_configs"][last_llm] - else: - llm_conf = { - "api_key": "", - "base_url": "https://api.openai.com/v1", - "model_name": "gpt-4o-mini", - "temperature": 0.7, - "max_tokens": 8192, - "timeout": 600 - } - - if self.loaded_config and "embedding_configs" in self.loaded_config and last_embedding in self.loaded_config["embedding_configs"]: - emb_conf = self.loaded_config["embedding_configs"][last_embedding] - else: - emb_conf = { - "api_key": "", - "base_url": "https://api.openai.com/v1", - "model_name": "text-embedding-ada-002", - "retrieval_k": 4 - } - - # --------------- 主要属性变量 --------------- - # -- LLM通用参数 -- - self.api_key_var = ctk.StringVar(value=llm_conf.get("api_key", "")) - self.base_url_var = ctk.StringVar(value=llm_conf.get("base_url", "https://api.openai.com/v1")) - self.interface_format_var = ctk.StringVar(value=last_llm) - self.model_name_var = ctk.StringVar(value=llm_conf.get("model_name", "gpt-4o-mini")) - self.temperature_var = ctk.DoubleVar(value=llm_conf.get("temperature", 0.7)) - self.max_tokens_var = ctk.IntVar(value=llm_conf.get("max_tokens", 8192)) - self.timeout_var = ctk.IntVar(value=llm_conf.get("timeout", 600)) - - # -- Embedding相关 -- - self.embedding_interface_format_var = ctk.StringVar(value=last_embedding) - self.embedding_api_key_var = ctk.StringVar(value=emb_conf.get("api_key", "")) - self.embedding_url_var = ctk.StringVar(value=emb_conf.get("base_url", "https://api.openai.com/v1")) - self.embedding_model_name_var = ctk.StringVar(value=emb_conf.get("model_name", "text-embedding-ada-002")) - self.embedding_retrieval_k_var = ctk.StringVar(value=str(emb_conf.get("retrieval_k", 4))) - - # -- 小说参数相关 -- - if self.loaded_config and "other_params" in self.loaded_config: - op = self.loaded_config["other_params"] - self.topic_default = op.get("topic", "") - self.genre_var = ctk.StringVar(value=op.get("genre", "玄幻")) - self.num_chapters_var = ctk.StringVar(value=str(op.get("num_chapters", 10))) - self.word_number_var = ctk.StringVar(value=str(op.get("word_number", 3000))) - self.filepath_var = ctk.StringVar(value=op.get("filepath", "")) - self.chapter_num_var = ctk.StringVar(value=str(op.get("chapter_num", "1"))) - self.characters_involved_var = ctk.StringVar(value=op.get("characters_involved", "")) - self.key_items_var = ctk.StringVar(value=op.get("key_items", "")) - self.scene_location_var = ctk.StringVar(value=op.get("scene_location", "")) - self.time_constraint_var = ctk.StringVar(value=op.get("time_constraint", "")) - self.user_guidance_default = op.get("user_guidance", "") - else: - self.topic_default = "" - self.genre_var = ctk.StringVar(value="玄幻") - self.num_chapters_var = ctk.StringVar(value="10") - self.word_number_var = ctk.StringVar(value="3000") - self.filepath_var = ctk.StringVar(value="") - self.chapter_num_var = ctk.StringVar(value="1") - self.characters_involved_var = ctk.StringVar(value="") - self.key_items_var = ctk.StringVar(value="") - self.scene_location_var = ctk.StringVar(value="") - self.time_constraint_var = ctk.StringVar(value="") - self.user_guidance_default = "" - - # --------------- 整体Tab布局 --------------- - self.tabview = ctk.CTkTabview(self.master) - self.tabview.pack(fill="both", expand=True) - - # 创建各个标签页 - self.main_tab = self.tabview.add("Main Functions") - self.setting_tab = self.tabview.add("Novel Architecture") - self.directory_tab = self.tabview.add("Chapter Blueprint") - self.character_tab = self.tabview.add("Character State") - self.summary_tab = self.tabview.add("Global Summary") - self.chapters_view_tab = self.tabview.add("Chapters Manage") - - # 构建各个Tab的UI - self.build_main_tab() - self.build_setting_tab() - self.build_directory_tab() - self.build_character_tab() - self.build_summary_tab() - self.build_chapters_tab() - - # ----------------- 通用辅助函数 ----------------- - def show_tooltip(self, key: str): - """显示自定义的tooltip文本。""" - info_text = tooltips.get(key, "暂无说明") - messagebox.showinfo("参数说明", info_text) - - def safe_get_int(self, var, default=1): - """从CTk的StringVar中安全地读取整数,若失败则返回default并重置变量值。""" - try: - val_str = str(var.get()).strip() - return int(val_str) - except: - var.set(str(default)) - return default - - def log(self, message: str): - """在左侧日志框输出信息。""" - 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 safe_log(self, message: str): - """线程安全地在日志框输出信息。""" - self.master.after(0, lambda: self.log(message)) - - 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 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): - """ - 主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区 - """ - self.main_tab.rowconfigure(0, weight=1) - self.main_tab.columnconfigure(0, weight=1) - self.main_tab.columnconfigure(1, weight=0) - - self.left_frame = ctk.CTkFrame(self.main_tab) - self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) - - 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): - """ - 左侧区域:本章内容(可编辑) + Step流程按钮 + 输出日志(只读) - """ - self.left_frame.grid_rowconfigure(0, weight=0) - self.left_frame.grid_rowconfigure(1, weight=2) - self.left_frame.grid_rowconfigure(2, weight=0) - self.left_frame.grid_rowconfigure(3, weight=0) - self.left_frame.grid_rowconfigure(4, weight=1) - self.left_frame.columnconfigure(0, weight=1) - - chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容 (可编辑)", font=("Microsoft YaHei", 12)) - chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") - - # 章节文本编辑框 - self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) - TextWidgetContextMenu(self.chapter_result) - self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - - # Step 按钮区域 - self.step_buttons_frame = ctk.CTkFrame(self.left_frame) - self.step_buttons_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=5) - self.step_buttons_frame.columnconfigure((0, 1, 2, 3), weight=1) - - self.btn_generate_architecture = ctk.CTkButton( - self.step_buttons_frame, - text="Step1. 生成架构", - command=self.generate_novel_architecture_ui, - font=("Microsoft YaHei", 12) - ) - self.btn_generate_architecture.grid(row=0, column=0, padx=5, pady=2, sticky="ew") - - self.btn_generate_directory = ctk.CTkButton( - self.step_buttons_frame, - text="Step2. 生成目录", - command=self.generate_chapter_blueprint_ui, - font=("Microsoft YaHei", 12) - ) - self.btn_generate_directory.grid(row=0, column=1, padx=5, pady=2, sticky="ew") - - self.btn_generate_chapter = ctk.CTkButton( - self.step_buttons_frame, - text="Step3. 生成草稿", - command=self.generate_chapter_draft_ui, - font=("Microsoft YaHei", 12) - ) - self.btn_generate_chapter.grid(row=0, column=2, padx=5, pady=2, sticky="ew") - - self.btn_finalize_chapter = ctk.CTkButton( - self.step_buttons_frame, - text="Step4. 定稿章节", - command=self.finalize_chapter_ui, - font=("Microsoft YaHei", 12) - ) - self.btn_finalize_chapter.grid(row=0, column=3, padx=5, pady=2, sticky="ew") - - # 日志文本框 - log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 12)) - log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w") - - self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) - TextWidgetContextMenu(self.log_text) - self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) - self.log_text.configure(state="disabled") - - def build_right_layout(self): - """ - 右侧区域:配置区(tabview) + 小说主参数 + 可选功能按钮 - """ - self.right_frame.grid_rowconfigure(0, weight=0) - self.right_frame.grid_rowconfigure(1, weight=1) - self.right_frame.grid_rowconfigure(2, weight=0) - self.right_frame.columnconfigure(0, weight=1) - - # 配置区(AI/Embedding) - self.config_frame = ctk.CTkFrame(self.right_frame, corner_radius=10, border_width=2, border_color="gray") - self.config_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) - self.config_frame.columnconfigure(0, weight=1) - - self.build_config_tabview() - - # 小说参数(主题、类型、章节数、字数、保存路径、可选元素等) - self.build_novel_params_area(start_row=1) - - # 可选功能按钮(一致性审校、导入知识库、清空向量库、查看剧情要点) - self.build_optional_buttons_area(start_row=2) - - # ----------------- 配置TabView ----------------- - def build_config_tabview(self): - """ - 创建包含 LLM Model settings 和 Embedding settings 的选项卡。 - """ - self.config_tabview = ctk.CTkTabview(self.config_frame) - self.config_tabview.grid(row=0, column=0, 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") - - self.build_ai_config_tab() - self.build_embeddings_config_tab() - - # 底部的"保存配置"和"加载配置"按钮 - self.btn_frame_config = ctk.CTkFrame(self.config_frame) - self.btn_frame_config.grid(row=1, column=0, 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") - - def create_label_with_help(self, parent, label_text, tooltip_key, row, column, - font=None, sticky="e", padx=5, pady=5): - """ - 封装一个带"?"按钮的Label,用于展示提示信息。 - """ - frame = ctk.CTkFrame(parent) - frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) - frame.columnconfigure(0, weight=0) - - 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 - - # --------------- LLM 模型配置 --------------- - def build_ai_config_tab(self): - def on_interface_format_changed(new_value): - """ - 当切换LLM接口格式时,自动设置base_url为对应的默认值, - 并尝试加载该接口的已保存配置(若存在)。 - """ - self.interface_format_var.set(new_value) - config_data = load_config(self.config_file) - if config_data: - config_data["last_interface_format"] = new_value - save_config(config_data, self.config_file) - # 尝试加载已保存的配置 - if self.loaded_config and "llm_configs" in self.loaded_config and new_value in self.loaded_config["llm_configs"]: - llm_conf = self.loaded_config["llm_configs"][new_value] - self.api_key_var.set(llm_conf.get("api_key", "")) - self.base_url_var.set(llm_conf.get("base_url", self.base_url_var.get())) - self.model_name_var.set(llm_conf.get("model_name", "")) - self.temperature_var.set(llm_conf.get("temperature", 0.7)) - self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) - self.timeout_var.set(llm_conf.get("timeout", 600)) - else: - match new_value: - case "Ollama": - self.base_url_var.set("http://localhost:11434/v1") - case "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") - case "OpenAI": - self.base_url_var.set("https://api.openai.com/v1") - case "Azure OpenAI": - self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") - case "DeepSeek": - self.base_url_var.set("https://api.deepseek.com/v1") - case "Gemini": - self.base_url_var.set("") - case "Azure AI": - self.base_url_var.set("https://.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview") - - for i in range(7): - 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) - - # 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, columnspan=2, sticky="nsew") - - # 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, columnspan=2, sticky="nsew") - - # 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", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini"] - interface_dropdown = ctk.CTkOptionMenu( - self.ai_config_tab, - values=interface_options, - variable=self.interface_format_var, - command=on_interface_format_changed, - font=("Microsoft YaHei", 12) - ) - interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - - # 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, columnspan=2, sticky="nsew") - - # 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}") - - temp_scale = ctk.CTkSlider( - self.ai_config_tab, - from_=0.0, to=2.0, - number_of_steps=200, - 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=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") - - # 7) Timeout (sec) - self.create_label_with_help( - parent=self.ai_config_tab, - label_text="Timeout (sec):", - tooltip_key="timeout", - row=6, - column=0, - font=("Microsoft YaHei", 12) - ) - - def update_timeout_label(value): - integer_val = int(float(value)) - self.timeout_value_label.configure(text=str(integer_val)) - - timeout_slider = ctk.CTkSlider( - self.ai_config_tab, - from_=0, - to=3600, - number_of_steps=3600, - command=update_timeout_label, - variable=self.timeout_var - ) - timeout_slider.grid(row=6, column=1, padx=5, pady=5, sticky="we") - - self.timeout_value_label = ctk.CTkLabel( - self.ai_config_tab, - text=str(self.timeout_var.get()), - font=("Microsoft YaHei", 12) - ) - self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w") - - # 添加测试按钮 - test_btn = ctk.CTkButton( - self.ai_config_tab, - text="测试配置", - command=self.test_llm_config, - font=("Microsoft YaHei", 12) - ) - test_btn.grid(row=7, column=0, columnspan=3, padx=5, pady=5, sticky="ew") - - # --------------- Embedding 模型配置 --------------- - def build_embeddings_config_tab(self): - def on_embedding_interface_changed(new_value): - """ - 当切换Embedding接口格式时,自动设置embedding_url为对应的默认值, - 并尝试加载该接口的已保存配置(若存在)。 - """ - self.embedding_interface_format_var.set(new_value) - # 自动更新配置文件中的 last_embedding_interface_format - config_data = load_config(self.config_file) - if config_data: - config_data["last_embedding_interface_format"] = new_value - save_config(config_data, self.config_file) - # 尝试加载已保存的配置 - if self.loaded_config and "embedding_configs" in self.loaded_config and new_value in self.loaded_config["embedding_configs"]: - emb_conf = self.loaded_config["embedding_configs"][new_value] - self.embedding_api_key_var.set(emb_conf.get("api_key", "")) - self.embedding_url_var.set(emb_conf.get("base_url", self.embedding_url_var.get())) - self.embedding_model_name_var.set(emb_conf.get("model_name", "")) - self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) - else: - match new_value: - case "Ollama": - self.embedding_url_var.set("http://localhost:11434/api") - case "ML Studio": - self.embedding_url_var.set("http://localhost:1234/v1") - case "OpenAI": - self.embedding_url_var.set("https://api.openai.com/v1") - self.embedding_model_name_var.set("text-embedding-ada-002") - case "Azure OpenAI": - self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") - case "DeepSeek": - self.embedding_url_var.set("https://api.deepseek.com/v1") - case "Gemini": - self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") - self.embedding_model_name_var.set("models/text-embedding-004") - - 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) - - # 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") - - # 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", "Azure OpenAI", "Gemini", "Ollama", "ML Studio"] - emb_interface_dropdown = ctk.CTkOptionMenu( - self.embeddings_config_tab, - values=emb_interface_options, - variable=self.embedding_interface_format_var, - command=on_embedding_interface_changed, - font=("Microsoft YaHei", 12) - ) - emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") - - # 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") - - # 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") - - # 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") - - # 添加测试按钮 - test_btn = ctk.CTkButton( - self.embeddings_config_tab, - text="测试配置", - command=self.test_embedding_config, - font=("Microsoft YaHei", 12) - ) - test_btn.grid(row=5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") - - # ----------------- 小说参数区 ----------------- - def build_novel_params_area(self, start_row=1): - """ - 在右侧 frame 中创建:主题、类型、章节数/字数、保存路径、本章指导、可选要素等参数输入区。 - """ - self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical") - self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) - self.params_frame.columnconfigure(1, weight=1) - - # 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)) - TextWidgetContextMenu(self.topic_text) - 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) - - # 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 - 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) - ) - - 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) - - 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(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=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)) - 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") - - # 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=row_chap_num, column=1, padx=5, pady=5, sticky="w") - - # 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)) - TextWidgetContextMenu(self.user_guide_text) - self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") - if self.user_guidance_default: - self.user_guide_text.insert("0.0", self.user_guidance_default) - - # 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_idx, column=1, padx=5, pady=5, sticky="ew") - row_idx += 1 - - 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_idx, column=1, padx=5, pady=5, sticky="ew") - row_idx += 1 - - 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_idx, column=1, padx=5, pady=5, sticky="ew") - row_idx += 1 - - 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_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) - self.optional_btn_frame.grid(row=start_row, column=0, sticky="ew", padx=5, pady=5) - self.optional_btn_frame.columnconfigure((0, 1, 2, 3), weight=1) - - self.btn_check_consistency = ctk.CTkButton( - self.optional_btn_frame, - text="一致性审校", - command=self.do_consistency_check, - font=("Microsoft YaHei", 12) - ) - self.btn_check_consistency.grid(row=0, column=0, padx=5, pady=5, sticky="ew") - - self.btn_import_knowledge = ctk.CTkButton( - self.optional_btn_frame, - text="导入知识库", - command=self.import_knowledge_handler, - font=("Microsoft YaHei", 12) - ) - self.btn_import_knowledge.grid(row=0, column=1, padx=5, pady=5, sticky="ew") - - self.btn_clear_vectorstore = ctk.CTkButton( - self.optional_btn_frame, - text="清空向量库", - fg_color="red", - command=self.clear_vectorstore_handler, - font=("Microsoft YaHei", 12) - ) - self.btn_clear_vectorstore.grid(row=0, column=2, padx=5, pady=5, sticky="ew") - - self.plot_arcs_btn = ctk.CTkButton( - self.optional_btn_frame, - text="查看剧情要点", - command=self.show_plot_arcs_ui, - font=("Microsoft YaHei", 12) - ) - self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") - - # ----------------- 配置的加载与保存 ----------------- - def load_config_btn(self): - """ - 从 config.json 中加载配置,并更新界面显示 - """ - cfg = load_config(self.config_file) - if cfg: - # 加载上次使用的接口类型 - last_llm = cfg.get("last_interface_format", "OpenAI") - last_embedding = cfg.get("last_embedding_interface_format", "OpenAI") - self.interface_format_var.set(last_llm) - self.embedding_interface_format_var.set(last_embedding) - # 加载对应的LLM配置 - llm_configs = cfg.get("llm_configs", {}) - if last_llm in llm_configs: - llm_conf = llm_configs[last_llm] - self.api_key_var.set(llm_conf.get("api_key", "")) - self.base_url_var.set(llm_conf.get("base_url", "https://api.openai.com/v1")) - self.model_name_var.set(llm_conf.get("model_name", "gpt-4o-mini")) - self.temperature_var.set(llm_conf.get("temperature", 0.7)) - self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) - self.timeout_var.set(llm_conf.get("timeout", 600)) - # 加载对应的Embedding配置 - embedding_configs = cfg.get("embedding_configs", {}) - if last_embedding in embedding_configs: - emb_conf = embedding_configs[last_embedding] - self.embedding_api_key_var.set(emb_conf.get("api_key", "")) - self.embedding_url_var.set(emb_conf.get("base_url", "https://api.openai.com/v1")) - self.embedding_model_name_var.set(emb_conf.get("model_name", "text-embedding-ada-002")) - self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) - # 加载其它参数 - other_params = cfg.get("other_params", {}) - self.topic_text.delete("0.0", "end") - self.topic_text.insert("0.0", other_params.get("topic", "")) - self.genre_var.set(other_params.get("genre", "玄幻")) - self.num_chapters_var.set(str(other_params.get("num_chapters", 10))) - self.word_number_var.set(str(other_params.get("word_number", 3000))) - self.filepath_var.set(other_params.get("filepath", "")) - self.chapter_num_var.set(str(other_params.get("chapter_num", "1"))) - self.user_guide_text.delete("0.0", "end") - self.user_guide_text.insert("0.0", other_params.get("user_guidance", "")) - self.characters_involved_var.set(other_params.get("characters_involved", "")) - self.key_items_var.set(other_params.get("key_items", "")) - self.scene_location_var.set(other_params.get("scene_location", "")) - self.time_constraint_var.set(other_params.get("time_constraint", "")) - self.log("已加载配置。") - else: - messagebox.showwarning("提示", "未找到或无法读取配置文件。") - - def save_config_btn(self): - """ - 将当前界面的配置信息保存到 config.json, - 按照不同接口类型分别保存 LLM 与 Embedding 配置,确保各自独立。 - """ - current_llm_interface = self.interface_format_var.get().strip() - current_embedding_interface = self.embedding_interface_format_var.get().strip() - # 构造当前LLM配置 - llm_config = { - "api_key": self.api_key_var.get(), - "base_url": self.base_url_var.get(), - "model_name": self.model_name_var.get(), - "temperature": self.temperature_var.get(), - "max_tokens": self.max_tokens_var.get(), - "timeout": self.safe_get_int(self.timeout_var, 600) - } - # 构造当前Embedding配置 - embedding_config = { - "api_key": self.embedding_api_key_var.get(), - "base_url": self.embedding_url_var.get(), - "model_name": self.embedding_model_name_var.get(), - "retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4) - } - # 构造其它参数 - other_params = { - "topic": self.topic_text.get("0.0", "end").strip(), - "genre": self.genre_var.get(), - "num_chapters": self.safe_get_int(self.num_chapters_var, 10), - "word_number": self.safe_get_int(self.word_number_var, 3000), - "filepath": self.filepath_var.get(), - "chapter_num": self.chapter_num_var.get(), - "user_guidance": self.user_guide_text.get("0.0", "end").strip(), - "characters_involved": self.characters_involved_var.get(), - "key_items": self.key_items_var.get(), - "scene_location": self.scene_location_var.get(), - "time_constraint": self.time_constraint_var.get() - } - - # 读取已有配置(若存在) - existing_config = load_config(self.config_file) - if not existing_config: - existing_config = {} - existing_config["last_interface_format"] = current_llm_interface - existing_config["last_embedding_interface_format"] = current_embedding_interface - if "llm_configs" not in existing_config: - existing_config["llm_configs"] = {} - existing_config["llm_configs"][current_llm_interface] = llm_config - - if "embedding_configs" not in existing_config: - existing_config["embedding_configs"] = {} - existing_config["embedding_configs"][current_embedding_interface] = embedding_config - - existing_config["other_params"] = other_params - - if save_config(existing_config, self.config_file): - messagebox.showinfo("提示", "配置已保存至 config.json") - self.log("配置已保存。") - else: - messagebox.showerror("错误", "保存配置失败。") - - def browse_folder(self): - selected_dir = filedialog.askdirectory() - if selected_dir: - self.filepath_var.set(selected_dir) - - # ----------------- 生成与定稿的函数 ----------------- - def generate_novel_architecture_ui(self): - """ - Step1:生成小说整体架构 - """ - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - 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() - timeout_val = self.safe_get_int(self.timeout_var, 600) - - topic = self.topic_text.get("0.0", "end").strip() - genre = self.genre_var.get().strip() - num_chapters = self.safe_get_int(self.num_chapters_var, 10) - word_number = self.safe_get_int(self.word_number_var, 3000) - - self.safe_log("开始生成小说架构...") - Novel_architecture_generate( - interface_format=interface_format, - api_key=api_key, - base_url=base_url, - llm_model=model_name, - topic=topic, - genre=genre, - number_of_chapters=num_chapters, - word_number=word_number, - filepath=filepath, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout_val - ) - self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") - except Exception: - self.handle_exception("生成小说架构时出错") - finally: - self.enable_button_safe(self.btn_generate_architecture) - - threading.Thread(target=task, daemon=True).start() - - def generate_chapter_blueprint_ui(self): - """ - Step2:生成章节蓝图 - """ - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - 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() - number_of_chapters = self.safe_get_int(self.num_chapters_var, 10) - temperature = self.temperature_var.get() - max_tokens = self.max_tokens_var.get() - timeout_val = self.safe_get_int(self.timeout_var, 600) - - self.safe_log("开始生成章节蓝图...") - Chapter_blueprint_generate( - interface_format=interface_format, - api_key=api_key, - base_url=base_url, - llm_model=model_name, - number_of_chapters=number_of_chapters, - filepath=filepath, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout_val - ) - self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") - except Exception: - self.handle_exception("生成章节蓝图时出错") - finally: - self.enable_button_safe(self.btn_generate_directory) - - threading.Thread(target=task, daemon=True).start() - - def generate_chapter_draft_ui(self): - """ - Step3:生成章节草稿 - """ - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - def task(): - self.disable_button_safe(self.btn_generate_chapter) - 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() - timeout_val = self.safe_get_int(self.timeout_var, 600) - - 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_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() - embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4) - - self.safe_log(f"开始生成第{chap_num}章草稿...") - draft_text = generate_chapter_draft( - api_key=api_key, - base_url=base_url, - model_name=model_name, - filepath=filepath, - novel_number=chap_num, - word_number=word_number, - temperature=temperature, - user_guidance=user_guidance, - characters_involved=char_inv, - key_items=key_items, - scene_location=scene_loc, - time_constraint=time_constr, - embedding_api_key=embedding_api_key, - embedding_url=embedding_url, - embedding_interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - embedding_retrieval_k=embedding_k, - interface_format=interface_format, - max_tokens=max_tokens, - timeout=timeout_val - ) - if draft_text: - self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") - self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) - else: - self.safe_log("⚠️ 本章草稿生成失败或无内容。") - - except Exception: - self.handle_exception("生成章节草稿时出错") - finally: - self.enable_button_safe(self.btn_generate_chapter) - - 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): - """ - Step4:定稿章节,更新全局摘要、角色状态、向量库等。 - """ - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - def task(): - self.disable_button_safe(self.btn_finalize_chapter) - 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() - timeout_val = self.safe_get_int(self.timeout_var, 600) - - 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") - - edited_text = self.chapter_result.get("0.0", "end").strip() - - # 如果字数不足70%,询问是否扩写 - if len(edited_text) < 0.7 * word_number: - ask = messagebox.askyesno( - "字数不足", - f"当前章节字数 ({len(edited_text)}) 低于目标字数({word_number})的70%,是否要尝试扩写?" - ) - if ask: - self.safe_log("正在扩写章节内容...") - enriched = enrich_chapter_text( - chapter_text=edited_text, - word_number=word_number, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - interface_format=interface_format, - max_tokens=max_tokens, - timeout=timeout_val - ) - edited_text = enriched - self.master.after(0, lambda: self.chapter_result.delete("0.0", "end")) - self.master.after(0, lambda: self.chapter_result.insert("0.0", edited_text)) - - clear_file_content(chapter_file) - save_string_to_txt(edited_text, chapter_file) - - finalize_chapter( - novel_number=chap_num, - word_number=word_number, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - filepath=filepath, - embedding_api_key=embedding_api_key, - embedding_url=embedding_url, - embedding_interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - interface_format=interface_format, - max_tokens=max_tokens, - timeout=timeout_val - ) - self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") - - final_text = read_file(chapter_file) - self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) - - except Exception: - self.handle_exception("定稿章节时出错") - finally: - self.enable_button_safe(self.btn_finalize_chapter) - - threading.Thread(target=task, daemon=True).start() - - # ----------------- 一致性审校 ----------------- - def do_consistency_check(self): - """ - 可选功能:对当前章节进行一致性审校,基于全局摘要和角色状态等进行检查。 - """ - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - def task(): - 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() - interface_format = self.interface_format_var.get() - max_tokens = self.max_tokens_var.get() - timeout = self.timeout_var.get() - - chap_num = self.safe_get_int(self.chapter_num_var, 1) - chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") - chapter_text = read_file(chap_file) - - if not chapter_text.strip(): - self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。") - return - - self.safe_log("开始一致性审校...") - result = check_consistency( - novel_setting="", - character_state=read_file(os.path.join(filepath, "character_state.txt")), - global_summary=read_file(os.path.join(filepath, "global_summary.txt")), - chapter_text=chapter_text, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - interface_format=interface_format, - max_tokens=max_tokens, - timeout=timeout, - plot_arcs="" - ) - self.safe_log("审校结果:") - self.safe_log(result) - - except Exception: - self.handle_exception("审校时出错") - finally: - self.enable_button_safe(self.btn_check_consistency) - - threading.Thread(target=task, daemon=True).start() - - # ----------------- 知识库操作 ----------------- - def import_knowledge_handler(self): - """ - 导入文本文件到本地知识库,供后续生成章节时的检索。 - """ - selected_file = filedialog.askopenfilename( - title="选择要导入的知识库文件", - filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] - ) - if selected_file: - def task(): - self.disable_button_safe(self.btn_import_knowledge) - try: - emb_api_key = self.embedding_api_key_var.get().strip() - emb_url = self.embedding_url_var.get().strip() - emb_format = self.embedding_interface_format_var.get().strip() - emb_model = self.embedding_model_name_var.get().strip() - - self.safe_log(f"开始导入知识库文件: {selected_file}") - import_knowledge_file( - embedding_api_key=emb_api_key, - embedding_url=emb_url, - embedding_interface_format=emb_format, - embedding_model_name=emb_model, - file_path=selected_file, - filepath=self.filepath_var.get().strip() - ) - self.safe_log("✅ 知识库文件导入完成。") - except Exception: - self.handle_exception("导入知识库时出错") - finally: - self.enable_button_safe(self.btn_import_knowledge) - - threading.Thread(target=task, daemon=True).start() - - def clear_vectorstore_handler(self): - """ - 清空本地向量库文件夹。 - """ - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") - if first_confirm: - second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") - if second_confirm: - if clear_vector_store(filepath): - self.log("已清空向量库。") - else: - self.log(f"未能清空向量库,请关闭程序后手动删除 {filepath} 下的 vectorstore 文件夹。") - - def show_plot_arcs_ui(self): - """ - 查看剧情要点/未解决冲突的记录(plot_arcs.txt)。 - """ - 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 = 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") - - # ----------------- Novel Architecture 标签页 ----------------- - 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_architecture.txt", - command=self.load_novel_architecture, - 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_architecture, - 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)) - TextWidgetContextMenu(self.setting_text) - self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - - def load_novel_architecture(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先设置保存文件路径") - return - filename = os.path.join(filepath, "Novel_architecture.txt") - content = read_file(filename) - self.setting_text.delete("0.0", "end") - self.setting_text.insert("0.0", content) - self.log("已加载 Novel_architecture.txt 内容到编辑区。") - - def save_novel_architecture(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先设置保存文件路径。") - return - content = self.setting_text.get("0.0", "end").strip() - filename = os.path.join(filepath, "Novel_architecture.txt") - clear_file_content(filename) - save_string_to_txt(content, filename) - self.log("已保存对 Novel_architecture.txt 的修改。") - - # ----------------- Chapter Blueprint 标签页 ----------------- - 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_chapter_blueprint, - 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_chapter_blueprint, - 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)) - TextWidgetContextMenu(self.directory_text) - self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - - def load_chapter_blueprint(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先设置保存文件路径") - return - filename = os.path.join(filepath, "Novel_directory.txt") - content = read_file(filename) - self.directory_text.delete("0.0", "end") - self.directory_text.insert("0.0", content) - self.log("已加载 Novel_directory.txt 内容到编辑区。") - - def save_chapter_blueprint(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先设置保存文件路径") - return - content = self.directory_text.get("0.0", "end").strip() - filename = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(filename) - save_string_to_txt(content, filename) - self.log("已保存对 Novel_directory.txt 的修改。") - - # ----------------- Character State 标签页 ----------------- - 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)) - TextWidgetContextMenu(self.character_text) - 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("警告", "请先设置保存文件路径") - return - filename = os.path.join(filepath, "character_state.txt") - content = read_file(filename) - 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("警告", "请先设置保存文件路径") - return - content = self.character_text.get("0.0", "end").strip() - filename = os.path.join(filepath, "character_state.txt") - clear_file_content(filename) - save_string_to_txt(content, filename) - self.log("已保存对 character_state.txt 的修改。") - - # ----------------- Global Summary 标签页 ----------------- - 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)) - TextWidgetContextMenu(self.summary_text) - 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("警告", "请先设置保存文件路径") - return - filename = os.path.join(filepath, "global_summary.txt") - content = read_file(filename) - 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("警告", "请先设置保存文件路径") - return - content = self.summary_text.get("0.0", "end").strip() - filename = os.path.join(filepath, "global_summary.txt") - clear_file_content(filename) - save_string_to_txt(content, filename) - self.log("已保存对 global_summary.txt 的修改。") - - # ----------------- Chapters Manage 标签页 ----------------- - def build_chapters_tab(self): - """ - 章节浏览与管理:可选择某一章进行查看/编辑,并保存修改。 - """ - self.chapters_view_tab.rowconfigure(0, weight=0) - self.chapters_view_tab.rowconfigure(1, weight=1) - self.chapters_view_tab.columnconfigure(0, weight=1) - - top_frame = ctk.CTkFrame(self.chapters_view_tab) - top_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) - top_frame.columnconfigure(0, weight=0) - top_frame.columnconfigure(1, weight=0) - top_frame.columnconfigure(2, weight=0) - top_frame.columnconfigure(3, weight=0) - top_frame.columnconfigure(4, weight=1) - - prev_btn = ctk.CTkButton(top_frame, text="<< 上一章", command=self.prev_chapter, font=("Microsoft YaHei", 12)) - prev_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") - - next_btn = ctk.CTkButton(top_frame, text="下一章 >>", command=self.next_chapter, font=("Microsoft YaHei", 12)) - next_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w") - - self.chapter_select_var = ctk.StringVar(value="") - - self.chapter_select_menu = ctk.CTkOptionMenu( - top_frame, - values=[], - variable=self.chapter_select_var, - command=self.on_chapter_selected, - font=("Microsoft YaHei", 12) - ) - self.chapter_select_menu.grid(row=0, column=2, padx=5, pady=5, sticky="w") - - save_btn = ctk.CTkButton(top_frame, text="保存修改", command=self.save_current_chapter, font=("Microsoft YaHei", 12)) - save_btn.grid(row=0, column=3, padx=5, pady=5, sticky="w") - - refresh_btn = ctk.CTkButton(top_frame, text="刷新章节列表", command=self.refresh_chapters_list, font=("Microsoft YaHei", 12)) - refresh_btn.grid(row=0, column=4, padx=5, pady=5, sticky="e") - - self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12)) - TextWidgetContextMenu(self.chapter_view_text) - self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - - self.chapters_list = [] - self.refresh_chapters_list() - - def refresh_chapters_list(self): - """ - 刷新并获取当前保存路径下的所有章节txt文件,并更新下拉菜单。 - """ - filepath = self.filepath_var.get().strip() - chapters_dir = os.path.join(filepath, "chapters") - if not os.path.exists(chapters_dir): - self.safe_log("尚未找到 chapters 文件夹,请先生成章节或检查保存路径。") - self.chapter_select_menu.configure(values=[]) - return - - all_files = os.listdir(chapters_dir) - chapter_nums = [] - for f in all_files: - if f.startswith("chapter_") and f.endswith(".txt"): - number_part = f.replace("chapter_", "").replace(".txt", "") - if number_part.isdigit(): - chapter_nums.append(number_part) - - chapter_nums.sort(key=lambda x: int(x)) - self.chapters_list = chapter_nums - self.chapter_select_menu.configure(values=self.chapters_list) - - current_selected = self.chapter_select_var.get() - if current_selected not in self.chapters_list: - if self.chapters_list: - self.chapter_select_var.set(self.chapters_list[0]) - self.load_chapter_content(self.chapters_list[0]) - else: - self.chapter_select_var.set("") - self.chapter_view_text.delete("0.0", "end") - - def on_chapter_selected(self, value): - """ - 当下拉菜单选择变化时,加载对应章节内容到文本框。 - """ - self.load_chapter_content(value) - - def load_chapter_content(self, chapter_number_str): - if not chapter_number_str: - return - filepath = self.filepath_var.get().strip() - chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") - if not os.path.exists(chapter_file): - self.safe_log(f"章节文件 {chapter_file} 不存在!") - return - - content = read_file(chapter_file) - self.chapter_view_text.delete("0.0", "end") - self.chapter_view_text.insert("0.0", content) - - def save_current_chapter(self): - """ - 将当前查看的章节内容保存回对应文件。 - """ - chapter_number_str = self.chapter_select_var.get() - if not chapter_number_str: - messagebox.showwarning("警告", "尚未选择章节,无法保存。") - return - - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径") - return - - chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") - content = self.chapter_view_text.get("0.0", "end").strip() - - clear_file_content(chapter_file) - save_string_to_txt(content, chapter_file) - self.safe_log(f"已保存对第 {chapter_number_str} 章的修改。") - - def prev_chapter(self): - """ - 切换到上一章 - """ - if not self.chapters_list: - return - current = self.chapter_select_var.get() - if current not in self.chapters_list: - return - idx = self.chapters_list.index(current) - if idx > 0: - new_idx = idx - 1 - self.chapter_select_var.set(self.chapters_list[new_idx]) - self.load_chapter_content(self.chapters_list[new_idx]) - else: - messagebox.showinfo("提示", "已经是第一章了。") - - def next_chapter(self): - """ - 切换到下一章 - """ - if not self.chapters_list: - return - current = self.chapter_select_var.get() - if current not in self.chapters_list: - return - idx = self.chapters_list.index(current) - if idx < len(self.chapters_list) - 1: - new_idx = idx + 1 - self.chapter_select_var.set(self.chapters_list[new_idx]) - self.load_chapter_content(self.chapters_list[new_idx]) - else: - messagebox.showinfo("提示", "已经是最后一章了。") - - def test_llm_config(self): - """ - 测试当前的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() - timeout = self.timeout_var.get() - - test_llm_config( - interface_format=interface_format, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - max_tokens=max_tokens, - timeout=timeout, - log_func=self.safe_log, - handle_exception_func=self.handle_exception - ) - - def test_embedding_config(self): - """ - 测试当前的Embedding配置是否可用 - """ - api_key = self.embedding_api_key_var.get().strip() - base_url = self.embedding_url_var.get().strip() - interface_format = self.embedding_interface_format_var.get().strip() - model_name = self.embedding_model_name_var.get().strip() - - test_embedding_config( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - model_name=model_name, - log_func=self.safe_log, - handle_exception_func=self.handle_exception - ) - - -# ----------------- 程序入口 ----------------- -if __name__ == "__main__": - app = ctk.CTk() - gui = NovelGeneratorGUI(app) - app.mainloop() +# ui.py +# -*- coding: utf-8 -*- +import logging +import os +import threading +import customtkinter as ctk +from tkinter import filedialog, messagebox +import tkinter as tk +import traceback + +from config_manager import load_config, save_config, test_llm_config, test_embedding_config +from utils import read_file, save_string_to_txt, clear_file_content + +from novel_generator import ( + Novel_architecture_generate, + Chapter_blueprint_generate, + generate_chapter_draft, + finalize_chapter, + import_knowledge_file, + clear_vector_store, + get_last_n_chapters_text, + enrich_chapter_text +) + +from consistency_checker import check_consistency + +# ---- Import the tooltip texts ---- +from tooltips import tooltips + + +# ----------------- 右键菜单功能封装 ----------------- +class TextWidgetContextMenu: + """ + 为 customtkinter.TextBox 或 tkinter.Text 提供右键复制/剪切/粘贴/全选的功能。 + """ + def __init__(self, widget): + self.widget = widget + self.menu = tk.Menu(widget, tearoff=0) + self.menu.add_command(label="复制", command=self.copy) + self.menu.add_command(label="粘贴", command=self.paste) + self.menu.add_command(label="剪切", command=self.cut) + self.menu.add_separator() + self.menu.add_command(label="全选", command=self.select_all) + + # 绑定右键事件 + self.widget.bind("", self.show_menu) + + def show_menu(self, event): + if isinstance(self.widget, ctk.CTkTextbox): + try: + self.menu.tk_popup(event.x_root, event.y_root) + finally: + self.menu.grab_release() + + def copy(self): + try: + text = self.widget.get("sel.first", "sel.last") + self.widget.clipboard_clear() + self.widget.clipboard_append(text) + except tk.TclError: + pass # 没有选中文本时忽略错误 + + def paste(self): + try: + text = self.widget.clipboard_get() + self.widget.insert("insert", text) + except tk.TclError: + pass # 剪贴板为空时忽略错误 + + def cut(self): + try: + text = self.widget.get("sel.first", "sel.last") + self.widget.delete("sel.first", "sel.last") + self.widget.clipboard_clear() + self.widget.clipboard_append(text) + except tk.TclError: + pass # 没有选中文本时忽略错误 + + def select_all(self): + self.widget.tag_add("sel", "1.0", "end") + + +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: + """ + 小说生成器的主GUI类,包含所有的界面布局、事件处理、与后端逻辑的交互等。 + """ + def __init__(self, master): + self.master = master + self.master.title("Novel Generator GUI") + + # 设置窗口图标 + try: + if os.path.exists("icon.ico"): + self.master.iconbitmap("icon.ico") + except Exception: + pass + + self.master.geometry("1350x840") + + # --------------- 配置文件路径 --------------- + self.config_file = "config.json" + self.loaded_config = load_config(self.config_file) + + # 根据配置加载上一次使用的接口类型及对应配置 + if self.loaded_config: + last_llm = self.loaded_config.get("last_interface_format", "OpenAI") + last_embedding = self.loaded_config.get("last_embedding_interface_format", "OpenAI") + else: + last_llm = "OpenAI" + last_embedding = "OpenAI" + + if self.loaded_config and "llm_configs" in self.loaded_config and last_llm in self.loaded_config["llm_configs"]: + llm_conf = self.loaded_config["llm_configs"][last_llm] + else: + llm_conf = { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model_name": "gpt-4o-mini", + "temperature": 0.7, + "max_tokens": 8192, + "timeout": 600 + } + + if self.loaded_config and "embedding_configs" in self.loaded_config and last_embedding in self.loaded_config["embedding_configs"]: + emb_conf = self.loaded_config["embedding_configs"][last_embedding] + else: + emb_conf = { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model_name": "text-embedding-ada-002", + "retrieval_k": 4 + } + + # --------------- 主要属性变量 --------------- + # -- LLM通用参数 -- + self.api_key_var = ctk.StringVar(value=llm_conf.get("api_key", "")) + self.base_url_var = ctk.StringVar(value=llm_conf.get("base_url", "https://api.openai.com/v1")) + self.interface_format_var = ctk.StringVar(value=last_llm) + self.model_name_var = ctk.StringVar(value=llm_conf.get("model_name", "gpt-4o-mini")) + self.temperature_var = ctk.DoubleVar(value=llm_conf.get("temperature", 0.7)) + self.max_tokens_var = ctk.IntVar(value=llm_conf.get("max_tokens", 8192)) + self.timeout_var = ctk.IntVar(value=llm_conf.get("timeout", 600)) + + # -- Embedding相关 -- + self.embedding_interface_format_var = ctk.StringVar(value=last_embedding) + self.embedding_api_key_var = ctk.StringVar(value=emb_conf.get("api_key", "")) + self.embedding_url_var = ctk.StringVar(value=emb_conf.get("base_url", "https://api.openai.com/v1")) + self.embedding_model_name_var = ctk.StringVar(value=emb_conf.get("model_name", "text-embedding-ada-002")) + self.embedding_retrieval_k_var = ctk.StringVar(value=str(emb_conf.get("retrieval_k", 4))) + + # -- 小说参数相关 -- + if self.loaded_config and "other_params" in self.loaded_config: + op = self.loaded_config["other_params"] + self.topic_default = op.get("topic", "") + self.genre_var = ctk.StringVar(value=op.get("genre", "玄幻")) + self.num_chapters_var = ctk.StringVar(value=str(op.get("num_chapters", 10))) + self.word_number_var = ctk.StringVar(value=str(op.get("word_number", 3000))) + self.filepath_var = ctk.StringVar(value=op.get("filepath", "")) + self.chapter_num_var = ctk.StringVar(value=str(op.get("chapter_num", "1"))) + self.characters_involved_var = ctk.StringVar(value=op.get("characters_involved", "")) + self.key_items_var = ctk.StringVar(value=op.get("key_items", "")) + self.scene_location_var = ctk.StringVar(value=op.get("scene_location", "")) + self.time_constraint_var = ctk.StringVar(value=op.get("time_constraint", "")) + self.user_guidance_default = op.get("user_guidance", "") + else: + self.topic_default = "" + self.genre_var = ctk.StringVar(value="玄幻") + self.num_chapters_var = ctk.StringVar(value="10") + self.word_number_var = ctk.StringVar(value="3000") + self.filepath_var = ctk.StringVar(value="") + self.chapter_num_var = ctk.StringVar(value="1") + self.characters_involved_var = ctk.StringVar(value="") + self.key_items_var = ctk.StringVar(value="") + self.scene_location_var = ctk.StringVar(value="") + self.time_constraint_var = ctk.StringVar(value="") + self.user_guidance_default = "" + + # --------------- 整体Tab布局 --------------- + self.tabview = ctk.CTkTabview(self.master) + self.tabview.pack(fill="both", expand=True) + + # 创建各个标签页 + self.main_tab = self.tabview.add("Main Functions") + self.setting_tab = self.tabview.add("Novel Architecture") + self.directory_tab = self.tabview.add("Chapter Blueprint") + self.character_tab = self.tabview.add("Character State") + self.summary_tab = self.tabview.add("Global Summary") + self.chapters_view_tab = self.tabview.add("Chapters Manage") + + # 构建各个Tab的UI + self.build_main_tab() + self.build_setting_tab() + self.build_directory_tab() + self.build_character_tab() + self.build_summary_tab() + self.build_chapters_tab() + + # ----------------- 通用辅助函数 ----------------- + def show_tooltip(self, key: str): + """显示自定义的tooltip文本。""" + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + + def safe_get_int(self, var, default=1): + """从CTk的StringVar中安全地读取整数,若失败则返回default并重置变量值。""" + try: + val_str = str(var.get()).strip() + return int(val_str) + except: + var.set(str(default)) + return default + + def log(self, message: str): + """在左侧日志框输出信息。""" + 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 safe_log(self, message: str): + """线程安全地在日志框输出信息。""" + self.master.after(0, lambda: self.log(message)) + + 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 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): + """ + 主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区 + """ + self.main_tab.rowconfigure(0, weight=1) + self.main_tab.columnconfigure(0, weight=1) + self.main_tab.columnconfigure(1, weight=0) + + self.left_frame = ctk.CTkFrame(self.main_tab) + self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) + + 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): + """ + 左侧区域:本章内容(可编辑) + Step流程按钮 + 输出日志(只读) + """ + self.left_frame.grid_rowconfigure(0, weight=0) + self.left_frame.grid_rowconfigure(1, weight=2) + self.left_frame.grid_rowconfigure(2, weight=0) + self.left_frame.grid_rowconfigure(3, weight=0) + self.left_frame.grid_rowconfigure(4, weight=1) + self.left_frame.columnconfigure(0, weight=1) + + chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容 (可编辑)", font=("Microsoft YaHei", 12)) + chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") + + # 章节文本编辑框 + self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) + TextWidgetContextMenu(self.chapter_result) + self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + + # Step 按钮区域 + self.step_buttons_frame = ctk.CTkFrame(self.left_frame) + self.step_buttons_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=5) + self.step_buttons_frame.columnconfigure((0, 1, 2, 3), weight=1) + + self.btn_generate_architecture = ctk.CTkButton( + self.step_buttons_frame, + text="Step1. 生成架构", + command=self.generate_novel_architecture_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_architecture.grid(row=0, column=0, padx=5, pady=2, sticky="ew") + + self.btn_generate_directory = ctk.CTkButton( + self.step_buttons_frame, + text="Step2. 生成目录", + command=self.generate_chapter_blueprint_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_directory.grid(row=0, column=1, padx=5, pady=2, sticky="ew") + + self.btn_generate_chapter = ctk.CTkButton( + self.step_buttons_frame, + text="Step3. 生成草稿", + command=self.generate_chapter_draft_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_chapter.grid(row=0, column=2, padx=5, pady=2, sticky="ew") + + self.btn_finalize_chapter = ctk.CTkButton( + self.step_buttons_frame, + text="Step4. 定稿章节", + command=self.finalize_chapter_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_finalize_chapter.grid(row=0, column=3, padx=5, pady=2, sticky="ew") + + # 日志文本框 + log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 12)) + log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w") + + self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) + TextWidgetContextMenu(self.log_text) + self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) + self.log_text.configure(state="disabled") + + def build_right_layout(self): + """ + 右侧区域:配置区(tabview) + 小说主参数 + 可选功能按钮 + """ + self.right_frame.grid_rowconfigure(0, weight=0) + self.right_frame.grid_rowconfigure(1, weight=1) + self.right_frame.grid_rowconfigure(2, weight=0) + self.right_frame.columnconfigure(0, weight=1) + + # 配置区(AI/Embedding) + self.config_frame = ctk.CTkFrame(self.right_frame, corner_radius=10, border_width=2, border_color="gray") + self.config_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + self.config_frame.columnconfigure(0, weight=1) + + self.build_config_tabview() + + # 小说参数(主题、类型、章节数、字数、保存路径、可选元素等) + self.build_novel_params_area(start_row=1) + + # 可选功能按钮(一致性审校、导入知识库、清空向量库、查看剧情要点) + self.build_optional_buttons_area(start_row=2) + + # ----------------- 配置TabView ----------------- + def build_config_tabview(self): + """ + 创建包含 LLM Model settings 和 Embedding settings 的选项卡。 + """ + self.config_tabview = ctk.CTkTabview(self.config_frame) + self.config_tabview.grid(row=0, column=0, 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") + + self.build_ai_config_tab() + self.build_embeddings_config_tab() + + # 底部的"保存配置"和"加载配置"按钮 + self.btn_frame_config = ctk.CTkFrame(self.config_frame) + self.btn_frame_config.grid(row=1, column=0, 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") + + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, + font=None, sticky="e", padx=5, pady=5): + """ + 封装一个带"?"按钮的Label,用于展示提示信息。 + """ + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + + 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 + + # --------------- LLM 模型配置 --------------- + def build_ai_config_tab(self): + def on_interface_format_changed(new_value): + """ + 当切换LLM接口格式时,自动设置base_url为对应的默认值, + 并尝试加载该接口的已保存配置(若存在)。 + """ + self.interface_format_var.set(new_value) + config_data = load_config(self.config_file) + if config_data: + config_data["last_interface_format"] = new_value + save_config(config_data, self.config_file) + # 尝试加载已保存的配置 + if self.loaded_config and "llm_configs" in self.loaded_config and new_value in self.loaded_config["llm_configs"]: + llm_conf = self.loaded_config["llm_configs"][new_value] + self.api_key_var.set(llm_conf.get("api_key", "")) + self.base_url_var.set(llm_conf.get("base_url", self.base_url_var.get())) + self.model_name_var.set(llm_conf.get("model_name", "")) + self.temperature_var.set(llm_conf.get("temperature", 0.7)) + self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) + self.timeout_var.set(llm_conf.get("timeout", 600)) + else: + match new_value: + case "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + case "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + case "OpenAI": + self.base_url_var.set("https://api.openai.com/v1") + case "Azure OpenAI": + self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") + case "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") + case "Gemini": + self.base_url_var.set("") + case "Azure AI": + self.base_url_var.set("https://.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview") + + for i in range(7): + 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) + + # 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, columnspan=2, sticky="nsew") + + # 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, columnspan=2, sticky="nsew") + + # 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", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini"] + interface_dropdown = ctk.CTkOptionMenu( + self.ai_config_tab, + values=interface_options, + variable=self.interface_format_var, + command=on_interface_format_changed, + font=("Microsoft YaHei", 12) + ) + interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") + + # 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, columnspan=2, sticky="nsew") + + # 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}") + + temp_scale = ctk.CTkSlider( + self.ai_config_tab, + from_=0.0, to=2.0, + number_of_steps=200, + 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=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") + + # 7) Timeout (sec) + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="Timeout (sec):", + tooltip_key="timeout", + row=6, + column=0, + font=("Microsoft YaHei", 12) + ) + + def update_timeout_label(value): + integer_val = int(float(value)) + self.timeout_value_label.configure(text=str(integer_val)) + + timeout_slider = ctk.CTkSlider( + self.ai_config_tab, + from_=0, + to=3600, + number_of_steps=3600, + command=update_timeout_label, + variable=self.timeout_var + ) + timeout_slider.grid(row=6, column=1, padx=5, pady=5, sticky="we") + + self.timeout_value_label = ctk.CTkLabel( + self.ai_config_tab, + text=str(self.timeout_var.get()), + font=("Microsoft YaHei", 12) + ) + self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w") + + # 添加测试按钮 + test_btn = ctk.CTkButton( + self.ai_config_tab, + text="测试配置", + command=self.test_llm_config, + font=("Microsoft YaHei", 12) + ) + test_btn.grid(row=7, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + + # --------------- Embedding 模型配置 --------------- + def build_embeddings_config_tab(self): + def on_embedding_interface_changed(new_value): + """ + 当切换Embedding接口格式时,自动设置embedding_url为对应的默认值, + 并尝试加载该接口的已保存配置(若存在)。 + """ + self.embedding_interface_format_var.set(new_value) + # 自动更新配置文件中的 last_embedding_interface_format + config_data = load_config(self.config_file) + if config_data: + config_data["last_embedding_interface_format"] = new_value + save_config(config_data, self.config_file) + # 尝试加载已保存的配置 + if self.loaded_config and "embedding_configs" in self.loaded_config and new_value in self.loaded_config["embedding_configs"]: + emb_conf = self.loaded_config["embedding_configs"][new_value] + self.embedding_api_key_var.set(emb_conf.get("api_key", "")) + self.embedding_url_var.set(emb_conf.get("base_url", self.embedding_url_var.get())) + self.embedding_model_name_var.set(emb_conf.get("model_name", "")) + self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) + else: + match new_value: + case "Ollama": + self.embedding_url_var.set("http://localhost:11434/api") + case "ML Studio": + self.embedding_url_var.set("http://localhost:1234/v1") + case "OpenAI": + self.embedding_url_var.set("https://api.openai.com/v1") + self.embedding_model_name_var.set("text-embedding-ada-002") + case "Azure OpenAI": + self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") + case "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + case "Gemini": + self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") + self.embedding_model_name_var.set("models/text-embedding-004") + + 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) + + # 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") + + # 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", "Azure OpenAI", "Gemini", "Ollama", "ML Studio"] + emb_interface_dropdown = ctk.CTkOptionMenu( + self.embeddings_config_tab, + values=emb_interface_options, + variable=self.embedding_interface_format_var, + command=on_embedding_interface_changed, + font=("Microsoft YaHei", 12) + ) + emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + + # 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") + + # 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") + + # 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") + + # 添加测试按钮 + test_btn = ctk.CTkButton( + self.embeddings_config_tab, + text="测试配置", + command=self.test_embedding_config, + font=("Microsoft YaHei", 12) + ) + test_btn.grid(row=5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + # ----------------- 小说参数区 ----------------- + def build_novel_params_area(self, start_row=1): + """ + 在右侧 frame 中创建:主题、类型、章节数/字数、保存路径、本章指导、可选要素等参数输入区。 + """ + self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical") + self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) + self.params_frame.columnconfigure(1, weight=1) + + # 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)) + TextWidgetContextMenu(self.topic_text) + 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) + + # 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 + 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) + ) + + 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) + + 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(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=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)) + 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") + + # 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=row_chap_num, column=1, padx=5, pady=5, sticky="w") + + # 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)) + TextWidgetContextMenu(self.user_guide_text) + self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") + if self.user_guidance_default: + self.user_guide_text.insert("0.0", self.user_guidance_default) + + # 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_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + + 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_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + + 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_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + + 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_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) + self.optional_btn_frame.grid(row=start_row, column=0, sticky="ew", padx=5, pady=5) + self.optional_btn_frame.columnconfigure((0, 1, 2, 3), weight=1) + + self.btn_check_consistency = ctk.CTkButton( + self.optional_btn_frame, + text="一致性审校", + command=self.do_consistency_check, + font=("Microsoft YaHei", 12) + ) + self.btn_check_consistency.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + self.btn_import_knowledge = ctk.CTkButton( + self.optional_btn_frame, + text="导入知识库", + command=self.import_knowledge_handler, + font=("Microsoft YaHei", 12) + ) + self.btn_import_knowledge.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + self.btn_clear_vectorstore = ctk.CTkButton( + self.optional_btn_frame, + text="清空向量库", + fg_color="red", + command=self.clear_vectorstore_handler, + font=("Microsoft YaHei", 12) + ) + self.btn_clear_vectorstore.grid(row=0, column=2, padx=5, pady=5, sticky="ew") + + self.plot_arcs_btn = ctk.CTkButton( + self.optional_btn_frame, + text="查看剧情要点", + command=self.show_plot_arcs_ui, + font=("Microsoft YaHei", 12) + ) + self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") + + # ----------------- 配置的加载与保存 ----------------- + def load_config_btn(self): + """ + 从 config.json 中加载配置,并更新界面显示 + """ + cfg = load_config(self.config_file) + if cfg: + # 加载上次使用的接口类型 + last_llm = cfg.get("last_interface_format", "OpenAI") + last_embedding = cfg.get("last_embedding_interface_format", "OpenAI") + self.interface_format_var.set(last_llm) + self.embedding_interface_format_var.set(last_embedding) + # 加载对应的LLM配置 + llm_configs = cfg.get("llm_configs", {}) + if last_llm in llm_configs: + llm_conf = llm_configs[last_llm] + self.api_key_var.set(llm_conf.get("api_key", "")) + self.base_url_var.set(llm_conf.get("base_url", "https://api.openai.com/v1")) + self.model_name_var.set(llm_conf.get("model_name", "gpt-4o-mini")) + self.temperature_var.set(llm_conf.get("temperature", 0.7)) + self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) + self.timeout_var.set(llm_conf.get("timeout", 600)) + # 加载对应的Embedding配置 + embedding_configs = cfg.get("embedding_configs", {}) + if last_embedding in embedding_configs: + emb_conf = embedding_configs[last_embedding] + self.embedding_api_key_var.set(emb_conf.get("api_key", "")) + self.embedding_url_var.set(emb_conf.get("base_url", "https://api.openai.com/v1")) + self.embedding_model_name_var.set(emb_conf.get("model_name", "text-embedding-ada-002")) + self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) + # 加载其它参数 + other_params = cfg.get("other_params", {}) + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", other_params.get("topic", "")) + self.genre_var.set(other_params.get("genre", "玄幻")) + self.num_chapters_var.set(str(other_params.get("num_chapters", 10))) + self.word_number_var.set(str(other_params.get("word_number", 3000))) + self.filepath_var.set(other_params.get("filepath", "")) + self.chapter_num_var.set(str(other_params.get("chapter_num", "1"))) + self.user_guide_text.delete("0.0", "end") + self.user_guide_text.insert("0.0", other_params.get("user_guidance", "")) + self.characters_involved_var.set(other_params.get("characters_involved", "")) + self.key_items_var.set(other_params.get("key_items", "")) + self.scene_location_var.set(other_params.get("scene_location", "")) + self.time_constraint_var.set(other_params.get("time_constraint", "")) + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") + + def save_config_btn(self): + """ + 将当前界面的配置信息保存到 config.json, + 按照不同接口类型分别保存 LLM 与 Embedding 配置,确保各自独立。 + """ + current_llm_interface = self.interface_format_var.get().strip() + current_embedding_interface = self.embedding_interface_format_var.get().strip() + # 构造当前LLM配置 + llm_config = { + "api_key": self.api_key_var.get(), + "base_url": self.base_url_var.get(), + "model_name": self.model_name_var.get(), + "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), + "timeout": self.safe_get_int(self.timeout_var, 600) + } + # 构造当前Embedding配置 + embedding_config = { + "api_key": self.embedding_api_key_var.get(), + "base_url": self.embedding_url_var.get(), + "model_name": self.embedding_model_name_var.get(), + "retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4) + } + # 构造其它参数 + other_params = { + "topic": self.topic_text.get("0.0", "end").strip(), + "genre": self.genre_var.get(), + "num_chapters": self.safe_get_int(self.num_chapters_var, 10), + "word_number": self.safe_get_int(self.word_number_var, 3000), + "filepath": self.filepath_var.get(), + "chapter_num": self.chapter_num_var.get(), + "user_guidance": self.user_guide_text.get("0.0", "end").strip(), + "characters_involved": self.characters_involved_var.get(), + "key_items": self.key_items_var.get(), + "scene_location": self.scene_location_var.get(), + "time_constraint": self.time_constraint_var.get() + } + + # 读取已有配置(若存在) + existing_config = load_config(self.config_file) + if not existing_config: + existing_config = {} + existing_config["last_interface_format"] = current_llm_interface + existing_config["last_embedding_interface_format"] = current_embedding_interface + if "llm_configs" not in existing_config: + existing_config["llm_configs"] = {} + existing_config["llm_configs"][current_llm_interface] = llm_config + + if "embedding_configs" not in existing_config: + existing_config["embedding_configs"] = {} + existing_config["embedding_configs"][current_embedding_interface] = embedding_config + + existing_config["other_params"] = other_params + + if save_config(existing_config, self.config_file): + messagebox.showinfo("提示", "配置已保存至 config.json") + self.log("配置已保存。") + else: + messagebox.showerror("错误", "保存配置失败。") + + def browse_folder(self): + selected_dir = filedialog.askdirectory() + if selected_dir: + self.filepath_var.set(selected_dir) + + # ----------------- 生成与定稿的函数 ----------------- + def generate_novel_architecture_ui(self): + """ + Step1:生成小说整体架构 + """ + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + topic = self.topic_text.get("0.0", "end").strip() + genre = self.genre_var.get().strip() + num_chapters = self.safe_get_int(self.num_chapters_var, 10) + word_number = self.safe_get_int(self.word_number_var, 3000) + + self.safe_log("开始生成小说架构...") + Novel_architecture_generate( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + llm_model=model_name, + topic=topic, + genre=genre, + number_of_chapters=num_chapters, + word_number=word_number, + filepath=filepath, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout_val + ) + self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成小说架构时出错") + finally: + self.enable_button_safe(self.btn_generate_architecture) + + threading.Thread(target=task, daemon=True).start() + + def generate_chapter_blueprint_ui(self): + """ + Step2:生成章节蓝图 + """ + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + 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() + number_of_chapters = self.safe_get_int(self.num_chapters_var, 10) + temperature = self.temperature_var.get() + max_tokens = self.max_tokens_var.get() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + self.safe_log("开始生成章节蓝图...") + Chapter_blueprint_generate( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + llm_model=model_name, + number_of_chapters=number_of_chapters, + filepath=filepath, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout_val + ) + self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成章节蓝图时出错") + finally: + self.enable_button_safe(self.btn_generate_directory) + + threading.Thread(target=task, daemon=True).start() + + def generate_chapter_draft_ui(self): + """ + Step3:生成章节草稿 + """ + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + self.disable_button_safe(self.btn_generate_chapter) + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + 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_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() + embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4) + + self.safe_log(f"开始生成第{chap_num}章草稿...") + draft_text = generate_chapter_draft( + api_key=api_key, + base_url=base_url, + model_name=model_name, + filepath=filepath, + novel_number=chap_num, + word_number=word_number, + temperature=temperature, + user_guidance=user_guidance, + characters_involved=char_inv, + key_items=key_items, + scene_location=scene_loc, + time_constraint=time_constr, + embedding_api_key=embedding_api_key, + embedding_url=embedding_url, + embedding_interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, + embedding_retrieval_k=embedding_k, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + if draft_text: + self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") + self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) + else: + self.safe_log("⚠️ 本章草稿生成失败或无内容。") + + except Exception: + self.handle_exception("生成章节草稿时出错") + finally: + self.enable_button_safe(self.btn_generate_chapter) + + 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): + """ + Step4:定稿章节,更新全局摘要、角色状态、向量库等。 + """ + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + self.disable_button_safe(self.btn_finalize_chapter) + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + 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") + + edited_text = self.chapter_result.get("0.0", "end").strip() + + # 如果字数不足70%,询问是否扩写 + if len(edited_text) < 0.7 * word_number: + ask = messagebox.askyesno( + "字数不足", + f"当前章节字数 ({len(edited_text)}) 低于目标字数({word_number})的70%,是否要尝试扩写?" + ) + if ask: + self.safe_log("正在扩写章节内容...") + enriched = enrich_chapter_text( + chapter_text=edited_text, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + edited_text = enriched + self.master.after(0, lambda: self.chapter_result.delete("0.0", "end")) + self.master.after(0, lambda: self.chapter_result.insert("0.0", edited_text)) + + clear_file_content(chapter_file) + save_string_to_txt(edited_text, chapter_file) + + finalize_chapter( + novel_number=chap_num, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + filepath=filepath, + embedding_api_key=embedding_api_key, + embedding_url=embedding_url, + embedding_interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") + + final_text = read_file(chapter_file) + self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) + + except Exception: + self.handle_exception("定稿章节时出错") + finally: + self.enable_button_safe(self.btn_finalize_chapter) + + threading.Thread(target=task, daemon=True).start() + + # ----------------- 一致性审校 ----------------- + def do_consistency_check(self): + """ + 可选功能:对当前章节进行一致性审校,基于全局摘要和角色状态等进行检查。 + """ + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + 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() + interface_format = self.interface_format_var.get() + max_tokens = self.max_tokens_var.get() + timeout = self.timeout_var.get() + + chap_num = self.safe_get_int(self.chapter_num_var, 1) + chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") + chapter_text = read_file(chap_file) + + if not chapter_text.strip(): + self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。") + return + + self.safe_log("开始一致性审校...") + result = check_consistency( + novel_setting="", + character_state=read_file(os.path.join(filepath, "character_state.txt")), + global_summary=read_file(os.path.join(filepath, "global_summary.txt")), + chapter_text=chapter_text, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout, + plot_arcs="" + ) + self.safe_log("审校结果:") + self.safe_log(result) + + except Exception: + self.handle_exception("审校时出错") + finally: + self.enable_button_safe(self.btn_check_consistency) + + threading.Thread(target=task, daemon=True).start() + + # ----------------- 知识库操作 ----------------- + def import_knowledge_handler(self): + """ + 导入文本文件到本地知识库,供后续生成章节时的检索。 + """ + selected_file = filedialog.askopenfilename( + title="选择要导入的知识库文件", + filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] + ) + if selected_file: + def task(): + self.disable_button_safe(self.btn_import_knowledge) + try: + emb_api_key = self.embedding_api_key_var.get().strip() + emb_url = self.embedding_url_var.get().strip() + emb_format = self.embedding_interface_format_var.get().strip() + emb_model = self.embedding_model_name_var.get().strip() + + self.safe_log(f"开始导入知识库文件: {selected_file}") + import_knowledge_file( + embedding_api_key=emb_api_key, + embedding_url=emb_url, + embedding_interface_format=emb_format, + embedding_model_name=emb_model, + file_path=selected_file, + filepath=self.filepath_var.get().strip() + ) + self.safe_log("✅ 知识库文件导入完成。") + except Exception: + self.handle_exception("导入知识库时出错") + finally: + self.enable_button_safe(self.btn_import_knowledge) + + threading.Thread(target=task, daemon=True).start() + + def clear_vectorstore_handler(self): + """ + 清空本地向量库文件夹。 + """ + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") + if first_confirm: + second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") + if second_confirm: + if clear_vector_store(filepath): + self.log("已清空向量库。") + else: + self.log(f"未能清空向量库,请关闭程序后手动删除 {filepath} 下的 vectorstore 文件夹。") + + def show_plot_arcs_ui(self): + """ + 查看剧情要点/未解决冲突的记录(plot_arcs.txt)。 + """ + 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 = 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") + + # ----------------- Novel Architecture 标签页 ----------------- + 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_architecture.txt", + command=self.load_novel_architecture, + 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_architecture, + 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)) + TextWidgetContextMenu(self.setting_text) + self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_novel_architecture(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "Novel_architecture.txt") + content = read_file(filename) + self.setting_text.delete("0.0", "end") + self.setting_text.insert("0.0", content) + self.log("已加载 Novel_architecture.txt 内容到编辑区。") + + def save_novel_architecture(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径。") + return + content = self.setting_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "Novel_architecture.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 Novel_architecture.txt 的修改。") + + # ----------------- Chapter Blueprint 标签页 ----------------- + 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_chapter_blueprint, + 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_chapter_blueprint, + 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)) + TextWidgetContextMenu(self.directory_text) + self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + def load_chapter_blueprint(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "Novel_directory.txt") + content = read_file(filename) + self.directory_text.delete("0.0", "end") + self.directory_text.insert("0.0", content) + self.log("已加载 Novel_directory.txt 内容到编辑区。") + + def save_chapter_blueprint(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + content = self.directory_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 Novel_directory.txt 的修改。") + + # ----------------- Character State 标签页 ----------------- + 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)) + TextWidgetContextMenu(self.character_text) + 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("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "character_state.txt") + content = read_file(filename) + 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("警告", "请先设置保存文件路径") + return + content = self.character_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "character_state.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 character_state.txt 的修改。") + + # ----------------- Global Summary 标签页 ----------------- + 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)) + TextWidgetContextMenu(self.summary_text) + 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("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "global_summary.txt") + content = read_file(filename) + 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("警告", "请先设置保存文件路径") + return + content = self.summary_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "global_summary.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 global_summary.txt 的修改。") + + # ----------------- Chapters Manage 标签页 ----------------- + def build_chapters_tab(self): + """ + 章节浏览与管理:可选择某一章进行查看/编辑,并保存修改。 + """ + self.chapters_view_tab.rowconfigure(0, weight=0) + self.chapters_view_tab.rowconfigure(1, weight=1) + self.chapters_view_tab.columnconfigure(0, weight=1) + + top_frame = ctk.CTkFrame(self.chapters_view_tab) + top_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + top_frame.columnconfigure(0, weight=0) + top_frame.columnconfigure(1, weight=0) + top_frame.columnconfigure(2, weight=0) + top_frame.columnconfigure(3, weight=0) + top_frame.columnconfigure(4, weight=1) + + prev_btn = ctk.CTkButton(top_frame, text="<< 上一章", command=self.prev_chapter, font=("Microsoft YaHei", 12)) + prev_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + next_btn = ctk.CTkButton(top_frame, text="下一章 >>", command=self.next_chapter, font=("Microsoft YaHei", 12)) + next_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + self.chapter_select_var = ctk.StringVar(value="") + + self.chapter_select_menu = ctk.CTkOptionMenu( + top_frame, + values=[], + variable=self.chapter_select_var, + command=self.on_chapter_selected, + font=("Microsoft YaHei", 12) + ) + self.chapter_select_menu.grid(row=0, column=2, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(top_frame, text="保存修改", command=self.save_current_chapter, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=3, padx=5, pady=5, sticky="w") + + refresh_btn = ctk.CTkButton(top_frame, text="刷新章节列表", command=self.refresh_chapters_list, font=("Microsoft YaHei", 12)) + refresh_btn.grid(row=0, column=4, padx=5, pady=5, sticky="e") + + self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12)) + TextWidgetContextMenu(self.chapter_view_text) + self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + + self.chapters_list = [] + self.refresh_chapters_list() + + def refresh_chapters_list(self): + """ + 刷新并获取当前保存路径下的所有章节txt文件,并更新下拉菜单。 + """ + filepath = self.filepath_var.get().strip() + chapters_dir = os.path.join(filepath, "chapters") + if not os.path.exists(chapters_dir): + self.safe_log("尚未找到 chapters 文件夹,请先生成章节或检查保存路径。") + self.chapter_select_menu.configure(values=[]) + return + + all_files = os.listdir(chapters_dir) + chapter_nums = [] + for f in all_files: + if f.startswith("chapter_") and f.endswith(".txt"): + number_part = f.replace("chapter_", "").replace(".txt", "") + if number_part.isdigit(): + chapter_nums.append(number_part) + + chapter_nums.sort(key=lambda x: int(x)) + self.chapters_list = chapter_nums + self.chapter_select_menu.configure(values=self.chapters_list) + + current_selected = self.chapter_select_var.get() + if current_selected not in self.chapters_list: + if self.chapters_list: + self.chapter_select_var.set(self.chapters_list[0]) + self.load_chapter_content(self.chapters_list[0]) + else: + self.chapter_select_var.set("") + self.chapter_view_text.delete("0.0", "end") + + def on_chapter_selected(self, value): + """ + 当下拉菜单选择变化时,加载对应章节内容到文本框。 + """ + self.load_chapter_content(value) + + def load_chapter_content(self, chapter_number_str): + if not chapter_number_str: + return + filepath = self.filepath_var.get().strip() + chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") + if not os.path.exists(chapter_file): + self.safe_log(f"章节文件 {chapter_file} 不存在!") + return + + content = read_file(chapter_file) + self.chapter_view_text.delete("0.0", "end") + self.chapter_view_text.insert("0.0", content) + + def save_current_chapter(self): + """ + 将当前查看的章节内容保存回对应文件。 + """ + chapter_number_str = self.chapter_select_var.get() + if not chapter_number_str: + messagebox.showwarning("警告", "尚未选择章节,无法保存。") + return + + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径") + return + + chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") + content = self.chapter_view_text.get("0.0", "end").strip() + + clear_file_content(chapter_file) + save_string_to_txt(content, chapter_file) + self.safe_log(f"已保存对第 {chapter_number_str} 章的修改。") + + def prev_chapter(self): + """ + 切换到上一章 + """ + if not self.chapters_list: + return + current = self.chapter_select_var.get() + if current not in self.chapters_list: + return + idx = self.chapters_list.index(current) + if idx > 0: + new_idx = idx - 1 + self.chapter_select_var.set(self.chapters_list[new_idx]) + self.load_chapter_content(self.chapters_list[new_idx]) + else: + messagebox.showinfo("提示", "已经是第一章了。") + + def next_chapter(self): + """ + 切换到下一章 + """ + if not self.chapters_list: + return + current = self.chapter_select_var.get() + if current not in self.chapters_list: + return + idx = self.chapters_list.index(current) + if idx < len(self.chapters_list) - 1: + new_idx = idx + 1 + self.chapter_select_var.set(self.chapters_list[new_idx]) + self.load_chapter_content(self.chapters_list[new_idx]) + else: + messagebox.showinfo("提示", "已经是最后一章了。") + + def test_llm_config(self): + """ + 测试当前的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() + timeout = self.timeout_var.get() + + test_llm_config( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + log_func=self.safe_log, + handle_exception_func=self.handle_exception + ) + + def test_embedding_config(self): + """ + 测试当前的Embedding配置是否可用 + """ + api_key = self.embedding_api_key_var.get().strip() + base_url = self.embedding_url_var.get().strip() + interface_format = self.embedding_interface_format_var.get().strip() + model_name = self.embedding_model_name_var.get().strip() + + test_embedding_config( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + model_name=model_name, + log_func=self.safe_log, + handle_exception_func=self.handle_exception + ) + + +# ----------------- 程序入口 ----------------- +if __name__ == "__main__": + app = ctk.CTk() + gui = NovelGeneratorGUI(app) + app.mainloop()