diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py index a2c6311..696cbb8 100644 --- a/chapter_directory_parser.py +++ b/chapter_directory_parser.py @@ -1,47 +1,132 @@ -# chapter_directory_parser.py +# chapter_blueprint_parser.py # -*- coding: utf-8 -*- import re -def get_chapter_info_from_directory(novel_directory_content: str, chapter_number: int): +def parse_chapter_blueprint(blueprint_text: str): """ - 从给定的 novel_directory_content 文本中,解析 “第X章” 行,并提取本章的标题和可能的简述。 - 返回一个 dict: { - "chapter_title": <字符串>, - "chapter_brief": <字符串> (若没有则为空) + 解析整份章节蓝图文本,返回一个列表,每个元素是一个 dict: + { + "chapter_number": int, + "chapter_title": str, + "chapter_role": str, # 本章定位 + "chapter_purpose": str, # 核心作用 + "suspense_level": str, # 悬念密度 + "foreshadowing": str, # 伏笔操作 + "plot_twist_level": str, # 认知颠覆 + "chapter_summary": str # 本章简述 } - 注意:目录文本示例格式: - 第1章 :潮起 - 第2章 :阴影浮现 - 主要角色冲突爆发 - ... - 也可能没有简述,只有一个简单标题。 """ - # 将文本逐行拆分 - lines = novel_directory_content.splitlines() + # 先按空行进行分块,以免多章之间混淆 + chunks = re.split(r'\n\s*\n', blueprint_text.strip()) + results = [] - # 章节匹配:形如 “第5章 :xxx” or “第5章: xxx” or “第5章 xxx” - pattern = re.compile(r'^第\s*(\d+)\s*章\s*[::]?\s*(.*)$') + # 兼容是否使用方括号包裹章节标题 + # 例如: + # 第1章 - 紫极光下的预兆 + # 或 + # 第1章 - [紫极光下的预兆] + chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[?(.*?)\]?$') - for line in lines: - match = pattern.match(line.strip()) - if match: - chap_num = int(match.group(1)) - if chap_num == chapter_number: - full_title = match.group(2).strip() - if ' - ' in full_title: - parts = full_title.split(' - ', 1) - return { - "chapter_title": parts[0].strip(), - "chapter_brief": parts[1].strip() - } - else: - return { - "chapter_title": full_title, - "chapter_brief": "" - } + role_pattern = re.compile(r'^本章定位:\s*\[?(.*)\]?$') + purpose_pattern = re.compile(r'^核心作用:\s*\[?(.*)\]?$') + suspense_pattern = re.compile(r'^悬念密度:\s*\[?(.*)\]?$') + foreshadow_pattern = re.compile(r'^伏笔操作:\s*\[?(.*)\]?$') + twist_pattern = re.compile(r'^认知颠覆:\s*\[?(.*)\]?$') + summary_pattern = re.compile(r'^本章简述:\s*\[?(.*)\]?$') - # 如果没有匹配到,返回默认 + for chunk in chunks: + lines = chunk.strip().splitlines() + if not lines: + continue + + chapter_number = None + chapter_title = "" + chapter_role = "" + chapter_purpose = "" + suspense_level = "" + foreshadowing = "" + plot_twist_level = "" + chapter_summary = "" + + # 先匹配第一行(或前几行),找到章号和标题 + header_match = chapter_number_pattern.match(lines[0].strip()) + if not header_match: + # 不符合“第X章 - 标题”的格式,跳过 + continue + + chapter_number = int(header_match.group(1)) + chapter_title = header_match.group(2).strip() + + # 从后面的行匹配其他字段 + for line in lines[1:]: + line_stripped = line.strip() + if not line_stripped: + continue + + m_role = role_pattern.match(line_stripped) + if m_role: + chapter_role = m_role.group(1).strip() + continue + + m_purpose = purpose_pattern.match(line_stripped) + if m_purpose: + chapter_purpose = m_purpose.group(1).strip() + continue + + m_suspense = suspense_pattern.match(line_stripped) + if m_suspense: + suspense_level = m_suspense.group(1).strip() + continue + + m_foreshadow = foreshadow_pattern.match(line_stripped) + if m_foreshadow: + foreshadowing = m_foreshadow.group(1).strip() + continue + + m_twist = twist_pattern.match(line_stripped) + if m_twist: + plot_twist_level = m_twist.group(1).strip() + continue + + m_summary = summary_pattern.match(line_stripped) + if m_summary: + chapter_summary = m_summary.group(1).strip() + continue + + results.append({ + "chapter_number": chapter_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 + }) + + # 按照 chapter_number 排序后返回 + results.sort(key=lambda x: x["chapter_number"]) + return results + + +def get_chapter_info_from_blueprint(blueprint_text: str, target_chapter_number: int): + """ + 在已经加载好的章节蓝图文本中,找到对应章号的结构化信息,返回一个 dict。 + 若找不到则返回一个默认的结构。 + """ + all_chapters = parse_chapter_blueprint(blueprint_text) + for ch in all_chapters: + if ch["chapter_number"] == target_chapter_number: + return ch + # 默认返回 return { - "chapter_title": f"第{chapter_number}章", - "chapter_brief": "" + "chapter_number": target_chapter_number, + "chapter_title": f"第{target_chapter_number}章", + "chapter_role": "", + "chapter_purpose": "", + "suspense_level": "", + "foreshadowing": "", + "plot_twist_level": "", + "chapter_summary": "" } diff --git a/embedding_adapters.py b/embedding_adapters.py new file mode 100644 index 0000000..9b0d7a8 --- /dev/null +++ b/embedding_adapters.py @@ -0,0 +1,120 @@ +# embedding_adapters.py +# -*- coding: utf-8 -*- +import logging +import requests +import traceback +from typing import List +from langchain_openai import OpenAIEmbeddings + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + +class BaseEmbeddingAdapter: + """ + Embedding 接口统一基类 + """ + def embed_documents(self, texts: List[str]) -> List[List[float]]: + raise NotImplementedError + + def embed_query(self, query: str) -> List[float]: + raise NotImplementedError + +class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 OpenAIEmbeddings(或兼容接口)的适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + self._embedding = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=ensure_openai_base_url_has_v1(base_url), + model=model_name + ) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embedding.embed_documents(texts) + + def embed_query(self, query: str) -> List[float]: + return self._embedding.embed_query(query) + +class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 其接口路径为 /api/embeddings + """ + def __init__(self, model_name: str, base_url: str): + self.model_name = model_name + self.base_url = base_url.rstrip("/") + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + embeddings = [] + for text in texts: + vec = self._embed_single(text) + embeddings.append(vec) + return embeddings + + def embed_query(self, query: str) -> List[float]: + return self._embed_single(query) + + def _embed_single(self, text: str) -> List[float]: + """ + 调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding + """ + url = self.base_url + if "api/embeddings" not in url: + url = f"{url}/api/embeddings" + + data = { + "model": self.model_name, + "prompt": text + } + try: + response = requests.post(url, json=data) + response.raise_for_status() + result = response.json() + if "embedding" not in result: + raise ValueError("No 'embedding' field in Ollama response.") + return result["embedding"] + except requests.exceptions.RequestException as e: + logging.error(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}") + return [] + +class MLStudioEmbeddingAdapter(BaseEmbeddingAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str): + self._embedding = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=ensure_openai_base_url_has_v1(base_url), + model=model_name + ) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embedding.embed_documents(texts) + + def embed_query(self, query: str) -> List[float]: + return self._embedding.embed_query(query) + +def create_embedding_adapter( + interface_format: str, + api_key: str, + base_url: str, + model_name: str +) -> BaseEmbeddingAdapter: + """ + 工厂函数:根据 interface_format 返回不同的 embedding 适配器实例 + """ + if interface_format.lower() == "openai": + return OpenAIEmbeddingAdapter(api_key, base_url, model_name) + elif interface_format.lower() == "ollama": + return OllamaEmbeddingAdapter(model_name, base_url) + elif interface_format.lower() == "ml studio": + return MLStudioEmbeddingAdapter(api_key, base_url, model_name) + else: + raise ValueError(f"Unknown embedding interface_format: {interface_format}") diff --git a/embedding_ollama.py b/embedding_ollama.py deleted file mode 100644 index 749a462..0000000 --- a/embedding_ollama.py +++ /dev/null @@ -1,59 +0,0 @@ -# embedding_ollama.py -import requests -import traceback -from typing import List - -class OllamaEmbeddings: - def __init__(self, model_name: str, base_url: str): - self.model_name = model_name - self.base_url = base_url - - def embed(self, texts: List[str]) -> List[List[float]]: - """ - 批量将多段文本转换为embedding向量 - """ - embeddings = [] - for text in texts: - embeddings.append(self.embed_single_document(text)) - return embeddings - - def embed_documents(self, texts: List[str]) -> List[List[float]]: - """ - 兼容langchain的接口写法 - """ - return self.embed(texts) - - def embed_query(self, query: str) -> List[float]: - """ - 将单条 query 转换为 embedding 向量 - """ - return self.embed_single_document(query) - - def embed_single_document(self, text: str) -> List[float]: - """ - 调用 Ollama 本地服务接口,获取文本的 embedding。 - """ - if self.base_url.endswith("/"): - self.base_url = self.base_url.rstrip("/") - if "api/embeddings" in self.base_url: - # 如果 base_url 已经包含 'api/embeddings',则保持不变 - url = f"{self.base_url.rstrip('/')}/api/embeddings" - else: - if "/v1" in self.base_url: - self.base_url = self.base_url.split("/v1")[0] - if "/api" in self.base_url: - self.base_url = self.base_url.split("/api")[0] - url = f"{self.base_url}/api/embeddings" - data = { - "model": self.model_name, - "prompt": text - } - try: - response = requests.post(url, json=data) - response.raise_for_status() - result = response.json() - if "embedding" not in result: - raise ValueError("No 'embedding' field in Ollama response.") - return result["embedding"] - except requests.exceptions.RequestException as e: - raise Exception(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}") diff --git a/llm_adapters.py b/llm_adapters.py new file mode 100644 index 0000000..714e72a --- /dev/null +++ b/llm_adapters.py @@ -0,0 +1,149 @@ +# llm_adapters.py +# -*- coding: utf-8 -*- +import logging +from typing import Optional +from langchain_openai import ChatOpenAI + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + +class BaseLLMAdapter: + """ + 统一的 LLM 接口基类,为不同后端(OpenAI、Ollama、ML Studio 等)提供一致的方法签名。 + """ + def invoke(self, prompt: str) -> str: + raise NotImplementedError("Subclasses must implement .invoke(prompt) method.") + +class DeepSeekAdapter(BaseLLMAdapter): + """ + 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.content + +class OpenAIAdapter(BaseLLMAdapter): + """ + 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from OpenAIAdapter.") + return "" + return response.content + +class OllamaAdapter(BaseLLMAdapter): + """ + Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 + 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from OllamaAdapter.") + return "" + return response.content + +class MLStudioAdapter(BaseLLMAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from MLStudioAdapter.") + return "" + return response.content + +def create_llm_adapter( + interface_format: str, + base_url: str, + model_name: str, + api_key: str, + temperature: float, + max_tokens: int +) -> BaseLLMAdapter: + """ + 工厂函数:根据 interface_format 返回不同的适配器实例。 + """ + if interface_format.lower() == "deepseek": + return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature) + elif interface_format.lower() == "openai": + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature) + elif interface_format.lower() == "ollama": + return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature) + elif interface_format.lower() == "ml studio": + return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature) + else: + raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/main.spec b/main.spec index 4694d44..94baffe 100644 --- a/main.spec +++ b/main.spec @@ -14,7 +14,8 @@ hiddenimports = ['typing_extensions', 'pydantic', 'pydantic.deprecated.decorator', 'tiktoken_ext.openai_public', - 'tiktoken_ext' + 'tiktoken_ext', + 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2' ] tmp_ret = collect_all('chromadb') @@ -44,7 +45,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.3.2', + name='AI_NovelGenerator_V1.3.3', debug=True, bootloader_ignore_signals=False, strip=False, @@ -65,5 +66,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.3.2' + name='AI_NovelGenerator_V1.3.3' ) diff --git a/novel_generator.py b/novel_generator.py index 7505948..6c4e47f 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -5,17 +5,14 @@ import logging import re import time import traceback -from typing import List, Optional +from typing import List, Optional, Tuple -# langchain 相关 -from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_chroma import Chroma from chromadb.config import Settings from langchain.docstore.document import Document # nltk、sentence_transformers 及文本处理相关 import nltk -import math from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity @@ -27,157 +24,114 @@ from utils import ( # prompt模板 from prompt_definitions import ( - # 设定相关 - set_prompt, character_prompt, dark_lines_prompt, - finalize_setting_prompt, novel_directory_prompt, - - # 写作流程相关 - summary_prompt, update_character_state_prompt, - chapter_outline_prompt, chapter_write_prompt + 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 ) -# Ollama嵌入 (如使用Ollama时需要) -from embedding_ollama import OllamaEmbeddings - -# 用于目录解析章节标题/简介 -from chapter_directory_parser import get_chapter_info_from_directory +# 章节目录解析 +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 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") + logging.info( + f"\n[######################################### Prompt #########################################]\n{prompt}\n" + ) + logging.info( + f"\n[######################################### Response #########################################]\n{response_content}\n" + ) -def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: - """通用封装:调用模型并移除 ... 文本,记录日志后返回""" - response = model.invoke(prompt) +def invoke_with_cleaning(llm_adapter, prompt: str) -> str: + """通用封装:调用 LLM,并移除 ... 文本,记录日志后返回""" + response = llm_adapter.invoke(prompt) if not response: logging.warning("No response from model.") return "" - cleaned_text = remove_think_tags(response.content) + cleaned_text = remove_think_tags(response) debug_log(prompt, cleaned_text) return cleaned_text.strip() -def ensure_openai_base_url_has_v1(url: str) -> str: - """ - 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 - """ - import re - url = url.strip() - if not url: - return url - if not re.search(r'/v\d+$', url): - if '/v1' not in url: - url = url.rstrip('/') + '/v1' - return url - -def is_using_ollama_api(interface_format: str) -> bool: - return interface_format.lower() == "ollama" - -def is_using_ml_studio_api(interface_format: str) -> bool: - return interface_format.lower() == "ml studio" - # ============ 获取 vectorstore 路径 ============ + def get_vectorstore_dir(filepath: str) -> str: - """ - 返回存储向量库的本地路径: - 在用户指定的 `filepath` 下创建/使用 'vectorstore' 文件夹。 - """ return os.path.join(filepath, "vectorstore") +# ============ 清空向量库 ============ -# ============ 创建 Embeddings 对象 ============ -def create_embeddings_object( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str -): - """ - 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 - """ - if is_using_ollama_api(interface_format): - fixed_url = base_url.rstrip("/") - return OllamaEmbeddings( - model_name=embedding_model_name, - base_url=fixed_url - ) - else: - # OpenAI 或 ML Studio 均使用 OpenAIEmbeddings,注意 base_url 可能需要 ensure /v1 - fixed_url = ensure_openai_base_url_has_v1(base_url) - return OpenAIEmbeddings( - openai_api_key=api_key, - openai_api_base=fixed_url, - model=embedding_model_name - ) - - -# ============ 向量库相关操作 ============ 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: - if os.path.exists(store_dir): - shutil.rmtree(store_dir) - logging.info(f"Vector store directory '{store_dir}' removed.") + 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)}") + logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}") traceback.print_exc() return False + +# ============ 根据 embedding 接口创建/加载 Chroma ============ + def init_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, + embedding_adapter, texts: List[str], filepath: str ) -> Chroma: """ 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + 这里 embedding_adapter 是一个实现了 embed_documents(texts) 的对象 """ store_dir = get_vectorstore_dir(filepath) os.makedirs(store_dir, exist_ok=True) - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) documents = [Document(page_content=str(t)) for t in texts] + + from langchain.embeddings.base import Embeddings as LCEmbeddings + + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return embedding_adapter.embed_documents(doc_texts) + + def embed_query(self, query_text: str) -> List[float]: + return embedding_adapter.embed_query(query_text) + + chroma_embedding = LCEmbeddingWrapper() + vectorstore = Chroma.from_documents( documents, - embedding=embeddings, + embedding=chroma_embedding, persist_directory=store_dir, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) return vectorstore - def load_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, + embedding_adapter, filepath: str ) -> Optional[Chroma]: """ @@ -188,20 +142,27 @@ def load_vector_store( logging.info("Vector store not found. Will return None.") return None - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) + from langchain.embeddings.base import Embeddings as LCEmbeddings + + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return embedding_adapter.embed_documents(doc_texts) + + def embed_query(self, query_text: str) -> List[float]: + return embedding_adapter.embed_query(query_text) + + chroma_embedding = LCEmbeddingWrapper() + return Chroma( persist_directory=store_dir, - embedding_function=embeddings, + embedding_function=chroma_embedding, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) +# ============ 文本分段工具 ============ + def split_by_length(text: str, max_length: int = 500) -> List[str]: segments = [] start_idx = 0 @@ -212,23 +173,21 @@ def split_by_length(text: str, max_length: int = 500) -> List[str]: 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) @@ -249,7 +208,6 @@ def split_text_for_vectorstore(chapter_text: str, if current_sentences: merged_paragraphs.append(" ".join(current_sentences)) - # 再对合并好的段落做 max_length 切分 final_segments = [] for para in merged_paragraphs: if len(para) > max_length: @@ -260,13 +218,11 @@ def split_text_for_vectorstore(chapter_text: str, return final_segments +# ============ 更新向量库 ============ def update_vector_store( - api_key: str, - base_url: str, + embedding_adapter, new_chapter: str, - interface_format: str, - embedding_model_name: str, filepath: str ): """ @@ -277,49 +233,28 @@ def update_vector_store( logging.warning("No valid text to insert into vector store. Skipping.") return - store = load_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("Vector store does not exist. Initializing a new one for new chapter...") - init_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=splitted_texts, - filepath=filepath - ) + init_vector_store(embedding_adapter, splitted_texts, filepath) return docs = [Document(page_content=str(t)) for t in splitted_texts] store.add_documents(docs) logging.info("Vector store updated with the new chapter splitted segments.") +# ============ 向量检索上下文 ============ def get_relevant_context_from_vector_store( - api_key: str, - base_url: str, + embedding_adapter, query: str, - interface_format: str, - embedding_model_name: str, filepath: str, k: int = 2 ) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 """ - store = load_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("No vector store found. Returning empty context.") return "" @@ -332,9 +267,71 @@ def get_relevant_context_from_vector_store( combined = "\n".join([d.page_content for d in docs]) return combined +# ============ 从目录中获取最近 n 章文本 ============ -# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ -def Novel_setting_generate( +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] +) -> 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 + ) + + 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) + + +# ============ 1) 生成总体架构 ============ + +def Novel_architecture_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, @@ -343,273 +340,348 @@ def Novel_setting_generate( number_of_chapters: int, word_number: int, filepath: str, - temperature: float = 0.7 + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: + """ + 依次调用: + 1. core_seed_prompt + 2. character_dynamics_prompt + 3. world_building_prompt + 4. plot_architecture_prompt + 最终输出 Novel_architecture.txt + """ os.makedirs(filepath, exist_ok=True) - model = ChatOpenAI( - model=llm_model, + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=llm_model, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) - # Step1: 基础设定 - prompt_base = set_prompt.format( + # Step1: 核心种子 + prompt_core = core_seed_prompt.format( topic=topic, genre=genre, number_of_chapters=number_of_chapters, word_number=word_number ) - base_setting = invoke_with_cleaning(model, prompt_base) + core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) - # Step2: 角色设定 - prompt_char = character_prompt.format( - novel_setting=base_setting + # Step2: 角色动力学 + prompt_character = character_dynamics_prompt.format(core_seed=core_seed_result.strip()) + character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) + + # Step3: 世界观 + prompt_world = world_building_prompt.format(core_seed=core_seed_result.strip()) + world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) + + # Step4: 三幕式情节 + prompt_plot = plot_architecture_prompt.format( + core_seed=core_seed_result.strip(), + character_dynamics=character_dynamics_result.strip(), + world_building=world_building_result.strip() ) - character_setting = invoke_with_cleaning(model, prompt_char) + plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) - # Step3: 暗线/雷点 - prompt_dark = dark_lines_prompt.format( - character_info=character_setting + 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" ) - dark_lines = invoke_with_cleaning(model, prompt_dark) - # Step4: 最终整合 - prompt_final = finalize_setting_prompt.format( - novel_setting_base=base_setting, - character_setting=character_setting, - dark_lines=dark_lines - ) - final_novel_setting = invoke_with_cleaning(model, prompt_final) - - filename_set = os.path.join(filepath, "Novel_setting.txt") - clear_file_content(filename_set) - - final_novel_setting_cleaned = final_novel_setting.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_setting_cleaned, filename_set) - logging.info("Novel_setting.txt has been generated successfully.") + 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.") -# ============ 2. 生成小说目录 (Novel_directory.txt) ============ -def Novel_directory_generate( +# ============ 计算分块大小的工具函数 ============ + +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 # 例如:8192 / 100 = 81.92 + # 先取到最接近的10倍 + ratio_rounded_to_10 = int(ratio // 10) * 10 # => 80 + # 再减10 + chunk_size = ratio_rounded_to_10 - 10 # => 70 + if chunk_size < 1: + chunk_size = 1 + if chunk_size > number_of_chapters: + chunk_size = number_of_chapters + return chunk_size + + +# ============ 2) 生成章节蓝图(新增分块逻辑) ============ + +def Chapter_blueprint_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, - number_of_chapters: int, filepath: str, - temperature: float = 0.7 + number_of_chapters: int, + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: - filename_set = os.path.join(filepath, "Novel_setting.txt") - final_novel_setting = read_file(filename_set).strip() - if not final_novel_setting: - logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") + """ + 如果章节数小于等于 chunk_size,则直接使用 chapter_blueprint_prompt 一次性生成。 + 如果章节数较多,则进行分块生成: + 1) 首先说明要生成的总章节数 + 2) 先生成 [1..chunk_size] 的章节 + 3) 将生成的文本作为已有目录传入,继续生成 [chunk_size+1..] 的章节 + 4) 最后汇总全部章节目录写入 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 - model = ChatOpenAI( - model=llm_model, + 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, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) - prompt_dir = novel_directory_prompt.format( - final_novel_setting=final_novel_setting, - number_of_chapters=number_of_chapters - ) - final_novel_directory = invoke_with_cleaning(model, prompt_dir) - if not final_novel_directory.strip(): - logging.warning("Novel_directory生成结果为空。") + # 计算分块大小 + 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 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 + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + 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 + + # 否则,分块生成 + final_blueprint = "" + current_start = 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + + # 分块提示 + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=final_blueprint, # 已有的章节列表文本 + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + chunk_result = "" + + # 将本次生成的文本拼接到最终结果中 + if final_blueprint.strip(): + final_blueprint += "\n\n" + chunk_result + else: + final_blueprint = chunk_result + + current_start = current_end + 1 + + if not final_blueprint.strip(): + logging.warning("All chunked generation results are empty, cannot create blueprint.") return filename_dir = os.path.join(filepath, "Novel_directory.txt") clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) - final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_directory_cleaned, filename_dir) - - logging.info("Novel_directory.txt has been generated successfully.") + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") -# ============ 获取最近 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() - if text: - texts.append(text) - if len(texts) < n: - texts = [''] * (n - len(texts)) + texts - return texts +# ============ 3) 生成章节草稿(分「第一章」与「后续章节」) ============ -def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - temperature: float, - chapters_text_list: List[str] -) -> str: - if not chapters_text_list: - return "" - if all(not txt.strip() for txt in chapters_text_list): - return "暂无摘要。" - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: -{combined_text} - -请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" - - summary_text = invoke_with_cleaning(model, prompt) - if not summary_text: - return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text - return summary_text - - -# ============ 剧情要点/冲突 ============ -PLOT_ARCS_PROMPT = """\ -下面是新生成的章节内容: -{chapter_text} - -这里是已记录的剧情要点/未解决冲突(可能为空): -{old_plot_arcs} - -请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 -若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 -""" - -def update_plot_arcs( - chapter_text: str, - old_plot_arcs: str, - api_key: str, - base_url: str, - model_name: str, - temperature: float -) -> str: - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - prompt = PLOT_ARCS_PROMPT.format( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs - ) - arcs_text = invoke_with_cleaning(model, prompt) - if not arcs_text: - logging.warning("update_plot_arcs: No response or empty result.") - return old_plot_arcs - return arcs_text - - -# ============ 生成章节草稿 ============ def generate_chapter_draft( - novel_settings: str, - global_summary: str, - character_state: str, - recent_chapters_summary: str, - user_guidance: str, api_key: str, base_url: str, model_name: str, + filepath: str, novel_number: int, word_number: int, temperature: float, - novel_novel_directory: str, - filepath: str, - interface_format: str, + 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_base_url: str, - embedding_retrieval_k: int = 4 + embedding_retrieval_k: int = 2, + interface_format: str = "openai", + max_tokens: int = 2048 ) -> str: - # 1) 根据目录解析标题、简介 - chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) + """ + 根据 novel_number 判断是否为第一章。 + - 若是第一章,则使用 first_chapter_draft_prompt + - 否则使用 next_chapter_draft_prompt + """ + 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_brief = chapter_info["chapter_brief"] - - # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) - combined_query_parts = [] - if user_guidance.strip(): - combined_query_parts.append(user_guidance) - if chapter_brief.strip(): - combined_query_parts.append(chapter_brief) - if recent_chapters_summary.strip(): - combined_query_parts.append(recent_chapters_summary) - # 额外加一个关键字 - combined_query_parts.append("回顾剧情") - - merged_query_str = "\n".join(combined_query_parts) - - # 2) 从向量库检索上下文 - relevant_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=embedding_base_url if embedding_base_url else base_url, - query=merged_query_str, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath, - k=embedding_retrieval_k - ) - if not relevant_context.strip(): - relevant_context = "暂无相关内容。" - - # 3) 生成本章大纲 - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - outline_prompt_text = chapter_outline_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_outline = invoke_with_cleaning(model, outline_prompt_text) - - outlines_dir = os.path.join(filepath, "outlines") - os.makedirs(outlines_dir, exist_ok=True) - outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") - clear_file_content(outline_file) - save_string_to_txt(chapter_outline, outline_file) - - # 4) 生成正文草稿 - writing_prompt_text = chapter_write_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - chapter_outline=chapter_outline, - word_number=word_number, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_content = invoke_with_cleaning(model, writing_prompt_text) + 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, + 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 + ) + + # 从最近章节中获取最后一段内容作为前章结尾 + previous_chapter_excerpt = "" + for text_block in reversed(recent_3_texts): + if text_block.strip(): + if len(text_block) > 1500: + previous_chapter_excerpt = text_block[-1500:] + else: + previous_chapter_excerpt = text_block + break + + # 从向量库检索上下文 + 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, + 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生成 + 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 + ) + 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) @@ -618,19 +690,22 @@ def generate_chapter_draft( return chapter_content -# ============ 定稿章节 ============ +# ============ 4) 定稿章节 ============ + def finalize_chapter( novel_number: int, word_number: int, api_key: str, base_url: str, - interface_format: str, - embedding_model_name: str, model_name: str, temperature: float, filepath: str, - embedding_base_url: str, - embedding_api_key: str + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + interface_format: str, + max_tokens: int ): chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") @@ -639,117 +714,89 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - old_char_state = read_file(character_state_file) - old_global_summary = read_file(global_summary_file) - old_plot_arcs = read_file(plot_arcs_file) - - # 篇幅不足,二次扩写 - if len(chapter_text) < 0.8 * word_number: - logging.info("Chapter text is shorter than 80% of desired length. Enriching...") - chapter_text = enrich_chapter_text( - chapter_text=chapter_text, - word_number=word_number, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature - ) + # 如果内容过短,则尝试扩写 + if len(chapter_text) < 0.7 * word_number: + chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - # 更新全局摘要 - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) + 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) - def update_global_summary(chapter_text: str, old_summary: str) -> str: - prompt = summary_prompt.format( - chapter_text=chapter_text, - global_summary=old_summary - ) - return invoke_with_cleaning(model, prompt) or old_summary - - new_global_summary = update_global_summary(chapter_text, old_global_summary) - - # 更新角色状态 - def update_character_state(chapter_text: str, old_state: str) -> str: - prompt = update_character_state_prompt.format( - chapter_text=chapter_text, - old_state=old_state - ) - return invoke_with_cleaning(model, prompt) or old_state - - new_char_state = update_character_state(chapter_text, old_char_state) - - # 更新剧情要点 - new_plot_arcs = update_plot_arcs( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs, - api_key=api_key, + llm_adapter = create_llm_adapter( + interface_format=interface_format, base_url=base_url, model_name=model_name, - temperature=temperature + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens ) + 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 - # 写回文件 - clear_file_content(character_state_file) - save_string_to_txt(new_char_state, character_state_file) + 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(plot_arcs_file) - save_string_to_txt(new_plot_arcs, plot_arcs_file) + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) - # 更新向量库(此时用 embedding_api_key/embedding_base_url) - update_vector_store( - api_key=embedding_api_key, - base_url=embedding_base_url if embedding_base_url else base_url, - new_chapter=chapter_text, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath + # 更新向量库 + 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 + temperature: float, + interface_format: str, + max_tokens: int ) -> str: - model = ChatOpenAI( - model=model_name, + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) - prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 - -原章节内容: -{chapter_text}""" - enriched_text = invoke_with_cleaning(model, prompt) + 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]: - """ - 将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。 - """ nltk.download('punkt', quiet=True) sentences = nltk.sent_tokenize(content) if not sentences: @@ -786,15 +833,14 @@ def advanced_split_content(content: str, return final_segments def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, embedding_model_name: str, file_path: str, - embedding_base_url: str, filepath: str ): - logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}") + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_interface_format}, 模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -806,24 +852,17 @@ def import_knowledge_file( paragraphs = advanced_split_content(content) - # 若向量库不存在则初始化,否则追加 - store = load_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath + 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. Initializing a new one for knowledge import...") - init_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=paragraphs, - filepath=filepath - ) + init_vector_store(embedding_adapter, paragraphs, filepath) else: docs = [Document(page_content=str(p)) for p in paragraphs] store.add_documents(docs) diff --git a/prompt_definitions.py b/prompt_definitions.py index 9e99408..a6b2d2b 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,86 +1,215 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -集中存放所有提示词(Prompt),新版本更精确、更具创新性,结合部分雪花写作法等理论。 +集中存放所有提示词 (Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型等 +并包含新增加的短期摘要/下一章关键字提炼提示词,以及章节正文写作提示词。 """ -# =============== 1. 整体设定 =================== -set_prompt = """\ -请根据主题: {topic}、类型: {genre}、章数: {number_of_chapters}、每章字数: {word_number} 来设计小说的总体结构和世界观。 -写作时请参考雪花写作法等创作理论,结合以下要点: +# =============== 摘要与下一章关键字提炼 =============== +summarize_recent_chapters_prompt = """\ +你是一名资深长篇小说编辑,请分析以下合并文本(可能包含最近几章内容): +{combined_text} -• 小说标题与大致篇幅(总字数范围)。 -• 类型与基调(如:都市、魔幻、战争、轻松、暗黑等)。 -• 主要写作风格(视角、叙述方式、语言风格等)。 -• 世界观设定(背景时间、地理环境、社会结构、科技或魔法水平、重要历史等)。 -• 整体故事框架(可提及常见叙事结构:三幕、英雄之旅等)。 -• 主线与副线的初步构想,以及它们如何交织。 -• 关键角色群像定位与主要冲突关系。 -• 结局的可能方向(圆满、悲剧、开放式等)。 +现在请你基于目前故事的进展,完成以下两件事: +1) 用最多200字,写一个简洁明了的「当前情节短期摘要」。 +2) 提炼「下一章」的关键字(例如关键物品、重要人物、地点、事件、情节等),可以用逗号分隔或条目列出。 -请以简洁、逻辑清晰的方式输出,保留足够细节以支撑后续创作。 +请按如下格式输出(不需要额外解释): +短期摘要: <这里写短期摘要> +下一章关键字: <这里写下一章关键字> """ -# =============== 2. 角色设定 =================== -character_prompt = """\ -基于已生成的小说整体设定: -{novel_setting} +# =============== 1. 核心种子设定(雪花第1层)=================== +core_seed_prompt = """\ +作为专业作家,请用"雪花写作法"第一步构建故事核心: +主题:{topic} +类型:{genre} +篇幅:约{number_of_chapters}章(每章{word_number}字) -请进一步扩展角色设置,至少包含三名核心角色。对每位角色说明: -• 角色背景、外貌与主要性格特征。 -• 内在冲突、目标与动机。 -• 暗藏的秘密或潜在弱点(可与世界观或其他角色有关)。 -• 与其他角色的关系或对立点,如何推动或阻碍情节发展。 +请用单句公式概括故事本质,例如: +"当[主角]遭遇[核心事件],必须[关键行动],否则[灾难后果];与此同时,[隐藏的更大危机]正在发酵。" -请重点突出角色的多重面向,为后续情节埋下伏笔。 +要求: +1. 必须包含显性冲突与潜在危机 +2. 体现人物核心驱动力 +3. 暗示世界观关键矛盾 +4. 使用25-100字精准表达 + +仅返回故事核心文本,不要解释任何内容。 """ -# =============== 3. 暗线与伏笔 =================== -dark_lines_prompt = """\ -在当前世界观与角色关系中: -{character_info} +# =============== 2. 角色动力学设定(角色弧光模型)=================== +character_dynamics_prompt = """\ +基于核心种子: +{core_seed} -请构思若干暗线、伏笔或隐藏冲突,结合雪花写作法“细节逐步扩展”的思路。要求: -• 每条暗线给出初始迹象、后续发展与可能的爆发条件。 -• 与角色背景、世界观或关键事件相呼应。 -• 保持合理的悬念设置,与现有设定不冲突。 -• 为后续剧情保留足够展开空间,强调持续影响故事进程。 +请设计3-6个具有动态变化潜力的核心角色,每个角色需包含: +特征: +- 背景、外貌、性别、年龄、职业等 +- 暗藏的秘密或潜在弱点(可与世界观或其他角色有关) -请避免一次性透漏全部细节,以逐步揭示的方式为后文做铺垫。 +核心驱动力三角: +- 表面追求(物质目标) +- 深层渴望(情感需求) +- 灵魂需求(哲学层面) + +角色弧线设计: +初始状态 → 触发事件 → 认知失调 → 蜕变节点 → 最终状态 + +关系冲突网: +- 与其他角色的关系或对立点 +- 与至少两个其他角色的价值观冲突 +- 一个合作纽带 +- 一个隐藏的背叛可能性 + +要求: +仅给出最终文本,不要解释任何内容。 """ -# =============== 4. 最终设定整合 =================== -finalize_setting_prompt = """\ -基于以下内容,请整合并输出终稿《小说设定》: -1. 整体设定: -{novel_setting_base} -2. 角色设定: -{character_setting} -3. 暗线与伏笔: -{dark_lines} +# =============== 3. 世界构建矩阵(三维度交织法)=================== +world_building_prompt = """\ +为服务核心冲突"{core_seed}",请构建三维交织的世界观: -整合要求: -• 以整体视角整理世界观、角色与暗线,形成一个有机结合的故事框架。 -• 注意角色动机与暗线如何与世界观互相呼应,并兼顾主线与副线节奏。 -• 语言通顺、层次分明。直接输出文本,不使用Markdown格式。 +1. 物理维度: +- 空间结构(地理×社会阶层分布图) +- 时间轴(关键历史事件年表) +- 法则体系(物理/魔法/社会规则的漏洞点) + +2. 社会维度: +- 权力结构断层线(可引发冲突的阶层/种族/组织矛盾) +- 文化禁忌(可被打破的禁忌及其后果) +- 经济命脉(资源争夺焦点) + +3. 隐喻维度: +- 贯穿全书的视觉符号系统(如反复出现的意象) +- 氣候/环境变化映射的心理状态 +- 建筑风格暗示的文明困境 + +要求: +每个维度至少包含3个可与角色决策产生互动的动态元素。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 5. 小说目录 =================== -novel_directory_prompt = """\ -根据以下最终《小说设定》: -{final_novel_setting} +# =============== 4. 情节架构(三幕式悬念)=================== +plot_architecture_prompt = """\ +基于以下元素构建三幕式悬念架构: +核心种子:{core_seed} +角色体系:{character_dynamics} +世界观:{world_building} -请列出共 {number_of_chapters} 章的目录,并保证每章的标题或简述能呼应主要冲突、角色成长及暗线埋设。 -目录格式示例: -第1章 :<标题> - <简要说明> -第2章 :<标题> - <简要说明> -... -第{number_of_chapters}章 :<标题> - <简要说明> +要求按以下结构设计: +第一幕(触发) +- 日常状态中的异常征兆(3处铺垫) +- 引出故事:展示主线、暗线、副线的开端 +- 关键事件:打破平衡的催化剂(需改变至少3个角色的关系) +- 错误抉择:主角的认知局限导致的错误反应 -每章可酌情加入一句简述,引导读者了解本章看点。直接输出文本,不使用Markdown。 +第二幕(对抗) +- 剧情升级:主线+副线的交叉点 +- 双重压力:外部障碍升级+内部挫折 +- 虚假胜利:看似解决实则深化危机的转折点 +- 灵魂黑夜:世界观认知颠覆时刻 + +第三幕(解决) +- 代价显现:解决危机必须牺牲的核心价值 +- 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) +- 余波:留下2个开放式悬念因子 + +每个阶段需包含3个关键转折点及其对应的伏笔回收方案。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 6. 全局摘要更新 =================== +# =============== 5. 章节目录生成(悬念节奏曲线)=================== +chapter_blueprint_prompt = """\ +根据小说架构:\n +{novel_architecture} + +设计{number_of_chapters}章的节奏分布: +1. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +2. 每章需明确: +- 章节定位(角色/事件/主题等) +- 核心悬念类型(信息差/道德困境/时间压力等) +- 情感基调迁移(如从怀疑→恐惧→决绝) +- 伏笔操作(埋设/强化/回收) +- 认知颠覆强度(1-5级) + +输出格式示例: +第n章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +第n+1章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +要求: +- 使用精炼语言描述,每章字数控制在100字以内。 +- 合理安排节奏,确保整体悬念曲线的连贯性。 +- 在生成{number_of_chapters}章前不要出现结局章节。 + +仅给出最终文本,不要解释任何内容。 +""" + +chunked_chapter_blueprint_prompt = """\ +根据小说架构:\n +{novel_architecture} + +需要生成总共{number_of_chapters}章的节奏分布, + +当前已有章节目录(若未空则说明是初始生成):\n +{chapter_list} + +现在请设计第{n}章到第{m}的节奏分布: +1. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +2. 每章需明确: +- 章节定位(角色/事件/主题等) +- 核心悬念类型(信息差/道德困境/时间压力等) +- 情感基调迁移(如从怀疑→恐惧→决绝) +- 伏笔操作(埋设/强化/回收) +- 认知颠覆强度(1-5级) + +输出格式示例: +第n章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +第n+1章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +要求: +- 使用精炼语言描述,每章字数控制在100字以内。 +- 合理安排节奏,确保整体悬念曲线的连贯性。 +- 在生成{number_of_chapters}章前不要出现结局章节。 + +仅给出最终文本,不要解释任何内容。 +""" + +# =============== 6. 全局摘要更新 =================== summary_prompt = """\ 以下是新完成的章节文本: {chapter_text} @@ -88,10 +217,17 @@ summary_prompt = """\ 这是当前的全局摘要(可为空): {global_summary} -请根据本章新增内容,更新全局摘要。保留既有重要信息,同时融入新剧情要点,勿剧透未来。控制在不超过3000字的范围内,语言简练流畅。 +请根据本章新增内容,更新全局摘要。 +要求: +- 保留既有重要信息,同时融入新剧情要点 +- 以简洁、连贯的语言描述全书进展 +- 客观描绘,不展开联想或解释 +- 字数控制在2000字以内 + +仅返回全局摘要文本,不要解释任何内容。 """ -# =============== 7. 角色状态更新 =================== +# =============== 7. 角色状态更新 =================== update_character_state_prompt = """\ 以下是新完成的章节文本: {chapter_text} @@ -99,45 +235,157 @@ update_character_state_prompt = """\ 这是当前的角色状态文档(可为空): {old_state} -请更新角色状态,内容包括: -• 角色的物品、能力或心理状态变化。 -• 角色间关系的最新进展或冲突。 -• 是否触发或加深了某些暗线或关键事件。 -• 任何新增角色或临时出场人物的基本信息。 +请更新角色状态,内容格式: +角色A属性: +├──物品: + ├──某物(道具):描述 + ├──XX长剑(武器):描述 + ... +├──能力 + ├──技能1:描述 + ├──技能2:描述 + ... +├──状态 + ├──身体状态: + ├──Buff/Debuff + ├──心理状态:描述 + +├──主要角色间关系网 + ├──角色B:描述 + ├──角色C:描述 + ... +├──触发或加深的事件 + ├──事件1:描述 + ├──事件2:描述 + ... -请直接在已有文档基础上进行增删,语言尽量简洁、有条理。 +角色B属性: +├──物品 + ├──... +├──能力 + ├──... +├──状态 + ├──... +├──主要角色间关系网 + ├──... +├──触发或加深的事件 + ├──... + +角色C属性: +...... + +新出场角色: +- 任何新增角色或临时出场人物的基本信息 + +要求: +- 请直接在已有文档基础上进行增删 +- 不改变原有结构,语言尽量简洁、有条理 + +仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 8. 章节大纲设计 =================== -chapter_outline_prompt = """\ -这是当前小说的重要信息: -- 小说设定:{novel_setting} -- 角色状态:{character_state} -- 全局摘要:{global_summary} +# =============== 8. 章节正文写作 =================== -我们即将写第 {novel_number} 章,标题:{chapter_title},简述(若有):{chapter_brief} +# 8.1 第一章草稿提示 +first_chapter_draft_prompt = """\ +即将创作:第 {novel_number} 章《{chapter_title}》 +本章定位:{chapter_role} +核心作用:{chapter_purpose} +悬念密度:{suspense_level} +伏笔操作:{foreshadowing} +认知颠覆:{plot_twist_level} +本章简述:{chapter_summary} -请按照以下思路设计本章大纲: -1. 本章的主要冲突或情节目标,如何与标题呼应。 -2. 出场角色及其动机,对剧情走向的推动或阻碍。 -3. 暗线或伏笔如何有所进展或被揭示。 -4. 本章结尾的悬念或转折,如何为下一章做铺垫。 +可用元素: +- 核心人物(可能未指定):{characters_involved} +- 关键道具(可能未指定):{key_items} +- 空间坐标(可能未指定):{scene_location} +- 时间压力(可能未指定):{time_constraint} -请以简要分点方式输出,不要使用Markdown格式。 +参考文档: +- 小说设定: +{novel_setting} + +请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +1. 对话场景: + - 潜台词冲突(表面谈论A,实际博弈B) + - 权力关系变化(通过非对称对话长度体现) + - 至少1处双关语暗示未来危机 + +2. 动作场景: + - 环境交互细节(至少3个感官描写) + - 节奏控制(短句加速+比喻减速) + - 动作揭示人物隐藏特质 + +3. 心理场景: + - 认知失调的具体表现(行为矛盾) + - 隐喻系统的运用(连接世界观符号) + - 决策前的价值天平描写 + +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 + +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 + +额外指导(可能未指定):{user_guidance} """ -# =============== 9. 章节正文写作 =================== -chapter_write_prompt = """\ -以下信息供你参考: -1. 小说设定:{novel_setting} -2. 角色状态:{character_state} -3. 全局摘要:{global_summary} -4. 本章大纲:{chapter_outline} +# 8.2 后续章节草稿提示 +next_chapter_draft_prompt = """\ +参考文档: +- 小说设定: +{novel_setting} -请写出第 {novel_number} 章的正文,标题为“{chapter_title}”。需要: -• 字数不少于 {word_number} 字,与标题和简述相呼应。 -• 保持连贯叙述,可增加环境、心理、对话等细节描写。 -• 适度呼应前文暗线或角色矛盾,为后续发展留出悬念。 +- 全局摘要: +{global_summary} -直接输出正文内容,不使用分章节小标题,章节末模仿正常小说中断或转场。 -""" +- 角色状态: +{character_state} + +本地知识库检索到的片段: +{context_excerpt} + +即将创作:第 {novel_number} 章《{chapter_title}》 +本章定位:{chapter_role} +核心作用:{chapter_purpose} +悬念密度:{suspense_level} +伏笔操作:{foreshadowing} +认知颠覆:{plot_twist_level} +本章简述:{chapter_summary} + +可用元素: +- 核心人物(可能未指定):{characters_involved} +- 关键道具(可能未指定):{key_items} +- 空间坐标(可能未指定):{scene_location} +- 时间压力(可能未指定):{time_constraint} + +前章结尾段: +{previous_chapter_excerpt} + +请依据前章结尾片段,继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +1. 对话场景: + - 潜台词冲突(表面谈论A,实际博弈B) + - 权力关系变化(通过非对称对话长度体现) + - 至少1处双关语暗示未来危机 + +2. 动作场景: + - 环境交互细节(至少3个感官描写) + - 节奏控制(短句加速+比喻减速) + - 动作揭示人物隐藏特质 + +3. 心理场景: + - 认知失调的具体表现(行为矛盾) + - 隐喻系统的运用(连接世界观符号) + - 决策前的价值天平描写 + +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 + +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 + +额外指导(可能未指定):{user_guidance} +""" \ No newline at end of file diff --git a/tooltips.py b/tooltips.py new file mode 100644 index 0000000..062de2f --- /dev/null +++ b/tooltips.py @@ -0,0 +1,37 @@ +# tooltips.py +# -*- coding: utf-8 -*- + +tooltips = { + "api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。", + "base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。", + "interface_format": "指定LLM接口兼容格式,可选DeepSeek\OpenAI\Ollama\ML Studio等。\n\n注意:"+ + "OpenAI 兼容是指的可以通过该标准请求的任何接口,不是只允许使用api.openai.com接口\n"+ + "例如Ollama接口格式也兼容OpenAI,可以无需修改直接使用\n"+ + "ML Studio接口格式与OpenAI接口格式也一致。", + "model_name": "要使用的模型名称,例如deepseek-reasoner、gpt-4o等。如果是Ollama等,请填写你下载好的本地模型名。", + "temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。", + "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。\n"+ + "以下是一些常见模型的最大值:\n"+ + "o1:100,000\n"+ + "o1-mini:65,536\n"+ + "gpt-4o:16384\n"+ + "gpt-4o-mini:16384\n"+ + "deepseek-reasoner:8192\n"+ + "deepseek-chat:4096\n", + "embedding_api_key": "调用Embedding模型时所需的API Key。", + "embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。", + "embedding_url": "Embedding模型接口地址。", + "embedding_model_name": "Embedding模型名称,如text-embedding-ada-002。", + "embedding_retrieval_k": "向量检索时返回的Top-K结果数量。", + "topic": "小说的大致主题或主要故事背景描述。", + "genre": "小说的题材类型,如玄幻、都市、科幻等。", + "num_chapters": "小说期望的章节总数。", + "word_number": "每章的目标字数。", + "filepath": "生成文件存储的根目录路径。所有txt文件、向量库等放在该目录下。", + "chapter_num": "当前正在处理的章节号,用于生成草稿或定稿操作。", + "user_guidance": "为本章提供的一些额外指令或写作引导。", + "characters_involved": "本章需要重点描写或影响剧情的角色名单。", + "key_items": "在本章中出现的重要道具、线索或物品。", + "scene_location": "本章主要发生的地点或场景描述。", + "time_constraint": "本章剧情中涉及的时间压力或时限设置。" +} diff --git a/ui.py b/ui.py index 7e2f461..22b3bbc 100644 --- a/ui.py +++ b/ui.py @@ -7,111 +7,100 @@ import threading import customtkinter as ctk from tkinter import filedialog, messagebox import traceback + from config_manager import load_config, save_config from utils import read_file, save_string_to_txt, clear_file_content + from novel_generator import ( - Novel_setting_generate, - Novel_directory_generate, + Novel_architecture_generate, + Chapter_blueprint_generate, generate_chapter_draft, finalize_chapter, import_knowledge_file, clear_vector_store, - get_last_n_chapters_text, - summarize_recent_chapters + get_last_n_chapters_text ) from consistency_checker import check_consistency +# ---- Import the tooltip texts ---- +from tooltips import tooltips def log_error(message: str): - """ - 用于打印详细的错误信息和堆栈信息。 - """ logging.error(f"{message}\n{traceback.format_exc()}") - -# 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") - class NovelGeneratorGUI: def __init__(self, master): self.master = master self.master.title("Novel Generator GUI") - # 防止因 icon.ico 不存在导致程序崩溃 try: if os.path.exists("icon.ico"): self.master.iconbitmap("icon.ico") except Exception: pass - # 配置窗口大小 self.master.geometry("1350x840") # 配置持久化 self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # ========== 主要的属性变量 ========== - - # LLM 接口相关 + # 主要属性变量 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) - self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) + self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.openai.com/v1")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.model_name_var = ctk.StringVar(value=self.loaded_config.get("model_name", "gpt-4o-mini")) - - # 仍然用 DoubleVar,但因为是 Slider,不会让用户手动清空文本,一般不会出现空字符串问题 self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7)) + self.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) - # Embedding 接口相关 + # Embedding相关 self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) self.embedding_api_key_var = ctk.StringVar(value=self.loaded_config.get("embedding_api_key", "")) - self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "")) - self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "")) - - # ### CHANGED:将 IntVar 改为 StringVar,避免用户清空输入时抛错 + self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "https://api.openai.com/v1")) + self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "text-embedding-ada-002")) self.embedding_retrieval_k_var = ctk.StringVar(value=str(self.loaded_config.get("embedding_retrieval_k", 4))) - # 小说通用参数 self.topic_default = self.loaded_config.get("topic", "") self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻")) - - # ### CHANGED:将章节数、每章字数改为 StringVar self.num_chapters_var = ctk.StringVar(value=str(self.loaded_config.get("num_chapters", 10))) self.word_number_var = ctk.StringVar(value=str(self.loaded_config.get("word_number", 3000))) - self.filepath_var = ctk.StringVar(value=self.loaded_config.get("filepath", "")) - # ### CHANGED:章节号也改为 StringVar self.chapter_num_var = ctk.StringVar(value="1") - # ========== 主容器使用 TabView ========== - self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) + # 四个可选要素 + 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="") + + # UI 布局 + self.tabview = ctk.CTkTabview(self.master) self.tabview.pack(fill="both", expand=True) - # 创建各个Tab self.main_tab = self.tabview.add("Main Functions") - self.setting_tab = self.tabview.add("Novel Settings") - self.directory_tab = self.tabview.add("Novel Directory") + self.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 的布局 self.build_main_tab() self.build_setting_tab() self.build_directory_tab() self.build_character_tab() self.build_summary_tab() - self.build_chapters_tab() # 新增 + self.build_chapters_tab() + + def show_tooltip(self, key: str): + """Display a popup with tooltip text.""" + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) - # ------------------ 工具方法:安全获取 IntVar (现已兼容 StringVar) ------------------ def safe_get_int(self, var, default=1): - """ - 尝试把 StringVar 或 IntVar 中的值转换为 int; - 若失败则将其重置为 default 并返回 default。 - """ try: val_str = str(var.get()).strip() return int(val_str) @@ -119,102 +108,51 @@ class NovelGeneratorGUI: var.set(str(default)) return default - # ------------------ 主功能 Tab ------------------ + # ------------------ 主 Tab ------------------ def build_main_tab(self): - """ - 主Tab分为左右两栏: - 左侧:本章内容、Step按钮、日志 - 右侧:配置区域(带边框) + 保存/加载配置 + 小说参数 + 可选功能按钮 - """ self.main_tab.rowconfigure(0, weight=1) self.main_tab.columnconfigure(0, weight=1) self.main_tab.columnconfigure(1, weight=0) - # 左侧Frame self.left_frame = ctk.CTkFrame(self.main_tab) self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) - - # 右侧Frame self.right_frame = ctk.CTkFrame(self.main_tab) self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2) - # 左侧布局 self.build_left_layout() - # 右侧布局 self.build_right_layout() def build_left_layout(self): - """ - 左侧布局: - row=0 -> “本章内容”文本框 (chapter_result) - row=1 -> Step1~4按钮 - row=2 -> “输出日志”标题 - row=3 -> “输出日志”文本框 (log_text) - """ 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.grid_columnconfigure(0, weight=1) + self.left_frame.columnconfigure(0, weight=1) - # ========== 本章内容 ========== - chapter_label = ctk.CTkLabel( - self.left_frame, - text="本章内容 (可编辑)", - font=("Microsoft YaHei", 12) - ) + 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) - ) + self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # ========== Step1~4按钮 ========== - self.build_step_buttons_area() - - # ========== 输出日志 label ========== - 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) - ) - self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) - self.log_text.configure(state="disabled") - - def build_step_buttons_area(self): - """ - 在左侧,仅放 Step1~Step4 四个按钮 - """ + # 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_setting = ctk.CTkButton( + self.btn_generate_architecture = ctk.CTkButton( self.step_buttons_frame, - text="Step1. 生成设定", - command=self.generate_novel_setting_ui, + text="Step1. 生成架构", + command=self.generate_novel_architecture_ui, font=("Microsoft YaHei", 12) ) - self.btn_generate_setting.grid(row=0, column=0, padx=5, pady=2, sticky="ew") + 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_novel_directory_ui, + 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") @@ -235,42 +173,451 @@ class NovelGeneratorGUI: ) 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)) + 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): - """ - 右侧布局,包含: - row=0 -> 带边框的配置区 (TabView + 保存/加载配置按钮) - row=1 -> 小说参数区域 - row=2 -> 可选功能按钮 (一致性审校 / 导入知识库 / 清空向量库 / 查看剧情要点) - """ 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) - # 1) 配置区 - self.config_frame = ctk.CTkFrame( - self.right_frame, - corner_radius=10, - border_width=2, - border_color="gray" - ) + # 配置区 + 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() # LLM、Embedding等配置 - self.build_main_buttons_area() # 保存/加载配置按钮 + self.build_config_tabview() + self.build_main_buttons_area() - # 2) 小说参数 + # 小说参数 self.build_novel_params_area(start_row=1) - # 3) 可选功能按钮 + # 可选功能按钮 self.build_optional_buttons_area(start_row=2) - # ------------------ 可选功能按钮区域(右下) ------------------ + def build_config_tabview(self): + 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() + + # 封装一个小工具函数,用来创建「标签 + 问号按钮」的组合 + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + # frame容器:同一格子里存放 label + "?"按钮 + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + # 先放 label + label = ctk.CTkLabel(frame, text=label_text, font=font) + label.pack(side="left") + # 再放问号按钮 + btn = ctk.CTkButton( + frame, + text="?", + width=22, + height=22, + font=("Microsoft YaHei", 10), + command=lambda: self.show_tooltip(tooltip_key) + ) + btn.pack(side="left", padx=3) + return frame + + def build_ai_config_tab(self): + def on_interface_format_changed(new_value): + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.base_url_var.set("https://api.openai.com/v1") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") + + for i in range(6): + self.ai_config_tab.grid_rowconfigure(i, weight=0) + self.ai_config_tab.grid_columnconfigure(0, weight=0) + self.ai_config_tab.grid_columnconfigure(1, weight=1) + self.ai_config_tab.grid_columnconfigure(2, weight=0) + + # 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", "Ollama", "ML Studio"] + 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") + + def build_embeddings_config_tab(self): + def on_embedding_interface_changed(new_value): + if new_value == "Ollama": + self.embedding_url_var.set("http://localhost:11434/api") + elif new_value == "ML Studio": + self.embedding_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": + self.embedding_url_var.set("https://api.openai.com/v1") + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + + for i in range(5): + self.embeddings_config_tab.grid_rowconfigure(i, weight=0) + self.embeddings_config_tab.grid_columnconfigure(0, weight=0) + self.embeddings_config_tab.grid_columnconfigure(1, weight=1) + self.embeddings_config_tab.grid_columnconfigure(2, weight=0) + + # 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", "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") + + def build_main_buttons_area(self): + self.btn_frame_config = ctk.CTkFrame(self.config_frame) + self.btn_frame_config.grid(row=1, column=0, padx=5, pady=5, sticky="ew") + self.btn_frame_config.columnconfigure(0, weight=1) + self.btn_frame_config.columnconfigure(1, weight=1) + + save_config_btn = ctk.CTkButton(self.btn_frame_config, text="保存配置", command=self.save_config_btn, font=("Microsoft YaHei", 12)) + save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + load_config_btn = ctk.CTkButton(self.btn_frame_config, text="加载配置", command=self.load_config_btn, font=("Microsoft YaHei", 12)) + load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + def build_novel_params_area(self, start_row=1): + self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical") + self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) + self.params_frame.columnconfigure(1, weight=1) + + # 1) 主题(Topic) + topic_label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="主题(Topic):", + tooltip_key="topic", + row=0, + column=0, + font=("Microsoft YaHei", 12), + sticky="ne" + ) + self.topic_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + if self.topic_default: + self.topic_text.insert("0.0", self.topic_default) + + # 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 + 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) + + # 左边标签 + label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="章节数 & 每章字数:", + tooltip_key="num_chapters", + row=row_for_chapter_and_word, + column=0, + font=("Microsoft YaHei", 12) + ) + + # 输入框 + num_chapters_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)) + self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") + + # 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) @@ -308,363 +655,418 @@ class NovelGeneratorGUI: ) self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") - # ========== 配置区域(TabView) ========== - def build_config_tabview(self): - self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) - self.config_tabview.grid(row=0, column=0, sticky="we", padx=5, pady=5) + def load_config_btn(self): + cfg = load_config(self.config_file) + if cfg: + self.api_key_var.set(cfg.get("api_key", "")) + self.base_url_var.set(cfg.get("base_url", "")) + self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) + self.model_name_var.set(cfg.get("model_name", "")) + self.temperature_var.set(cfg.get("temperature", 0.7)) + self.max_tokens_var.set(cfg.get("max_tokens", 2048)) - self.ai_config_tab = self.config_tabview.add("LLM Model settings") - self.embeddings_config_tab = self.config_tabview.add("Embedding settings") + self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) + self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) + self.embedding_url_var.set(cfg.get("embedding_url", "")) + self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) + self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) - self.build_ai_config_tab() - self.build_embeddings_config_tab() + self.genre_var.set(cfg.get("genre", "")) + self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) + self.word_number_var.set(str(cfg.get("word_number", 3000))) + self.filepath_var.set(cfg.get("filepath", "")) - def build_ai_config_tab(self): - def on_interface_format_changed(new_value): - if new_value == "Ollama": - self.base_url_var.set("http://localhost:11434/v1") - elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.base_url_var.set("https://api.openai.com/v1") + topic_value = cfg.get("topic", "") + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", topic_value) - for i in range(5): - self.ai_config_tab.grid_rowconfigure(i, weight=0) - self.ai_config_tab.grid_columnconfigure(0, weight=0) - self.ai_config_tab.grid_columnconfigure(1, weight=1) - self.ai_config_tab.grid_columnconfigure(2, weight=0) # for temp label + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") - api_key_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM API Key:", - font=("Microsoft YaHei", 12) - ) - api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - api_key_entry = ctk.CTkEntry( - self.ai_config_tab, - textvariable=self.api_key_var, - font=("Microsoft YaHei", 12) - ) - api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + def save_config_btn(self): + config_data = { + "api_key": self.api_key_var.get(), + "base_url": self.base_url_var.get(), + "interface_format": self.interface_format_var.get(), + "model_name": self.model_name_var.get(), + "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), - base_url_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM Base URL:", - font=("Microsoft YaHei", 12) - ) - base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - base_url_entry = ctk.CTkEntry( - self.ai_config_tab, - textvariable=self.base_url_var, - font=("Microsoft YaHei", 12) - ) - base_url_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + "embedding_api_key": self.embedding_api_key_var.get(), + "embedding_interface_format": self.embedding_interface_format_var.get(), + "embedding_url": self.embedding_url_var.get(), + "embedding_model_name": self.embedding_model_name_var.get(), + "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - interface_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM 接口格式:", - font=("Microsoft YaHei", 12) - ) - interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI", "Ollama", "ML Studio"] - interface_dropdown = ctk.CTkOptionMenu( - self.ai_config_tab, - values=interface_options, - variable=self.interface_format_var, - command=on_interface_format_changed, - font=("Microsoft YaHei", 12) - ) - interface_dropdown.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") + "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() + } + if save_config(config_data, self.config_file): + messagebox.showinfo("提示", "配置已保存至 config.json") + self.log("配置已保存。") + else: + messagebox.showerror("错误", "保存配置失败。") - model_name_label = ctk.CTkLabel( - self.ai_config_tab, - text="Model Name:", - font=("Microsoft YaHei", 12) - ) - model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - model_name_entry = ctk.CTkEntry( - self.ai_config_tab, - textvariable=self.model_name_var, - font=("Microsoft YaHei", 12) - ) - model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + def browse_folder(self): + selected_dir = filedialog.askdirectory() + if selected_dir: + self.filepath_var.set(selected_dir) - temp_label = ctk.CTkLabel( - self.ai_config_tab, - text="Temperature:", - font=("Microsoft YaHei", 12) - ) - temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + def 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 update_temp_label(value): - self.temp_value_label.configure(text=f"{float(value):.2f}") + def safe_log(self, message: str): + self.master.after(0, lambda: self.log(message)) - temp_scale = ctk.CTkSlider( - self.ai_config_tab, - from_=0.0, to=1.0, - number_of_steps=100, - command=update_temp_label, - variable=self.temperature_var - ) - temp_scale.grid(row=4, column=1, padx=5, pady=5, sticky="we") + def disable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="disabled")) - self.temp_value_label = ctk.CTkLabel( - self.ai_config_tab, - text=f"{self.temperature_var.get():.2f}", - font=("Microsoft YaHei", 12) - ) - self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") + def enable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="normal")) - def build_embeddings_config_tab(self): - def on_embedding_interface_changed(new_value): - if new_value == "Ollama": - self.embedding_url_var.set("http://localhost:11434/api") - elif new_value == "ML Studio": - self.embedding_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.embedding_url_var.set("https://api.openai.com/v1") + def handle_exception(self, context: str): + full_message = f"{context}\n{traceback.format_exc()}" + logging.error(full_message) + self.safe_log(full_message) - 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) + # ============ Step1: 生成小说架构 ============ + def generate_novel_architecture_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return - emb_api_key_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding API Key:", - font=("Microsoft YaHei", 12) - ) - emb_api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - 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") + 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() - emb_interface_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding 接口格式:", - font=("Microsoft YaHei", 12) - ) - emb_interface_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - emb_interface_options = ["OpenAI", "Ollama", "ML Studio"] - 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") + 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) - emb_url_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding Base URL:", - font=("Microsoft YaHei", 12) - ) - emb_url_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - 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") + 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 + ) + self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成小说架构时出错") + finally: + self.enable_button_safe(self.btn_generate_architecture) - emb_model_name_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding Model Name:", - font=("Microsoft YaHei", 12) - ) - emb_model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - 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") + threading.Thread(target=task, daemon=True).start() - emb_retrieval_k_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Retrieval Top-K:", - font=("Microsoft YaHei", 12) - ) - emb_retrieval_k_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - 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") + # ============ Step2: 生成章节蓝图 ============ + def generate_chapter_blueprint_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return - # ========== 保存/加载 配置按钮区域 ========== - def build_main_buttons_area(self): - """ - 放置在带边框配置区(config_frame)内部,位于TabView下方 - """ - 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) + 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() - 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") + 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 + ) + self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成章节蓝图时出错") + finally: + self.enable_button_safe(self.btn_generate_directory) - 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") + threading.Thread(target=task, daemon=True).start() - # ========== 小说参数区域 ========== - def build_novel_params_area(self, start_row=1): - """ - 右侧下方区域: 输入主题, 类型, 章节数, 字数, 保存路径, 指导信息等 - """ - self.params_frame = ctk.CTkScrollableFrame( - self.right_frame, - orientation="vertical" - ) - self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) - self.params_frame.columnconfigure(1, weight=1) + # ============ Step3: 生成章节草稿 ============ + def generate_chapter_draft_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return - # 主题(Topic) - topic_label = ctk.CTkLabel( - self.params_frame, - text="主题(Topic):", - font=("Microsoft YaHei", 12) - ) - topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox( - self.params_frame, - width=200, - height=80, - wrap="word", - font=("Microsoft YaHei", 12) - ) - self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") - if self.topic_default: - self.topic_text.insert("0.0", self.topic_default) + 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() - # 类型(Genre) - genre_label = ctk.CTkLabel( - self.params_frame, - text="类型(Genre):", - font=("Microsoft YaHei", 12) - ) - genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - 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") + 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() - # 章节数、每章字数 放在同一行 - row_for_chapter_and_word = 2 - num_chapters_label = ctk.CTkLabel( - self.params_frame, - text="章节数:", - font=("Microsoft YaHei", 12) - ) - num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + 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() - ch_word_frame = ctk.CTkFrame(self.params_frame) - ch_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") - ch_word_frame.columnconfigure(0, weight=0) - ch_word_frame.columnconfigure(1, weight=0) - ch_word_frame.columnconfigure(2, weight=0) - ch_word_frame.columnconfigure(3, weight=1) + 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) - num_chapters_entry = ctk.CTkEntry( - ch_word_frame, - textvariable=self.num_chapters_var, - width=60, - font=("Microsoft YaHei", 12) - ) - num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + 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 + ) + 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("⚠️ 本章草稿生成失败或无内容。") - word_number_label = ctk.CTkLabel( - ch_word_frame, - text="每章字数:", - font=("Microsoft YaHei", 12) - ) - word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") + except Exception: + self.handle_exception("生成章节草稿时出错") + finally: + self.enable_button_safe(self.btn_generate_chapter) - word_number_entry = ctk.CTkEntry( - ch_word_frame, - textvariable=self.word_number_var, - width=60, - font=("Microsoft YaHei", 12) - ) - word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") + threading.Thread(target=task, daemon=True).start() - # 保存路径 - filepath_label = ctk.CTkLabel( - self.params_frame, - text="保存路径:", - font=("Microsoft YaHei", 12) - ) - filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + 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") - self.filepath_frame = ctk.CTkFrame(self.params_frame) - self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") - self.filepath_frame.columnconfigure(0, weight=1) + # ============ Step4: 定稿章节 ============ + def finalize_chapter_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return - 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") + 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() - # 章节号 - chapter_num_label = ctk.CTkLabel( - self.params_frame, - text="章节号:", - font=("Microsoft YaHei", 12) - ) - chapter_num_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") - chapter_num_entry = ctk.CTkEntry( - self.params_frame, - textvariable=self.chapter_num_var, - width=80, - font=("Microsoft YaHei", 12) - ) - chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w") + 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() - # 用户指导 - guide_label = ctk.CTkLabel( - self.params_frame, - text="本章指导:", - font=("Microsoft YaHei", 12) - ) - guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox( - self.params_frame, - width=200, - height=80, - wrap="word", - font=("Microsoft YaHei", 12) - ) - self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + chap_num = self.safe_get_int(self.chapter_num_var, 1) + word_number = self.safe_get_int(self.word_number_var, 3000) - # ------------------ 其他Tab的构建 ------------------ + 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() + 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 + ) + 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() + + 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, + 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): + 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, Chapter Blueprint, Character State, Summary ============ def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -672,8 +1074,8 @@ class NovelGeneratorGUI: load_btn = ctk.CTkButton( self.setting_tab, - text="加载 Novel_setting.txt", - command=self.load_novel_setting, + 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") @@ -681,18 +1083,36 @@ class NovelGeneratorGUI: save_btn = ctk.CTkButton( self.setting_tab, text="保存修改", - command=self.save_novel_setting, + 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) - ) + self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12)) self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_novel_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 的修改。") + def build_directory_tab(self): self.directory_tab.rowconfigure(0, weight=0) self.directory_tab.rowconfigure(1, weight=1) @@ -701,7 +1121,7 @@ class NovelGeneratorGUI: load_btn = ctk.CTkButton( self.directory_tab, text="加载 Novel_directory.txt", - command=self.load_novel_directory, + command=self.load_chapter_blueprint, font=("Microsoft YaHei", 12) ) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") @@ -709,18 +1129,36 @@ class NovelGeneratorGUI: save_btn = ctk.CTkButton( self.directory_tab, text="保存修改", - command=self.save_novel_directory, + 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) - ) + self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12)) self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_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 的修改。") + def build_character_tab(self): self.character_tab.rowconfigure(0, weight=0) self.character_tab.rowconfigure(1, weight=1) @@ -742,13 +1180,31 @@ class NovelGeneratorGUI: ) save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.character_text = ctk.CTkTextbox( - self.character_tab, - wrap="word", - font=("Microsoft YaHei", 12) - ) + self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12)) self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_character_state(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + 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 的修改。") + def build_summary_tab(self): self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(1, weight=1) @@ -770,17 +1226,33 @@ class NovelGeneratorGUI: ) save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.summary_text = ctk.CTkTextbox( - self.summary_tab, - wrap="word", - font=("Microsoft YaHei", 12) - ) + self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12)) self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_global_summary(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + 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 的修改。") + + # ============ 章节管理标签页 ============ def build_chapters_tab(self): - """ - 新增的 Tab,用于查看、编辑和保存已生成的各章节内容。 - """ self.chapters_view_tab.rowconfigure(0, weight=0) self.chapters_view_tab.rowconfigure(1, weight=1) self.chapters_view_tab.columnconfigure(0, weight=1) @@ -821,7 +1293,6 @@ class NovelGeneratorGUI: self.chapters_list = [] self.refresh_chapters_list() - # ------------------ 章节管理辅助方法 ------------------ def refresh_chapters_list(self): filepath = self.filepath_var.get().strip() chapters_dir = os.path.join(filepath, "chapters") @@ -857,7 +1328,6 @@ class NovelGeneratorGUI: 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): @@ -914,528 +1384,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") - # ------------------ 配置管理 ------------------ - def load_config_btn(self): - cfg = load_config(self.config_file) - if cfg: - # LLM - self.api_key_var.set(cfg.get("api_key", "")) - self.base_url_var.set(cfg.get("base_url", "")) - self.interface_format_var.set(cfg.get("interface_format", "OpenAI")) - self.model_name_var.set(cfg.get("model_name", "")) - self.temperature_var.set(cfg.get("temperature", 0.7)) - # Embedding - self.embedding_api_key_var.set(cfg.get("embedding_api_key", "")) - self.embedding_interface_format_var.set(cfg.get("embedding_interface_format", "OpenAI")) - self.embedding_url_var.set(cfg.get("embedding_url", "")) - self.embedding_model_name_var.set(cfg.get("embedding_model_name", "")) - # ### CHANGED:用字符串形式设值 - self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) - - # Novel - self.genre_var.set(cfg.get("genre", "")) - - # ### CHANGED:用字符串形式设值 - self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) - self.word_number_var.set(str(cfg.get("word_number", 3000))) - self.filepath_var.set(cfg.get("filepath", "")) - - topic_value = cfg.get("topic", "") - self.topic_text.delete("0.0", "end") - self.topic_text.insert("0.0", topic_value) - - self.log("已加载配置。") - else: - messagebox.showwarning("提示", "未找到或无法读取配置文件。") - - def save_config_btn(self): - config_data = { - # LLM - "api_key": self.api_key_var.get(), - "base_url": self.base_url_var.get(), - "interface_format": self.interface_format_var.get(), - "model_name": self.model_name_var.get(), - "temperature": self.temperature_var.get(), - - # Embedding - "embedding_api_key": self.embedding_api_key_var.get(), - "embedding_interface_format": self.embedding_interface_format_var.get(), - "embedding_url": self.embedding_url_var.get(), - "embedding_model_name": self.embedding_model_name_var.get(), - "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - - # Novel - "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() - } - if save_config(config_data, 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 log(self, message: str): - self.log_text.configure(state="normal") - self.log_text.insert("end", message + "\n") - self.log_text.see("end") - self.log_text.configure(state="disabled") - - def safe_log(self, message: str): - self.master.after(0, lambda: self.log(message)) - - def disable_button_safe(self, btn): - self.master.after(0, lambda: btn.configure(state="disabled")) - - def enable_button_safe(self, btn): - self.master.after(0, lambda: btn.configure(state="normal")) - - def handle_exception(self, context: str): - full_message = f"{context}\n{traceback.format_exc()}" - logging.error(full_message) - self.safe_log(full_message) - - # ------------------ 分步操作:生成设定、目录、章节草稿、定稿 ------------------ - def generate_novel_setting_ui(self): - """Step1. 生成小说设定(Novel_setting.txt)""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - def task(): - self.disable_button_safe(self.btn_generate_setting) - try: - api_key = self.api_key_var.get().strip() - base_url = self.base_url_var.get().strip() - model_name = self.model_name_var.get().strip() - - topic = self.topic_text.get("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) - temperature = self.temperature_var.get() - - self.safe_log("开始生成小说设定...") - Novel_setting_generate( - 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 - ) - self.safe_log("✅ 小说设定生成完成。请在 'Novel Settings' 标签页进行查看或编辑。") - except Exception: - self.handle_exception("生成小说设定时出错") - finally: - self.enable_button_safe(self.btn_generate_setting) - - threading.Thread(target=task, daemon=True).start() - - def generate_novel_directory_ui(self): - """Step2. 基于已有 Novel_setting.txt 生成 Novel_directory.txt""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - def task(): - self.disable_button_safe(self.btn_generate_directory) - 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() - num_chapters = self.safe_get_int(self.num_chapters_var, 10) - temperature = self.temperature_var.get() - - self.safe_log("开始生成小说目录...") - Novel_directory_generate( - api_key=api_key, - base_url=base_url, - llm_model=model_name, - number_of_chapters=num_chapters, - filepath=filepath, - temperature=temperature - ) - self.safe_log("✅ 小说目录生成完成。请在 'Novel Directory' 标签页查看或编辑。") - 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: - 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() - - novel_settings_file = os.path.join(filepath, "Novel_setting.txt") - novel_settings = read_file(novel_settings_file) - if not novel_settings.strip(): - self.safe_log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") - return - - character_state_file = os.path.join(filepath, "character_state.txt") - character_state = read_file(character_state_file) - global_summary_file = os.path.join(filepath, "global_summary.txt") - global_summary = read_file(global_summary_file) - novel_directory_file = os.path.join(filepath, "Novel_directory.txt") - novel_directory = read_file(novel_directory_file) - - 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() - - # 获取最近3章文本 - chapters_dir = os.path.join(filepath, "chapters") - recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - - # 生成最近章节摘要 - recent_chapters_summary = summarize_recent_chapters( - llm_model=model_name, - api_key=api_key, - base_url=base_url, - temperature=temperature, - chapters_text_list=recent_3_texts - ) - - self.safe_log(f"开始生成第{chap_num}章草稿...") - draft_text = generate_chapter_draft( - novel_settings=novel_settings, - global_summary=global_summary, - character_state=character_state, - recent_chapters_summary=recent_chapters_summary, - user_guidance=user_guidance, - api_key=api_key, - base_url=base_url, - model_name=model_name, - novel_number=chap_num, - word_number=word_number, - temperature=temperature, - novel_novel_directory=novel_directory, - filepath=filepath, - - # Embedding 配置 - interface_format=self.embedding_interface_format_var.get().strip(), - embedding_model_name=self.embedding_model_name_var.get().strip(), - embedding_base_url=self.embedding_url_var.get().strip(), - # 新增:检索 K 值 - embedding_retrieval_k=self.safe_get_int(self.embedding_retrieval_k_var, 4) - ) - 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: - 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.embedding_interface_format_var.get().strip() - embedding_model_name = self.embedding_model_name_var.get().strip() - embedding_base_url = self.embedding_url_var.get().strip() - embedding_api_key = self.embedding_api_key_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") - chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt") - edited_text = self.chapter_result.get("0.0", "end").strip() - 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, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - model_name=model_name, - temperature=temperature, - filepath=filepath, - embedding_base_url=embedding_base_url, - embedding_api_key=embedding_api_key - ) - self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") - - # 读取定稿后的文本显示 - chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") - final_text = read_file(chap_file) - self.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): - """使用审校Agent对最新章节进行简单一致性或冲突检查""" - 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() - - novel_settings_file = os.path.join(filepath, "Novel_setting.txt") - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - novel_setting = read_file(novel_settings_file) - character_state = read_file(character_state_file) - global_summary = read_file(global_summary_file) - plot_arcs = read_file(plot_arcs_file) - - 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=novel_setting, - character_state=character_state, - global_summary=global_summary, - chapter_text=chapter_text, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - plot_arcs=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: - self.safe_log(f"开始导入知识库文件: {selected_file}") - import_knowledge_file( - 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(), - embedding_model_name=self.embedding_model_name_var.get().strip(), - file_path=selected_file, - embedding_base_url=self.embedding_url_var.get().strip(), - filepath=self.filepath_var.get().strip() - ) - self.safe_log("✅ 知识库文件导入完成。") - except Exception: - self.handle_exception("导入知识库时出错") - finally: - self.enable_button_safe(self.btn_import_knowledge) - - threading.Thread(target=task, daemon=True).start() - - def clear_vectorstore_handler(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!") - if first_confirm: - second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!") - if second_confirm: - if clear_vector_store(filepath): - self.log("已清空向量库。") - else: - self.log(f"未能清空向量库。请关闭程序后前往{filepath}文件夹手动删除vectorstore目录。") - - def show_plot_arcs_ui(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - if not os.path.exists(plot_arcs_file): - messagebox.showinfo("剧情要点", "当前还未生成任何剧情要点或未解决冲突。") - return - - arcs_text = read_file(plot_arcs_file).strip() - if not arcs_text: - arcs_text = "当前没有记录的剧情要点或冲突。" - - top = ctk.CTkToplevel(self.master) - top.title("剧情要点/未解决冲突") - top.geometry("600x400") - - text_area = ctk.CTkTextbox(top, wrap="word", font=("Microsoft YaHei", 12)) - text_area.pack(fill="both", expand=True, padx=10, pady=10) - - text_area.insert("0.0", arcs_text) - text_area.configure(state="disabled") - - # ------------------ Novel Settings/Directory/Character/Global Summary 的加载与保存 ------------------ - def load_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - setting_file = os.path.join(filepath, "Novel_setting.txt") - content = read_file(setting_file) - self.setting_text.delete("0.0", "end") - self.setting_text.insert("0.0", content) - self.log("已加载 Novel_setting.txt 内容到编辑区。") - - def save_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.setting_text.get("0.0", "end").strip() - setting_file = os.path.join(filepath, "Novel_setting.txt") - clear_file_content(setting_file) - save_string_to_txt(content, setting_file) - self.log("已保存对 Novel_setting.txt 的修改。") - - def load_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - directory_file = os.path.join(filepath, "Novel_directory.txt") - content = read_file(directory_file) - self.directory_text.delete("0.0", "end") - self.directory_text.insert("0.0", content) - self.log("已加载 Novel_directory.txt 内容到编辑区。") - - def save_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.directory_text.get("0.0", "end").strip() - directory_file = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(directory_file) - save_string_to_txt(content, directory_file) - self.log("已保存对 Novel_directory.txt 的修改。") - - def load_character_state(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - char_file = os.path.join(filepath, "character_state.txt") - content = read_file(char_file) - self.character_text.delete("0.0", "end") - self.character_text.insert("0.0", content) - self.log("已加载 character_state.txt 内容到编辑区。") - - def save_character_state(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.character_text.get("0.0", "end").strip() - char_file = os.path.join(filepath, "character_state.txt") - clear_file_content(char_file) - save_string_to_txt(content, char_file) - self.log("已保存对 character_state.txt 的修改。") - - def load_global_summary(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - summary_file = os.path.join(filepath, "global_summary.txt") - content = read_file(summary_file) - self.summary_text.delete("0.0", "end") - self.summary_text.insert("0.0", content) - self.log("已加载 global_summary.txt 内容到编辑区。") - - def save_global_summary(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.summary_text.get("0.0", "end").strip() - summary_file = os.path.join(filepath, "global_summary.txt") - clear_file_content(summary_file) - save_string_to_txt(content, summary_file) - self.log("已保存对 global_summary.txt 的修改。") - - -# 入口 if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app)