diff --git a/.gitignore b/.gitignore
index 6d519ce..fcb067b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@
/__pycache__
/markdown
/vectorstore
+/example
config.json
diff --git a/novel_generator.py b/novel_generator.py
index f394186..3e3648d 100644
--- a/novel_generator.py
+++ b/novel_generator.py
@@ -43,21 +43,16 @@ from embedding_ollama import OllamaEmbeddings
from chapter_directory_parser import get_chapter_info_from_directory
-# ============ 日志配置 ============
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 invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str:
- """
- 通用封装:调用模型并移除 ... 文本,记录日志后返回
- """
+ """通用封装:调用模型并移除 ... 文本,记录日志后返回"""
response = model.invoke(prompt)
if not response:
logging.warning("No response from model.")
@@ -67,70 +62,60 @@ def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str:
return cleaned_text.strip()
def debug_log(prompt: str, response_content: str):
- """
- 打印prompt和response的辅助函数
- """
logging.info(f"\n[Prompt >>>] {prompt}\n")
logging.info(f"[Response >>>] {response_content}\n")
-# ============ 判断接口格式相关 ============
-def is_using_ollama_api(interface_format: str, base_url: str) -> bool:
- """
- 当 interface_format == "Ollama" 时返回 True
- """
- return interface_format.lower() == "ollama"
-
-def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool:
- """
- 如果用户在下拉里选择了 ML Studio
- """
- return interface_format.lower() == "ml studio"
-
-
-# ============ 帮助函数:自动检查 & 补充 /v1 ============
-import re
-
def ensure_openai_base_url_has_v1(url: str) -> str:
"""
- 如果用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。
- 如果已经包含 '/v1',则不再重复追加。
+ 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。
"""
+ import re
url = url.strip()
if not url:
return url
- # 若末尾没有 /v\d+,但也没出现 /v1,才补上
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,
- embed_url: str,
interface_format: str,
embedding_model_name: str
):
"""
- 根据用户在UI中配置的参数,返回对应的 embeddings 对象。
- - 当 interface_format = "Ollama" => OllamaEmbeddings(...)
- - 当 interface_format = "OpenAI"/"ML Studio" => OpenAIEmbeddings(...)
- 这里统一把 base_url/embed_url 处理为含 /v1。
+ 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。
+ base_url: 在 OpenAI 或 ML Studio 时,需要自动补'/v1';Ollama 则通常是 http://localhost:11434/v1
"""
- if is_using_ollama_api(interface_format, embed_url):
- fixed_url = embed_url.rstrip("/")
+ 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
- # 并设置 model=embedding_model_name
- # base_url/embed_url 若不含 /v1,需要自动补上
- fixed_url = ensure_openai_base_url_has_v1(embed_url if embed_url else base_url)
+ # 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,
@@ -138,20 +123,17 @@ def create_embeddings_object(
)
-# ============ 向量库相关 ============
-VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore")
-if not os.path.exists(VECTOR_STORE_DIR):
- os.makedirs(VECTOR_STORE_DIR)
-
-def clear_vector_store():
+# ============ 向量库相关操作 ============
+def clear_vector_store(filepath: str):
"""
- 清空本地向量库(删除 vectorstore 文件夹内的所有内容)
+ 清空本地向量库(删除 filepath/vectorstore 文件夹内的所有内容)
"""
- if os.path.exists(VECTOR_STORE_DIR):
+ store_dir = get_vectorstore_dir(filepath)
+ if os.path.exists(store_dir):
import shutil
try:
- for filename in os.listdir(VECTOR_STORE_DIR):
- file_path = os.path.join(VECTOR_STORE_DIR, filename)
+ for filename in os.listdir(store_dir):
+ file_path = os.path.join(store_dir, filename)
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path)
elif os.path.isdir(file_path):
@@ -169,25 +151,25 @@ def init_vector_store(
interface_format: str,
embedding_model_name: str,
texts: List[str],
- embedding_base_url: str = ""
+ filepath: str
) -> Chroma:
"""
- 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。
+ 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。
"""
- embed_url = embedding_base_url if embedding_base_url else base_url
+ 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,
- embed_url=embed_url,
interface_format=interface_format,
embedding_model_name=embedding_model_name
)
- documents = [Document(page_content=str(t)) for t in texts] # 确保是字符串
+ documents = [Document(page_content=str(t)) for t in texts]
vectorstore = Chroma.from_documents(
documents,
embedding=embeddings,
- persist_directory=VECTOR_STORE_DIR,
- client_settings=Settings(anonymized_telemetry=False)
+ persist_directory=store_dir
)
vectorstore.persist()
return vectorstore
@@ -198,24 +180,26 @@ def load_vector_store(
base_url: str,
interface_format: str,
embedding_model_name: str,
- embedding_base_url: str = ""
+ filepath: str
) -> Optional[Chroma]:
"""
- 读取已存在的向量库。若不存在则返回 None。
+ 读取已存在的 Chroma 向量库。若不存在则返回 None。
"""
- if not os.path.exists(VECTOR_STORE_DIR):
+ store_dir = get_vectorstore_dir(filepath)
+ if not os.path.exists(store_dir):
logging.info("Vector store not found. Will return None.")
return None
- embed_url = embedding_base_url if embedding_base_url else base_url
embeddings = create_embeddings_object(
api_key=api_key,
base_url=base_url,
- embed_url=embed_url,
interface_format=interface_format,
embedding_model_name=embedding_model_name
)
- return Chroma(persist_directory=VECTOR_STORE_DIR, embedding_function=embeddings,client_settings=Settings(anonymized_telemetry=False))
+ return Chroma(
+ persist_directory=store_dir,
+ embedding_function=embeddings
+ )
def update_vector_store(
@@ -224,19 +208,18 @@ def update_vector_store(
new_chapter: str,
interface_format: str,
embedding_model_name: str,
- embedding_base_url: str = ""
-) -> None:
+ filepath: str
+):
"""
- 将最新章节文本插入到向量库里,用于后续检索参考。若库不存在则初始化。
+ 将最新章节文本插入到向量库中。若库不存在则初始化。
"""
store = load_vector_store(
api_key=api_key,
base_url=base_url,
interface_format=interface_format,
embedding_model_name=embedding_model_name,
- embedding_base_url=embedding_base_url
+ filepath=filepath
)
-
if not store:
logging.info("Vector store does not exist. Initializing a new one for new chapter...")
init_vector_store(
@@ -245,7 +228,7 @@ def update_vector_store(
interface_format=interface_format,
embedding_model_name=embedding_model_name,
texts=[new_chapter],
- embedding_base_url=embedding_base_url
+ filepath=filepath
)
return
@@ -261,19 +244,18 @@ def get_relevant_context_from_vector_store(
query: str,
interface_format: str,
embedding_model_name: str,
- embedding_base_url: 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,
- embedding_base_url=embedding_base_url
+ filepath=filepath
)
if not store:
logging.info("No vector store found. Returning empty context.")
@@ -288,7 +270,7 @@ def get_relevant_context_from_vector_store(
return combined
-# ============ 1. 独立:生成小说“设定” (Novel_setting.txt) ============
+# ============ 1. 生成小说“设定” (Novel_setting.txt) ============
def Novel_setting_generate(
api_key: str,
base_url: str,
@@ -300,16 +282,12 @@ def Novel_setting_generate(
filepath: str,
temperature: float = 0.7
) -> None:
- """
- 分步生成 Novel_setting.txt (含世界观、角色信息、暗线等)
- 不包括目录。
- """
os.makedirs(filepath, exist_ok=True)
model = ChatOpenAI(
model=llm_model,
api_key=api_key,
- base_url=ensure_openai_base_url_has_v1(base_url), # 确保带 /v1
+ base_url=ensure_openai_base_url_has_v1(base_url),
temperature=temperature
)
@@ -334,7 +312,7 @@ def Novel_setting_generate(
)
dark_lines = invoke_with_cleaning(model, prompt_dark)
- # Step4: 最终整合为“小说设定”
+ # Step4: 最终整合
prompt_final = finalize_setting_prompt.format(
novel_setting_base=base_setting,
character_setting=character_setting,
@@ -342,17 +320,15 @@ def Novel_setting_generate(
)
final_novel_setting = invoke_with_cleaning(model, prompt_final)
- # 写入 Novel_setting.txt
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.")
-# ============ 2. 独立:基于已有设定,生成小说目录 (Novel_directory.txt) ============
+# ============ 2. 生成小说目录 (Novel_directory.txt) ============
def Novel_directory_generate(
api_key: str,
base_url: str,
@@ -361,10 +337,6 @@ def Novel_directory_generate(
filepath: str,
temperature: float = 0.7
) -> None:
- """
- 基于先前已经生成并保存的 Novel_setting.txt,来生成 Novel_directory.txt
- """
- # 读取已有的小说设定
filename_set = os.path.join(filepath, "Novel_setting.txt")
final_novel_setting = read_file(filename_set).strip()
if not final_novel_setting:
@@ -378,7 +350,6 @@ def Novel_directory_generate(
temperature=temperature
)
- # 生成目录
prompt_dir = novel_directory_prompt.format(
final_novel_setting=final_novel_setting,
number_of_chapters=number_of_chapters
@@ -388,7 +359,6 @@ def Novel_directory_generate(
logging.warning("Novel_directory生成结果为空。")
return
- # 写入 Novel_directory.txt
filename_dir = os.path.join(filepath, "Novel_directory.txt")
clear_file_content(filename_dir)
@@ -400,10 +370,6 @@ def Novel_directory_generate(
# ============ 获取最近 N 章内容,生成短期摘要 ============
def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]:
- """
- 从指定文件夹中,读取最近 n 章的内容(如果存在),并按从旧到新的顺序返回文本列表。
- 不包含当前章,只拿之前的 n 章。
- """
texts = []
start_chap = max(1, current_chapter_num - n)
for c in range(start_chap, current_chapter_num):
@@ -413,7 +379,6 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int
if text:
texts.append(text)
if len(texts) < n:
- # 如果前面章节不足 n 章,用空字符串填充
texts = [''] * (n - len(texts)) + texts
return texts
@@ -424,9 +389,6 @@ def summarize_recent_chapters(
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):
@@ -451,7 +413,7 @@ def summarize_recent_chapters(
return summary_text
-# ============ 剧情要点/未解决冲突 ============
+# ============ 剧情要点/冲突 ============
PLOT_ARCS_PROMPT = """\
下面是新生成的章节内容:
{chapter_text}
@@ -508,10 +470,7 @@ def generate_chapter_draft(
embedding_model_name: str,
embedding_base_url: str
) -> str:
- """
- 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。
- """
- # 1) 从目录中获取本章标题、简介
+ # 1) 根据目录解析标题、简介
chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number)
chapter_title = chapter_info["chapter_title"]
chapter_brief = chapter_info["chapter_brief"]
@@ -528,11 +487,11 @@ def generate_chapter_draft(
for q in queries:
partial_context = get_relevant_context_from_vector_store(
api_key=api_key,
- base_url=base_url,
+ base_url=embedding_base_url if embedding_base_url else base_url,
query=q,
interface_format=interface_format,
embedding_model_name=embedding_model_name,
- embedding_base_url=embedding_base_url,
+ filepath=filepath,
k=2
)
if partial_context.strip():
@@ -540,7 +499,7 @@ def generate_chapter_draft(
if not relevant_context:
relevant_context = "暂无相关内容。"
- # 创建 ChatOpenAI,用于大纲和写作
+ # 3) 生成本章大纲
model = ChatOpenAI(
model=model_name,
api_key=api_key,
@@ -548,7 +507,6 @@ def generate_chapter_draft(
temperature=temperature
)
- # 3) 生成本章大纲
outline_prompt_text = chapter_outline_prompt.format(
novel_setting=novel_settings,
character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context,
@@ -603,16 +561,10 @@ def finalize_chapter(
embedding_model_name: str,
model_name: str,
temperature: float,
- filepath: str
+ filepath: str,
+ embedding_base_url: str,
+ embedding_api_key: str
):
- """
- 对当前章节进行定稿:
- 1. 读取草稿文本
- 2. 若字数太短则再次扩写
- 3. 更新全局摘要、角色状态
- 4. 更新剧情要点
- 5. 更新向量库
- """
chapters_dir = os.path.join(filepath, "chapters")
chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt")
chapter_text = read_file(chapter_file).strip()
@@ -628,7 +580,7 @@ def finalize_chapter(
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(
@@ -649,7 +601,6 @@ def finalize_chapter(
base_url=ensure_openai_base_url_has_v1(base_url),
temperature=temperature
)
-
def update_global_summary(chapter_text: str, old_summary: str) -> str:
prompt = summary_prompt.format(
chapter_text=chapter_text,
@@ -689,13 +640,14 @@ def finalize_chapter(
clear_file_content(plot_arcs_file)
save_string_to_txt(new_plot_arcs, plot_arcs_file)
- # 更新向量库
+ # 更新向量库(此时用 embedding_api_key/embedding_base_url)
update_vector_store(
- api_key=api_key,
- base_url=base_url,
+ 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
+ embedding_model_name=embedding_model_name,
+ filepath=filepath
)
logging.info(f"Chapter {novel_number} has been finalized.")
@@ -709,9 +661,6 @@ def enrich_chapter_text(
model_name: str,
temperature: float
) -> str:
- """
- 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。
- """
model = ChatOpenAI(
model=model_name,
api_key=api_key,
@@ -726,18 +675,16 @@ def enrich_chapter_text(
return enriched_text if enriched_text else chapter_text
-# ============ 导入外部知识文本 ============
+# ============ 导入外部知识文本到向量库 ============
def import_knowledge_file(
api_key: str,
base_url: str,
interface_format: str,
embedding_model_name: str,
file_path: str,
- embedding_base_url: str = ""
-) -> None:
- """
- 将用户选定的文本文件导入到向量库,以便在写作时检索。
- """
+ embedding_base_url: str,
+ filepath: str
+):
logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}")
if not os.path.exists(file_path):
logging.warning(f"知识库文件不存在: {file_path}")
@@ -752,22 +699,28 @@ def import_knowledge_file(
paragraphs = advanced_split_content(content)
- store = load_vector_store(api_key, base_url, interface_format, embedding_model_name, embedding_base_url)
+ # 若向量库不存在则初始化,否则追加
+ 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
+ )
if not store:
logging.info("Vector store does not exist. Initializing a new one for knowledge import...")
init_vector_store(
- api_key,
- base_url,
- interface_format,
- embedding_model_name,
- paragraphs,
- embedding_base_url
+ 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
)
- return
-
- docs = [Document(page_content=str(p)) for p in paragraphs]
- store.add_documents(docs)
- store.persist()
+ else:
+ docs = [Document(page_content=str(p)) for p in paragraphs]
+ store.add_documents(docs)
+ store.persist()
logging.info("知识库文件已成功导入至向量库。")
@@ -776,7 +729,6 @@ def advanced_split_content(content: str,
max_length: int = 500) -> List[str]:
"""
将文本先按句子切分,然后根据语义相似度进行合并,最后按 max_length 二次切分。
- 可根据需要微调此逻辑。
"""
sentences = nltk.sent_tokenize(content)
if not sentences:
diff --git a/prompt_definitions.py b/prompt_definitions.py
index b2859f8..60bf818 100644
--- a/prompt_definitions.py
+++ b/prompt_definitions.py
@@ -1,139 +1,249 @@
# prompt_definitions.py
# -*- coding: utf-8 -*-
"""
-集中存放所有提示词(Prompt),便于统一管理和修改。
+小说创作辅助系统的提示词(Prompt)集合。
+这些提示词被设计用于引导AI生成连贯、丰富的小说内容。
"""
-# =============== 提示词:设定 & 目录 ===================
+# =============== 基础设定与规划提示词 ===================
set_prompt = """\
-请根据主题:{topic}、类型:{genre}、章数:{number_of_chapters}、每章字数:{word_number}来完善小说整体设定。
-需要包含以下信息:
-1. 小说名称、总字数走向(大致范围即可)。
-2. 小说类型与基调(如:都市、穿越、战争等类型,以及轻松、爆笑、暗黑等基调)。
-3. 写作风格(正式 / 轻松;细腻 / 简洁;抒情 / 客观;叙事视角等)。
-4. 整体世界观(时间背景、地理环境、社会结构、科技或魔法水平、重要历史事件等)。
-5. 核心内容梗概(可以使用常见叙事结构,如三幕结构、英雄之旅等)。
-6. 初步的情节安排设想(主线、副线、交织等关键思路)。
-7. 初步的人物关系与主要角色设定(角色定位、主要冲突或关系)。
-8. 结尾可能的方向(圆满、悲剧、开放式等)。
+基于主题「{topic}」、类型「{genre}」,将创作一部{number_of_chapters}章、每章约{word_number}字的小说。
+请详细规划以下要素:
-请按照上述要点详细输出,但不用标数字。要清晰、有逻辑、有条理。
+【基本信息】
+• 建议书名(可含副标题)
+• 预估总字数区间
+• 主要类型定位(如:奇幻/都市/科幻等)
+• 基调与氛围(如:史诗/轻松/黑暗等)
+
+【创作风格】
+• 叙事视角选择及理由
+• 语言风格特点
+• 节奏把控思路
+
+【世界观构筑】
+• 时空背景设定
+• 世界运行规则(社会/科技/魔法体系等)
+• 重大历史事件或背景
+
+【核心故事】
+• 主线故事框架
+• 重要支线规划
+• 核心冲突设置
+• 结局走向构思
+
+请具体阐述以上各点,确保前后呼应、逻辑自洽。
"""
character_prompt = """\
-基于已生成的小说整体设定:
+基于已确立的小说设定:
{novel_setting}
-请你完善以下内容,帮助我们更好地维持人物形象和成长轨迹:
-1. 列出核心角色(至少3个),并对每个角色进行详细性格特征描述。
-2. 强调每个角色的潜在内心冲突、目标与动机。
-3. 为每个角色添加至少一个“暗线”或隐藏秘密,以及在故事进行中可能如何被揭示。
-4. 指出主要角色之间的关键关系和冲突点,为后续情节埋下伏笔。
+
+请完善以下角色体系:
+
+【核心角色塑造】(至少3个)
+• 角色基本信息(名字/年龄/身份等)
+• 外在特征与性格特点
+• 核心价值观与行为模式
+• 个人成长轨迹设想
+• 独特能力或专长
+
+【人物关系网络】
+• 角色间的重要关联
+• 潜在矛盾点
+• 关系发展预期
+
+【隐藏维度】
+• 每个角色的个人秘密
+• 待揭示的过往经历
+• 性格中的矛盾面
+
+重点说明这些特质如何推动故事发展,为情节转折提供基础。
"""
dark_lines_prompt = """\
-在当前设定中已出现以下角色与背景:
+根据已设定的角色与背景:
{character_info}
-请帮助我们构思若干暗线、伏笔或隐藏冲突,以便在后续章节中逐渐揭示并影响故事走向。要求:
-1. 每个暗线至少说明其最初的表现、发展走向,以及揭示或爆发的条件。
-2. 这些暗线可以与角色背景、世界观、关键事件等有关。
-3. 需注意保留悬念,与已知设定不冲突。
-4. 在后续创作中可多次提及这些暗线,并在中后期通过角色行为或剧情变化逐步揭示。
+
+请构建以下隐藏线索体系:
+
+【关键暗线设计】
+• 暗线起源与表现形式
+• 发展脉络规划
+• 揭示时机与方式
+• 对整体故事的影响
+
+【伏笔布置】
+• 早期暗示点设置
+• 中期发展线索
+• 后期爆发契机
+
+【隐藏冲突】
+• 角色间潜在矛盾
+• 阵营对立根源
+• 价值观冲突点
+
+确保这些暗线自然融入故事,避免生硬设置。建议提供具体场景建议。
"""
finalize_setting_prompt = """\
-请基于以下信息,整合并输出最终的《小说设定》:
-1. 之前的“整体设定”:
+请整合以下创作准备内容:
+
+【现有设定】
{novel_setting_base}
-2. 扩充的“角色设定”:
+
+【角色系统】
{character_setting}
-3. 暗线与伏笔构思:
+
+【暗线规划】
{dark_lines}
-要求:
-1. 结构清晰,将以上内容融合为一个完整的设定说明。
-2. 着重强调角色与暗线的衔接、世界观与角色动机的结合,方便后续写作保持前后一致。
-3. 语言通畅,不使用Markdown格式,直接输出文本内容。
+将以上要素整合为完整的创作蓝图:
+
+1. 总体框架
+ • 核心故事脉络
+ • 世界观体系
+ • 主题表达方式
+
+2. 人物系统
+ • 角色群像
+ • 关系网络
+ • 成长轨迹
+
+3. 情节编排
+ • 主线发展
+ • 支线设计
+ • 暗线铺陈
+
+4. 创作建议
+ • 重点场景构思
+ • 节奏控制要点
+ • 细节描写建议
+
+请以流畅的叙述文本呈现,突出要素间的有机联系。
"""
novel_directory_prompt = """\
-根据以下最终《小说设定》:
+依据最终设定:
{final_novel_setting}
-并按照下面的小说目录模板生成 {number_of_chapters} 章的目录,同时确保目录符合小说设定中的叙事结构、角色发展及暗线伏笔。
-目录模板(示例):
-第1章 :< text >
-第2章 :< text >
-...
-第{number_of_chapters}章 :< text >
-请严格按照上述格式输出每一章的名称,最好在重要情节标题后增加提示性简述,
-若要加更详细的简述,用“ - ”分隔,举例如“第n章 :< text > - 主要角色冲突爆发,角色A发生xx意外”。
-请直接输出,不要使用Markdown语法。
+请规划{number_of_chapters}章的详细目录。每章格式:
+第N章:章节名 - 核心内容提示
+
+要求:
+1. 章节名需简明扼要,富有吸引力
+2. 核心内容提示需点明关键信息,为创作提供指引
+3. 整体节奏要富有张力,符合三幕结构
+4. 适当预留转折与高潮
+
+示例:
+第n章:黎明前的暗影 - 主角遭遇神秘袭击,接触核心谜题
+...
"""
-# =============== 提示词:章节+角色状态流程 ===================
+# =============== 章节创作辅助提示词 ===================
summary_prompt = """\
-这是新生成的章节文本:
+新增章节内容:
{chapter_text}
-这是当前的全局摘要(可能为空):
+当前全局摘要:
{global_summary}
-请在不超过3000字的前提下,基于当前全局摘要和本章新增剧情,更新全局摘要。
-保留原有重要信息,并融入本章的新内容。
-不要透露结局,不要过度展开未来剧情。
+请更新全局摘要(控制在3000字以内):
+
+【已发生】
+• 关键事件梳理
+• 人物关系变化
+• 重要线索进展
+
+【正在进行】
+• 当前危机/冲突
+• 角色动态
+• 悬而未决的问题
+
+确保摘要重点突出,为后续创作提供清晰参考。
+不展开未来发展,保持故事悬念。
"""
update_character_state_prompt = """\
-这是新生成的章节文本:
+本章内容:
{chapter_text}
-这是当前角色状态文档(可能为空):
+现有角色状态:
{old_state}
-请更新角色状态,包括:
-1. 角色持有的物品或能力变化。
-2. 角色间关系、冲突或合作的新动向。
-3. 正在发生的重要事件列表,有无进展或新事件产生。
-4. 任意新增角色或出场人物等。
-5. 请保证结构完整,能在后续章节继续引用。
+请更新角色状态档案:
-使用简洁、易读的方式描述,可用条目或段落表示。保持与旧文档风格一致。
+【角色发展】
+• 能力/状态变化
+• 重要物品获得/失去
+• 性格/观念的微妙改变
+
+【人际关系】
+• 新建立的联系
+• 关系的强化或弱化
+• 潜在矛盾点
+
+【事件参与】
+• 正在进行的事件
+• 个人目标进展
+• 新接触的任务
+
+请保持简洁明了,便于后续参考。
"""
chapter_outline_prompt = """\
-以下是当前小说设定与角色状态信息:
-- 小说设定:{novel_setting}
+创作参考资料:
+- 设定:{novel_setting}
- 角色状态:{character_state}
- 全局摘要:{global_summary}
-现在要为第 {novel_number} 章进行大纲构思。
-本章标题:{chapter_title}
-简述(若有):{chapter_brief}
+第{novel_number}章:{chapter_title}
+章节说明:{chapter_brief}
-请围绕本章标题与简述,设计一个详细大纲:
-1. 本章的主要冲突或事件?如何与标题呼应?
-2. 哪些角色会出现?他们在此章的目标与动机是否有所变化?
-3. 如何推动或暗示已存在的暗线、角色冲突或新的悬念?
-4. 在结尾留下什么悬念或转折?(与本章标题或简述形成呼应或对比)
+请设计本章节详细大纲:
-请直接用 1、2、3、4 分点说明大纲要点即可。
+【核心设计】
+• 本章主要冲突/事件
+• 与章节主题的呼应方式
+• 情节推进目标
+
+【人物安排】
+• 出场角色及其状态
+• 个人目标与动机
+• 互动关系设计
+
+【暗线发展】
+• 已有伏笔的推进
+• 新增悬念的埋设
+• 线索的暗示方式
+
+【结构布局】
+• 章节节奏规划
+• 高潮设计
+• 结尾悬念构思
"""
chapter_write_prompt = """\
-下面是该章写作所需信息:
-1. 小说设定:{novel_setting}
+创作参考信息:
+1. 设定:{novel_setting}
2. 角色状态:{character_state}
-3. 全局摘要:{global_summary}
-4. 本章大纲:{chapter_outline}
+3. 摘要:{global_summary}
+4. 大纲:{chapter_outline}
-本章标题:{chapter_title}
-简述:{chapter_brief}
+第{chapter_title}章
+核心:{chapter_brief}
-请写出本章节的完整正文:
-1. 确保本章字数不少于 {word_number} 字。
-2. 内容需与标题“{chapter_title}”相呼应,并尽量呼应简述中的核心要点。
-3. 不要使用分节标题,直接整体输出正文。
-4. 可以着重描写人物心理、环境氛围,以保证足够长度。
-5. 在结尾部分保留一定悬念或剧情转折,为下一章做铺垫。
-"""
+创作要求:
+1. 字数不少于{word_number}字
+2. 紧扣章节主题
+3. 注重细节描写
+4. 深入角色内心
+5. 为下章预留引子
+建议:
+• 通过环境描写渲染氛围
+• 展现人物细微情感变化
+• 适当运用对话推进情节
+• 保持节奏张弛有度
+"""
\ No newline at end of file
diff --git a/ui.py b/ui.py
index cb87419..3475b76 100644
--- a/ui.py
+++ b/ui.py
@@ -21,6 +21,7 @@ from novel_generator import (
)
from consistency_checker import check_consistency
+
def log_error(message: str):
"""
用于打印详细的错误信息和堆栈信息。
@@ -31,6 +32,7 @@ def log_error(message: str):
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
+
class NovelGeneratorGUI:
def __init__(self, master):
self.master = master
@@ -51,15 +53,20 @@ class NovelGeneratorGUI:
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.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"))
+ self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7))
- self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", ""))
+ # 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", ""))
- self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7))
+ # 小说通用参数
self.topic_default = self.loaded_config.get("topic", "")
self.genre_var = ctk.StringVar(value=self.loaded_config.get("genre", "玄幻"))
self.num_chapters_var = ctk.IntVar(value=self.loaded_config.get("num_chapters", 10))
@@ -298,13 +305,10 @@ class NovelGeneratorGUI:
def on_interface_format_changed(new_value):
if new_value == "Ollama":
self.base_url_var.set("http://localhost:11434/v1")
- self.embedding_url_var.set("http://localhost:11434/api")
elif new_value == "ML Studio":
self.base_url_var.set("http://localhost:1234/v1")
- self.embedding_url_var.set("http://localhost:1234/api")
elif new_value == "OpenAI":
self.base_url_var.set("https://api.openai.com/v1")
- self.embedding_url_var.set("https://api.openai.com/v1")
for i in range(5):
self.ai_config_tab.grid_rowconfigure(i, weight=0)
@@ -314,7 +318,7 @@ class NovelGeneratorGUI:
api_key_label = ctk.CTkLabel(
self.ai_config_tab,
- text="API Key:",
+ text="LLM API Key:",
font=("Microsoft YaHei", 12)
)
api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e")
@@ -327,7 +331,7 @@ class NovelGeneratorGUI:
base_url_label = ctk.CTkLabel(
self.ai_config_tab,
- text="Base URL:",
+ text="LLM Base URL:",
font=("Microsoft YaHei", 12)
)
base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e")
@@ -340,7 +344,7 @@ class NovelGeneratorGUI:
interface_label = ctk.CTkLabel(
self.ai_config_tab,
- text="接口格式:",
+ text="LLM 接口格式:",
font=("Microsoft YaHei", 12)
)
interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e")
@@ -394,36 +398,73 @@ class NovelGeneratorGUI:
self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w")
def build_embeddings_config_tab(self):
- for i in range(2):
+ def on_embedding_interface_changed(new_value):
+ if new_value == "Ollama":
+ self.embedding_url_var.set("http://localhost:11434/v1")
+ 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")
+
+ for i in range(3):
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)
- embedding_url_label = ctk.CTkLabel(
+ emb_api_key_label = ctk.CTkLabel(
self.embeddings_config_tab,
- text="Embedding URL:",
+ text="Embedding API Key:",
font=("Microsoft YaHei", 12)
)
- embedding_url_label.grid(row=0, column=0, padx=5, pady=5, sticky="e")
- embedding_url_entry = ctk.CTkEntry(
+ 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")
+
+ 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")
+
+ 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)
)
- embedding_url_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew")
+ emb_url_entry.grid(row=2, column=1, padx=5, pady=5, sticky="nsew")
emb_model_name_label = ctk.CTkLabel(
self.embeddings_config_tab,
text="Embedding Model Name:",
font=("Microsoft YaHei", 12)
)
- emb_model_name_label.grid(row=1, column=0, padx=5, pady=5, sticky="e")
+ 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=1, column=1, padx=5, pady=5, sticky="nsew")
+ emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew")
# ========== 保存/加载 配置按钮区域 ==========
def build_main_buttons_area(self):
@@ -837,13 +878,20 @@ class NovelGeneratorGUI:
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", ""))
- self.temperature_var.set(cfg.get("temperature", 0.7))
+
+ # Novel
self.genre_var.set(cfg.get("genre", ""))
self.num_chapters_var.set(cfg.get("num_chapters", 10))
self.word_number_var.set(cfg.get("word_number", 3000))
@@ -859,13 +907,20 @@ class NovelGeneratorGUI:
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(),
- "temperature": self.temperature_var.get(),
+
+ # Novel
"topic": self.topic_text.get("0.0", "end").strip(),
"genre": self.genre_var.get(),
"num_chapters": self.num_chapters_var.get(),
@@ -1031,9 +1086,11 @@ class NovelGeneratorGUI:
temperature=temperature,
novel_novel_directory=novel_directory,
filepath=filepath,
- interface_format=self.interface_format_var.get().strip(),
+
+ # 传入 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()
+ embedding_base_url=self.embedding_url_var.get().strip(),
)
if draft_text:
self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。")
@@ -1067,8 +1124,9 @@ class NovelGeneratorGUI:
base_url = self.base_url_var.get().strip()
model_name = self.model_name_var.get().strip()
temperature = self.temperature_var.get()
- interface_format = self.interface_format_var.get().strip()
+ 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()
chap_num = self.chapter_num_var.get()
word_number = self.word_number_var.get()
@@ -1083,7 +1141,9 @@ class NovelGeneratorGUI:
embedding_model_name=embedding_model_name,
model_name=model_name,
temperature=temperature,
- filepath=filepath
+ filepath=filepath,
+ embedding_base_url=embedding_base_url,
+ embedding_api_key=self.embedding_api_key_var.get().strip()
)
self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。")
@@ -1167,12 +1227,13 @@ class NovelGeneratorGUI:
try:
self.safe_log(f"开始导入知识库文件: {selected_file}")
import_knowledge_file(
- api_key=self.api_key_var.get().strip(),
- base_url=self.base_url_var.get().strip(),
- interface_format=self.interface_format_var.get().strip(),
+ 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()
+ embedding_base_url=self.embedding_url_var.get().strip(),
+ filepath=self.filepath_var.get().strip() # 新增,用于本地化 vectorstore
)
self.safe_log("✅ 知识库文件导入完成。")
except Exception:
@@ -1183,11 +1244,16 @@ class NovelGeneratorGUI:
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:
- clear_vector_store()
+ clear_vector_store(filepath)
self.log("已清空向量库。")
def show_plot_arcs_ui(self):
@@ -1304,6 +1370,7 @@ class NovelGeneratorGUI:
save_string_to_txt(content, summary_file)
self.log("已保存对 global_summary.txt 的修改。")
+
# 入口
if __name__ == "__main__":
app = ctk.CTk()