Merge pull request #43 from YILING0013/dev

update
This commit is contained in:
Xianyun
2025-02-04 18:29:11 +08:00
committed by GitHub
5 changed files with 359 additions and 369 deletions
-3
View File
@@ -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(),
+2 -2
View File
@@ -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'
)
+151 -110
View File
@@ -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:
"""移除 <think>...</think> 包裹的内容"""
return re.sub(r'<think>.*?</think>', '', 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:
"""通用封装:调用模型并移除 <think>...</think> 文本,记录日志后返回"""
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("知识库文件已成功导入至向量库。")
+80 -186
View File
@@ -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. 为下章预留引子
建议:
• 通过环境描写渲染氛围
• 展现人物细微情感变化
• 适当运用对话推进情节
• 保持节奏张弛有度
直接输出正文内容,不使用分章节小标题,章节末模仿正常小说中断或转场。
"""
+107 -49
View File
@@ -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")
@@ -53,11 +54,14 @@ 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"))
# 仍然用 DoubleVar,但因为是 Slider,不会让用户手动清空文本,一般不会出现空字符串问题
self.temperature_var = ctk.DoubleVar(value=self.loaded_config.get("temperature", 0.7))
# Embedding 接口相关
@@ -66,14 +70,21 @@ 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 ==========
self.tabview = ctk.CTkTabview(self.master, width=1200, height=800)
@@ -95,11 +106,18 @@ 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 ------------------
def build_main_tab(self):
@@ -144,7 +162,7 @@ class NovelGeneratorGUI:
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")
@@ -163,7 +181,7 @@ class NovelGeneratorGUI:
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")
@@ -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,7 +662,7 @@ 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的构建 ------------------
def build_setting_tab(self):
@@ -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):
@@ -954,6 +999,11 @@ 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"""
@@ -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}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。")
@@ -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)
@@ -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()