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