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()