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)