diff --git a/novel_generator_backup.txt b/novel_generator_backup.txt deleted file mode 100644 index 1ec5062..0000000 --- a/novel_generator_backup.txt +++ /dev/null @@ -1,1173 +0,0 @@ -# novel_generator.py -# -*- coding: utf-8 -*- -import os -import logging -import re -import time -import traceback -import json -from typing import List, Optional, Tuple - -from langchain_chroma import Chroma -from chromadb.config import Settings -from langchain.docstore.document import Document - -# nltk、sentence_transformers 及文本处理相关 -import nltk -from sentence_transformers import SentenceTransformer -from sklearn.metrics.pairwise import cosine_similarity - -# 工具函数 -from utils import ( - read_file, append_text_to_file, clear_file_content, - save_string_to_txt -) - -# prompt模板 -from prompt_definitions import ( - core_seed_prompt, - character_dynamics_prompt, - world_building_prompt, - plot_architecture_prompt, - chapter_blueprint_prompt, - chunked_chapter_blueprint_prompt, - summary_prompt, - update_character_state_prompt, - first_chapter_draft_prompt, - next_chapter_draft_prompt, - summarize_recent_chapters_prompt, - create_character_state_prompt -) - -# 章节目录解析 -from chapter_directory_parser import get_chapter_info_from_blueprint - -from llm_adapters import create_llm_adapter -from embedding_adapters import create_embedding_adapter - -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - -# ============ 通用的重试封装 ============ - -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() - - -# ============ 获取 vectorstore 路径 ============ - -def get_vectorstore_dir(filepath: str) -> str: - 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 - - -# ============ 根据 embedding 接口创建/加载 Chroma ============ - -def init_vector_store( - embedding_adapter, - texts: List[str], - filepath: str -) -> Optional[Chroma]: - """ - 在 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: List[str]) -> List[List[float]]: - return call_with_retry( - func=embedding_adapter.embed_documents, - max_retries=3, - fallback_return=[], - texts=texts - ) - - def embed_query(self, query: str) -> List[float]: - 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 -) -> Optional[Chroma]: - """ - 读取已存在的 Chroma 向量库。若不存在则返回 None。 - 如果加载失败(embedding 或IO问题),则返回 None。 - """ - store_dir = get_vectorstore_dir(filepath) - if not os.path.exists(store_dir): - logging.info("Vector store not found. Will return None.") - return None - - from langchain.embeddings.base import Embeddings as LCEmbeddings - - try: - class LCEmbeddingWrapper(LCEmbeddings): - def embed_documents(self, texts: List[str]) -> List[List[float]]: - return call_with_retry( - func=embedding_adapter.embed_documents, - max_retries=3, - fallback_return=[], - texts=texts - ) - - def embed_query(self, query: str) -> List[float]: - 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) -> List[str]: - segments = [] - start_idx = 0 - while start_idx < len(text): - end_idx = min(start_idx + max_length, len(text)) - segment = text[start_idx:end_idx] - segments.append(segment.strip()) - start_idx = end_idx - return segments - -def split_text_for_vectorstore(chapter_text: str, - max_length: int = 500, - similarity_threshold: float = 0.7) -> List[str]: - """ - 对新的章节文本进行分段后,再用于存入向量库。 - 先句子切分 -> 语义相似度合并 -> 再按 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 -): - """ - 将最新章节文本插入到向量库中。 - 若库不存在则初始化;若初始化/更新失败,则跳过。 - """ - 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 - - # 如果已有store,则直接往里插入 - 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]) - # 限制长度最多2000字符 - if len(combined) > 2000: - combined = combined[:2000] - return combined - except Exception as e: - logging.warning(f"Similarity search failed: {e}") - traceback.print_exc() - return "" - - -# ============ 从目录中获取最近 n 章文本 ============ - -def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: - 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[str], - timeout: int = 600 -) -> Tuple[str, str]: - """ - 生成 (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) - - -# ============ 持久化:情节架构(partial_architecture.json) ============ - -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}") - - -# ============ 1) 生成总体架构 ============ - -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_architecture.json,或保留做追溯 - # 这里选择删除 - 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).") - - -# ============ 计算分块大小的工具函数 ============ - -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() - - -# ============ 2) 生成章节蓝图(新增分块逻辑 + 断点续跑) ============ - -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()] - - if existing_chapter_numbers: - max_existing_chap = max(existing_chapter_numbers) - else: - max_existing_chap = 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, # 只保留最近100章 - 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.") - # 写入当前已经有的 final_blueprint,并结束 - 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 - - # 如果 Novel_directory.txt 为空,则分情况: - # 1) 如果 chunk_size >= number_of_chapters,可以一次性生成 - 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 - - # 2) 如果 chunk_size < number_of_chapters,则进行分块生成 - 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, # 只保留最近100章 - 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.") - # 写入已经生成的 final_blueprint - 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).") - - -# ============ 3) 生成章节草稿 ============ - -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(): - # 取后1500字符左右 - if len(text_block) > 1500: - previous_chapter_excerpt = text_block[-1500:] - else: - previous_chapter_excerpt = text_block - break - - # 从向量库检索上下文 - embedding_adapter = create_embedding_adapter( - embedding_interface_format, - embedding_api_key, - embedding_url, - embedding_model_name - ) - 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 - - -# ============ 4) 定稿章节 ============ - -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) - - # 更新向量库 - embedding_adapter = create_embedding_adapter( - embedding_interface_format, - embedding_api_key, - embedding_url, - embedding_model_name - ) - update_vector_store(embedding_adapter, chapter_text, 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 - - -# ============ 导入知识文件到向量库 ============ - -def advanced_split_content(content: str, - similarity_threshold: float = 0.7, - max_length: int = 500) -> List[str]: - 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 = split_by_length(para, max_length=max_length) - 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) - - embedding_adapter = create_embedding_adapter( - interface_format=embedding_interface_format, - api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/api", - model_name=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/ui_backup.txt b/ui_backup.txt deleted file mode 100644 index 80836f4..0000000 --- a/ui_backup.txt +++ /dev/null @@ -1,1823 +0,0 @@ -# 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()