diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py
index f9fc23b..a2c6311 100644
--- a/chapter_directory_parser.py
+++ b/chapter_directory_parser.py
@@ -27,11 +27,8 @@ def get_chapter_info_from_directory(novel_directory_content: str, chapter_number
if match:
chap_num = int(match.group(1))
if chap_num == chapter_number:
- # group(2) 可能是标题及简述的混合
full_title = match.group(2).strip()
- # 这里假设用 '-' 进一步区分“标题 - 简述”,也可能用户没写“ - ”
if ' - ' in full_title:
- # 根据你的目录格式自由处理
parts = full_title.split(' - ', 1)
return {
"chapter_title": parts[0].strip(),
diff --git a/main.spec b/main.spec
index 2c7b79a..4694d44 100644
--- a/main.spec
+++ b/main.spec
@@ -44,7 +44,7 @@ exe = EXE(
a.scripts,
[],
exclude_binaries=True,
- name='AI_NovelGenerator_V1.3.0',
+ name='AI_NovelGenerator_V1.3.2',
debug=True,
bootloader_ignore_signals=False,
strip=False,
@@ -65,5 +65,5 @@ coll = COLLECT(
strip=False,
upx=True,
upx_exclude=[],
- name='AI_NovelGenerator_V1.3.0'
+ name='AI_NovelGenerator_V1.3.2'
)
diff --git a/novel_generator.py b/novel_generator.py
index f6115c9..7505948 100644
--- a/novel_generator.py
+++ b/novel_generator.py
@@ -8,7 +8,7 @@ import traceback
from typing import List, Optional
# langchain 相关
-from langchain_openai import ChatOpenAI,OpenAIEmbeddings
+from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from chromadb.config import Settings
from langchain.docstore.document import Document
@@ -51,6 +51,10 @@ def remove_think_tags(text: str) -> str:
"""移除 ... 包裹的内容"""
return re.sub(r'.*?', '', text, flags=re.DOTALL)
+def debug_log(prompt: str, response_content: str):
+ logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n")
+ logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n")
+
def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str:
"""通用封装:调用模型并移除 ... 文本,记录日志后返回"""
response = model.invoke(prompt)
@@ -61,11 +65,6 @@ def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str:
debug_log(prompt, cleaned_text)
return cleaned_text.strip()
-def debug_log(prompt: str, response_content: str):
- logging.info(f"\n[Prompt >>>] {prompt}\n")
- logging.info(f"[Response >>>] {response_content}\n")
-
-
def ensure_openai_base_url_has_v1(url: str) -> str:
"""
若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。
@@ -79,7 +78,6 @@ def ensure_openai_base_url_has_v1(url: str) -> str:
url = url.rstrip('/') + '/v1'
return url
-
def is_using_ollama_api(interface_format: str) -> bool:
return interface_format.lower() == "ollama"
@@ -113,6 +111,7 @@ def create_embeddings_object(
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,
@@ -122,38 +121,26 @@ def create_embeddings_object(
# ============ 向量库相关操作 ============
-def clear_vector_store(filepath: str):
+def clear_vector_store(filepath: str) -> bool:
"""
- 不删除文件,仅通过 Chroma API 移除集合数据(保留空目录)
+ 返回值表示是否成功清空向量库。
"""
- from chromadb import Client
+ import shutil
store_dir = get_vectorstore_dir(filepath)
if not os.path.exists(store_dir):
logging.info("No vector store found to clear.")
- return
+ return False
try:
- client = Client(settings=Settings(
- persist_directory=store_dir,
- allow_reset=True # 允许重置操作
- ))
- print(client.list_collections())
-
- if client.list_collections():
- client.delete_collection(name="novel_collection")
- logging.info("Collection 'novel_collection' deleted via API.")
-
- client.reset()
-
+ if os.path.exists(store_dir):
+ shutil.rmtree(store_dir)
+ logging.info(f"Vector store directory '{store_dir}' removed.")
+ return True
except Exception as e:
- logging.error(f"API-based clear failed: {str(e)}")
+ logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}")
traceback.print_exc()
- finally:
- # 显式关闭客户端释放资源
- if 'client' in locals():
- del client
-
+ return False
def init_vector_store(
api_key: str,
@@ -215,6 +202,65 @@ def load_vector_store(
)
+def split_by_length(text: str, max_length: int = 500) -> List[str]:
+ segments = []
+ start_idx = 0
+ while start_idx < len(text):
+ end_idx = min(start_idx + max_length, len(text))
+ segment = text[start_idx:end_idx]
+ segments.append(segment.strip())
+ start_idx = end_idx
+ return segments
+
+
+def split_text_for_vectorstore(chapter_text: str,
+ max_length: int = 500,
+ similarity_threshold: float = 0.7) -> List[str]:
+ """
+ 对新的章节文本进行分段后,再用于存入向量库。
+ """
+ if not chapter_text.strip():
+ return []
+
+ nltk.download('punkt', quiet=True)
+ nltk.download('punkt_tab', quiet=True)
+ sentences = nltk.sent_tokenize(chapter_text)
+ if not sentences:
+ return []
+
+ # 先对相近句子进行合并
+ model = SentenceTransformer('paraphrase-MiniLM-L6-v2')
+ embeddings = model.encode(sentences)
+
+ merged_paragraphs = []
+ current_sentences = [sentences[0]]
+ current_embedding = embeddings[0]
+
+ for i in range(1, len(sentences)):
+ sim = cosine_similarity([current_embedding], [embeddings[i]])[0][0]
+ if sim >= similarity_threshold:
+ current_sentences.append(sentences[i])
+ current_embedding = (current_embedding + embeddings[i]) / 2.0
+ else:
+ merged_paragraphs.append(" ".join(current_sentences))
+ current_sentences = [sentences[i]]
+ current_embedding = embeddings[i]
+
+ if current_sentences:
+ merged_paragraphs.append(" ".join(current_sentences))
+
+ # 再对合并好的段落做 max_length 切分
+ final_segments = []
+ for para in merged_paragraphs:
+ if len(para) > max_length:
+ sub_segments = split_by_length(para, max_length=max_length)
+ final_segments.extend(sub_segments)
+ else:
+ final_segments.append(para)
+
+ return final_segments
+
+
def update_vector_store(
api_key: str,
base_url: str,
@@ -226,6 +272,11 @@ def update_vector_store(
"""
将最新章节文本插入到向量库中。若库不存在则初始化。
"""
+ splitted_texts = split_text_for_vectorstore(new_chapter)
+ if not splitted_texts:
+ logging.warning("No valid text to insert into vector store. Skipping.")
+ return
+
store = load_vector_store(
api_key=api_key,
base_url=base_url,
@@ -240,14 +291,14 @@ def update_vector_store(
base_url=base_url,
interface_format=interface_format,
embedding_model_name=embedding_model_name,
- texts=[new_chapter],
+ texts=splitted_texts,
filepath=filepath
)
return
- new_doc = Document(page_content=str(new_chapter))
- store.add_documents([new_doc])
- logging.info("Vector store updated with the new chapter.")
+ 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(
@@ -480,37 +531,38 @@ def generate_chapter_draft(
filepath: str,
interface_format: str,
embedding_model_name: str,
- embedding_base_url: str
+ embedding_base_url: str,
+ embedding_retrieval_k: int = 4
) -> str:
# 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"]
- # 2) 从向量库检索上下文
- queries = []
+ # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要)
+ combined_query_parts = []
if user_guidance.strip():
- queries.append(user_guidance)
+ combined_query_parts.append(user_guidance)
if chapter_brief.strip():
- queries.append(chapter_brief)
+ combined_query_parts.append(chapter_brief)
if recent_chapters_summary.strip():
- queries.append(recent_chapters_summary)
- queries.append("回顾剧情")
+ combined_query_parts.append(recent_chapters_summary)
+ # 额外加一个关键字
+ combined_query_parts.append("回顾剧情")
- relevant_context = ""
- for q in queries:
- partial_context = get_relevant_context_from_vector_store(
- api_key=api_key,
- base_url=embedding_base_url if embedding_base_url else base_url,
- query=q,
- interface_format=interface_format,
- embedding_model_name=embedding_model_name,
- filepath=filepath,
- k=2
- )
- if partial_context.strip():
- relevant_context += "\n" + partial_context
- if not relevant_context:
+ 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) 生成本章大纲
@@ -547,6 +599,7 @@ def generate_chapter_draft(
global_summary=global_summary,
chapter_outline=chapter_outline,
word_number=word_number,
+ novel_number=novel_number,
chapter_title=chapter_title,
chapter_brief=chapter_brief
)
@@ -615,6 +668,7 @@ 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,
@@ -690,59 +744,13 @@ def enrich_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,
- filepath: str
-):
- logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}")
- if not os.path.exists(file_path):
- logging.warning(f"知识库文件不存在: {file_path}")
- return
-
- content = read_file(file_path)
- if not content.strip():
- logging.warning("知识库文件内容为空。")
- return
-
- nltk.download('punkt', quiet=True)
-
- 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
- )
- 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
- )
- else:
- docs = [Document(page_content=str(p)) for p in paragraphs]
- store.add_documents(docs)
- logging.info("知识库文件已成功导入至向量库。")
-
-
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:
return []
@@ -777,13 +785,46 @@ def advanced_split_content(content: str,
return final_segments
+def import_knowledge_file(
+ api_key: str,
+ base_url: str,
+ 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}")
+ if not os.path.exists(file_path):
+ logging.warning(f"知识库文件不存在: {file_path}")
+ return
-def split_by_length(text: str, max_length: int = 500) -> List[str]:
- segments = []
- start_idx = 0
- while start_idx < len(text):
- end_idx = min(start_idx + max_length, len(text))
- segment = text[start_idx:end_idx]
- segments.append(segment.strip())
- start_idx = end_idx
- return segments
+ content = read_file(file_path)
+ if not content.strip():
+ logging.warning("知识库文件内容为空。")
+ return
+
+ 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
+ )
+ 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
+ )
+ else:
+ docs = [Document(page_content=str(p)) for p in paragraphs]
+ store.add_documents(docs)
+ logging.info("知识库文件已成功导入至向量库。")
diff --git a/prompt_definitions.py b/prompt_definitions.py
index 60bf818..9e99408 100644
--- a/prompt_definitions.py
+++ b/prompt_definitions.py
@@ -1,249 +1,143 @@
# prompt_definitions.py
# -*- coding: utf-8 -*-
"""
-小说创作辅助系统的提示词(Prompt)集合。
-这些提示词被设计用于引导AI生成连贯、丰富的小说内容。
+集中存放所有提示词(Prompt),新版本更精确、更具创新性,结合部分雪花写作法等理论。
"""
-# =============== 基础设定与规划提示词 ===================
+# =============== 1. 整体设定 ===================
set_prompt = """\
-基于主题「{topic}」、类型「{genre}」,将创作一部{number_of_chapters}章、每章约{word_number}字的小说。
-请详细规划以下要素:
+请根据主题: {topic}、类型: {genre}、章数: {number_of_chapters}、每章字数: {word_number} 来设计小说的总体结构和世界观。
+写作时请参考雪花写作法等创作理论,结合以下要点:
-【基本信息】
-• 建议书名(可含副标题)
-• 预估总字数区间
-• 主要类型定位(如:奇幻/都市/科幻等)
-• 基调与氛围(如:史诗/轻松/黑暗等)
+• 小说标题与大致篇幅(总字数范围)。
+• 类型与基调(如:都市、魔幻、战争、轻松、暗黑等)。
+• 主要写作风格(视角、叙述方式、语言风格等)。
+• 世界观设定(背景时间、地理环境、社会结构、科技或魔法水平、重要历史等)。
+• 整体故事框架(可提及常见叙事结构:三幕、英雄之旅等)。
+• 主线与副线的初步构想,以及它们如何交织。
+• 关键角色群像定位与主要冲突关系。
+• 结局的可能方向(圆满、悲剧、开放式等)。
-【创作风格】
-• 叙事视角选择及理由
-• 语言风格特点
-• 节奏把控思路
-
-【世界观构筑】
-• 时空背景设定
-• 世界运行规则(社会/科技/魔法体系等)
-• 重大历史事件或背景
-
-【核心故事】
-• 主线故事框架
-• 重要支线规划
-• 核心冲突设置
-• 结局走向构思
-
-请具体阐述以上各点,确保前后呼应、逻辑自洽。
+请以简洁、逻辑清晰的方式输出,保留足够细节以支撑后续创作。
"""
+# =============== 2. 角色设定 ===================
character_prompt = """\
-基于已确立的小说设定:
+基于已生成的小说整体设定:
{novel_setting}
-请完善以下角色体系:
+请进一步扩展角色设置,至少包含三名核心角色。对每位角色说明:
+• 角色背景、外貌与主要性格特征。
+• 内在冲突、目标与动机。
+• 暗藏的秘密或潜在弱点(可与世界观或其他角色有关)。
+• 与其他角色的关系或对立点,如何推动或阻碍情节发展。
-【核心角色塑造】(至少3个)
-• 角色基本信息(名字/年龄/身份等)
-• 外在特征与性格特点
-• 核心价值观与行为模式
-• 个人成长轨迹设想
-• 独特能力或专长
-
-【人物关系网络】
-• 角色间的重要关联
-• 潜在矛盾点
-• 关系发展预期
-
-【隐藏维度】
-• 每个角色的个人秘密
-• 待揭示的过往经历
-• 性格中的矛盾面
-
-重点说明这些特质如何推动故事发展,为情节转折提供基础。
+请重点突出角色的多重面向,为后续情节埋下伏笔。
"""
+# =============== 3. 暗线与伏笔 ===================
dark_lines_prompt = """\
-根据已设定的角色与背景:
+在当前世界观与角色关系中:
{character_info}
-请构建以下隐藏线索体系:
+请构思若干暗线、伏笔或隐藏冲突,结合雪花写作法“细节逐步扩展”的思路。要求:
+• 每条暗线给出初始迹象、后续发展与可能的爆发条件。
+• 与角色背景、世界观或关键事件相呼应。
+• 保持合理的悬念设置,与现有设定不冲突。
+• 为后续剧情保留足够展开空间,强调持续影响故事进程。
-【关键暗线设计】
-• 暗线起源与表现形式
-• 发展脉络规划
-• 揭示时机与方式
-• 对整体故事的影响
-
-【伏笔布置】
-• 早期暗示点设置
-• 中期发展线索
-• 后期爆发契机
-
-【隐藏冲突】
-• 角色间潜在矛盾
-• 阵营对立根源
-• 价值观冲突点
-
-确保这些暗线自然融入故事,避免生硬设置。建议提供具体场景建议。
+请避免一次性透漏全部细节,以逐步揭示的方式为后文做铺垫。
"""
+# =============== 4. 最终设定整合 ===================
finalize_setting_prompt = """\
-请整合以下创作准备内容:
-
-【现有设定】
+基于以下内容,请整合并输出终稿《小说设定》:
+1. 整体设定:
{novel_setting_base}
-
-【角色系统】
+2. 角色设定:
{character_setting}
-
-【暗线规划】
+3. 暗线与伏笔:
{dark_lines}
-将以上要素整合为完整的创作蓝图:
-
-1. 总体框架
- • 核心故事脉络
- • 世界观体系
- • 主题表达方式
-
-2. 人物系统
- • 角色群像
- • 关系网络
- • 成长轨迹
-
-3. 情节编排
- • 主线发展
- • 支线设计
- • 暗线铺陈
-
-4. 创作建议
- • 重点场景构思
- • 节奏控制要点
- • 细节描写建议
-
-请以流畅的叙述文本呈现,突出要素间的有机联系。
+整合要求:
+• 以整体视角整理世界观、角色与暗线,形成一个有机结合的故事框架。
+• 注意角色动机与暗线如何与世界观互相呼应,并兼顾主线与副线节奏。
+• 语言通顺、层次分明。直接输出文本,不使用Markdown格式。
"""
+# =============== 5. 小说目录 ===================
novel_directory_prompt = """\
-依据最终设定:
+根据以下最终《小说设定》:
{final_novel_setting}
-请规划{number_of_chapters}章的详细目录。每章格式:
-第N章:章节名 - 核心内容提示
-
-要求:
-1. 章节名需简明扼要,富有吸引力
-2. 核心内容提示需点明关键信息,为创作提供指引
-3. 整体节奏要富有张力,符合三幕结构
-4. 适当预留转折与高潮
-
-示例:
-第n章:黎明前的暗影 - 主角遭遇神秘袭击,接触核心谜题
+请列出共 {number_of_chapters} 章的目录,并保证每章的标题或简述能呼应主要冲突、角色成长及暗线埋设。
+目录格式示例:
+第1章 :<标题> - <简要说明>
+第2章 :<标题> - <简要说明>
...
+第{number_of_chapters}章 :<标题> - <简要说明>
+
+每章可酌情加入一句简述,引导读者了解本章看点。直接输出文本,不使用Markdown。
"""
-# =============== 章节创作辅助提示词 ===================
+# =============== 6. 全局摘要更新 ===================
summary_prompt = """\
-新增章节内容:
+以下是新完成的章节文本:
{chapter_text}
-当前全局摘要:
+这是当前的全局摘要(可为空):
{global_summary}
-请更新全局摘要(控制在3000字以内):
-
-【已发生】
-• 关键事件梳理
-• 人物关系变化
-• 重要线索进展
-
-【正在进行】
-• 当前危机/冲突
-• 角色动态
-• 悬而未决的问题
-
-确保摘要重点突出,为后续创作提供清晰参考。
-不展开未来发展,保持故事悬念。
+请根据本章新增内容,更新全局摘要。保留既有重要信息,同时融入新剧情要点,勿剧透未来。控制在不超过3000字的范围内,语言简练流畅。
"""
+# =============== 7. 角色状态更新 ===================
update_character_state_prompt = """\
-本章内容:
+以下是新完成的章节文本:
{chapter_text}
-现有角色状态:
+这是当前的角色状态文档(可为空):
{old_state}
-请更新角色状态档案:
+请更新角色状态,内容包括:
+• 角色的物品、能力或心理状态变化。
+• 角色间关系的最新进展或冲突。
+• 是否触发或加深了某些暗线或关键事件。
+• 任何新增角色或临时出场人物的基本信息。
-【角色发展】
-• 能力/状态变化
-• 重要物品获得/失去
-• 性格/观念的微妙改变
-
-【人际关系】
-• 新建立的联系
-• 关系的强化或弱化
-• 潜在矛盾点
-
-【事件参与】
-• 正在进行的事件
-• 个人目标进展
-• 新接触的任务
-
-请保持简洁明了,便于后续参考。
+请直接在已有文档基础上进行增删,语言尽量简洁、有条理。
"""
+# =============== 8. 章节大纲设计 ===================
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. 本章结尾的悬念或转折,如何为下一章做铺垫。
-【核心设计】
-• 本章主要冲突/事件
-• 与章节主题的呼应方式
-• 情节推进目标
-
-【人物安排】
-• 出场角色及其状态
-• 个人目标与动机
-• 互动关系设计
-
-【暗线发展】
-• 已有伏笔的推进
-• 新增悬念的埋设
-• 线索的暗示方式
-
-【结构布局】
-• 章节节奏规划
-• 高潮设计
-• 结尾悬念构思
+请以简要分点方式输出,不要使用Markdown格式。
"""
+# =============== 9. 章节正文写作 ===================
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}
+请写出第 {novel_number} 章的正文,标题为“{chapter_title}”。需要:
+• 字数不少于 {word_number} 字,与标题和简述相呼应。
+• 保持连贯叙述,可增加环境、心理、对话等细节描写。
+• 适度呼应前文暗线或角色矛盾,为后续发展留出悬念。
-创作要求:
-1. 字数不少于{word_number}字
-2. 紧扣章节主题
-3. 注重细节描写
-4. 深入角色内心
-5. 为下章预留引子
-
-建议:
-• 通过环境描写渲染氛围
-• 展现人物细微情感变化
-• 适当运用对话推进情节
-• 保持节奏张弛有度
-"""
\ No newline at end of file
+直接输出正文内容,不使用分章节小标题,章节末模仿正常小说中断或转场。
+"""
diff --git a/ui.py b/ui.py
index 9a2f53e..73d4d12 100644
--- a/ui.py
+++ b/ui.py
@@ -28,6 +28,7 @@ def log_error(message: str):
"""
logging.error(f"{message}\n{traceback.format_exc()}")
+
# 设置全局主题和颜色
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
@@ -52,12 +53,15 @@ class NovelGeneratorGUI:
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.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))
# Embedding 接口相关
@@ -66,16 +70,23 @@ class NovelGeneratorGUI:
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_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", "玄幻"))
- self.num_chapters_var = ctk.IntVar(value=self.loaded_config.get("num_chapters", 10))
- self.word_number_var = ctk.IntVar(value=self.loaded_config.get("word_number", 3000))
+
+ # ### 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", ""))
- self.chapter_num_var = ctk.IntVar(value=1)
+ # ### CHANGED:章节号也改为 StringVar
+ self.chapter_num_var = ctk.StringVar(value="1")
- # ========== 主容器使用 TabView ==========
+ # ========== 主容器使用 TabView ==========
self.tabview = ctk.CTkTabview(self.master, width=1200, height=800)
self.tabview.pack(fill="both", expand=True)
@@ -95,13 +106,20 @@ class NovelGeneratorGUI:
self.build_summary_tab()
self.build_chapters_tab() # 新增
- # ------------------ 统一异常处理方法 ------------------
- def handle_exception(self, context: str):
- full_message = f"{context}\n{traceback.format_exc()}"
- logging.error(full_message)
- self.safe_log(full_message)
+ # ------------------ 工具方法:安全获取 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)
+ except:
+ var.set(str(default))
+ return default
- # ------------------ 主功能 Tab ------------------
+ # ------------------ 主功能 Tab ------------------
def build_main_tab(self):
"""
主Tab分为左右两栏:
@@ -140,34 +158,34 @@ class NovelGeneratorGUI:
self.left_frame.grid_rowconfigure(4, weight=1)
self.left_frame.grid_columnconfigure(0, weight=1)
- # ========== 本章内容 ==========
+ # ========== 本章内容 ==========
chapter_label = ctk.CTkLabel(
self.left_frame,
text="本章内容 (可编辑)",
- font=("Microsoft YaHei", 14)
+ 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",
+ 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按钮 ==========
+ # ========== Step1~4按钮 ==========
self.build_step_buttons_area()
- # ========== 输出日志 label ==========
+ # ========== 输出日志 label ==========
log_label = ctk.CTkLabel(
self.left_frame,
text="输出日志 (只读)",
- font=("Microsoft YaHei", 14)
+ 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",
@@ -248,7 +266,7 @@ class NovelGeneratorGUI:
# 3) 可选功能按钮
self.build_optional_buttons_area(start_row=2)
- # ------------------ 可选功能按钮区域(右下) ------------------
+ # ------------------ 可选功能按钮区域(右下) ------------------
def build_optional_buttons_area(self, start_row=2):
"""
放在右侧的最下方:包括 一致性审校、导入知识库、清空向量库、查看剧情要点
@@ -389,7 +407,7 @@ class NovelGeneratorGUI:
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}",
@@ -400,13 +418,13 @@ class NovelGeneratorGUI:
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/v1")
+ 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")
- for i in range(3):
+ 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)
@@ -466,6 +484,19 @@ class NovelGeneratorGUI:
)
emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew")
+ 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")
+
# ========== 保存/加载 配置按钮区域 ==========
def build_main_buttons_area(self):
"""
@@ -499,7 +530,7 @@ class NovelGeneratorGUI:
"""
self.params_frame = ctk.CTkScrollableFrame(
self.right_frame,
- orientation="vertical" # 默认垂直滚动
+ orientation="vertical"
)
self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5)
self.params_frame.columnconfigure(1, weight=1)
@@ -536,35 +567,44 @@ class NovelGeneratorGUI:
)
genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
- # 章节数
+ # 章节数、每章字数 放在同一行
+ row_for_chapter_and_word = 2
num_chapters_label = ctk.CTkLabel(
self.params_frame,
text="章节数:",
font=("Microsoft YaHei", 12)
)
- num_chapters_label.grid(row=2, column=0, padx=5, pady=5, sticky="e")
+ num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e")
+
+ 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)
+
num_chapters_entry = ctk.CTkEntry(
- self.params_frame,
+ ch_word_frame,
textvariable=self.num_chapters_var,
- width=80,
+ width=60,
font=("Microsoft YaHei", 12)
)
- num_chapters_entry.grid(row=2, column=1, padx=5, pady=5, sticky="w")
+ num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w")
- # 每章字数
word_number_label = ctk.CTkLabel(
- self.params_frame,
+ ch_word_frame,
text="每章字数:",
font=("Microsoft YaHei", 12)
)
- word_number_label.grid(row=3, column=0, padx=5, pady=5, sticky="e")
+ word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e")
+
word_number_entry = ctk.CTkEntry(
- self.params_frame,
+ ch_word_frame,
textvariable=self.word_number_var,
- width=80,
+ width=60,
font=("Microsoft YaHei", 12)
)
- word_number_entry.grid(row=3, column=1, padx=5, pady=5, sticky="w")
+ word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w")
# 保存路径
filepath_label = ctk.CTkLabel(
@@ -572,10 +612,10 @@ class NovelGeneratorGUI:
text="保存路径:",
font=("Microsoft YaHei", 12)
)
- filepath_label.grid(row=4, column=0, padx=5, pady=5, sticky="e")
-
+ filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e")
+
self.filepath_frame = ctk.CTkFrame(self.params_frame)
- self.filepath_frame.grid(row=4, column=1, padx=5, pady=5, sticky="nsew")
+ self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew")
self.filepath_frame.columnconfigure(0, weight=1)
filepath_entry = ctk.CTkEntry(
@@ -599,14 +639,14 @@ class NovelGeneratorGUI:
text="章节号:",
font=("Microsoft YaHei", 12)
)
- chapter_num_label.grid(row=5, column=0, padx=5, pady=5, sticky="e")
+ 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=5, column=1, padx=5, pady=5, sticky="w")
+ chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w")
# 用户指导
guide_label = ctk.CTkLabel(
@@ -614,7 +654,7 @@ class NovelGeneratorGUI:
text="本章指导:",
font=("Microsoft YaHei", 12)
)
- guide_label.grid(row=6, column=0, padx=5, pady=5, sticky="ne")
+ guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne")
self.user_guide_text = ctk.CTkTextbox(
self.params_frame,
width=200,
@@ -622,9 +662,9 @@ class NovelGeneratorGUI:
wrap="word",
font=("Microsoft YaHei", 12)
)
- self.user_guide_text.grid(row=6, column=1, padx=5, pady=5, sticky="nsew")
+ self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew")
- # ------------------ 其他Tab的构建 ------------------
+ # ------------------ 其他Tab的构建 ------------------
def build_setting_tab(self):
self.setting_tab.rowconfigure(0, weight=0)
self.setting_tab.rowconfigure(1, weight=1)
@@ -874,7 +914,7 @@ class NovelGeneratorGUI:
else:
messagebox.showinfo("提示", "已经是最后一章了。")
- # ------------------ 配置管理 ------------------
+ # ------------------ 配置管理 ------------------
def load_config_btn(self):
cfg = load_config(self.config_file)
if cfg:
@@ -890,16 +930,20 @@ class NovelGeneratorGUI:
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", ""))
- self.num_chapters_var.set(cfg.get("num_chapters", 10))
- self.word_number_var.set(cfg.get("word_number", 3000))
+
+ # ### 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", cfg.get("topic", ""))
+ self.topic_text.insert("0.0", topic_value)
self.log("已加载配置。")
else:
@@ -919,12 +963,13 @@ class NovelGeneratorGUI:
"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.num_chapters_var.get(),
- "word_number": self.word_number_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):
@@ -938,7 +983,7 @@ class NovelGeneratorGUI:
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")
@@ -954,7 +999,12 @@ class NovelGeneratorGUI:
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()
@@ -968,10 +1018,11 @@ class NovelGeneratorGUI:
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.num_chapters_var.get()
- word_number = self.word_number_var.get()
+ 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("开始生成小说设定...")
@@ -1007,7 +1058,7 @@ class NovelGeneratorGUI:
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.num_chapters_var.get()
+ num_chapters = self.safe_get_int(self.num_chapters_var, 10)
temperature = self.temperature_var.get()
self.safe_log("开始生成小说目录...")
@@ -1041,6 +1092,7 @@ class NovelGeneratorGUI:
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():
@@ -1054,8 +1106,8 @@ class NovelGeneratorGUI:
novel_directory_file = os.path.join(filepath, "Novel_directory.txt")
novel_directory = read_file(novel_directory_file)
- chap_num = self.chapter_num_var.get()
- word_number = self.word_number_var.get()
+ 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章文本
@@ -1087,10 +1139,12 @@ class NovelGeneratorGUI:
novel_novel_directory=novel_directory,
filepath=filepath,
- # 传入 Embedding 的专用配置
+ # 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}章草稿生成完成。请在左侧查看或编辑。")
@@ -1124,12 +1178,14 @@ 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.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.chapter_num_var.get()
- word_number = self.word_number_var.get()
+ 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}章...")
finalize_chapter(
@@ -1143,7 +1199,7 @@ class NovelGeneratorGUI:
temperature=temperature,
filepath=filepath,
embedding_base_url=embedding_base_url,
- embedding_api_key=self.embedding_api_key_var.get().strip()
+ embedding_api_key=embedding_api_key
)
self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。")
@@ -1159,7 +1215,7 @@ class NovelGeneratorGUI:
threading.Thread(target=task, daemon=True).start()
- # ------------------ 一致性审校 ------------------
+ # ------------------ 一致性审校 ------------------
def do_consistency_check(self):
"""使用审校Agent对最新章节进行简单一致性或冲突检查"""
filepath = self.filepath_var.get().strip()
@@ -1185,7 +1241,7 @@ class NovelGeneratorGUI:
global_summary = read_file(global_summary_file)
plot_arcs = read_file(plot_arcs_file)
- chap_num = self.chapter_num_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)
@@ -1215,7 +1271,7 @@ class NovelGeneratorGUI:
threading.Thread(target=task, daemon=True).start()
- # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------
+ # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------
def import_knowledge_handler(self):
selected_file = filedialog.askopenfilename(
title="选择要导入的知识库文件",
@@ -1233,7 +1289,7 @@ class NovelGeneratorGUI:
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() # 用于本地化 vectorstore
+ filepath=self.filepath_var.get().strip()
)
self.safe_log("✅ 知识库文件导入完成。")
except Exception:
@@ -1253,8 +1309,10 @@ class NovelGeneratorGUI:
if first_confirm:
second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!")
if second_confirm:
- clear_vector_store(filepath)
- self.log("已清空向量库。")
+ 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()