This commit is contained in:
YILING0013
2025-02-02 18:25:35 +08:00
parent 9822f3b3ff
commit 231575c2da
3 changed files with 186 additions and 143 deletions
+43
View File
@@ -0,0 +1,43 @@
<think>
好,我现在需要帮用户构思小说的第1章大纲,标题是“星河坠落提瓦特”,简述是“穿越与初遇派蒙,危机初现端倪”。首先,我得理解这个标题和简述的意思。看起来这是一部含有玄幻或奇幻元素的小说,可能涉及时间旅行、门派纷争或者某种隐藏的危机。
用户给出的内容里提到要围绕本章标题和简述设计详细大纲,并且用户已经提供了一个示例的结构。所以我需要按照那个格式来写,而不是用数字分点。也就是说,先描述每个部分,然后是主要冲突、出现的角色,他们的动机,暗线推进,以及结尾的悬念。
首先,本章标题“星河坠落提瓦特”,听起来像是主角从一个繁荣的地方坠落到另一个世界。简述说的是穿越和初遇派蒙,同时危机开始显现。这可能意味着主角从一个平行世界穿越到这个世界,并在门派之间初次相遇,引发了一些危机。
接下来,我需要构思主要冲突。可能是主角到达提瓦特城,发现这里有一个古老的力量或秘密,门派之间开始竞争,而主角作为穿越者,带来了改变,同时也面临着被追杀的风险。这样既呼应了标题,又体现了简述中的危机初现。
然后是角色部分。可能需要包括主角、对方的门派成员,比如派蒙,以及提瓦特城里的其他关键人物。每个角色在这章里有什么目标和动机,比如追杀主角的人,或者试图掌控力量的人。
接下来是推动暗线和悬念的部分。比如,提瓦特城的秘密可能涉及到星河的力量,而派蒙门派对这个力量的争夺可能引发更大的冲突。同时,主角可能带有未知的任务或使命,这会影响后续章节。
最后是结尾的悬念,可能是一个转折点,比如主角揭示了某个秘密,或者派蒙开始怀疑自己,而星河的力量在台面上或下面开始显现,为后续剧情埋下伏笔。
整体来看,我需要确保大纲紧扣标题和简述,同时为后续章节留有余地。可能还要注意角色的性格发展和潜在冲突,以保持故事的吸引力。
</think>
### 第 1 章:星河坠落提瓦特
#### 主要冲突:
- **星河降临**:穿越者从一个繁荣世纪跨越到提瓦特大陆,带来了“星河”般的强大力量,引发了提瓦特城内外的关注与恐慌。
- **门派初遇**:派蒙门派首领派长派出追杀队伍,试图抓住这股未知力量,以掌控“星河之力”。
- **危机初现**:一场隐藏已久的秘密势力开始苏醒,在提瓦特城内外的阴谋逐渐浮现,为后续的灾难埋下伏笔。
#### 角色出现:
1. **主角(未命名)**:穿越者,带着星河般的强大力量,性格冷静果敢,却对自己的来历一无所知。
2. **派长**:派蒙门派领袖,精通古老法术,眼光独到,对“星河之力”嗜求不已。
3. **追杀队伍**:派长派出的十名绝技高手,打着门派的旗号,在提瓦特城内外肆虐。
4. **提瓦特市民**:生活在平静世代的普通百姓,见证并参与这场突如其来的震撼。
5. **神秘老者**:提瓦特城中的隐世高人,对“星河之力”知之甚详,暗中观察与等待。
#### 推动暗线:
- **星河力量的真相**:主角身上蕴含的力量竟是千年前就被预言的“星河之力”,而提瓦特城内外的秘密势力对此虎视眈眈。
- **派蒙门派的野心**:派长不仅想要获取“星河之力”,还暗藏着更大的图谋,试图借此重塑整个世界。
- **神秘老者的警告**:一位隐世高人试图接触主角,给出“星河之力”与世界危机的隐喻,为后续剧情埋下重大伏笔。
#### 结尾悬念:
- **力量暴露**:在一次追杀中,主角无意间释放出“星河之力”,震撼整个提瓦特城,引发更大的关注与恐慌。
- **派长的挣扎**:派长对自己的能力和未来感到迷茫,开始怀疑自己是否已经被力量的猎头所误导。
- **神秘老者的突然消失**:一位神秘老者在关键时刻悄然离去,为后续追杀埋下更多谜团。
通过这场开篇的震撼,未命名的主角将从一个普通穿越者逐渐成长为“星河”的继承者,而提瓦特城与整个世界也将迎来一场前所未有的巨变。
+108 -103
View File
@@ -4,17 +4,12 @@ import os
import logging import logging
import re import re
from typing import Dict, List, Optional from typing import Dict, List, Optional
try: from typing import TypedDict
from typing import TypedDict
except ImportError:
from typing_extensions import TypedDict
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END from langgraph.graph import StateGraph, START, END
from langchain_openai import OpenAIEmbeddings from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma from langchain_community.vectorstores import Chroma
from langchain.docstore.document import Document from langchain.docstore.document import Document
import nltk import nltk
import math import math
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
@@ -34,15 +29,15 @@ from embedding_ollama import OllamaEmbeddings
from chapter_directory_parser import get_chapter_info_from_directory from chapter_directory_parser import get_chapter_info_from_directory
# ============ 日志配置 ============ # ============ 日志配置 ============
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
def debug_log(prompt: str, response_content: str): def debug_log(prompt: str, response_content: str):
"""打印Prompt与Response,可根据需要保留或去掉。""" logging.info(f"\n[Prompt >>>] {prompt}\n")
logging.info(f"\n[Prompt >>>] {prompt}\n") logging.info(f"[Response >>>] {response_content}\n")
logging.info(f"[Response >>>] {response_content}\n")
# ============ 判断接口格式相关 ============
# ============ 接口判断函数 ============
def is_using_ollama_api(interface_format: str, base_url: str) -> bool: def is_using_ollama_api(interface_format: str, base_url: str) -> bool:
""" """
当 interface_format == "Ollama" 时返回 True 当 interface_format == "Ollama" 时返回 True
@@ -60,6 +55,8 @@ def is_using_ml_studio_api(interface_format: str, base_url: str) -> bool:
return False return False
# ============ 创建 Embeddings 对象 ============
def create_embeddings_object( def create_embeddings_object(
api_key: str, api_key: str,
base_url: str, base_url: str,
@@ -72,27 +69,25 @@ def create_embeddings_object(
- 当 interface_format = "Ollama" => OllamaEmbeddings(...) - 当 interface_format = "Ollama" => OllamaEmbeddings(...)
(此时把 embed_url 中的 /v1 替换成 /api,以便最后调用 /api/embed (此时把 embed_url 中的 /v1 替换成 /api,以便最后调用 /api/embed
- 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings - 当 interface_format = "OpenAI" or "ML Studio" => OpenAIEmbeddings
- 其它情况可自行扩展 - 其它情况视需求可扩展
""" """
if is_using_ollama_api(interface_format, embed_url): if is_using_ollama_api(interface_format, embed_url):
# 去除末尾斜杠
fixed_url = embed_url.rstrip("/") fixed_url = embed_url.rstrip("/")
# 如果包含 /v1 则替换为 /api
fixed_url = fixed_url.replace("/v1", "/api") fixed_url = fixed_url.replace("/v1", "/api")
return OllamaEmbeddings( return OllamaEmbeddings(
model_name=embedding_model_name, model_name=embedding_model_name,
base_url=fixed_url base_url=fixed_url
) )
elif is_using_ml_studio_api(interface_format, base_url): elif is_using_ml_studio_api(interface_format, base_url):
# ML Studio / OpenAI 兼容
return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url)
else: else:
# 默认使用 OpenAIEmbeddings # 默认使用 OpenAIEmbeddings
return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url) return OpenAIEmbeddings(openai_api_key=api_key, openai_api_base=base_url)
# ============ 日志配置 ============
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
# ============ 向量库相关 ============ # ============ 向量库相关 ============
VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore") VECTOR_STORE_DIR = os.path.join(os.getcwd(), "vectorstore")
if not os.path.exists(VECTOR_STORE_DIR): if not os.path.exists(VECTOR_STORE_DIR):
os.makedirs(VECTOR_STORE_DIR) os.makedirs(VECTOR_STORE_DIR)
@@ -102,8 +97,8 @@ def clear_vector_store():
清空本地向量库(删除 vectorstore 文件夹内的内容)。 清空本地向量库(删除 vectorstore 文件夹内的内容)。
""" """
if os.path.exists(VECTOR_STORE_DIR): if os.path.exists(VECTOR_STORE_DIR):
import shutil
try: try:
import shutil
for filename in os.listdir(VECTOR_STORE_DIR): for filename in os.listdir(VECTOR_STORE_DIR):
file_path = os.path.join(VECTOR_STORE_DIR, filename) file_path = os.path.join(VECTOR_STORE_DIR, filename)
if os.path.isfile(file_path) or os.path.islink(file_path): if os.path.isfile(file_path) or os.path.islink(file_path):
@@ -126,7 +121,6 @@ def init_vector_store(
) -> Chroma: ) -> Chroma:
""" """
初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。 初始化并返回一个Chroma向量库,将传入的文本进行嵌入并保存到本地目录。
embedding_base_url 若不为空,则用于 Ollama 模式下;否则默认使用 base_url。
""" """
embed_url = embedding_base_url if embedding_base_url else base_url embed_url = embedding_base_url if embedding_base_url else base_url
embeddings = create_embeddings_object( embeddings = create_embeddings_object(
@@ -156,6 +150,7 @@ def load_vector_store(
读取已存在的向量库。若不存在则返回 None。 读取已存在的向量库。若不存在则返回 None。
""" """
if not os.path.exists(VECTOR_STORE_DIR): if not os.path.exists(VECTOR_STORE_DIR):
logging.info("Vector store not found. Initializing a new one...")
return None return None
embed_url = embedding_base_url if embedding_base_url else base_url embed_url = embedding_base_url if embedding_base_url else base_url
embeddings = create_embeddings_object( embeddings = create_embeddings_object(
@@ -185,8 +180,10 @@ def update_vector_store(
embedding_model_name=embedding_model_name, embedding_model_name=embedding_model_name,
embedding_base_url=embedding_base_url embedding_base_url=embedding_base_url
) )
# 如果向量库不存在,初始化它
if not store: if not store:
logging.info("Vector store does not exist. Initializing a new one...") logging.info("Vector store does not exist. Initializing a new one for new chapter...")
init_vector_store( init_vector_store(
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
@@ -200,6 +197,7 @@ def update_vector_store(
new_doc = Document(page_content=new_chapter) new_doc = Document(page_content=new_chapter)
store.add_documents([new_doc]) store.add_documents([new_doc])
store.persist() store.persist()
logging.info("Vector store updated with the new chapter.")
def get_relevant_context_from_vector_store( def get_relevant_context_from_vector_store(
api_key: str, api_key: str,
@@ -212,7 +210,7 @@ def get_relevant_context_from_vector_store(
) -> str: ) -> str:
""" """
从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。
若向量库不存在则返回空字符串。 若向量库不存在或没有足够的内容,则返回空字符串。
""" """
store = load_vector_store( store = load_vector_store(
api_key=api_key, api_key=api_key,
@@ -221,10 +219,19 @@ def get_relevant_context_from_vector_store(
embedding_model_name=embedding_model_name, embedding_model_name=embedding_model_name,
embedding_base_url=embedding_base_url embedding_base_url=embedding_base_url
) )
# 如果向量库为空,直接返回空字符串
if not store: if not store:
logging.warning("Vector store not found. Returning empty context.") logging.info("No vector store found. Returning empty context.")
return "" return ""
# 向量库存在,但没有足够的内容时也避免索引错误
docs = store.similarity_search(query, k=k) docs = store.similarity_search(query, k=k)
if not docs:
logging.info(f"No relevant documents found for query '{query}'. Returning empty context.")
return ""
combined = "\n".join([d.page_content for d in docs]) combined = "\n".join([d.page_content for d in docs])
return combined return combined
@@ -256,7 +263,6 @@ def Novel_novel_directory_generate(
""" """
使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。 使用多步流程,生成 Novel_setting.txt 与 Novel_directory.txt 并保存到 filepath。
""" """
# 确保文件夹存在
os.makedirs(filepath, exist_ok=True) os.makedirs(filepath, exist_ok=True)
model = ChatOpenAI( model = ChatOpenAI(
@@ -327,7 +333,6 @@ def Novel_novel_directory_generate(
debug_log(prompt, response.content) debug_log(prompt, response.content)
return {"novel_directory": response.content.strip()} return {"novel_directory": response.content.strip()}
# 构建状态图
graph = StateGraph(OverallState) graph = StateGraph(OverallState)
graph.add_node("generate_base_setting", generate_base_setting) graph.add_node("generate_base_setting", generate_base_setting)
graph.add_node("generate_character_setting", generate_character_setting) graph.add_node("generate_character_setting", generate_character_setting)
@@ -363,7 +368,6 @@ def Novel_novel_directory_generate(
logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。") logging.warning("生成失败:缺少 final_novel_setting 或 novel_directory。")
return return
# 写入文件
filename_set = os.path.join(filepath, "Novel_setting.txt") filename_set = os.path.join(filepath, "Novel_setting.txt")
filename_novel_directory = os.path.join(filepath, "Novel_directory.txt") filename_novel_directory = os.path.join(filepath, "Novel_directory.txt")
@@ -375,7 +379,6 @@ def Novel_novel_directory_generate(
append_text_to_file(final_novel_setting_cleaned, filename_set) append_text_to_file(final_novel_setting_cleaned, filename_set)
append_text_to_file(final_novel_directory_cleaned, filename_novel_directory) append_text_to_file(final_novel_directory_cleaned, filename_novel_directory)
logging.info("Novel settings and directory generated successfully.") logging.info("Novel settings and directory generated successfully.")
@@ -394,19 +397,25 @@ def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int
text = read_file(chap_file).strip() text = read_file(chap_file).strip()
if text: if text:
texts.append(text) texts.append(text)
if len(texts) < n:
texts = [''] * (n - len(texts)) + texts
return texts return texts
def summarize_recent_chapters( def summarize_recent_chapters(
llm_model: str, llm_model: str,
api_key: str, api_key: str,
base_url: str, base_url: str,
temperature: float, temperature: float,
chapters_text_list: List[str] chapters_text_list: List[str]
) -> str: ) -> str:
""" """
将最近几章文本拼接,通过模型生成一个相对详细的“短期内容摘要”。 将最近几章文本拼接,通过模型生成相对简要的“短期内容摘要”。
如果没有可用的模型(model=None),则退化为简单截断示例。
""" """
if not chapters_text_list:
return ""
if chapters_text_list==['', '', '']:
return "暂无摘要。"
model = ChatOpenAI( model = ChatOpenAI(
model=llm_model, model=llm_model,
api_key=api_key, api_key=api_key,
@@ -414,33 +423,19 @@ def summarize_recent_chapters(
temperature=temperature temperature=temperature
) )
if not chapters_text_list:
return ""
combined_text = "\n".join(chapters_text_list) combined_text = "\n".join(chapters_text_list)
# 如果未传入model,就做个简单的退化输出 prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本:
if not model:
return f"【摘要-演示】\n{combined_text[:800]}..."
# 构造一个提示词(Prompt),指示模型生成精简摘要
prompt = f"""你是一名资深的长篇小说写作辅助AI。下面是最近几章的合并文本内容:
{combined_text} {combined_text}
你为此文本生成一段简洁扼要的摘要,突出主要剧情进展、角色变化、冲突焦点等要点 用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点"""
1.请用中文输出,不超过500字。
2.仅回复摘要内容,不需要其他信息。
"""
# 调用模型获取摘要
response = model.invoke(prompt) response = model.invoke(prompt)
if not response or not response.content.strip(): if not response or not response.content.strip():
# 若模型无响应或空,返回简单截断 return combined_text[:800] + "..." if len(combined_text) > 800 else combined_text
return f"【摘要-演示】\n{combined_text[:800]}..."
# 返回模型生成的摘要文本
return response.content.strip() return response.content.strip()
# ============ 新增:更新剧情要点/未解决冲突 ============ # ============ 新增:剧情要点/未解决冲突 ============
PLOT_ARCS_PROMPT = """\ PLOT_ARCS_PROMPT = """\
下面是新生成的章节内容: 下面是新生成的章节内容:
@@ -449,9 +444,9 @@ PLOT_ARCS_PROMPT = """\
这里是已记录的剧情要点/未解决冲突(可能为空): 这里是已记录的剧情要点/未解决冲突(可能为空):
{old_plot_arcs} {old_plot_arcs}
请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。
若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。
最终输出一份更新后的剧情要点列表,以帮助后续保持故事整体一致性和悬念延续。 最终输出更新后的剧情要点列表,以帮助后续保持故事整体一致性和悬念延续。
""" """
def update_plot_arcs( def update_plot_arcs(
@@ -462,10 +457,6 @@ def update_plot_arcs(
model_name: str, model_name: str,
temperature: float temperature: float
) -> str: ) -> str:
"""
利用模型分析最新章节文本,提炼或更新“未解决冲突或剧情要点”。
并返回更新后的字符串。
"""
model = ChatOpenAI( model = ChatOpenAI(
model=model_name, model=model_name,
api_key=api_key, api_key=api_key,
@@ -480,7 +471,6 @@ def update_plot_arcs(
if not response: if not response:
logging.warning("update_plot_arcs: No response.") logging.warning("update_plot_arcs: No response.")
return old_plot_arcs return old_plot_arcs
debug_log(prompt, response.content)
return response.content.strip() return response.content.strip()
@@ -499,28 +489,44 @@ def generate_chapter_draft(
word_number: int, word_number: int,
temperature: float, temperature: float,
novel_novel_directory: str, novel_novel_directory: str,
filepath: str filepath: str,
interface_format: str,
embedding_model_name: str,
embedding_base_url: str
) -> str: ) -> str:
""" """
生成当前章节的草稿,不更新全局摘要/角色状态/向量库。 生成当前章节的草稿,不更新全局摘要/角色状态/向量库。
并将生成的内容写到 "chapter_{novel_number}.txt" 覆盖写入。
同时生成 "outline_{novel_number}.txt" 存储大纲内容。
""" """
# 0) 根据 novel_number 从 novel_novel_directory 中获取本章标题及简述 # 根据目录信息获取本章标题、简介
chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number)
chapter_title = chapter_info["chapter_title"] chapter_title = chapter_info["chapter_title"]
chapter_brief = chapter_info["chapter_brief"] chapter_brief = chapter_info["chapter_brief"]
# 1) 从向量库检索上下文 (此处仅演示 query="回顾剧情") # 从向量库检索多次上下文(示例:对本章简介、用户指导分别做查询,再合并)
relevant_context = get_relevant_context_from_vector_store( queries = []
api_key=api_key, if user_guidance.strip():
base_url=base_url, queries.append(user_guidance)
query="回顾剧情", if chapter_brief.strip():
interface_format="OpenAI", queries.append(chapter_brief)
embedding_model_name="", # 也可加一句“回顾剧情”之类
embedding_base_url="", queries.append("回顾剧情")
k=2
) relevant_context = ""
for q in queries:
partial_context = get_relevant_context_from_vector_store(
api_key=api_key,
base_url=base_url,
query=q,
interface_format=interface_format,
embedding_model_name=embedding_model_name,
embedding_base_url=embedding_base_url,
k=2
)
if partial_context.strip():
relevant_context += "\n" + partial_context
# 如果检索结果为空,使用默认值(如空字符串)
if not relevant_context:
relevant_context = "暂无相关内容。"
model = ChatOpenAI( model = ChatOpenAI(
model=model_name, model=model_name,
@@ -529,10 +535,10 @@ def generate_chapter_draft(
temperature=temperature temperature=temperature
) )
# 2) 生成大纲 # 1) 生成本章大纲
outline_prompt_text = chapter_outline_prompt.format( outline_prompt_text = chapter_outline_prompt.format(
novel_setting=novel_settings, novel_setting=novel_settings,
character_state=character_state + "\n\n历史上下文】\n" + relevant_context, character_state=character_state + "\n\n检索到的上下文】\n" + relevant_context,
global_summary=global_summary, global_summary=global_summary,
novel_number=novel_number, novel_number=novel_number,
chapter_title=chapter_title, chapter_title=chapter_title,
@@ -550,10 +556,10 @@ def generate_chapter_draft(
clear_file_content(outline_file) clear_file_content(outline_file)
save_string_to_txt(chapter_outline, outline_file) save_string_to_txt(chapter_outline, outline_file)
# 3) 生成正文草稿 # 2) 生成正文草稿
writing_prompt_text = chapter_write_prompt.format( writing_prompt_text = chapter_write_prompt.format(
novel_setting=novel_settings, novel_setting=novel_settings,
character_state=character_state + "\n\n历史上下文】\n" + relevant_context, character_state=character_state + "\n\n检索到的上下文】\n" + relevant_context,
global_summary=global_summary, global_summary=global_summary,
chapter_outline=chapter_outline, chapter_outline=chapter_outline,
word_number=word_number, word_number=word_number,
@@ -588,13 +594,12 @@ def finalize_chapter(
): ):
""" """
对当前章节进行定稿: 对当前章节进行定稿:
1. 读取 chapter_{novel_number}.txt 的最终内容; 1. 读取草稿文本
2. 更新全局摘要、角色状态文件; 2. 若字数太短则再次扩写
3. 如果字数明显少于 word_number 的 80%,则自动调用 enrich_chapter_text 再次扩写; 3. 更新全局摘要、角色状态
4. 更新向量库; 4. 更新剧情要点
5. 新增:更新剧情要点/未解决冲突 -> plot_arcs.txt 5. 更新向量库
""" """
# 读取当前章节内容
chapters_dir = os.path.join(filepath, "chapters") chapters_dir = os.path.join(filepath, "chapters")
chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt")
chapter_text = read_file(chapter_file).strip() chapter_text = read_file(chapter_file).strip()
@@ -610,9 +615,9 @@ def finalize_chapter(
old_global_summary = read_file(global_summary_file) old_global_summary = read_file(global_summary_file)
old_plot_arcs = read_file(plot_arcs_file) old_plot_arcs = read_file(plot_arcs_file)
# 1) 若字数明显不足,做 enrich # 若篇幅过短,二次扩写
if len(chapter_text) < 0.8 * word_number: if len(chapter_text) < 0.8 * word_number:
logging.info("Chapter text seems shorter than 80% of desired length. Attempting to enrich content...") logging.info("Chapter text is shorter than 80% of desired length. Enriching...")
chapter_text = enrich_chapter_text( chapter_text = enrich_chapter_text(
chapter_text=chapter_text, chapter_text=chapter_text,
word_number=word_number, word_number=word_number,
@@ -623,9 +628,8 @@ def finalize_chapter(
) )
clear_file_content(chapter_file) clear_file_content(chapter_file)
save_string_to_txt(chapter_text, chapter_file) save_string_to_txt(chapter_text, chapter_file)
logging.info("Chapter text has been enriched and updated.")
# 2) 更新全局摘要 # 更新全局摘要
model = ChatOpenAI( model = ChatOpenAI(
model=model_name, model=model_name,
api_key=api_key, api_key=api_key,
@@ -643,7 +647,7 @@ def finalize_chapter(
new_global_summary = update_global_summary(chapter_text, old_global_summary) new_global_summary = update_global_summary(chapter_text, old_global_summary)
# 3) 更新角色状态 # 更新角色状态
def update_character_state(chapter_text: str, old_state: str) -> str: def update_character_state(chapter_text: str, old_state: str) -> str:
prompt = update_character_state_prompt.format( prompt = update_character_state_prompt.format(
chapter_text=chapter_text, chapter_text=chapter_text,
@@ -654,7 +658,7 @@ def finalize_chapter(
new_char_state = update_character_state(chapter_text, old_char_state) new_char_state = update_character_state(chapter_text, old_char_state)
# 4) 更新剧情要点 # 更新剧情要点
new_plot_arcs = update_plot_arcs( new_plot_arcs = update_plot_arcs(
chapter_text=chapter_text, chapter_text=chapter_text,
old_plot_arcs=old_plot_arcs, old_plot_arcs=old_plot_arcs,
@@ -664,7 +668,7 @@ def finalize_chapter(
temperature=temperature temperature=temperature
) )
# 5) 覆盖写入文件 # 写回文件
clear_file_content(character_state_file) clear_file_content(character_state_file)
save_string_to_txt(new_char_state, character_state_file) save_string_to_txt(new_char_state, character_state_file)
@@ -674,7 +678,7 @@ def finalize_chapter(
clear_file_content(plot_arcs_file) clear_file_content(plot_arcs_file)
save_string_to_txt(new_plot_arcs, plot_arcs_file) save_string_to_txt(new_plot_arcs, plot_arcs_file)
# 6) 更新向量库 # 更新向量库
update_vector_store( update_vector_store(
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
@@ -695,7 +699,6 @@ def enrich_chapter_text(
) -> str: ) -> str:
""" """
当章节篇幅不足时,调用此函数对章节文本进行二次扩写。 当章节篇幅不足时,调用此函数对章节文本进行二次扩写。
可以让模型补充场景描写、角色心理等,保证与现有文本风格一致。
""" """
model = ChatOpenAI( model = ChatOpenAI(
model=model_name, model=model_name,
@@ -713,20 +716,21 @@ def enrich_chapter_text(
return chapter_text return chapter_text
return response.content.strip() return response.content.strip()
# ============ 导入外部知识文本 ============ # ============ 导入外部知识文本 ============
def import_knowledge_file( def import_knowledge_file(
api_key: str, api_key: str,
base_url: str, base_url: str,
interface_format: str, interface_format: str,
embedding_model_name: str, embedding_model_name: str,
file_path: str, file_path: str,
embedding_base_url: str = "" embedding_base_url: str = ""
) -> None: ) -> None:
""" """
将用户选定的文本文件导入到向量库,以便在写作时检索。 将用户选定的文本文件导入到向量库,以便在写作时检索。
""" """
logging.info(f"开始导入知识库文件: {file_path},当前接口格式: {interface_format},当前模型: {embedding_model_name}") logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {interface_format}, 模型: {embedding_model_name}")
if not os.path.exists(file_path): if not os.path.exists(file_path):
logging.warning(f"知识库文件不存在: {file_path}") logging.warning(f"知识库文件不存在: {file_path}")
return return
@@ -760,11 +764,12 @@ def advanced_split_content(content: str,
similarity_threshold: float = 0.7, similarity_threshold: float = 0.7,
max_length: int = 500) -> List[str]: max_length: int = 500) -> List[str]:
""" """
将文本先按句子切分,然后根据语义相似度进行合并,最后根据max_length进行二次切分。 将文本先按句子切分,然后根据语义相似度进行合并,最后max_length二次切分。
""" """
nltk.download('punkt_tab', quiet=True) # 纠正下载punkt包:'punkt' 而非 'punkt_tab'
sentences = nltk.sent_tokenize(content) nltk.download('punkt', quiet=True)
sentences = nltk.sent_tokenize(content)
if not sentences: if not sentences:
return [] return []
+32 -37
View File
@@ -1,11 +1,12 @@
# ui.py # ui.py
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging
import os import os
import threading import threading
import customtkinter as ctk import customtkinter as ctk
from tkinter import filedialog, messagebox from tkinter import filedialog, messagebox
import traceback
from config_manager import load_config, save_config from config_manager import load_config, save_config
from utils import read_file, save_string_to_txt from utils import read_file, save_string_to_txt
from novel_generator import ( from novel_generator import (
@@ -19,6 +20,12 @@ from novel_generator import (
) )
from consistency_checker import check_consistency from consistency_checker import check_consistency
def log_error(message: str):
"""
用于打印详细的错误信息和堆栈信息。
"""
logging.error(f"{message}\n{traceback.format_exc()}")
# 设置全局主题和颜色 # 设置全局主题和颜色
ctk.set_appearance_mode("System") ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue") ctk.set_default_color_theme("blue")
@@ -38,7 +45,6 @@ class NovelGeneratorGUI:
self.loaded_config = load_config(self.config_file) self.loaded_config = load_config(self.config_file)
# ========== 主要的属性变量 ========== # ========== 主要的属性变量 ==========
# 注意:配置项较多,此处增加 embedding_model_name_var 以支持 Embedding 模型名称
self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", ""))
self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1"))
self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI")) self.interface_format_var = ctk.StringVar(value=self.loaded_config.get("interface_format", "OpenAI"))
@@ -128,8 +134,6 @@ class NovelGeneratorGUI:
# 日志:只读 # 日志:只读
self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12))
self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) self.log_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5))
# 设置只读
self.log_text.configure(state="disabled") self.log_text.configure(state="disabled")
def build_right_layout(self): def build_right_layout(self):
@@ -137,8 +141,6 @@ class NovelGeneratorGUI:
右侧用于显示配置与功能按钮。 右侧用于显示配置与功能按钮。
其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。 其中配置被拆分到一个子 TabView:AI接口配置 和 Embedding配置。
下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。 下面再放与小说相关的输入(主题/类型/章节数等)与功能按钮。
另:我们只使用 2 列布局,0 列放标签,1 列放输入框或子区域,保证对齐。
""" """
self.right_frame.grid_rowconfigure(0, weight=0) self.right_frame.grid_rowconfigure(0, weight=0)
self.right_frame.grid_rowconfigure(1, weight=0) self.right_frame.grid_rowconfigure(1, weight=0)
@@ -172,14 +174,13 @@ class NovelGeneratorGUI:
- 模型名称 - 模型名称
- Temperature - Temperature
""" """
# 配置网格
for i in range(5): for i in range(5):
self.ai_config_tab.grid_rowconfigure(i, weight=0) self.ai_config_tab.grid_rowconfigure(i, weight=0)
self.ai_config_tab.grid_columnconfigure(0, weight=0) self.ai_config_tab.grid_columnconfigure(0, weight=0)
self.ai_config_tab.grid_columnconfigure(1, weight=1) self.ai_config_tab.grid_columnconfigure(1, weight=1)
# 回调:当接口格式下拉框发生变更时,如果 Base URL 为空,则根据接口类型自动填默认值
def on_interface_format_changed(new_value): def on_interface_format_changed(new_value):
# 如果用户切换接口格式,可根据需要修改BaseURL为默认值
if new_value == "Ollama": if new_value == "Ollama":
self.base_url_var.set("http://localhost:11434/v1") self.base_url_var.set("http://localhost:11434/v1")
elif new_value == "ML Studio": elif new_value == "ML Studio":
@@ -263,7 +264,6 @@ class NovelGeneratorGUI:
def build_novel_params_area(self, start_row=2): def build_novel_params_area(self, start_row=2):
""" """
放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等 放置与小说本身相关的一些配置:主题、类型、章节数、文件路径等
只使用两列:0列放Label,1列放输入框(可能加子Frame)。
""" """
# 主题(Topic) # 主题(Topic)
topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) topic_label = ctk.CTkLabel(self.right_frame, text="主题(Topic):", font=("Microsoft YaHei", 12))
@@ -291,7 +291,7 @@ class NovelGeneratorGUI:
word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12)) word_number_entry = ctk.CTkEntry(self.right_frame, textvariable=self.word_number_var, width=80, font=("Microsoft YaHei", 12))
word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w") word_number_entry.grid(row=start_row+3, column=1, padx=5, pady=5, sticky="w")
# 保存路径 (使用子Frame放置 Entry + Button) # 保存路径
filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12)) filepath_label = ctk.CTkLabel(self.right_frame, text="保存路径:", font=("Microsoft YaHei", 12))
filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e") filepath_label.grid(row=start_row+4, column=0, padx=5, pady=5, sticky="e")
@@ -320,11 +320,8 @@ class NovelGeneratorGUI:
""" """
主要功能按钮 + 配置保存/加载 按钮 主要功能按钮 + 配置保存/加载 按钮
""" """
# 调整下布局:0,1列
# 这里按行摆放多个按钮
self.right_frame.grid_rowconfigure(start_row, weight=0) self.right_frame.grid_rowconfigure(start_row, weight=0)
# 先放“保存配置”和“加载配置”按钮
self.btn_frame_config = ctk.CTkFrame(self.right_frame) self.btn_frame_config = ctk.CTkFrame(self.right_frame)
self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew") self.btn_frame_config.grid(row=start_row, column=0, columnspan=2, padx=5, pady=5, sticky="ew")
self.btn_frame_config.columnconfigure(0, weight=1) self.btn_frame_config.columnconfigure(0, weight=1)
@@ -336,7 +333,6 @@ class NovelGeneratorGUI:
load_config_btn = ctk.CTkButton(self.btn_frame_config, text="加载配置", command=self.load_config_btn, font=("Microsoft YaHei", 12)) load_config_btn = ctk.CTkButton(self.btn_frame_config, text="加载配置", command=self.load_config_btn, font=("Microsoft YaHei", 12))
load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="ew") load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
# 下面是主要功能按钮
self.btn_generate_full = ctk.CTkButton( self.btn_generate_full = ctk.CTkButton(
self.right_frame, text="Step1. 生成设定 & 目录", self.right_frame, text="Step1. 生成设定 & 目录",
command=self.generate_full_novel, command=self.generate_full_novel,
@@ -389,9 +385,6 @@ class NovelGeneratorGUI:
# ------------------ Novel Settings Tab ------------------ # ------------------ Novel Settings Tab ------------------
def build_setting_tab(self): def build_setting_tab(self):
"""
可查看/编辑 Novel_setting.txt 并保存
"""
self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(0, weight=0)
self.setting_tab.rowconfigure(1, weight=1) self.setting_tab.rowconfigure(1, weight=1)
self.setting_tab.columnconfigure(0, weight=1) self.setting_tab.columnconfigure(0, weight=1)
@@ -430,9 +423,6 @@ class NovelGeneratorGUI:
# ------------------ Novel Directory Tab ------------------ # ------------------ Novel Directory Tab ------------------
def build_directory_tab(self): def build_directory_tab(self):
"""
可查看/编辑 Novel_directory.txt 并保存
"""
self.directory_tab.rowconfigure(0, weight=0) self.directory_tab.rowconfigure(0, weight=0)
self.directory_tab.rowconfigure(1, weight=1) self.directory_tab.rowconfigure(1, weight=1)
self.directory_tab.columnconfigure(0, weight=1) self.directory_tab.columnconfigure(0, weight=1)
@@ -471,9 +461,6 @@ class NovelGeneratorGUI:
# ------------------ Character State Tab ------------------ # ------------------ Character State Tab ------------------
def build_character_tab(self): def build_character_tab(self):
"""
查看/编辑 character_state.txt
"""
self.character_tab.rowconfigure(0, weight=0) self.character_tab.rowconfigure(0, weight=0)
self.character_tab.rowconfigure(1, weight=1) self.character_tab.rowconfigure(1, weight=1)
self.character_tab.columnconfigure(0, weight=1) self.character_tab.columnconfigure(0, weight=1)
@@ -512,9 +499,6 @@ class NovelGeneratorGUI:
# ------------------ Global Summary Tab ------------------ # ------------------ Global Summary Tab ------------------
def build_summary_tab(self): def build_summary_tab(self):
"""
查看/编辑 global_summary.txt
"""
self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(0, weight=0)
self.summary_tab.rowconfigure(1, weight=1) self.summary_tab.rowconfigure(1, weight=1)
self.summary_tab.columnconfigure(0, weight=1) self.summary_tab.columnconfigure(0, weight=1)
@@ -603,11 +587,9 @@ class NovelGeneratorGUI:
# ------------------ 日志输出 ------------------ # ------------------ 日志输出 ------------------
def log(self, message: str): def log(self, message: str):
# 临时切换为可写
self.log_text.configure(state="normal") self.log_text.configure(state="normal")
self.log_text.insert("end", message + "\n") self.log_text.insert("end", message + "\n")
self.log_text.see("end") self.log_text.see("end")
# 再切回只读
self.log_text.configure(state="disabled") self.log_text.configure(state="disabled")
# ------------------ 功能区 -------------------- # ------------------ 功能区 --------------------
@@ -650,6 +632,7 @@ class NovelGeneratorGUI:
) )
self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。") self.log("✅ 小说设定和目录生成完成。查看 Novel_setting.txt 和 Novel_directory.txt。")
except Exception as e: except Exception as e:
logging.error(f"生成小说设定 & 目录时出错: {e}")
self.log(f"❌ 生成小说设定 & 目录时出错: {e}") self.log(f"❌ 生成小说设定 & 目录时出错: {e}")
finally: finally:
self.enable_button(self.btn_generate_full) self.enable_button(self.btn_generate_full)
@@ -693,10 +676,13 @@ class NovelGeneratorGUI:
chapters_dir = os.path.join(filepath, "chapters") chapters_dir = os.path.join(filepath, "chapters")
recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3)
# 简易生成最近章节摘要(示例) # 生成最近章节摘要
recent_chapters_summary = summarize_recent_chapters( recent_chapters_summary = summarize_recent_chapters(
model_name, # 此处仅示例 llm_model=model_name,
recent_3_texts api_key=api_key,
base_url=base_url,
temperature=temperature,
chapters_text_list=recent_3_texts
) )
self.log(f"开始生成第{chap_num}章草稿...") self.log(f"开始生成第{chap_num}章草稿...")
@@ -713,7 +699,10 @@ class NovelGeneratorGUI:
word_number=word_number, word_number=word_number,
temperature=temperature, temperature=temperature,
novel_novel_directory=novel_directory, novel_novel_directory=novel_directory,
filepath=filepath filepath=filepath,
interface_format=self.interface_format_var.get().strip(),
embedding_model_name=self.embedding_model_name_var.get().strip(),
embedding_base_url=self.embedding_url_var.get().strip()
) )
if draft_text: if draft_text:
self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") self.log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。")
@@ -724,6 +713,7 @@ class NovelGeneratorGUI:
self.log("⚠️ 本章草稿生成失败或无内容。") self.log("⚠️ 本章草稿生成失败或无内容。")
except Exception as e: except Exception as e:
logging.error(f"生成章节草稿时出错: {e}")
self.log(f"❌ 生成章节草稿时出错: {e}") self.log(f"❌ 生成章节草稿时出错: {e}")
finally: finally:
self.enable_button(self.btn_generate_chapter) self.enable_button(self.btn_generate_chapter)
@@ -741,6 +731,8 @@ class NovelGeneratorGUI:
model_name = self.model_name_var.get().strip() model_name = self.model_name_var.get().strip()
temperature = self.temperature_var.get() temperature = self.temperature_var.get()
filepath = self.filepath_var.get().strip() filepath = self.filepath_var.get().strip()
interface_format = self.interface_format_var.get().strip()
embedding_model_name = self.embedding_model_name_var.get().strip()
if not filepath: if not filepath:
self.log("请先配置保存文件路径。") self.log("请先配置保存文件路径。")
@@ -755,6 +747,8 @@ class NovelGeneratorGUI:
word_number=word_number, word_number=word_number,
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
interface_format=interface_format,
embedding_model_name=embedding_model_name,
model_name=model_name, model_name=model_name,
temperature=temperature, temperature=temperature,
filepath=filepath filepath=filepath
@@ -769,6 +763,7 @@ class NovelGeneratorGUI:
self.chapter_result.see("end") self.chapter_result.see("end")
except Exception as e: except Exception as e:
logging.error(f"定稿章节时出错: {e}")
self.log(f"❌ 定稿章节时出错: {e}") self.log(f"❌ 定稿章节时出错: {e}")
finally: finally:
self.enable_button(self.btn_finalize_chapter) self.enable_button(self.btn_finalize_chapter)
@@ -801,7 +796,6 @@ class NovelGeneratorGUI:
global_summary = read_file(global_summary_file) global_summary = read_file(global_summary_file)
plot_arcs = read_file(plot_arcs_file) plot_arcs = read_file(plot_arcs_file)
# 获取当前章节文本
chap_num = self.chapter_num_var.get() chap_num = self.chapter_num_var.get()
chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt")
chapter_text = read_file(chap_file) chapter_text = read_file(chap_file)
@@ -826,6 +820,7 @@ class NovelGeneratorGUI:
self.log(result) self.log(result)
except Exception as e: except Exception as e:
logging.error(f"审校时出错: {e}")
self.log(f"❌ 审校时出错: {e}") self.log(f"❌ 审校时出错: {e}")
finally: finally:
self.enable_button(self.btn_check_consistency) self.enable_button(self.btn_check_consistency)
@@ -848,12 +843,13 @@ class NovelGeneratorGUI:
api_key=self.api_key_var.get().strip(), api_key=self.api_key_var.get().strip(),
base_url=self.base_url_var.get().strip(), base_url=self.base_url_var.get().strip(),
interface_format=self.interface_format_var.get().strip(), interface_format=self.interface_format_var.get().strip(),
embedding_base_url=self.embedding_url_var.get().strip(), embedding_model_name=self.embedding_model_name_var.get().strip(),
embedding_base_url=self.embedding_url_var.get().strip(), file_path=selected_file,
file_path=selected_file embedding_base_url=self.embedding_url_var.get().strip()
) )
self.log("✅ 知识库文件导入完成。") self.log("✅ 知识库文件导入完成。")
except Exception as e: except Exception as e:
logging.error(f"导入知识库时出错: {e}")
self.log(f"❌ 导入知识库时出错: {e}") self.log(f"❌ 导入知识库时出错: {e}")
finally: finally:
self.enable_button(self.btn_import_knowledge) self.enable_button(self.btn_import_knowledge)
@@ -886,7 +882,6 @@ class NovelGeneratorGUI:
if not arcs_text: if not arcs_text:
arcs_text = "当前没有记录的剧情要点或冲突。" arcs_text = "当前没有记录的剧情要点或冲突。"
# 弹窗显示
top = ctk.CTkToplevel(self.master) top = ctk.CTkToplevel(self.master)
top.title("剧情要点/未解决冲突") top.title("剧情要点/未解决冲突")
top.geometry("600x400") top.geometry("600x400")