From d230d4ba239fe990d34c74388f1b500f5c106af5 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 21:12:57 +0800 Subject: [PATCH 01/11] new thinks --- main.spec | 7 +- prompt_definitions.py | 271 ++++++++++++++++++++++++++++-------------- 2 files changed, 183 insertions(+), 95 deletions(-) diff --git a/main.spec b/main.spec index 4694d44..94baffe 100644 --- a/main.spec +++ b/main.spec @@ -14,7 +14,8 @@ hiddenimports = ['typing_extensions', 'pydantic', 'pydantic.deprecated.decorator', 'tiktoken_ext.openai_public', - 'tiktoken_ext' + 'tiktoken_ext', + 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2' ] tmp_ret = collect_all('chromadb') @@ -44,7 +45,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name='AI_NovelGenerator_V1.3.2', + name='AI_NovelGenerator_V1.3.3', debug=True, bootloader_ignore_signals=False, strip=False, @@ -65,5 +66,5 @@ coll = COLLECT( strip=False, upx=True, upx_exclude=[], - name='AI_NovelGenerator_V1.3.2' + name='AI_NovelGenerator_V1.3.3' ) diff --git a/prompt_definitions.py b/prompt_definitions.py index 9e99408..ebe588c 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,86 +1,148 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -集中存放所有提示词(Prompt),新版本更精确、更具创新性,结合部分雪花写作法等理论。 +集中存放所有提示词(Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型 """ -# =============== 1. 整体设定 =================== -set_prompt = """\ -请根据主题: {topic}、类型: {genre}、章数: {number_of_chapters}、每章字数: {word_number} 来设计小说的总体结构和世界观。 -写作时请参考雪花写作法等创作理论,结合以下要点: +# =============== 1. 核心种子设定(雪花第1层)=================== +core_seed_prompt = """\ +作为专业作家,请用"雪花写作法"第一步构建故事核心: +主题:{topic} +类型:{genre} +篇幅:约{number_of_chapters}章(每章{word_number}字) -• 小说标题与大致篇幅(总字数范围)。 -• 类型与基调(如:都市、魔幻、战争、轻松、暗黑等)。 -• 主要写作风格(视角、叙述方式、语言风格等)。 -• 世界观设定(背景时间、地理环境、社会结构、科技或魔法水平、重要历史等)。 -• 整体故事框架(可提及常见叙事结构:三幕、英雄之旅等)。 -• 主线与副线的初步构想,以及它们如何交织。 -• 关键角色群像定位与主要冲突关系。 -• 结局的可能方向(圆满、悲剧、开放式等)。 +请用单句公式概括故事本质,例如: +"当[主角]遭遇[核心事件],必须[关键行动],否则[灾难后果];与此同时,[隐藏的更大危机]正在发酵。" -请以简洁、逻辑清晰的方式输出,保留足够细节以支撑后续创作。 +要求: +1. 必须包含显性冲突与潜在危机 +2. 体现人物核心驱动力 +3. 暗示世界观关键矛盾 +4. 使用25-100字精准表达 + +仅返回故事核心文本,不要解释任何内容。 """ -# =============== 2. 角色设定 =================== -character_prompt = """\ -基于已生成的小说整体设定: -{novel_setting} +# =============== 2. 角色动力学设定(角色弧光模型)=================== +character_dynamics_prompt = """\ +基于核心种子: +{core_seed} -请进一步扩展角色设置,至少包含三名核心角色。对每位角色说明: -• 角色背景、外貌与主要性格特征。 -• 内在冲突、目标与动机。 -• 暗藏的秘密或潜在弱点(可与世界观或其他角色有关)。 -• 与其他角色的关系或对立点,如何推动或阻碍情节发展。 +请设计3-6个具有动态变化潜力的核心角色,每个角色需包含: +特征: +- 背景、外貌、性别、年龄、职业等 +- 暗藏的秘密或潜在弱点(可与世界观或其他角色有关) -请重点突出角色的多重面向,为后续情节埋下伏笔。 +核心驱动力三角: +- 表面追求(物质目标) +- 深层渴望(情感需求) +- 灵魂需求(哲学层面) + +角色弧线设计: +初始状态 → 触发事件 → 认知失调 → 蜕变节点 → 最终状态 + +关系冲突网: +- 与其他角色的关系或对立点 +- 与至少两个其他角色的价值观冲突 +- 一个合作纽带 +- 一个隐藏的背叛可能性 + +要求: +仅给出最终文本,不要解释任何内容。 """ -# =============== 3. 暗线与伏笔 =================== -dark_lines_prompt = """\ -在当前世界观与角色关系中: -{character_info} +# =============== 3. 世界构建矩阵(三维度交织法)=================== +world_building_prompt = """\ +为服务核心冲突"{core_seed}",请构建三维交织的世界观: -请构思若干暗线、伏笔或隐藏冲突,结合雪花写作法“细节逐步扩展”的思路。要求: -• 每条暗线给出初始迹象、后续发展与可能的爆发条件。 -• 与角色背景、世界观或关键事件相呼应。 -• 保持合理的悬念设置,与现有设定不冲突。 -• 为后续剧情保留足够展开空间,强调持续影响故事进程。 +1. 物理维度: +- 空间结构(地理×社会阶层分布图) +- 时间轴(关键历史事件年表) +- 法则体系(物理/魔法/社会规则的漏洞点) -请避免一次性透漏全部细节,以逐步揭示的方式为后文做铺垫。 +2. 社会维度: +- 权力结构断层线(可引发冲突的阶层/种族/组织矛盾) +- 文化禁忌(可被打破的禁忌及其后果) +- 经济命脉(资源争夺焦点) + +3. 隐喻维度: +- 贯穿全书的视觉符号系统(如反复出现的意象) +- 气候/环境变化映射的心理状态 +- 建筑风格暗示的文明困境 + +要求: +每个维度至少包含3个可与角色决策产生互动的动态元素。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 4. 最终设定整合 =================== -finalize_setting_prompt = """\ -基于以下内容,请整合并输出终稿《小说设定》: -1. 整体设定: -{novel_setting_base} -2. 角色设定: -{character_setting} -3. 暗线与伏笔: -{dark_lines} +# =============== 4. 情节架构(悬念三幕式)=================== +plot_architecture_prompt = """\ +基于以下元素构建三幕式悬念架构: +核心种子:{core_seed} +角色体系:{character_dynamics} +世界观:{world_building} -整合要求: -• 以整体视角整理世界观、角色与暗线,形成一个有机结合的故事框架。 -• 注意角色动机与暗线如何与世界观互相呼应,并兼顾主线与副线节奏。 -• 语言通顺、层次分明。直接输出文本,不使用Markdown格式。 +要求按以下结构设计: +▶ 第一幕(触发) +- 日常状态中的异常征兆(3处铺垫) +- 引出故事:展示主线、暗线、副线的开端 +- 关键事件:打破平衡的催化剂(需改变至少3个角色的关系) +- 错误抉择:主角的认知局限导致的错误反应 + +▶ 第二幕(对抗) +- 剧情升级:主线+副线的交叉点 +- 双重压力:外部障碍升级+内部挫折 +- 虚假胜利:看似解决实则深化危机的转折点 +- 灵魂黑夜:世界观认知颠覆时刻 + +▶ 第三幕(解决) +- 代价显现:解决危机必须牺牲的核心价值 +- 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) +- 余波:留下2个开放式悬念因子 + +每个阶段需包含3个关键转折点及其对应的伏笔回收方案。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 5. 小说目录 =================== -novel_directory_prompt = """\ -根据以下最终《小说设定》: -{final_novel_setting} +# =============== 5. 章节目录生成(悬念节奏曲线)=================== +chapter_blueprint_prompt = """\ +根据三幕式架构: +{plot_architecture} -请列出共 {number_of_chapters} 章的目录,并保证每章的标题或简述能呼应主要冲突、角色成长及暗线埋设。 -目录格式示例: -第1章 :<标题> - <简要说明> -第2章 :<标题> - <简要说明> -... -第{number_of_chapters}章 :<标题> - <简要说明> +设计{number_of_chapters}章的节奏分布: +1. 每章需明确: +- 核心悬念类型(信息差/道德困境/时间压力等) +- 情感基调迁移(如从怀疑→恐惧→决绝) +- 伏笔操作(埋设/强化/回收) +- 认知颠覆强度(1-5级) -每章可酌情加入一句简述,引导读者了解本章看点。直接输出文本,不使用Markdown。 +2. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +输出格式示例: +第n章 - [标题] +本章定位:[角色/事件/主题] +核心作用:[推进/转折/揭示] +悬念密度:[紧凑/渐进/爆发] +伏笔操作:埋设(A线索)→强化(B矛盾) +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +第n+1章 - [标题] +本章定位:[角色/事件/主题] +核心作用:[推进/转折/揭示] +悬念密度:[紧凑/渐进/爆发] +伏笔操作:埋设(A线索)→强化(B矛盾) +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +使用精炼语言描述,每章字数控制在100字以内。 +仅给出最终文本,不要解释任何内容。 """ -# =============== 6. 全局摘要更新 =================== +# =============== 6. 全局摘要更新 =================== summary_prompt = """\ 以下是新完成的章节文本: {chapter_text} @@ -88,10 +150,17 @@ summary_prompt = """\ 这是当前的全局摘要(可为空): {global_summary} -请根据本章新增内容,更新全局摘要。保留既有重要信息,同时融入新剧情要点,勿剧透未来。控制在不超过3000字的范围内,语言简练流畅。 +请根据本章新增内容,更新全局摘要。 +要求: +- 保留既有重要信息,同时融入新剧情要点 +- 以简洁、连贯的语言描述全书进展 +- 客观描绘,不展开联想或解释 +- 字数控制在2000字以内 + +仅返回全局摘要文本,不要解释任何内容。 """ -# =============== 7. 角色状态更新 =================== +# =============== 7. 角色状态更新 =================== update_character_state_prompt = """\ 以下是新完成的章节文本: {chapter_text} @@ -100,44 +169,62 @@ update_character_state_prompt = """\ {old_state} 请更新角色状态,内容包括: -• 角色的物品、能力或心理状态变化。 -• 角色间关系的最新进展或冲突。 -• 是否触发或加深了某些暗线或关键事件。 -• 任何新增角色或临时出场人物的基本信息。 +角色属性: +- 角色物品 +- 能力 +- 状态 +- 主要角色间关系网 +- 触发或加深的事件 -请直接在已有文档基础上进行增删,语言尽量简洁、有条理。 +新出场角色: +- 任何新增角色或临时出场人物的基本信息 + +要求: +- 请直接在已有文档基础上进行增删 +- 不改变原有结构,语言尽量简洁、有条理 + +仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 8. 章节大纲设计 =================== -chapter_outline_prompt = """\ -这是当前小说的重要信息: +# =============== 7. 章节正文写作 =================== +scene_dynamics_prompt = """\ +即将创作:第{novel_number}章《{chapter_title}》 +本章定位:{chapter_role} +核心作用:{chapter_purpose} +悬念密度:{suspense_level} +伏笔操作:{foreshadowing} +认知颠覆:{plot_twist_level} +本章简述:{chapter_summary} + +可用元素: +- 核心人物(可能未指定):{characters_involved} +- 关键道具(可能未指定):{key_items} +- 空间坐标(可能未指定):{scene_location} +- 时间压力(可能未指定):{time_constraint} + +参考文档: +- 世界观:{world_building} - 小说设定:{novel_setting} -- 角色状态:{character_state} - 全局摘要:{global_summary} +- 角色状态:{character_state} -我们即将写第 {novel_number} 章,标题:{chapter_title},简述(若有):{chapter_brief} +请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +1. 对话场景: +- 潜台词冲突(表面谈论A,实际博弈B) +- 权力关系变化(使用非对称对话长度控制) +- 至少1处双关语暗示未来危机 -请按照以下思路设计本章大纲: -1. 本章的主要冲突或情节目标,如何与标题呼应。 -2. 出场角色及其动机,对剧情走向的推动或阻碍。 -3. 暗线或伏笔如何有所进展或被揭示。 -4. 本章结尾的悬念或转折,如何为下一章做铺垫。 +2. 动作场景: +- 环境交互细节(至少3个感官描写维度) +- 节奏控制(短句加速+比喻减速) +- 动作揭示人物隐藏特质 -请以简要分点方式输出,不要使用Markdown格式。 -""" +3. 心理场景: +- 认知失调的具体表现(行为矛盾) +- 隐喻系统的运用(连接世界观符号) +- 决策前的价值天平描写 -# =============== 9. 章节正文写作 =================== -chapter_write_prompt = """\ -以下信息供你参考: -1. 小说设定:{novel_setting} -2. 角色状态:{character_state} -3. 全局摘要:{global_summary} -4. 本章大纲:{chapter_outline} +最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 -请写出第 {novel_number} 章的正文,标题为“{chapter_title}”。需要: -• 字数不少于 {word_number} 字,与标题和简述相呼应。 -• 保持连贯叙述,可增加环境、心理、对话等细节描写。 -• 适度呼应前文暗线或角色矛盾,为后续发展留出悬念。 - -直接输出正文内容,不使用分章节小标题,章节末模仿正常小说中断或转场。 -""" +仅返回章节正文文本,不要解释任何内容。 +""" \ No newline at end of file From dd786660717512e2381816bfa585c2db537ea30d Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 21:55:48 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chapter_directory_parser.py | 149 +++- novel_generator copy.py | 830 +++++++++++++++++ novel_generator.py | 409 +++++---- prompt_definitions.py | 4 +- ui.py | 1676 ++++++++++++++--------------------- 5 files changed, 1859 insertions(+), 1209 deletions(-) create mode 100644 novel_generator copy.py diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py index a2c6311..796b9f5 100644 --- a/chapter_directory_parser.py +++ b/chapter_directory_parser.py @@ -1,47 +1,126 @@ -# chapter_directory_parser.py +# chapter_blueprint_parser.py # -*- coding: utf-8 -*- import re -def get_chapter_info_from_directory(novel_directory_content: str, chapter_number: int): +def parse_chapter_blueprint(blueprint_text: str): """ - 从给定的 novel_directory_content 文本中,解析 “第X章” 行,并提取本章的标题和可能的简述。 - 返回一个 dict: { - "chapter_title": <字符串>, - "chapter_brief": <字符串> (若没有则为空) + 解析整份章节蓝图文本,返回一个列表,每个元素是一个 dict: + { + "chapter_number": int, + "chapter_title": str, + "chapter_role": str, # 本章定位 + "chapter_purpose": str, # 核心作用 + "suspense_level": str, # 悬念密度 + "foreshadowing": str, # 伏笔操作 + "plot_twist_level": str, # 认知颠覆 + "chapter_summary": str # 本章简述 } - 注意:目录文本示例格式: - 第1章 :潮起 - 第2章 :阴影浮现 - 主要角色冲突爆发 - ... - 也可能没有简述,只有一个简单标题。 """ - # 将文本逐行拆分 - lines = novel_directory_content.splitlines() + # 先按空行进行分块,以免多章之间混淆 + chunks = re.split(r'\n\s*\n', blueprint_text.strip()) + results = [] - # 章节匹配:形如 “第5章 :xxx” or “第5章: xxx” or “第5章 xxx” - pattern = re.compile(r'^第\s*(\d+)\s*章\s*[::]?\s*(.*)$') + chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[(.*?)\]') # 捕获章号与标题 + role_pattern = re.compile(r'^本章定位:\s*(.*)$') + purpose_pattern = re.compile(r'^核心作用:\s*(.*)$') + suspense_pattern = re.compile(r'^悬念密度:\s*(.*)$') + foreshadow_pattern = re.compile(r'^伏笔操作:\s*(.*)$') + twist_pattern = re.compile(r'^认知颠覆:\s*(.*)$') + summary_pattern = re.compile(r'^本章简述:\s*\[(.*)\]$') - for line in lines: - match = pattern.match(line.strip()) - if match: - chap_num = int(match.group(1)) - if chap_num == chapter_number: - full_title = match.group(2).strip() - if ' - ' in full_title: - parts = full_title.split(' - ', 1) - return { - "chapter_title": parts[0].strip(), - "chapter_brief": parts[1].strip() - } - else: - return { - "chapter_title": full_title, - "chapter_brief": "" - } + for chunk in chunks: + lines = chunk.strip().splitlines() + if not lines: + continue - # 如果没有匹配到,返回默认 + chapter_number = None + chapter_title = "" + chapter_role = "" + chapter_purpose = "" + suspense_level = "" + foreshadowing = "" + plot_twist_level = "" + chapter_summary = "" + + # 先匹配第一行(或前几行),找到章号和标题 + header_match = chapter_number_pattern.match(lines[0].strip()) if lines else None + if not header_match: + # 不符合格式,跳过 + continue + + chapter_number = int(header_match.group(1)) + chapter_title = header_match.group(2).strip() + + # 从后面的行匹配其他字段 + for line in lines[1:]: + line_stripped = line.strip() + if not line_stripped: + continue + + m_role = role_pattern.match(line_stripped) + if m_role: + chapter_role = m_role.group(1).strip() + continue + + m_purpose = purpose_pattern.match(line_stripped) + if m_purpose: + chapter_purpose = m_purpose.group(1).strip() + continue + + m_suspense = suspense_pattern.match(line_stripped) + if m_suspense: + suspense_level = m_suspense.group(1).strip() + continue + + m_foreshadow = foreshadow_pattern.match(line_stripped) + if m_foreshadow: + foreshadowing = m_foreshadow.group(1).strip() + continue + + m_twist = twist_pattern.match(line_stripped) + if m_twist: + plot_twist_level = m_twist.group(1).strip() + continue + + m_summary = summary_pattern.match(line_stripped) + if m_summary: + chapter_summary = m_summary.group(1).strip() + continue + + results.append({ + "chapter_number": chapter_number, + "chapter_title": chapter_title, + "chapter_role": chapter_role, + "chapter_purpose": chapter_purpose, + "suspense_level": suspense_level, + "foreshadowing": foreshadowing, + "plot_twist_level": plot_twist_level, + "chapter_summary": chapter_summary + }) + + # 按照 chapter_number 排序后返回 + results.sort(key=lambda x: x["chapter_number"]) + return results + + +def get_chapter_info_from_blueprint(blueprint_text: str, target_chapter_number: int): + """ + 在已经加载好的章节蓝图文本中,找到对应章号的结构化信息,返回一个 dict。 + 若找不到则返回一个默认的结构。 + """ + all_chapters = parse_chapter_blueprint(blueprint_text) + for ch in all_chapters: + if ch["chapter_number"] == target_chapter_number: + return ch + # 默认返回 return { - "chapter_title": f"第{chapter_number}章", - "chapter_brief": "" + "chapter_number": target_chapter_number, + "chapter_title": f"第{target_chapter_number}章", + "chapter_role": "", + "chapter_purpose": "", + "suspense_level": "", + "foreshadowing": "", + "plot_twist_level": "", + "chapter_summary": "" } diff --git a/novel_generator copy.py b/novel_generator copy.py new file mode 100644 index 0000000..7505948 --- /dev/null +++ b/novel_generator copy.py @@ -0,0 +1,830 @@ +# novel_generator.py +# -*- coding: utf-8 -*- +import os +import logging +import re +import time +import traceback +from typing import List, Optional + +# langchain 相关 +from langchain_openai import ChatOpenAI, OpenAIEmbeddings +from langchain_chroma import Chroma +from chromadb.config import Settings +from langchain.docstore.document import Document + +# nltk、sentence_transformers 及文本处理相关 +import nltk +import math +from sentence_transformers import SentenceTransformer +from sklearn.metrics.pairwise import cosine_similarity + +# 工具函数 +from utils import ( + read_file, append_text_to_file, clear_file_content, + save_string_to_txt +) + +# prompt模板 +from prompt_definitions import ( + # 设定相关 + set_prompt, character_prompt, dark_lines_prompt, + finalize_setting_prompt, novel_directory_prompt, + + # 写作流程相关 + summary_prompt, update_character_state_prompt, + chapter_outline_prompt, chapter_write_prompt +) + +# Ollama嵌入 (如使用Ollama时需要) +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 debug_log(prompt: str, response_content: str): + logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") + logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") + +def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: + """通用封装:调用模型并移除 ... 文本,记录日志后返回""" + response = model.invoke(prompt) + if not response: + logging.warning("No response from model.") + return "" + cleaned_text = remove_think_tags(response.content) + debug_log(prompt, cleaned_text) + return cleaned_text.strip() + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + 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, + interface_format: str, + embedding_model_name: str +): + """ + 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 + """ + 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,注意 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, + model=embedding_model_name + ) + + +# ============ 向量库相关操作 ============ +def clear_vector_store(filepath: str) -> bool: + """ + 返回值表示是否成功清空向量库。 + """ + import shutil + + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("No vector store found to clear.") + return False + + try: + 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"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") + traceback.print_exc() + return False + +def init_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + texts: List[str], + filepath: str +) -> Chroma: + """ + 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + """ + 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, + interface_format=interface_format, + embedding_model_name=embedding_model_name + ) + documents = [Document(page_content=str(t)) for t in texts] + vectorstore = Chroma.from_documents( + documents, + embedding=embeddings, + persist_directory=store_dir, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + return vectorstore + + +def load_vector_store( + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + filepath: str +) -> Optional[Chroma]: + """ + 读取已存在的 Chroma 向量库。若不存在则返回 None。 + """ + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("Vector store not found. Will return None.") + return None + + embeddings = create_embeddings_object( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name + ) + return Chroma( + persist_directory=store_dir, + embedding_function=embeddings, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + + +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, + new_chapter: str, + interface_format: str, + embedding_model_name: str, + filepath: str +): + """ + 将最新章节文本插入到向量库中。若库不存在则初始化。 + """ + 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, + 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 new chapter...") + init_vector_store( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=splitted_texts, + filepath=filepath + ) + return + + 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( + api_key: str, + base_url: str, + query: str, + interface_format: str, + embedding_model_name: 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, + filepath=filepath + ) + if not store: + logging.info("No vector store found. Returning empty context.") + return "" + + 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]) + return combined + + +# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ +def Novel_setting_generate( + api_key: str, + base_url: str, + llm_model: str, + topic: str, + genre: str, + number_of_chapters: int, + word_number: int, + filepath: str, + temperature: float = 0.7 +) -> None: + 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), + temperature=temperature + ) + + # Step1: 基础设定 + prompt_base = set_prompt.format( + topic=topic, + genre=genre, + number_of_chapters=number_of_chapters, + word_number=word_number + ) + base_setting = invoke_with_cleaning(model, prompt_base) + + # Step2: 角色设定 + prompt_char = character_prompt.format( + novel_setting=base_setting + ) + character_setting = invoke_with_cleaning(model, prompt_char) + + # Step3: 暗线/雷点 + prompt_dark = dark_lines_prompt.format( + character_info=character_setting + ) + dark_lines = invoke_with_cleaning(model, prompt_dark) + + # Step4: 最终整合 + prompt_final = finalize_setting_prompt.format( + novel_setting_base=base_setting, + character_setting=character_setting, + dark_lines=dark_lines + ) + final_novel_setting = invoke_with_cleaning(model, prompt_final) + + 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) ============ +def Novel_directory_generate( + api_key: str, + base_url: str, + llm_model: str, + number_of_chapters: int, + filepath: str, + temperature: float = 0.7 +) -> None: + filename_set = os.path.join(filepath, "Novel_setting.txt") + final_novel_setting = read_file(filename_set).strip() + if not final_novel_setting: + logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") + return + + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + prompt_dir = novel_directory_prompt.format( + final_novel_setting=final_novel_setting, + number_of_chapters=number_of_chapters + ) + final_novel_directory = invoke_with_cleaning(model, prompt_dir) + if not final_novel_directory.strip(): + logging.warning("Novel_directory生成结果为空。") + return + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename_dir) + + final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') + save_string_to_txt(final_novel_directory_cleaned, filename_dir) + + logging.info("Novel_directory.txt has been generated successfully.") + + +# ============ 获取最近 N 章内容,生成短期摘要 ============ +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: + texts = [] + start_chap = max(1, current_chapter_num - n) + for c in range(start_chap, current_chapter_num): + chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") + if os.path.exists(chap_file): + text = read_file(chap_file).strip() + if text: + texts.append(text) + if len(texts) < n: + texts = [''] * (n - len(texts)) + texts + return texts + +def summarize_recent_chapters( + llm_model: str, + api_key: str, + base_url: str, + 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): + return "暂无摘要。" + + model = ChatOpenAI( + model=llm_model, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + combined_text = "\n".join(chapters_text_list) + prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: +{combined_text} + +请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" + + summary_text = invoke_with_cleaning(model, prompt) + if not summary_text: + return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text + return summary_text + + +# ============ 剧情要点/冲突 ============ +PLOT_ARCS_PROMPT = """\ +下面是新生成的章节内容: +{chapter_text} + +这里是已记录的剧情要点/未解决冲突(可能为空): +{old_plot_arcs} + +请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 +若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 +最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 +""" + +def update_plot_arcs( + chapter_text: str, + old_plot_arcs: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float +) -> str: + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + prompt = PLOT_ARCS_PROMPT.format( + chapter_text=chapter_text, + old_plot_arcs=old_plot_arcs + ) + arcs_text = invoke_with_cleaning(model, prompt) + if not arcs_text: + logging.warning("update_plot_arcs: No response or empty result.") + return old_plot_arcs + return arcs_text + + +# ============ 生成章节草稿 ============ +def generate_chapter_draft( + novel_settings: str, + global_summary: str, + character_state: str, + recent_chapters_summary: str, + user_guidance: str, + api_key: str, + base_url: str, + model_name: str, + novel_number: int, + word_number: int, + temperature: float, + novel_novel_directory: str, + filepath: str, + interface_format: str, + embedding_model_name: 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"] + + # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) + combined_query_parts = [] + if user_guidance.strip(): + combined_query_parts.append(user_guidance) + if chapter_brief.strip(): + combined_query_parts.append(chapter_brief) + if recent_chapters_summary.strip(): + combined_query_parts.append(recent_chapters_summary) + # 额外加一个关键字 + combined_query_parts.append("回顾剧情") + + 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) 生成本章大纲 + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + + outline_prompt_text = chapter_outline_prompt.format( + novel_setting=novel_settings, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, + global_summary=global_summary, + novel_number=novel_number, + chapter_title=chapter_title, + chapter_brief=chapter_brief + ) + outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" + outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" + + chapter_outline = invoke_with_cleaning(model, outline_prompt_text) + + outlines_dir = os.path.join(filepath, "outlines") + os.makedirs(outlines_dir, exist_ok=True) + outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") + clear_file_content(outline_file) + save_string_to_txt(chapter_outline, outline_file) + + # 4) 生成正文草稿 + writing_prompt_text = chapter_write_prompt.format( + novel_setting=novel_settings, + character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, + global_summary=global_summary, + chapter_outline=chapter_outline, + word_number=word_number, + novel_number=novel_number, + chapter_title=chapter_title, + chapter_brief=chapter_brief + ) + writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" + writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" + + chapter_content = invoke_with_cleaning(model, writing_prompt_text) + + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + clear_file_content(chapter_file) + save_string_to_txt(chapter_content, chapter_file) + + logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") + return chapter_content + + +# ============ 定稿章节 ============ +def finalize_chapter( + novel_number: int, + word_number: int, + api_key: str, + base_url: str, + interface_format: str, + embedding_model_name: str, + model_name: str, + temperature: float, + filepath: str, + embedding_base_url: str, + embedding_api_key: str +): + 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() + if not chapter_text: + logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") + return + + character_state_file = os.path.join(filepath, "character_state.txt") + global_summary_file = os.path.join(filepath, "global_summary.txt") + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") + + old_char_state = read_file(character_state_file) + 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( + chapter_text=chapter_text, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature + ) + clear_file_content(chapter_file) + save_string_to_txt(chapter_text, chapter_file) + + # 更新全局摘要 + model = ChatOpenAI( + model=model_name, + api_key=api_key, + 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, + global_summary=old_summary + ) + return invoke_with_cleaning(model, prompt) or old_summary + + new_global_summary = update_global_summary(chapter_text, old_global_summary) + + # 更新角色状态 + def update_character_state(chapter_text: str, old_state: str) -> str: + prompt = update_character_state_prompt.format( + chapter_text=chapter_text, + old_state=old_state + ) + return invoke_with_cleaning(model, prompt) or old_state + + new_char_state = update_character_state(chapter_text, old_char_state) + + # 更新剧情要点 + new_plot_arcs = update_plot_arcs( + chapter_text=chapter_text, + old_plot_arcs=old_plot_arcs, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature + ) + + # 写回文件 + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) + + clear_file_content(global_summary_file) + save_string_to_txt(new_global_summary, global_summary_file) + + 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=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, + filepath=filepath + ) + + logging.info(f"Chapter {novel_number} has been finalized.") + + +def enrich_chapter_text( + chapter_text: str, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float +) -> str: + model = ChatOpenAI( + model=model_name, + api_key=api_key, + base_url=ensure_openai_base_url_has_v1(base_url), + temperature=temperature + ) + prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 + +原章节内容: +{chapter_text}""" + enriched_text = invoke_with_cleaning(model, prompt) + return enriched_text if enriched_text else chapter_text + + +# ============ 导入外部知识文本到向量库 ============ +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 [] + + 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)) + + 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 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 + + paragraphs = advanced_split_content(content) + + # 若向量库不存在则初始化,否则追加 + store = load_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + filepath=filepath + ) + if not store: + logging.info("Vector store does not exist. Initializing a new one for knowledge import...") + init_vector_store( + api_key=api_key, + base_url=base_url if base_url else "http://localhost:11434/v1", + interface_format=interface_format, + embedding_model_name=embedding_model_name, + texts=paragraphs, + filepath=filepath + ) + else: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库。") diff --git a/novel_generator.py b/novel_generator.py index 7505948..f29076c 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -15,7 +15,6 @@ from langchain.docstore.document import Document # nltk、sentence_transformers 及文本处理相关 import nltk -import math from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity @@ -27,26 +26,26 @@ from utils import ( # prompt模板 from prompt_definitions import ( - # 设定相关 - set_prompt, character_prompt, dark_lines_prompt, - finalize_setting_prompt, novel_directory_prompt, - - # 写作流程相关 - summary_prompt, update_character_state_prompt, - chapter_outline_prompt, chapter_write_prompt + core_seed_prompt, + character_dynamics_prompt, + world_building_prompt, + plot_architecture_prompt, + chapter_blueprint_prompt, + summary_prompt, + update_character_state_prompt, + scene_dynamics_prompt ) # Ollama嵌入 (如使用Ollama时需要) from embedding_ollama import OllamaEmbeddings # 用于目录解析章节标题/简介 -from chapter_directory_parser import get_chapter_info_from_directory - +from chapter_directory_parser import get_chapter_info_from_blueprint 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) @@ -333,8 +332,8 @@ def get_relevant_context_from_vector_store( return combined -# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ -def Novel_setting_generate( +# ========== 1) 生成总体架构 (Novel_architecture.txt) ========== +def Novel_architecture_generate( api_key: str, base_url: str, llm_model: str, @@ -345,8 +344,15 @@ def Novel_setting_generate( filepath: str, temperature: float = 0.7 ) -> None: + """ + 依次调用: + 1. core_seed_prompt + 2. character_dynamics_prompt + 3. world_building_prompt + 4. plot_architecture_prompt + 将结果整合为“Novel_architecture.txt”。 + """ os.makedirs(filepath, exist_ok=True) - model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -354,58 +360,95 @@ def Novel_setting_generate( temperature=temperature ) - # Step1: 基础设定 - prompt_base = set_prompt.format( + # 1) 核心种子 + prompt_core = core_seed_prompt.format( topic=topic, genre=genre, number_of_chapters=number_of_chapters, word_number=word_number ) - base_setting = invoke_with_cleaning(model, prompt_base) + core_seed_result = invoke_with_cleaning(model, prompt_core) + core_seed_text = core_seed_result.strip() - # Step2: 角色设定 - prompt_char = character_prompt.format( - novel_setting=base_setting + # 2) 角色动力学 + prompt_character = character_dynamics_prompt.format(core_seed=core_seed_text) + character_dynamics_result = invoke_with_cleaning(model, prompt_character) + character_dynamics_text = character_dynamics_result.strip() + + # 3) 世界观 + prompt_world = world_building_prompt.format(core_seed=core_seed_text) + world_building_result = invoke_with_cleaning(model, prompt_world) + world_building_text = world_building_result.strip() + + # 4) 三幕式情节架构 + prompt_plot = plot_architecture_prompt.format( + core_seed=core_seed_text, + character_dynamics=character_dynamics_text, + world_building=world_building_text ) - character_setting = invoke_with_cleaning(model, prompt_char) + plot_arch_result = invoke_with_cleaning(model, prompt_plot) + plot_arch_text = plot_arch_result.strip() - # Step3: 暗线/雷点 - prompt_dark = dark_lines_prompt.format( - character_info=character_setting + # 整合并写入 Novel_architecture.txt + final_content = ( + "#=== 1) 核心种子 ===\n" + f"{core_seed_text}\n\n" + "#=== 2) 角色动力学 ===\n" + f"{character_dynamics_text}\n\n" + "#=== 3) 世界观 ===\n" + f"{world_building_text}\n\n" + "#=== 4) 三幕式情节架构 ===\n" + f"{plot_arch_text}\n" ) - dark_lines = invoke_with_cleaning(model, prompt_dark) - # Step4: 最终整合 - prompt_final = finalize_setting_prompt.format( - novel_setting_base=base_setting, - character_setting=character_setting, - dark_lines=dark_lines - ) - final_novel_setting = invoke_with_cleaning(model, prompt_final) + arch_file = os.path.join(filepath, "Novel_architecture.txt") + clear_file_content(arch_file) + save_string_to_txt(final_content, arch_file) - 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.") + logging.info("Novel_architecture.txt has been generated successfully.") -# ============ 2. 生成小说目录 (Novel_directory.txt) ============ -def Novel_directory_generate( +# ========== 2) 生成章节蓝图 (Novel_directory.txt) ========== +def Chapter_blueprint_generate( api_key: str, base_url: str, llm_model: str, - number_of_chapters: int, filepath: str, temperature: float = 0.7 ) -> None: - filename_set = os.path.join(filepath, "Novel_setting.txt") - final_novel_setting = read_file(filename_set).strip() - if not final_novel_setting: - logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") + """ + 基于“Novel_architecture.txt”中的三幕式情节架构,调用 chapter_blueprint_prompt, + 生成章节蓝图并写入 Novel_directory.txt。 + """ + arch_file = os.path.join(filepath, "Novel_architecture.txt") + if not os.path.exists(arch_file): + logging.warning("Novel_architecture.txt not found. Please generate architecture first.") return + architecture_text = read_file(arch_file).strip() + if not architecture_text: + logging.warning("Novel_architecture.txt is empty.") + return + + # 从内容中尽量提取 number_of_chapters + # 如果之前已经存储了 number_of_chapters,可以在外面传入,这里做简化: + # 这里用正则或者其他逻辑提取,但演示时直接写 10 也可 + match_chaps = re.search(r'约(\d+)章', architecture_text) + if match_chaps: + number_of_chapters = int(match_chaps.group(1)) + else: + number_of_chapters = 10 # fallback + + # 提取三幕式文本 + # 在写入时,我们将 4) 三幕式情节架构 作为传给 prompt 的核心 + # 这里做一个简易匹配 + plot_arch_text = "" + # 假设 "#=== 4) 三幕式情节架构 ===" 是分隔点 + pat_plot = r'#=== 4\) 三幕式情节架构 ===\n([\s\S]+)$' + m = re.search(pat_plot, architecture_text) + if m: + plot_arch_text = m.group(1).strip() + model = ChatOpenAI( model=llm_model, api_key=api_key, @@ -413,22 +456,20 @@ def Novel_directory_generate( temperature=temperature ) - prompt_dir = novel_directory_prompt.format( - final_novel_setting=final_novel_setting, + prompt = chapter_blueprint_prompt.format( + plot_architecture=plot_arch_text, number_of_chapters=number_of_chapters ) - final_novel_directory = invoke_with_cleaning(model, prompt_dir) - if not final_novel_directory.strip(): - logging.warning("Novel_directory生成结果为空。") + blueprint_text = invoke_with_cleaning(model, prompt) + if not blueprint_text.strip(): + logging.warning("Chapter blueprint generation result is empty.") return filename_dir = os.path.join(filepath, "Novel_directory.txt") clear_file_content(filename_dir) + save_string_to_txt(blueprint_text, filename_dir) - final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_directory_cleaned, filename_dir) - - logging.info("Novel_directory.txt has been generated successfully.") + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") # ============ 获取最近 N 章内容,生成短期摘要 ============ @@ -514,58 +555,114 @@ def update_plot_arcs( return arcs_text -# ============ 生成章节草稿 ============ +# ========== 3) 生成章节草稿 ========== + def generate_chapter_draft( - novel_settings: str, - global_summary: str, - character_state: str, - recent_chapters_summary: str, - user_guidance: str, api_key: str, base_url: str, model_name: str, + filepath: str, novel_number: int, word_number: int, temperature: float, - novel_novel_directory: str, - filepath: str, - interface_format: str, - embedding_model_name: str, - embedding_base_url: str, - embedding_retrieval_k: int = 4 + user_guidance: str, + characters_involved: str, + key_items: str, + scene_location: str, + time_constraint: str, + embedding_retrieval_k: int = 2 ) -> str: - # 1) 根据目录解析标题、简介 - chapter_info = get_chapter_info_from_directory(novel_novel_directory, novel_number) + """ + 根据 scene_dynamics_prompt,生成本章草稿。 + - novel_architecture 取自 Novel_architecture.txt + - blueprint 取自 Novel_directory.txt + - global_summary, character_state 分别取自全局摘要、角色状态文件 + - 向量库检索上下文 + - 用户还可以额外提供四个可选元素:核心人物、关键道具、空间坐标、时间压力 + """ + + # 1) 读取相关文件 + arch_file = os.path.join(filepath, "Novel_architecture.txt") + novel_architecture_text = read_file(arch_file) + + directory_file = os.path.join(filepath, "Novel_directory.txt") + blueprint_text = read_file(directory_file) + + global_summary_file = os.path.join(filepath, "global_summary.txt") + global_summary_text = read_file(global_summary_file) + + character_state_file = os.path.join(filepath, "character_state.txt") + character_state_text = read_file(character_state_file) + + # 2) 解析 blueprint,得到本章所需的字段 + chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] - chapter_brief = chapter_info["chapter_brief"] + chapter_role = chapter_info["chapter_role"] + chapter_purpose = chapter_info["chapter_purpose"] + suspense_level = chapter_info["suspense_level"] + foreshadowing = chapter_info["foreshadowing"] + plot_twist_level = chapter_info["plot_twist_level"] + chapter_summary = chapter_info["chapter_summary"] - # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) - combined_query_parts = [] - if user_guidance.strip(): - combined_query_parts.append(user_guidance) - if chapter_brief.strip(): - combined_query_parts.append(chapter_brief) - if recent_chapters_summary.strip(): - combined_query_parts.append(recent_chapters_summary) - # 额外加一个关键字 - combined_query_parts.append("回顾剧情") + # 3) 取最近3章文本,拼成查询语句 => 用于向量库检索 + chapters_dir = os.path.join(filepath, "chapters") + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + merged_query_str = "回顾剧情:\n" + "\n".join(recent_3_texts) + "\n" + user_guidance - merged_query_str = "\n".join(combined_query_parts) - - # 2) 从向量库检索上下文 + # 4) 检索向量库上下文 relevant_context = get_relevant_context_from_vector_store( api_key=api_key, - base_url=embedding_base_url if embedding_base_url else base_url, + base_url=base_url, query=merged_query_str, - interface_format=interface_format, - embedding_model_name=embedding_model_name, + embedding_model_name=model_name, filepath=filepath, k=embedding_retrieval_k ) - if not relevant_context.strip(): - relevant_context = "暂无相关内容。" - # 3) 生成本章大纲 + if not relevant_context.strip(): + relevant_context = "(无检索到的上下文)" + + # 5) 构造prompt,调用 scene_dynamics_prompt + # 在这里,我们拆分架构文本,以便给模型提供: + # - “世界观”与“小说设定”可以从 arch_file 中的相应片段读取 + # 这里为了简化,直接把 novel_architecture_text 整体塞入 novel_setting + # 也可更精细地拆分 "#=== 3) 世界观 ===" 片段给 world_building + # 下方仅作示例。 + world_building_text = "" + match_world = re.search(r'#=== 3\) 世界观 ===\n([\s\S]+?)\n#===', novel_architecture_text) + if match_world: + world_building_text = match_world.group(1).strip() + else: + world_building_text = "暂无世界观信息" + + novel_setting_text = novel_architecture_text # 整份当做“小说设定”参考 + + prompt_text = scene_dynamics_prompt.format( + novel_number=novel_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + + world_building=world_building_text, + novel_setting=novel_setting_text, + global_summary=global_summary_text, + character_state=character_state_text + ) + + # 因为我们还想让模型了解向量库检索到的上下文,可以合并到最后 + prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" + # 也可合并用户指导 + prompt_text += f"\n\n【用户指导】\n{user_guidance}\n" + model = ChatOpenAI( model=model_name, api_key=api_key, @@ -573,44 +670,15 @@ def generate_chapter_draft( temperature=temperature ) - outline_prompt_text = chapter_outline_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_outline = invoke_with_cleaning(model, outline_prompt_text) - - outlines_dir = os.path.join(filepath, "outlines") - os.makedirs(outlines_dir, exist_ok=True) - outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") - clear_file_content(outline_file) - save_string_to_txt(chapter_outline, outline_file) - - # 4) 生成正文草稿 - writing_prompt_text = chapter_write_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - chapter_outline=chapter_outline, - word_number=word_number, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_content = invoke_with_cleaning(model, writing_prompt_text) + chapter_content = invoke_with_cleaning(model, prompt_text) + if not chapter_content.strip(): + logging.warning("Generated chapter draft is empty.") + # 6) 写入 chapters 目录 chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") + clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -618,20 +686,20 @@ def generate_chapter_draft( return chapter_content -# ============ 定稿章节 ============ +# ========== 4) 定稿章节 ========== def finalize_chapter( novel_number: int, word_number: int, api_key: str, base_url: str, - interface_format: str, - embedding_model_name: str, model_name: str, temperature: float, filepath: str, - embedding_base_url: str, - embedding_api_key: str + embedding_model_name: str ): + """ + 定稿:更新全局摘要、角色状态,并将本章文本插入向量库。 + """ 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() @@ -639,82 +707,55 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - old_char_state = read_file(character_state_file) - 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( - chapter_text=chapter_text, - word_number=word_number, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature - ) + # 如果长度比目标少很多,可考虑在此扩写 + if len(chapter_text) < 0.6 * word_number: + chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - # 更新全局摘要 + # 读取全局摘要、角色状态 + global_summary_file = os.path.join(filepath, "global_summary.txt") + old_global_summary = read_file(global_summary_file) + character_state_file = os.path.join(filepath, "character_state.txt") + old_character_state = read_file(character_state_file) + + # 1) 更新全局摘要 model = ChatOpenAI( model=model_name, api_key=api_key, 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, - global_summary=old_summary - ) - return invoke_with_cleaning(model, prompt) or old_summary - - new_global_summary = update_global_summary(chapter_text, old_global_summary) - - # 更新角色状态 - def update_character_state(chapter_text: str, old_state: str) -> str: - prompt = update_character_state_prompt.format( - chapter_text=chapter_text, - old_state=old_state - ) - return invoke_with_cleaning(model, prompt) or old_state - - new_char_state = update_character_state(chapter_text, old_char_state) - - # 更新剧情要点 - new_plot_arcs = update_plot_arcs( + prompt_summary = summary_prompt.format( chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature + global_summary=old_global_summary ) + new_global_summary = invoke_with_cleaning(model, prompt_summary) + if not new_global_summary.strip(): + new_global_summary = old_global_summary + + # 2) 更新角色状态 + prompt_char_state = update_character_state_prompt.format( + chapter_text=chapter_text, + old_state=old_character_state + ) + new_char_state = invoke_with_cleaning(model, prompt_char_state) + if not new_char_state.strip(): + new_char_state = old_character_state # 写回文件 - clear_file_content(character_state_file) - save_string_to_txt(new_char_state, character_state_file) - clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) - clear_file_content(plot_arcs_file) - save_string_to_txt(new_plot_arcs, plot_arcs_file) + clear_file_content(character_state_file) + save_string_to_txt(new_char_state, character_state_file) - # 更新向量库(此时用 embedding_api_key/embedding_base_url) + # 3) 更新向量库 update_vector_store( - api_key=embedding_api_key, - base_url=embedding_base_url if embedding_base_url else base_url, + api_key=api_key, + base_url=base_url, new_chapter=chapter_text, - interface_format=interface_format, - embedding_model_name=embedding_model_name, + model_name=embedding_model_name, # 用于embedding filepath=filepath ) diff --git a/prompt_definitions.py b/prompt_definitions.py index ebe588c..989236d 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -186,7 +186,7 @@ update_character_state_prompt = """\ 仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 7. 章节正文写作 =================== +# =============== 8. 章节正文写作 =================== scene_dynamics_prompt = """\ 即将创作:第{novel_number}章《{chapter_title}》 本章定位:{chapter_role} @@ -227,4 +227,4 @@ scene_dynamics_prompt = """\ 最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 仅返回章节正文文本,不要解释任何内容。 -""" \ No newline at end of file +""" diff --git a/ui.py b/ui.py index 7e2f461..2a4fb1d 100644 --- a/ui.py +++ b/ui.py @@ -7,17 +7,17 @@ import threading import customtkinter as ctk from tkinter import filedialog, messagebox import traceback + from config_manager import load_config, save_config from utils import read_file, save_string_to_txt, clear_file_content from novel_generator import ( - Novel_setting_generate, - Novel_directory_generate, + Novel_architecture_generate, + Chapter_blueprint_generate, generate_chapter_draft, finalize_chapter, import_knowledge_file, clear_vector_store, get_last_n_chapters_text, - summarize_recent_chapters ) from consistency_checker import check_consistency @@ -28,8 +28,6 @@ def log_error(message: str): """ logging.error(f"{message}\n{traceback.format_exc()}") - -# 设置全局主题和颜色 ctk.set_appearance_mode("System") ctk.set_default_color_theme("blue") @@ -39,79 +37,65 @@ class NovelGeneratorGUI: self.master = master self.master.title("Novel Generator GUI") - # 防止因 icon.ico 不存在导致程序崩溃 try: if os.path.exists("icon.ico"): self.master.iconbitmap("icon.ico") except Exception: pass - # 配置窗口大小 self.master.geometry("1350x840") # 配置持久化 self.config_file = "config.json" self.loaded_config = load_config(self.config_file) - # ========== 主要的属性变量 ========== - - # LLM 接口相关 + # 主要属性变量 self.api_key_var = ctk.StringVar(value=self.loaded_config.get("api_key", "")) - self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.agicto.cn/v1")) + self.base_url_var = ctk.StringVar(value=self.loaded_config.get("base_url", "https://api.openai.com/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 接口相关 + # 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", "")) - - # ### CHANGED:将 IntVar 改为 StringVar,避免用户清空输入时抛错 + self.embedding_url_var = ctk.StringVar(value=self.loaded_config.get("embedding_url", "https://api.openai.com/v1")) + self.embedding_model_name_var = ctk.StringVar(value=self.loaded_config.get("embedding_model_name", "text-embedding-ada-002")) 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", "玄幻")) - - # ### 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", "")) - # ### CHANGED:章节号也改为 StringVar self.chapter_num_var = ctk.StringVar(value="1") - # ========== 主容器使用 TabView ========== + # 新增四个可选要素 + self.characters_involved_var = ctk.StringVar(value="") + self.key_items_var = ctk.StringVar(value="") + self.scene_location_var = ctk.StringVar(value="") + self.time_constraint_var = ctk.StringVar(value="") + + # UI 布局 self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) self.tabview.pack(fill="both", expand=True) - # 创建各个Tab self.main_tab = self.tabview.add("Main Functions") - self.setting_tab = self.tabview.add("Novel Settings") - self.directory_tab = self.tabview.add("Novel Directory") + self.setting_tab = self.tabview.add("Novel Architecture") + self.directory_tab = self.tabview.add("Chapter Blueprint") self.character_tab = self.tabview.add("Character State") self.summary_tab = self.tabview.add("Global Summary") self.chapters_view_tab = self.tabview.add("Chapters Manage") - # 构建各个 Tab 的布局 self.build_main_tab() self.build_setting_tab() self.build_directory_tab() self.build_character_tab() self.build_summary_tab() - self.build_chapters_tab() # 新增 + self.build_chapters_tab() - # ------------------ 工具方法:安全获取 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) @@ -119,102 +103,51 @@ class NovelGeneratorGUI: var.set(str(default)) return default - # ------------------ 主功能 Tab ------------------ + # ------------------ 主 Tab ------------------ def build_main_tab(self): - """ - 主Tab分为左右两栏: - 左侧:本章内容、Step按钮、日志 - 右侧:配置区域(带边框) + 保存/加载配置 + 小说参数 + 可选功能按钮 - """ self.main_tab.rowconfigure(0, weight=1) self.main_tab.columnconfigure(0, weight=1) self.main_tab.columnconfigure(1, weight=0) - # 左侧Frame self.left_frame = ctk.CTkFrame(self.main_tab) self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) - - # 右侧Frame self.right_frame = ctk.CTkFrame(self.main_tab) self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2) - # 左侧布局 self.build_left_layout() - # 右侧布局 self.build_right_layout() def build_left_layout(self): - """ - 左侧布局: - row=0 -> “本章内容”文本框 (chapter_result) - row=1 -> Step1~4按钮 - row=2 -> “输出日志”标题 - row=3 -> “输出日志”文本框 (log_text) - """ self.left_frame.grid_rowconfigure(0, weight=0) self.left_frame.grid_rowconfigure(1, weight=2) self.left_frame.grid_rowconfigure(2, weight=0) self.left_frame.grid_rowconfigure(3, weight=0) self.left_frame.grid_rowconfigure(4, weight=1) - self.left_frame.grid_columnconfigure(0, weight=1) + self.left_frame.columnconfigure(0, weight=1) - # ========== 本章内容 ========== - chapter_label = ctk.CTkLabel( - self.left_frame, - text="本章内容 (可编辑)", - font=("Microsoft YaHei", 12) - ) + chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容 (可编辑)", font=("Microsoft YaHei", 12)) chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w") - # 章节草稿:可编辑 - self.chapter_result = ctk.CTkTextbox( - self.left_frame, - wrap="word", - font=("Microsoft YaHei", 14) - ) + self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14)) self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) - # ========== Step1~4按钮 ========== - self.build_step_buttons_area() - - # ========== 输出日志 label ========== - log_label = ctk.CTkLabel( - self.left_frame, - text="输出日志 (只读)", - font=("Microsoft YaHei", 12) - ) - log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w") - - # ========== 日志:只读 ========== - self.log_text = ctk.CTkTextbox( - self.left_frame, - wrap="word", - font=("Microsoft YaHei", 12) - ) - self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) - self.log_text.configure(state="disabled") - - def build_step_buttons_area(self): - """ - 在左侧,仅放 Step1~Step4 四个按钮 - """ + # Step 按钮区域 self.step_buttons_frame = ctk.CTkFrame(self.left_frame) self.step_buttons_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=5) - # 四个按钮平分横向空间 self.step_buttons_frame.columnconfigure((0, 1, 2, 3), weight=1) - self.btn_generate_setting = ctk.CTkButton( + self.btn_generate_architecture = ctk.CTkButton( self.step_buttons_frame, - text="Step1. 生成设定", - command=self.generate_novel_setting_ui, + text="Step1. 生成架构", + command=self.generate_novel_architecture_ui, font=("Microsoft YaHei", 12) ) - self.btn_generate_setting.grid(row=0, column=0, padx=5, pady=2, sticky="ew") + self.btn_generate_architecture.grid(row=0, column=0, padx=5, pady=2, sticky="ew") self.btn_generate_directory = ctk.CTkButton( self.step_buttons_frame, text="Step2. 生成目录", - command=self.generate_novel_directory_ui, + command=self.generate_chapter_blueprint_ui, font=("Microsoft YaHei", 12) ) self.btn_generate_directory.grid(row=0, column=1, padx=5, pady=2, sticky="ew") @@ -235,42 +168,240 @@ class NovelGeneratorGUI: ) self.btn_finalize_chapter.grid(row=0, column=3, padx=5, pady=2, sticky="ew") + # 日志 + log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 12)) + log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w") + + self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12)) + self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5)) + self.log_text.configure(state="disabled") + def build_right_layout(self): - """ - 右侧布局,包含: - row=0 -> 带边框的配置区 (TabView + 保存/加载配置按钮) - row=1 -> 小说参数区域 - row=2 -> 可选功能按钮 (一致性审校 / 导入知识库 / 清空向量库 / 查看剧情要点) - """ self.right_frame.grid_rowconfigure(0, weight=0) self.right_frame.grid_rowconfigure(1, weight=1) self.right_frame.grid_rowconfigure(2, weight=0) self.right_frame.columnconfigure(0, weight=1) - # 1) 配置区 - self.config_frame = ctk.CTkFrame( - self.right_frame, - corner_radius=10, - border_width=2, - border_color="gray" - ) + # 配置区 + self.config_frame = ctk.CTkFrame(self.right_frame, corner_radius=10, border_width=2, border_color="gray") self.config_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) self.config_frame.columnconfigure(0, weight=1) - self.build_config_tabview() # LLM、Embedding等配置 - self.build_main_buttons_area() # 保存/加载配置按钮 + self.build_config_tabview() + self.build_main_buttons_area() - # 2) 小说参数 + # 小说参数 self.build_novel_params_area(start_row=1) - # 3) 可选功能按钮 + # 可选功能按钮 self.build_optional_buttons_area(start_row=2) - # ------------------ 可选功能按钮区域(右下) ------------------ + def build_config_tabview(self): + self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) + self.config_tabview.grid(row=0, column=0, sticky="we", padx=5, pady=5) + + self.ai_config_tab = self.config_tabview.add("LLM Model settings") + self.embeddings_config_tab = self.config_tabview.add("Embedding settings") + + self.build_ai_config_tab() + self.build_embeddings_config_tab() + + def build_ai_config_tab(self): + def on_interface_format_changed(new_value): + if new_value == "OpenAI": + self.base_url_var.set("https://api.openai.com/v1") + # 可根据需要扩展 Ollama / ML Studio 等 + + for i in range(5): + self.ai_config_tab.grid_rowconfigure(i, 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(2, weight=0) + + api_key_label = ctk.CTkLabel(self.ai_config_tab, text="LLM API Key:", font=("Microsoft YaHei", 12)) + api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var, font=("Microsoft YaHei", 12)) + api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + + base_url_label = ctk.CTkLabel(self.ai_config_tab, text="LLM Base URL:", font=("Microsoft YaHei", 12)) + base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var, font=("Microsoft YaHei", 12)) + base_url_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + + interface_label = ctk.CTkLabel(self.ai_config_tab, text="LLM 接口格式:", font=("Microsoft YaHei", 12)) + interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") + interface_options = ["OpenAI"] + interface_dropdown = ctk.CTkOptionMenu( + self.ai_config_tab, + values=interface_options, + variable=self.interface_format_var, + command=on_interface_format_changed, + font=("Microsoft YaHei", 12) + ) + interface_dropdown.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") + + model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:", font=("Microsoft YaHei", 12)) + model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var, font=("Microsoft YaHei", 12)) + model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + + temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:", font=("Microsoft YaHei", 12)) + temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + + def update_temp_label(value): + self.temp_value_label.configure(text=f"{float(value):.2f}") + + temp_scale = ctk.CTkSlider( + self.ai_config_tab, + from_=0.0, to=1.0, + number_of_steps=100, + command=update_temp_label, + variable=self.temperature_var + ) + temp_scale.grid(row=4, column=1, padx=5, pady=5, sticky="we") + + self.temp_value_label = ctk.CTkLabel( + self.ai_config_tab, + text=f"{self.temperature_var.get():.2f}", + font=("Microsoft YaHei", 12) + ) + 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(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) + + emb_api_key_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding API Key:", font=("Microsoft YaHei", 12)) + 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"] # 可扩展 + emb_interface_dropdown = ctk.CTkOptionMenu( + self.embeddings_config_tab, + values=emb_interface_options, + variable=self.embedding_interface_format_var, + 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)) + 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=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=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): + self.btn_frame_config = ctk.CTkFrame(self.config_frame) + self.btn_frame_config.grid(row=1, column=0, padx=5, pady=5, sticky="ew") + self.btn_frame_config.columnconfigure(0, weight=1) + self.btn_frame_config.columnconfigure(1, weight=1) + + save_config_btn = ctk.CTkButton(self.btn_frame_config, text="保存配置", command=self.save_config_btn, font=("Microsoft YaHei", 12)) + save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + 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") + + def build_novel_params_area(self, start_row=1): + self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical") + self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) + self.params_frame.columnconfigure(1, weight=1) + + topic_label = ctk.CTkLabel(self.params_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) + topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + self.topic_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + if self.topic_default: + self.topic_text.insert("0.0", self.topic_default) + + genre_label = ctk.CTkLabel(self.params_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) + genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + genre_entry = ctk.CTkEntry(self.params_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) + 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=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, 1, 2, 3), weight=0) + + num_chapters_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12)) + num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + word_number_label = ctk.CTkLabel(ch_word_frame, text="每章字数:", font=("Microsoft YaHei", 12)) + word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") + + word_number_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12)) + word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") + + # 保存路径 + filepath_label = ctk.CTkLabel(self.params_frame, text="保存路径:", font=("Microsoft YaHei", 12)) + 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=3, column=1, padx=5, pady=5, sticky="nsew") + self.filepath_frame.columnconfigure(0, weight=1) + + filepath_entry = ctk.CTkEntry(self.filepath_frame, textvariable=self.filepath_var, font=("Microsoft YaHei", 12)) + filepath_entry.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + browse_btn = ctk.CTkButton(self.filepath_frame, text="浏览...", command=self.browse_folder, width=60, font=("Microsoft YaHei", 12)) + browse_btn.grid(row=0, column=1, padx=5, pady=5, sticky="e") + + # 章节号 + chapter_num_label = ctk.CTkLabel(self.params_frame, text="章节号:", font=("Microsoft YaHei", 12)) + 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=4, column=1, padx=5, pady=5, sticky="w") + + # 用户指导 + guide_label = ctk.CTkLabel(self.params_frame, text="本章指导:", font=("Microsoft YaHei", 12)) + guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") + self.user_guide_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + + # 新增:四个可选元素 + row_index = 6 + + char_inv_label = ctk.CTkLabel(self.params_frame, text="核心人物:", font=("Microsoft YaHei", 12)) + char_inv_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + char_inv_entry = ctk.CTkEntry(self.params_frame, textvariable=self.characters_involved_var, font=("Microsoft YaHei", 12)) + char_inv_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + + row_index += 1 + key_items_label = ctk.CTkLabel(self.params_frame, text="关键道具:", font=("Microsoft YaHei", 12)) + key_items_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12)) + key_items_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + + row_index += 1 + scene_loc_label = ctk.CTkLabel(self.params_frame, text="空间坐标:", font=("Microsoft YaHei", 12)) + scene_loc_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + scene_loc_entry = ctk.CTkEntry(self.params_frame, textvariable=self.scene_location_var, font=("Microsoft YaHei", 12)) + scene_loc_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + + row_index += 1 + time_const_label = ctk.CTkLabel(self.params_frame, text="时间压力:", font=("Microsoft YaHei", 12)) + time_const_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + time_const_entry = ctk.CTkEntry(self.params_frame, textvariable=self.time_constraint_var, font=("Microsoft YaHei", 12)) + time_const_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + def build_optional_buttons_area(self, start_row=2): - """ - 放在右侧的最下方:包括 一致性审校、导入知识库、清空向量库、查看剧情要点 - """ self.optional_btn_frame = ctk.CTkFrame(self.right_frame) self.optional_btn_frame.grid(row=start_row, column=0, sticky="ew", padx=5, pady=5) self.optional_btn_frame.columnconfigure((0, 1, 2, 3), weight=1) @@ -308,363 +439,385 @@ class NovelGeneratorGUI: ) self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") - # ========== 配置区域(TabView) ========== - def build_config_tabview(self): - self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) - self.config_tabview.grid(row=0, column=0, sticky="we", padx=5, pady=5) + def load_config_btn(self): + cfg = load_config(self.config_file) + if cfg: + 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)) + 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.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) + self.genre_var.set(cfg.get("genre", "")) + 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", "")) - self.ai_config_tab = self.config_tabview.add("LLM Model settings") - self.embeddings_config_tab = self.config_tabview.add("Embedding settings") + topic_value = cfg.get("topic", "") + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", topic_value) - self.build_ai_config_tab() - self.build_embeddings_config_tab() + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") - def build_ai_config_tab(self): - def on_interface_format_changed(new_value): - if new_value == "Ollama": - self.base_url_var.set("http://localhost:11434/v1") - elif new_value == "ML Studio": - self.base_url_var.set("http://localhost:1234/v1") - elif new_value == "OpenAI": - self.base_url_var.set("https://api.openai.com/v1") + def save_config_btn(self): + config_data = { + "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(), - for i in range(5): - self.ai_config_tab.grid_rowconfigure(i, 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(2, weight=0) # for temp label + "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(), + "embedding_retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4), - api_key_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM API Key:", - font=("Microsoft YaHei", 12) - ) - api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - api_key_entry = ctk.CTkEntry( - self.ai_config_tab, - textvariable=self.api_key_var, - font=("Microsoft YaHei", 12) - ) - api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + "topic": self.topic_text.get("0.0", "end").strip(), + "genre": self.genre_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): + messagebox.showinfo("提示", "配置已保存至 config.json") + self.log("配置已保存。") + else: + messagebox.showerror("错误", "保存配置失败。") - base_url_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM Base URL:", - font=("Microsoft YaHei", 12) - ) - base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - base_url_entry = ctk.CTkEntry( - self.ai_config_tab, - textvariable=self.base_url_var, - font=("Microsoft YaHei", 12) - ) - base_url_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + def browse_folder(self): + selected_dir = filedialog.askdirectory() + if selected_dir: + self.filepath_var.set(selected_dir) - interface_label = ctk.CTkLabel( - self.ai_config_tab, - text="LLM 接口格式:", - font=("Microsoft YaHei", 12) - ) - interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI", "Ollama", "ML Studio"] - interface_dropdown = ctk.CTkOptionMenu( - self.ai_config_tab, - values=interface_options, - variable=self.interface_format_var, - command=on_interface_format_changed, - font=("Microsoft YaHei", 12) - ) - interface_dropdown.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") + def log(self, message: str): + self.log_text.configure(state="normal") + self.log_text.insert("end", message + "\n") + self.log_text.see("end") + self.log_text.configure(state="disabled") - model_name_label = ctk.CTkLabel( - self.ai_config_tab, - text="Model Name:", - font=("Microsoft YaHei", 12) - ) - model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") - model_name_entry = ctk.CTkEntry( - self.ai_config_tab, - textvariable=self.model_name_var, - font=("Microsoft YaHei", 12) - ) - model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + def safe_log(self, message: str): + self.master.after(0, lambda: self.log(message)) - temp_label = ctk.CTkLabel( - self.ai_config_tab, - text="Temperature:", - font=("Microsoft YaHei", 12) - ) - temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + def disable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="disabled")) - def update_temp_label(value): - self.temp_value_label.configure(text=f"{float(value):.2f}") + def enable_button_safe(self, btn): + self.master.after(0, lambda: btn.configure(state="normal")) - temp_scale = ctk.CTkSlider( - self.ai_config_tab, - from_=0.0, to=1.0, - number_of_steps=100, - command=update_temp_label, - variable=self.temperature_var - ) - temp_scale.grid(row=4, column=1, padx=5, pady=5, sticky="we") + def handle_exception(self, context: str): + full_message = f"{context}\n{traceback.format_exc()}" + logging.error(full_message) + self.safe_log(full_message) - self.temp_value_label = ctk.CTkLabel( - self.ai_config_tab, - text=f"{self.temperature_var.get():.2f}", - font=("Microsoft YaHei", 12) - ) - self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") + # ------------------ Step1: 生成架构 ------------------ + def generate_novel_architecture_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return - 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/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") + def task(): + self.disable_button_safe(self.btn_generate_architecture) + try: + api_key = self.api_key_var.get().strip() + base_url = self.base_url_var.get().strip() + model_name = self.model_name_var.get().strip() + temperature = self.temperature_var.get() - 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) + topic = self.topic_text.get("0.0", "end").strip() + genre = self.genre_var.get().strip() + num_chapters = self.safe_get_int(self.num_chapters_var, 10) + word_number = self.safe_get_int(self.word_number_var, 3000) - emb_api_key_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding API Key:", - font=("Microsoft YaHei", 12) - ) - 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") + self.safe_log("开始生成小说架构...") + Novel_architecture_generate( + api_key=api_key, + base_url=base_url, + llm_model=model_name, + topic=topic, + genre=genre, + number_of_chapters=num_chapters, + word_number=word_number, + filepath=filepath, + temperature=temperature + ) + self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成小说架构时出错") + finally: + self.enable_button_safe(self.btn_generate_architecture) - 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") + threading.Thread(target=task, daemon=True).start() - 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) - ) - emb_url_entry.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") + # ------------------ Step2: 生成章节蓝图 ------------------ + def generate_chapter_blueprint_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return - emb_model_name_label = ctk.CTkLabel( - self.embeddings_config_tab, - text="Embedding Model Name:", - font=("Microsoft YaHei", 12) - ) - 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=3, column=1, padx=5, pady=5, sticky="nsew") + def task(): + self.disable_button_safe(self.btn_generate_directory) + try: + api_key = self.api_key_var.get().strip() + base_url = self.base_url_var.get().strip() + model_name = self.model_name_var.get().strip() + temperature = self.temperature_var.get() - 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") + self.safe_log("开始生成章节蓝图...") + Chapter_blueprint_generate( + api_key=api_key, + base_url=base_url, + llm_model=model_name, + filepath=filepath, + temperature=temperature + ) + self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成章节蓝图时出错") + finally: + self.enable_button_safe(self.btn_generate_directory) - # ========== 保存/加载 配置按钮区域 ========== - def build_main_buttons_area(self): - """ - 放置在带边框配置区(config_frame)内部,位于TabView下方 - """ - self.btn_frame_config = ctk.CTkFrame(self.config_frame) - self.btn_frame_config.grid(row=1, column=0, padx=5, pady=5, sticky="ew") - self.btn_frame_config.columnconfigure(0, weight=1) - self.btn_frame_config.columnconfigure(1, weight=1) + threading.Thread(target=task, daemon=True).start() - save_config_btn = ctk.CTkButton( - self.btn_frame_config, - text="保存配置", - command=self.save_config_btn, - font=("Microsoft YaHei", 12) - ) - save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + # ------------------ Step3: 生成草稿 ------------------ + def generate_chapter_draft_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return - 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") + def task(): + self.disable_button_safe(self.btn_generate_chapter) + try: + api_key = self.api_key_var.get().strip() + base_url = self.base_url_var.get().strip() + model_name = self.model_name_var.get().strip() + temperature = self.temperature_var.get() - # ========== 小说参数区域 ========== - def build_novel_params_area(self, start_row=1): - """ - 右侧下方区域: 输入主题, 类型, 章节数, 字数, 保存路径, 指导信息等 - """ - self.params_frame = ctk.CTkScrollableFrame( - self.right_frame, - orientation="vertical" - ) - self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) - self.params_frame.columnconfigure(1, weight=1) + 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() - # 主题(Topic) - topic_label = ctk.CTkLabel( - self.params_frame, - text="主题(Topic):", - font=("Microsoft YaHei", 12) - ) - topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox( - self.params_frame, - width=200, - height=80, - wrap="word", - font=("Microsoft YaHei", 12) - ) - self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") - if self.topic_default: - self.topic_text.insert("0.0", self.topic_default) + # 新增四个可选要素 + char_inv = self.characters_involved_var.get().strip() + key_items = self.key_items_var.get().strip() + scene_loc = self.scene_location_var.get().strip() + time_constr = self.time_constraint_var.get().strip() - # 类型(Genre) - genre_label = ctk.CTkLabel( - self.params_frame, - text="类型(Genre):", - font=("Microsoft YaHei", 12) - ) - genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") - genre_entry = ctk.CTkEntry( - self.params_frame, - textvariable=self.genre_var, - font=("Microsoft YaHei", 12) - ) - genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") + embedding_model_name = self.embedding_model_name_var.get().strip() + embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4) - # 章节数、每章字数 放在同一行 - row_for_chapter_and_word = 2 - num_chapters_label = ctk.CTkLabel( - self.params_frame, - text="章节数:", - font=("Microsoft YaHei", 12) - ) - num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + self.safe_log(f"开始生成第{chap_num}章草稿...") + draft_text = generate_chapter_draft( + api_key=api_key, + base_url=base_url, + model_name=model_name, + filepath=filepath, + novel_number=chap_num, + word_number=word_number, + temperature=temperature, + user_guidance=user_guidance, + characters_involved=char_inv, + key_items=key_items, + scene_location=scene_loc, + time_constraint=time_constr, + embedding_retrieval_k=embedding_k + ) + if draft_text: + self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") + self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) + else: + self.safe_log("⚠️ 本章草稿生成失败或无内容。") - 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) + except Exception: + self.handle_exception("生成章节草稿时出错") + finally: + self.enable_button_safe(self.btn_generate_chapter) - num_chapters_entry = ctk.CTkEntry( - ch_word_frame, - textvariable=self.num_chapters_var, - width=60, - font=("Microsoft YaHei", 12) - ) - num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + threading.Thread(target=task, daemon=True).start() - word_number_label = ctk.CTkLabel( - ch_word_frame, - text="每章字数:", - font=("Microsoft YaHei", 12) - ) - word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") + def show_chapter_in_textbox(self, text: str): + self.chapter_result.delete("0.0", "end") + self.chapter_result.insert("0.0", text) + self.chapter_result.see("end") - word_number_entry = ctk.CTkEntry( - ch_word_frame, - textvariable=self.word_number_var, - width=60, - font=("Microsoft YaHei", 12) - ) - word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") + # ------------------ Step4: 定稿章节 ------------------ + def finalize_chapter_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return - # 保存路径 - filepath_label = ctk.CTkLabel( - self.params_frame, - text="保存路径:", - font=("Microsoft YaHei", 12) - ) - filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + def task(): + self.disable_button_safe(self.btn_finalize_chapter) + try: + api_key = self.api_key_var.get().strip() + base_url = self.base_url_var.get().strip() + model_name = self.model_name_var.get().strip() + temperature = self.temperature_var.get() - self.filepath_frame = ctk.CTkFrame(self.params_frame) - self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") - self.filepath_frame.columnconfigure(0, weight=1) + embedding_model_name = self.embedding_model_name_var.get().strip() - filepath_entry = ctk.CTkEntry( - self.filepath_frame, - textvariable=self.filepath_var, - font=("Microsoft YaHei", 12) - ) - filepath_entry.grid(row=0, column=0, padx=5, pady=5, sticky="ew") - browse_btn = ctk.CTkButton( - self.filepath_frame, - text="浏览...", - command=self.browse_folder, - width=60, - font=("Microsoft YaHei", 12) - ) - browse_btn.grid(row=0, column=1, padx=5, pady=5, sticky="e") + chap_num = self.safe_get_int(self.chapter_num_var, 1) + word_number = self.safe_get_int(self.word_number_var, 3000) - # 章节号 - chapter_num_label = ctk.CTkLabel( - self.params_frame, - text="章节号:", - font=("Microsoft YaHei", 12) - ) - 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=4, column=1, padx=5, pady=5, sticky="w") + self.safe_log(f"开始定稿第{chap_num}章...") + # 先保存用户在左侧编辑框中的修改 + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt") + edited_text = self.chapter_result.get("0.0", "end").strip() + clear_file_content(chapter_file) + save_string_to_txt(edited_text, chapter_file) - # 用户指导 - guide_label = ctk.CTkLabel( - self.params_frame, - text="本章指导:", - font=("Microsoft YaHei", 12) - ) - guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox( - self.params_frame, - width=200, - height=80, - wrap="word", - font=("Microsoft YaHei", 12) - ) - self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + finalize_chapter( + novel_number=chap_num, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + filepath=filepath, + embedding_model_name=embedding_model_name + ) + self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") - # ------------------ 其他Tab的构建 ------------------ + final_text = read_file(chapter_file) + self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) + + except Exception: + self.handle_exception("定稿章节时出错") + finally: + self.enable_button_safe(self.btn_finalize_chapter) + + threading.Thread(target=task, daemon=True).start() + + # ------------------ 一致性审校 ------------------ + def do_consistency_check(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + self.disable_button_safe(self.btn_check_consistency) + try: + api_key = self.api_key_var.get().strip() + base_url = self.base_url_var.get().strip() + model_name = self.model_name_var.get().strip() + temperature = self.temperature_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) + + if not chapter_text.strip(): + self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。") + return + + self.safe_log("开始一致性审校...") + result = check_consistency( + novel_setting="", # 如果需要,可传入最新的 Novel_architecture 内容 + character_state=read_file(os.path.join(filepath, "character_state.txt")), + global_summary=read_file(os.path.join(filepath, "global_summary.txt")), + chapter_text=chapter_text, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + plot_arcs="" + ) + self.safe_log("审校结果:") + self.safe_log(result) + + except Exception: + self.handle_exception("审校时出错") + finally: + self.enable_button_safe(self.btn_check_consistency) + + threading.Thread(target=task, daemon=True).start() + + def import_knowledge_handler(self): + selected_file = filedialog.askopenfilename( + title="选择要导入的知识库文件", + filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] + ) + if selected_file: + def task(): + self.disable_button_safe(self.btn_import_knowledge) + try: + 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() + + self.safe_log(f"开始导入知识库文件: {selected_file}") + import_knowledge_file( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + embedding_model_name=embedding_model_name, + file_path=selected_file, + embedding_base_url=base_url, + filepath=self.filepath_var.get().strip() + ) + self.safe_log("✅ 知识库文件导入完成。") + except Exception: + self.handle_exception("导入知识库时出错") + finally: + self.enable_button_safe(self.btn_import_knowledge) + + 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: + 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() + if not filepath: + messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") + return + + plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") + if not os.path.exists(plot_arcs_file): + messagebox.showinfo("剧情要点", "当前还未生成任何剧情要点或冲突记录。") + return + + arcs_text = read_file(plot_arcs_file).strip() + if not arcs_text: + arcs_text = "当前没有记录的剧情要点或冲突。" + + top = ctk.CTkToplevel(self.master) + top.title("剧情要点/未解决冲突") + top.geometry("600x400") + + text_area = ctk.CTkTextbox(top, wrap="word", font=("Microsoft YaHei", 12)) + text_area.pack(fill="both", expand=True, padx=10, pady=10) + + text_area.insert("0.0", arcs_text) + text_area.configure(state="disabled") + + # ------------------ 其他标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ------------------ def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -672,8 +825,8 @@ class NovelGeneratorGUI: load_btn = ctk.CTkButton( self.setting_tab, - text="加载 Novel_setting.txt", - command=self.load_novel_setting, + text="加载 Novel_architecture.txt", + command=self.load_novel_architecture, font=("Microsoft YaHei", 12) ) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") @@ -681,18 +834,36 @@ class NovelGeneratorGUI: save_btn = ctk.CTkButton( self.setting_tab, text="保存修改", - command=self.save_novel_setting, + command=self.save_novel_architecture, font=("Microsoft YaHei", 12) ) save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.setting_text = ctk.CTkTextbox( - self.setting_tab, - wrap="word", - font=("Microsoft YaHei", 12) - ) + self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12)) self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_novel_architecture(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "Novel_architecture.txt") + content = read_file(filename) + self.setting_text.delete("0.0", "end") + self.setting_text.insert("0.0", content) + self.log("已加载 Novel_architecture.txt 内容到编辑区。") + + def save_novel_architecture(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + content = self.setting_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "Novel_architecture.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 Novel_architecture.txt 的修改。") + def build_directory_tab(self): self.directory_tab.rowconfigure(0, weight=0) self.directory_tab.rowconfigure(1, weight=1) @@ -701,7 +872,7 @@ class NovelGeneratorGUI: load_btn = ctk.CTkButton( self.directory_tab, text="加载 Novel_directory.txt", - command=self.load_novel_directory, + command=self.load_chapter_blueprint, font=("Microsoft YaHei", 12) ) load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") @@ -709,18 +880,36 @@ class NovelGeneratorGUI: save_btn = ctk.CTkButton( self.directory_tab, text="保存修改", - command=self.save_novel_directory, + command=self.save_chapter_blueprint, font=("Microsoft YaHei", 12) ) save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.directory_text = ctk.CTkTextbox( - self.directory_tab, - wrap="word", - font=("Microsoft YaHei", 12) - ) + self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12)) self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_chapter_blueprint(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "Novel_directory.txt") + content = read_file(filename) + self.directory_text.delete("0.0", "end") + self.directory_text.insert("0.0", content) + self.log("已加载 Novel_directory.txt 内容到编辑区。") + + def save_chapter_blueprint(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + content = self.directory_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 Novel_directory.txt 的修改。") + def build_character_tab(self): self.character_tab.rowconfigure(0, weight=0) self.character_tab.rowconfigure(1, weight=1) @@ -742,13 +931,31 @@ class NovelGeneratorGUI: ) save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.character_text = ctk.CTkTextbox( - self.character_tab, - wrap="word", - font=("Microsoft YaHei", 12) - ) + self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12)) self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_character_state(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "character_state.txt") + content = read_file(filename) + self.character_text.delete("0.0", "end") + self.character_text.insert("0.0", content) + self.log("已加载 character_state.txt 到编辑区。") + + def save_character_state(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + content = self.character_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "character_state.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 character_state.txt 的修改。") + def build_summary_tab(self): self.summary_tab.rowconfigure(0, weight=0) self.summary_tab.rowconfigure(1, weight=1) @@ -770,17 +977,33 @@ class NovelGeneratorGUI: ) save_btn.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.summary_text = ctk.CTkTextbox( - self.summary_tab, - wrap="word", - font=("Microsoft YaHei", 12) - ) + self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12)) self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def load_global_summary(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + filename = os.path.join(filepath, "global_summary.txt") + content = read_file(filename) + self.summary_text.delete("0.0", "end") + self.summary_text.insert("0.0", content) + self.log("已加载 global_summary.txt 到编辑区。") + + def save_global_summary(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先设置保存文件路径") + return + content = self.summary_text.get("0.0", "end").strip() + filename = os.path.join(filepath, "global_summary.txt") + clear_file_content(filename) + save_string_to_txt(content, filename) + self.log("已保存对 global_summary.txt 的修改。") + + # ------------------ 章节管理标签页 ------------------ def build_chapters_tab(self): - """ - 新增的 Tab,用于查看、编辑和保存已生成的各章节内容。 - """ self.chapters_view_tab.rowconfigure(0, weight=0) self.chapters_view_tab.rowconfigure(1, weight=1) self.chapters_view_tab.columnconfigure(0, weight=1) @@ -821,7 +1044,6 @@ class NovelGeneratorGUI: self.chapters_list = [] self.refresh_chapters_list() - # ------------------ 章节管理辅助方法 ------------------ def refresh_chapters_list(self): filepath = self.filepath_var.get().strip() chapters_dir = os.path.join(filepath, "chapters") @@ -857,7 +1079,6 @@ class NovelGeneratorGUI: def load_chapter_content(self, chapter_number_str): if not chapter_number_str: return - filepath = self.filepath_var.get().strip() chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") if not os.path.exists(chapter_file): @@ -914,528 +1135,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") - # ------------------ 配置管理 ------------------ - 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", "")) - # ### CHANGED:用字符串形式设值 - self.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) - - # Novel - self.genre_var.set(cfg.get("genre", "")) - - # ### 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", topic_value) - - self.log("已加载配置。") - else: - messagebox.showwarning("提示", "未找到或无法读取配置文件。") - - 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(), - "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.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): - messagebox.showinfo("提示", "配置已保存至 config.json") - self.log("配置已保存。") - else: - messagebox.showerror("错误", "保存配置失败。") - - def browse_folder(self): - selected_dir = filedialog.askdirectory() - if selected_dir: - self.filepath_var.set(selected_dir) - - # ------------------ 日志输出(主线程安全) ------------------ - def log(self, message: str): - self.log_text.configure(state="normal") - self.log_text.insert("end", message + "\n") - self.log_text.see("end") - self.log_text.configure(state="disabled") - - def safe_log(self, message: str): - self.master.after(0, lambda: self.log(message)) - - def disable_button_safe(self, btn): - self.master.after(0, lambda: btn.configure(state="disabled")) - - def enable_button_safe(self, btn): - self.master.after(0, lambda: btn.configure(state="normal")) - - def handle_exception(self, context: str): - full_message = f"{context}\n{traceback.format_exc()}" - logging.error(full_message) - self.safe_log(full_message) - - # ------------------ 分步操作:生成设定、目录、章节草稿、定稿 ------------------ - def generate_novel_setting_ui(self): - """Step1. 生成小说设定(Novel_setting.txt)""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - def task(): - self.disable_button_safe(self.btn_generate_setting) - try: - 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.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("开始生成小说设定...") - Novel_setting_generate( - api_key=api_key, - base_url=base_url, - llm_model=model_name, - topic=topic, - genre=genre, - number_of_chapters=num_chapters, - word_number=word_number, - filepath=filepath, - temperature=temperature - ) - self.safe_log("✅ 小说设定生成完成。请在 'Novel Settings' 标签页进行查看或编辑。") - except Exception: - self.handle_exception("生成小说设定时出错") - finally: - self.enable_button_safe(self.btn_generate_setting) - - threading.Thread(target=task, daemon=True).start() - - def generate_novel_directory_ui(self): - """Step2. 基于已有 Novel_setting.txt 生成 Novel_directory.txt""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先选择保存文件路径") - return - - def task(): - self.disable_button_safe(self.btn_generate_directory) - try: - 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.safe_get_int(self.num_chapters_var, 10) - temperature = self.temperature_var.get() - - self.safe_log("开始生成小说目录...") - Novel_directory_generate( - api_key=api_key, - base_url=base_url, - llm_model=model_name, - number_of_chapters=num_chapters, - filepath=filepath, - temperature=temperature - ) - self.safe_log("✅ 小说目录生成完成。请在 'Novel Directory' 标签页查看或编辑。") - except Exception: - self.handle_exception("生成小说目录时出错") - finally: - self.enable_button_safe(self.btn_generate_directory) - - threading.Thread(target=task, daemon=True).start() - - def generate_chapter_draft_ui(self): - """Step3. 生成当前章节草稿""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - def task(): - self.disable_button_safe(self.btn_generate_chapter) - try: - api_key = self.api_key_var.get().strip() - 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(): - self.safe_log("⚠️ 未找到 Novel_setting.txt,请先生成设定。") - return - - character_state_file = os.path.join(filepath, "character_state.txt") - character_state = read_file(character_state_file) - global_summary_file = os.path.join(filepath, "global_summary.txt") - global_summary = read_file(global_summary_file) - novel_directory_file = os.path.join(filepath, "Novel_directory.txt") - novel_directory = read_file(novel_directory_file) - - 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章文本 - chapters_dir = os.path.join(filepath, "chapters") - recent_3_texts = get_last_n_chapters_text(chapters_dir, chap_num, n=3) - - # 生成最近章节摘要 - recent_chapters_summary = summarize_recent_chapters( - llm_model=model_name, - api_key=api_key, - base_url=base_url, - temperature=temperature, - chapters_text_list=recent_3_texts - ) - - self.safe_log(f"开始生成第{chap_num}章草稿...") - draft_text = generate_chapter_draft( - novel_settings=novel_settings, - global_summary=global_summary, - character_state=character_state, - recent_chapters_summary=recent_chapters_summary, - user_guidance=user_guidance, - api_key=api_key, - base_url=base_url, - model_name=model_name, - novel_number=chap_num, - word_number=word_number, - temperature=temperature, - novel_novel_directory=novel_directory, - filepath=filepath, - - # 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}章草稿生成完成。请在左侧查看或编辑。") - self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text)) - else: - self.safe_log("⚠️ 本章草稿生成失败或无内容。") - - except Exception: - self.handle_exception("生成章节草稿时出错") - finally: - self.enable_button_safe(self.btn_generate_chapter) - - threading.Thread(target=task, daemon=True).start() - - def show_chapter_in_textbox(self, text: str): - self.chapter_result.delete("0.0", "end") - self.chapter_result.insert("0.0", text) - self.chapter_result.see("end") - - def finalize_chapter_ui(self): - """Step4. 定稿当前章节:更新全局摘要、角色状态、向量库等""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - def task(): - self.disable_button_safe(self.btn_finalize_chapter) - try: - api_key = self.api_key_var.get().strip() - 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.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}章...") - chapters_dir = os.path.join(filepath, "chapters") - chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt") - edited_text = self.chapter_result.get("0.0", "end").strip() - clear_file_content(chapter_file) - save_string_to_txt(edited_text, chapter_file) - - finalize_chapter( - novel_number=chap_num, - word_number=word_number, - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - model_name=model_name, - temperature=temperature, - filepath=filepath, - embedding_base_url=embedding_base_url, - embedding_api_key=embedding_api_key - ) - self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、剧情要点、向量库)。") - - # 读取定稿后的文本显示 - chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt") - final_text = read_file(chap_file) - self.master.after(0, lambda: self.show_chapter_in_textbox(final_text)) - - except Exception: - self.handle_exception("定稿章节时出错") - finally: - self.enable_button_safe(self.btn_finalize_chapter) - - threading.Thread(target=task, daemon=True).start() - - # ------------------ 一致性审校 ------------------ - def do_consistency_check(self): - """使用审校Agent对最新章节进行简单一致性或冲突检查""" - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先配置保存文件路径。") - return - - def task(): - self.disable_button_safe(self.btn_check_consistency) - try: - api_key = self.api_key_var.get().strip() - 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") - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - novel_setting = read_file(novel_settings_file) - character_state = read_file(character_state_file) - global_summary = read_file(global_summary_file) - plot_arcs = read_file(plot_arcs_file) - - 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) - - if not chapter_text.strip(): - self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。") - return - - self.safe_log("开始一致性审校...") - result = check_consistency( - novel_setting=novel_setting, - character_state=character_state, - global_summary=global_summary, - chapter_text=chapter_text, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - plot_arcs=plot_arcs - ) - self.safe_log("审校结果:") - self.safe_log(result) - - except Exception: - self.handle_exception("审校时出错") - finally: - self.enable_button_safe(self.btn_check_consistency) - - threading.Thread(target=task, daemon=True).start() - - # ------------------ 导入知识库/清空向量库/查看剧情要点 ------------------ - def import_knowledge_handler(self): - selected_file = filedialog.askopenfilename( - title="选择要导入的知识库文件", - filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] - ) - if selected_file: - def task(): - self.disable_button_safe(self.btn_import_knowledge) - try: - self.safe_log(f"开始导入知识库文件: {selected_file}") - import_knowledge_file( - 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(), - filepath=self.filepath_var.get().strip() - ) - self.safe_log("✅ 知识库文件导入完成。") - except Exception: - self.handle_exception("导入知识库时出错") - finally: - self.enable_button_safe(self.btn_import_knowledge) - - 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: - 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() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - if not os.path.exists(plot_arcs_file): - messagebox.showinfo("剧情要点", "当前还未生成任何剧情要点或未解决冲突。") - return - - arcs_text = read_file(plot_arcs_file).strip() - if not arcs_text: - arcs_text = "当前没有记录的剧情要点或冲突。" - - top = ctk.CTkToplevel(self.master) - top.title("剧情要点/未解决冲突") - top.geometry("600x400") - - text_area = ctk.CTkTextbox(top, wrap="word", font=("Microsoft YaHei", 12)) - text_area.pack(fill="both", expand=True, padx=10, pady=10) - - text_area.insert("0.0", arcs_text) - text_area.configure(state="disabled") - - # ------------------ Novel Settings/Directory/Character/Global Summary 的加载与保存 ------------------ - def load_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - setting_file = os.path.join(filepath, "Novel_setting.txt") - content = read_file(setting_file) - self.setting_text.delete("0.0", "end") - self.setting_text.insert("0.0", content) - self.log("已加载 Novel_setting.txt 内容到编辑区。") - - def save_novel_setting(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.setting_text.get("0.0", "end").strip() - setting_file = os.path.join(filepath, "Novel_setting.txt") - clear_file_content(setting_file) - save_string_to_txt(content, setting_file) - self.log("已保存对 Novel_setting.txt 的修改。") - - def load_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - directory_file = os.path.join(filepath, "Novel_directory.txt") - content = read_file(directory_file) - self.directory_text.delete("0.0", "end") - self.directory_text.insert("0.0", content) - self.log("已加载 Novel_directory.txt 内容到编辑区。") - - def save_novel_directory(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.directory_text.get("0.0", "end").strip() - directory_file = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(directory_file) - save_string_to_txt(content, directory_file) - self.log("已保存对 Novel_directory.txt 的修改。") - - def load_character_state(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - char_file = os.path.join(filepath, "character_state.txt") - content = read_file(char_file) - self.character_text.delete("0.0", "end") - self.character_text.insert("0.0", content) - self.log("已加载 character_state.txt 内容到编辑区。") - - def save_character_state(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.character_text.get("0.0", "end").strip() - char_file = os.path.join(filepath, "character_state.txt") - clear_file_content(char_file) - save_string_to_txt(content, char_file) - self.log("已保存对 character_state.txt 的修改。") - - def load_global_summary(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - summary_file = os.path.join(filepath, "global_summary.txt") - content = read_file(summary_file) - self.summary_text.delete("0.0", "end") - self.summary_text.insert("0.0", content) - self.log("已加载 global_summary.txt 内容到编辑区。") - - def save_global_summary(self): - filepath = self.filepath_var.get().strip() - if not filepath: - messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径") - return - content = self.summary_text.get("0.0", "end").strip() - summary_file = os.path.join(filepath, "global_summary.txt") - clear_file_content(summary_file) - save_string_to_txt(content, summary_file) - self.log("已保存对 global_summary.txt 的修改。") - - -# 入口 if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From f388403b08ae53ea66c61d217ab6d629f572f23b Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 22:43:09 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=AF=B9=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=80=BB=E8=BE=91=E7=9A=84=E4=BC=98=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E4=BD=86=E4=B9=9F=E5=8F=91=E7=8E=B0=E6=96=B0=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前后章节的衔接有问题,这里想着应该要把前一个章节内容发送作为参考,不然中断感很重 --- chapter_directory_parser.py | 24 +++++++----- novel_generator.py | 73 +++++++++++++++++-------------------- ui.py | 36 +++++++++++++----- 3 files changed, 76 insertions(+), 57 deletions(-) diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py index 796b9f5..696cbb8 100644 --- a/chapter_directory_parser.py +++ b/chapter_directory_parser.py @@ -21,13 +21,19 @@ def parse_chapter_blueprint(blueprint_text: str): chunks = re.split(r'\n\s*\n', blueprint_text.strip()) results = [] - chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[(.*?)\]') # 捕获章号与标题 - role_pattern = re.compile(r'^本章定位:\s*(.*)$') - purpose_pattern = re.compile(r'^核心作用:\s*(.*)$') - suspense_pattern = re.compile(r'^悬念密度:\s*(.*)$') - foreshadow_pattern = re.compile(r'^伏笔操作:\s*(.*)$') - twist_pattern = re.compile(r'^认知颠覆:\s*(.*)$') - summary_pattern = re.compile(r'^本章简述:\s*\[(.*)\]$') + # 兼容是否使用方括号包裹章节标题 + # 例如: + # 第1章 - 紫极光下的预兆 + # 或 + # 第1章 - [紫极光下的预兆] + chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[?(.*?)\]?$') + + role_pattern = re.compile(r'^本章定位:\s*\[?(.*)\]?$') + purpose_pattern = re.compile(r'^核心作用:\s*\[?(.*)\]?$') + suspense_pattern = re.compile(r'^悬念密度:\s*\[?(.*)\]?$') + foreshadow_pattern = re.compile(r'^伏笔操作:\s*\[?(.*)\]?$') + twist_pattern = re.compile(r'^认知颠覆:\s*\[?(.*)\]?$') + summary_pattern = re.compile(r'^本章简述:\s*\[?(.*)\]?$') for chunk in chunks: lines = chunk.strip().splitlines() @@ -44,9 +50,9 @@ def parse_chapter_blueprint(blueprint_text: str): chapter_summary = "" # 先匹配第一行(或前几行),找到章号和标题 - header_match = chapter_number_pattern.match(lines[0].strip()) if lines else None + header_match = chapter_number_pattern.match(lines[0].strip()) if not header_match: - # 不符合格式,跳过 + # 不符合“第X章 - 标题”的格式,跳过 continue chapter_number = int(header_match.group(1)) diff --git a/novel_generator.py b/novel_generator.py index f29076c..ebe885c 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -431,8 +431,6 @@ def Chapter_blueprint_generate( return # 从内容中尽量提取 number_of_chapters - # 如果之前已经存储了 number_of_chapters,可以在外面传入,这里做简化: - # 这里用正则或者其他逻辑提取,但演示时直接写 10 也可 match_chaps = re.search(r'约(\d+)章', architecture_text) if match_chaps: number_of_chapters = int(match_chaps.group(1)) @@ -440,10 +438,7 @@ def Chapter_blueprint_generate( number_of_chapters = 10 # fallback # 提取三幕式文本 - # 在写入时,我们将 4) 三幕式情节架构 作为传给 prompt 的核心 - # 这里做一个简易匹配 plot_arch_text = "" - # 假设 "#=== 4) 三幕式情节架构 ===" 是分隔点 pat_plot = r'#=== 4\) 三幕式情节架构 ===\n([\s\S]+)$' m = re.search(pat_plot, architecture_text) if m: @@ -556,7 +551,6 @@ def update_plot_arcs( # ========== 3) 生成章节草稿 ========== - def generate_chapter_draft( api_key: str, base_url: str, @@ -570,6 +564,10 @@ def generate_chapter_draft( key_items: str, scene_location: str, time_constraint: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, embedding_retrieval_k: int = 2 ) -> str: """ @@ -577,7 +575,7 @@ def generate_chapter_draft( - novel_architecture 取自 Novel_architecture.txt - blueprint 取自 Novel_directory.txt - global_summary, character_state 分别取自全局摘要、角色状态文件 - - 向量库检索上下文 + - 从向量库检索上下文(embedding_*参数) - 用户还可以额外提供四个可选元素:核心人物、关键道具、空间坐标、时间压力 """ @@ -609,25 +607,21 @@ def generate_chapter_draft( recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) merged_query_str = "回顾剧情:\n" + "\n".join(recent_3_texts) + "\n" + user_guidance - # 4) 检索向量库上下文 + # 4) 检索向量库上下文 (使用embedding_*参数) relevant_context = get_relevant_context_from_vector_store( - api_key=api_key, - base_url=base_url, + api_key=embedding_api_key, + base_url=embedding_url, query=merged_query_str, - embedding_model_name=model_name, + interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, filepath=filepath, k=embedding_retrieval_k ) - if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 5) 构造prompt,调用 scene_dynamics_prompt - # 在这里,我们拆分架构文本,以便给模型提供: - # - “世界观”与“小说设定”可以从 arch_file 中的相应片段读取 - # 这里为了简化,直接把 novel_architecture_text 整体塞入 novel_setting - # 也可更精细地拆分 "#=== 3) 世界观 ===" 片段给 world_building - # 下方仅作示例。 + # 5) 构造prompt + # 拆分 world_building_text, novel_architecture_text 等等 world_building_text = "" match_world = re.search(r'#=== 3\) 世界观 ===\n([\s\S]+?)\n#===', novel_architecture_text) if match_world: @@ -658,9 +652,8 @@ def generate_chapter_draft( character_state=character_state_text ) - # 因为我们还想让模型了解向量库检索到的上下文,可以合并到最后 + # 合并检索到的上下文和用户指导 prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" - # 也可合并用户指导 prompt_text += f"\n\n【用户指导】\n{user_guidance}\n" model = ChatOpenAI( @@ -674,8 +667,7 @@ def generate_chapter_draft( if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 6) 写入 chapters 目录 - chapters_dir = os.path.join(filepath, "chapters") + # 6) 写入 chapters os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") @@ -695,6 +687,9 @@ def finalize_chapter( model_name: str, temperature: float, filepath: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, embedding_model_name: str ): """ @@ -707,7 +702,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 如果长度比目标少很多,可考虑在此扩写 + # 若篇幅过短,可尝试扩写 if len(chapter_text) < 0.6 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) clear_file_content(chapter_file) @@ -750,12 +745,13 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 3) 更新向量库 + # 3) 更新向量库 (embedding相关) update_vector_store( - api_key=api_key, - base_url=base_url, + api_key=embedding_api_key, + base_url=embedding_url, new_chapter=chapter_text, - model_name=embedding_model_name, # 用于embedding + interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, filepath=filepath ) @@ -827,15 +823,14 @@ def advanced_split_content(content: str, return final_segments def import_knowledge_file( - api_key: str, - base_url: str, - interface_format: str, + embedding_api_key: str, + embedding_url: str, + embedding_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}") + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_interface_format}, 模型: {embedding_model_name}") if not os.path.exists(file_path): logging.warning(f"知识库文件不存在: {file_path}") return @@ -847,20 +842,20 @@ def import_knowledge_file( 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, + api_key=embedding_api_key, + base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + interface_format=embedding_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, + api_key=embedding_api_key, + base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, texts=paragraphs, filepath=filepath diff --git a/ui.py b/ui.py index 2a4fb1d..425dd40 100644 --- a/ui.py +++ b/ui.py @@ -597,11 +597,13 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_chapter) try: + # LLM相关 api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_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() @@ -612,6 +614,10 @@ class NovelGeneratorGUI: scene_loc = self.scene_location_var.get().strip() time_constr = self.time_constraint_var.get().strip() + # Embedding相关 + embedding_api_key = self.embedding_api_key_var.get().strip() + embedding_url = self.embedding_url_var.get().strip() + embedding_interface_format = self.embedding_interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4) @@ -629,6 +635,10 @@ class NovelGeneratorGUI: key_items=key_items, scene_location=scene_loc, time_constraint=time_constr, + embedding_api_key=embedding_api_key, + embedding_url=embedding_url, + embedding_interface_format=embedding_interface_format, + embedding_model_name=embedding_model_name, embedding_retrieval_k=embedding_k ) if draft_text: @@ -659,13 +669,19 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_finalize_chapter) try: + # LLM相关 api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + # Embedding相关 + embedding_api_key = self.embedding_api_key_var.get().strip() + embedding_url = self.embedding_url_var.get().strip() + embedding_interface_format = self.embedding_interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() + # 章节参数 chap_num = self.safe_get_int(self.chapter_num_var, 1) word_number = self.safe_get_int(self.word_number_var, 3000) @@ -686,6 +702,9 @@ class NovelGeneratorGUI: model_name=model_name, temperature=temperature, filepath=filepath, + embedding_api_key=embedding_api_key, + embedding_url=embedding_url, + embedding_interface_format=embedding_interface_format, embedding_model_name=embedding_model_name ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") @@ -754,19 +773,18 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_import_knowledge) try: - 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() + emb_api_key = self.embedding_api_key_var.get().strip() + emb_url = self.embedding_url_var.get().strip() + emb_format = self.embedding_interface_format_var.get().strip() + emb_model = self.embedding_model_name_var.get().strip() self.safe_log(f"开始导入知识库文件: {selected_file}") import_knowledge_file( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, + embedding_api_key=emb_api_key, + embedding_url=emb_url, + embedding_interface_format=emb_format, + embedding_model_name=emb_model, file_path=selected_file, - embedding_base_url=base_url, filepath=self.filepath_var.get().strip() ) self.safe_log("✅ 知识库文件导入完成。") From a000b6929b836a7b29dbeaaad828f2914690a658 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Wed, 5 Feb 2025 23:03:34 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E8=A1=94=E6=8E=A5=E7=9A=84=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++++- novel_generator.py | 4 +++- prompt_definitions.py | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 84696da..fde216c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # 📖 自动小说生成工具 -> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 +## 2025/2/5说明 +>当前**dev-2**分支采用了**雪花写作法、角色弧光理论、悬念三要素模型**的预设,尚在完善中,但就测试质量来说,小说质量会高于当前main分支效果。
@@ -18,6 +19,8 @@
+> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 + --- ## 📑 目录导航 diff --git a/novel_generator.py b/novel_generator.py index ebe885c..ed4f4b7 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -504,7 +504,9 @@ def summarize_recent_chapters( prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: {combined_text} -请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" +请用中文输出不超过1000字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点: +重点强调最后一章的重要内容,以及最后一幕场景细节。 +""" summary_text = invoke_with_cleaning(model, prompt) if not summary_text: diff --git a/prompt_definitions.py b/prompt_definitions.py index 989236d..d23bc04 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -226,5 +226,8 @@ scene_dynamics_prompt = """\ 最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 -仅返回章节正文文本,不要解释任何内容。 +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 """ From f0efb2947e2b67fb08f4d0c41ead5782e71e2881 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 15:01:07 +0800 Subject: [PATCH 05/11] test --- novel_generator.py | 24 +++++------------ prompt_definitions.py | 62 +++++++++++++++++++++++++++++++++---------- ui.py | 20 +++++++++++--- 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index ed4f4b7..6385e59 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -501,11 +501,9 @@ def summarize_recent_chapters( ) combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: -{combined_text} + prompt = f"""你是一名资深长篇小说编辑,分析以下合并文本:\n\n {combined_text} \n\n -请用中文输出不超过1000字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点: -重点强调最后一章的重要内容,以及最后一幕场景细节。 +从中提取并预测下一章节的关键字[关键物品/人物/地点/事件/情节] """ summary_text = invoke_with_cleaning(model, prompt) @@ -622,16 +620,7 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 5) 构造prompt - # 拆分 world_building_text, novel_architecture_text 等等 - world_building_text = "" - match_world = re.search(r'#=== 3\) 世界观 ===\n([\s\S]+?)\n#===', novel_architecture_text) - if match_world: - world_building_text = match_world.group(1).strip() - else: - world_building_text = "暂无世界观信息" - - novel_setting_text = novel_architecture_text # 整份当做“小说设定”参考 + novel_setting_text = novel_architecture_text prompt_text = scene_dynamics_prompt.format( novel_number=novel_number, @@ -648,7 +637,6 @@ def generate_chapter_draft( scene_location=scene_location, time_constraint=time_constraint, - world_building=world_building_text, novel_setting=novel_setting_text, global_summary=global_summary_text, character_state=character_state_text @@ -656,7 +644,7 @@ def generate_chapter_draft( # 合并检索到的上下文和用户指导 prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" - prompt_text += f"\n\n【用户指导】\n{user_guidance}\n" + prompt_text += f"\n\n【章节额外指导】\n{user_guidance}\n" model = ChatOpenAI( model=model_name, @@ -847,7 +835,7 @@ def import_knowledge_file( # 尝试加载已有的向量库 store = load_vector_store( api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + base_url=embedding_url if embedding_url else "http://localhost:11434/api", interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, filepath=filepath @@ -856,7 +844,7 @@ def import_knowledge_file( logging.info("Vector store does not exist. Initializing a new one for knowledge import...") init_vector_store( api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/v1", + base_url=embedding_url if embedding_url else "http://localhost:11434/api", interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, texts=paragraphs, diff --git a/prompt_definitions.py b/prompt_definitions.py index d23bc04..2410ce0 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -83,19 +83,19 @@ plot_architecture_prompt = """\ 世界观:{world_building} 要求按以下结构设计: -▶ 第一幕(触发) +第一幕(触发) - 日常状态中的异常征兆(3处铺垫) - 引出故事:展示主线、暗线、副线的开端 - 关键事件:打破平衡的催化剂(需改变至少3个角色的关系) - 错误抉择:主角的认知局限导致的错误反应 -▶ 第二幕(对抗) +第二幕(对抗) - 剧情升级:主线+副线的交叉点 - 双重压力:外部障碍升级+内部挫折 - 虚假胜利:看似解决实则深化危机的转折点 - 灵魂黑夜:世界观认知颠覆时刻 -▶ 第三幕(解决) +第三幕(解决) - 代价显现:解决危机必须牺牲的核心价值 - 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) - 余波:留下2个开放式悬念因子 @@ -169,12 +169,43 @@ update_character_state_prompt = """\ {old_state} 请更新角色状态,内容包括: -角色属性: -- 角色物品 -- 能力 -- 状态 -- 主要角色间关系网 -- 触发或加深的事件 +角色A属性: +├──物品: + ├──道具1:描述 + ├──道具2:描述 + ... +├──能力 + ├──技能1:描述 + ├──技能2:描述 + ... +├──状态 + ├──身体状态: + ├──Buff/Debuff + ├──心理状态:描述 + +├──主要角色间关系网 + ├──角色B:描述 + ├──角色C:描述 + ... +├──触发或加深的事件 + ├──事件1:描述 + ├──事件2:描述 + ... + +角色B属性: +├──物品 + ├──... +├──能力 + ├──... +├──状态 + ├──... +├──主要角色间关系网 + ├──... +├──触发或加深的事件 + ├──... + +角色C属性: +...... 新出场角色: - 任何新增角色或临时出场人物的基本信息 @@ -203,10 +234,13 @@ scene_dynamics_prompt = """\ - 时间压力(可能未指定):{time_constraint} 参考文档: -- 世界观:{world_building} -- 小说设定:{novel_setting} -- 全局摘要:{global_summary} -- 角色状态:{character_state} +- 小说设定:\n{novel_setting}\n +- 全局摘要:\n{global_summary}\n +- 角色状态:\n{character_state}\n + +前章片段(可能为空):\n{previous_chapter_excerpt}\n + +本地向量库检索到的上下文片段(可能为空):\n{context_excerpt}\n 请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: @@ -224,7 +258,7 @@ scene_dynamics_prompt = """\ - 隐喻系统的运用(连接世界观符号) - 决策前的价值天平描写 -最后设置一个"钩链转折":结尾同时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 格式要求: - 仅返回章节正文文本; diff --git a/ui.py b/ui.py index 425dd40..63f6360 100644 --- a/ui.py +++ b/ui.py @@ -208,9 +208,12 @@ class NovelGeneratorGUI: def build_ai_config_tab(self): def on_interface_format_changed(new_value): - if new_value == "OpenAI": + if new_value == "Ollama": + self.base_url_var.set("http://localhost:11434/v1") + elif new_value == "ML Studio": + self.base_url_var.set("http://localhost:1234/v1") + elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") - # 可根据需要扩展 Ollama / ML Studio 等 for i in range(5): self.ai_config_tab.grid_rowconfigure(i, weight=0) @@ -230,7 +233,7 @@ class NovelGeneratorGUI: interface_label = ctk.CTkLabel(self.ai_config_tab, text="LLM 接口格式:", font=("Microsoft YaHei", 12)) interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI"] + interface_options = ["OpenAI", "Ollama", "ML Studio"] interface_dropdown = ctk.CTkOptionMenu( self.ai_config_tab, values=interface_options, @@ -268,6 +271,14 @@ class NovelGeneratorGUI: self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") 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/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(5): self.embeddings_config_tab.grid_rowconfigure(i, weight=0) self.embeddings_config_tab.grid_columnconfigure(0, weight=0) @@ -280,11 +291,12 @@ class NovelGeneratorGUI: 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"] # 可扩展 + 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") From bf55e74ac9b9b96e7d45934c67380c9db48815b0 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 15:54:25 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E5=88=86=E7=A6=BB=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator.py | 187 ++++++++++++++++++++++-------------------- prompt_definitions.py | 68 ++++++++++----- 2 files changed, 142 insertions(+), 113 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index 6385e59..2337d5b 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -5,7 +5,7 @@ import logging import re import time import traceback -from typing import List, Optional +from typing import List, Optional, Tuple # langchain 相关 from langchain_openai import ChatOpenAI, OpenAIEmbeddings @@ -33,7 +33,8 @@ from prompt_definitions import ( chapter_blueprint_prompt, summary_prompt, update_character_state_prompt, - scene_dynamics_prompt + chapter_draft_prompt, + summarize_recent_chapters_prompt ) # Ollama嵌入 (如使用Ollama时需要) @@ -51,8 +52,12 @@ def remove_think_tags(text: str) -> str: return re.sub(r'.*?', '', text, flags=re.DOTALL) def debug_log(prompt: str, response_content: str): - logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") - logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") + 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: """通用封装:调用模型并移除 ... 文本,记录日志后返回""" @@ -132,9 +137,8 @@ def clear_vector_store(filepath: str) -> bool: return False try: - if os.path.exists(store_dir): - shutil.rmtree(store_dir) - logging.info(f"Vector store directory '{store_dir}' removed.") + shutil.rmtree(store_dir) + logging.info(f"Vector store directory '{store_dir}' removed.") return True except Exception as e: logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") @@ -222,7 +226,6 @@ def split_text_for_vectorstore(chapter_text: str, return [] nltk.download('punkt', quiet=True) - nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(chapter_text) if not sentences: return [] @@ -332,7 +335,7 @@ def get_relevant_context_from_vector_store( return combined -# ========== 1) 生成总体架构 (Novel_architecture.txt) ========== +# ============ 1) 生成总体架构 (Novel_architecture.txt) ============ def Novel_architecture_generate( api_key: str, base_url: str, @@ -408,7 +411,7 @@ def Novel_architecture_generate( logging.info("Novel_architecture.txt has been generated successfully.") -# ========== 2) 生成章节蓝图 (Novel_directory.txt) ========== +# ============ 2) 生成章节蓝图 (Novel_directory.txt) ============ def Chapter_blueprint_generate( api_key: str, base_url: str, @@ -467,31 +470,41 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") -# ============ 获取最近 N 章内容,生成短期摘要 ============ +# ============ 工具:获取最近N章内容 ============ + def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: + """ + 返回从 (current_chapter_num - n) 开始到 (current_chapter_num-1) 的章节文本列表。 + 若缺少文件,则对应位置为空字符串。 + """ texts = [] start_chap = max(1, current_chapter_num - n) for c in range(start_chap, current_chapter_num): chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") if os.path.exists(chap_file): text = read_file(chap_file).strip() - if text: - texts.append(text) - if len(texts) < n: - texts = [''] * (n - len(texts)) + texts + texts.append(text) + else: + texts.append("") return texts + +# ============ 新增函数:从合并文本中提炼「当前情节短期摘要」 & 「下一章关键字」 ============ def summarize_recent_chapters( llm_model: str, api_key: str, base_url: str, 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): - return "暂无摘要。" +) -> Tuple[str, str]: + """ + 输入若干章节文本,合并后调用 summarize_recent_chapters_prompt, + 返回 (short_summary, next_chapter_keywords) + 如果解析失败,则返回(合并文本, "") + """ + combined_text = "\n".join(chapters_text_list).strip() + if not combined_text: + return ("", "") model = ChatOpenAI( model=llm_model, @@ -500,57 +513,29 @@ def summarize_recent_chapters( temperature=temperature ) - combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说编辑,分析以下合并文本:\n\n {combined_text} \n\n + prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) + response_text = invoke_with_cleaning(model, prompt) -从中提取并预测下一章节的关键字[关键物品/人物/地点/事件/情节] -""" + # 简易解析 + short_summary = "" + next_chapter_keywords = "" - summary_text = invoke_with_cleaning(model, prompt) - if not summary_text: - return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text - return summary_text + for line in response_text.splitlines(): + line = line.strip() + if line.startswith("短期摘要:"): + short_summary = line.replace("短期摘要:", "").strip() + elif line.startswith("下一章关键字:"): + next_chapter_keywords = line.replace("下一章关键字:", "").strip() + + # 如果解析失败,就把返回文本当作短期摘要 + if not short_summary and not next_chapter_keywords: + short_summary = response_text + + return (short_summary, next_chapter_keywords) -# ============ 剧情要点/冲突 ============ -PLOT_ARCS_PROMPT = """\ -下面是新生成的章节内容: -{chapter_text} +# ============ 3) 生成章节草稿(新版) ============ -这里是已记录的剧情要点/未解决冲突(可能为空): -{old_plot_arcs} - -请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 -若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 -""" - -def update_plot_arcs( - chapter_text: str, - old_plot_arcs: str, - api_key: str, - base_url: str, - model_name: str, - temperature: float -) -> str: - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - prompt = PLOT_ARCS_PROMPT.format( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs - ) - arcs_text = invoke_with_cleaning(model, prompt) - if not arcs_text: - logging.warning("update_plot_arcs: No response or empty result.") - return old_plot_arcs - return arcs_text - - -# ========== 3) 生成章节草稿 ========== def generate_chapter_draft( api_key: str, base_url: str, @@ -571,12 +556,12 @@ def generate_chapter_draft( embedding_retrieval_k: int = 2 ) -> str: """ - 根据 scene_dynamics_prompt,生成本章草稿。 - - novel_architecture 取自 Novel_architecture.txt - - blueprint 取自 Novel_directory.txt - - global_summary, character_state 分别取自全局摘要、角色状态文件 - - 从向量库检索上下文(embedding_*参数) - - 用户还可以额外提供四个可选元素:核心人物、关键道具、空间坐标、时间压力 + 根据新的 chapter_draft_prompt,生成本章草稿。 + - 首先获取最近3章文本 => 提炼短期摘要 & 下一章关键字 + - 使用(短期摘要 + 下一章关键字) 拼成 query => 检索向量库 + - 同时取上一章(或最后一个非空章节)末尾1500字作为 "前章片段" + - 组合所有信息后,调用模型生成章节草稿 + - 最后保存到 chapters/chapter_{novel_number}.txt """ # 1) 读取相关文件 @@ -602,16 +587,37 @@ def generate_chapter_draft( plot_twist_level = chapter_info["plot_twist_level"] chapter_summary = chapter_info["chapter_summary"] - # 3) 取最近3章文本,拼成查询语句 => 用于向量库检索 chapters_dir = os.path.join(filepath, "chapters") - recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) - merged_query_str = "回顾剧情:\n" + "\n".join(recent_3_texts) + "\n" + user_guidance + os.makedirs(chapters_dir, exist_ok=True) - # 4) 检索向量库上下文 (使用embedding_*参数) + # 3) 获取最近3章文本 => 提炼 (短期摘要 & 下一章关键字) + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + short_summary, next_chapter_keywords = summarize_recent_chapters( + llm_model=model_name, + api_key=api_key, + base_url=base_url, + temperature=temperature, + chapters_text_list=recent_3_texts + ) + + # 4) 取上一章片段(或最后一个非空章节)的末尾1500字 + previous_chapter_excerpt = "" + for text_block in reversed(recent_3_texts): + if text_block.strip(): + # 找到最近一个非空章节 + if len(text_block) > 1500: + previous_chapter_excerpt = text_block[-1500:] + else: + previous_chapter_excerpt = text_block + break + # 如果全为空,则 previous_chapter_excerpt 就是 "" + + # 5) 构造向量检索查询: (短期摘要 + 下一章关键字) + retrieval_query = short_summary + " " + next_chapter_keywords relevant_context = get_relevant_context_from_vector_store( api_key=embedding_api_key, base_url=embedding_url, - query=merged_query_str, + query=retrieval_query, interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, filepath=filepath, @@ -620,9 +626,8 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - novel_setting_text = novel_architecture_text - - prompt_text = scene_dynamics_prompt.format( + # 6) 组装 Prompt + prompt_text = chapter_draft_prompt.format( novel_number=novel_number, chapter_title=chapter_title, chapter_role=chapter_role, @@ -636,16 +641,17 @@ def generate_chapter_draft( key_items=key_items, scene_location=scene_location, time_constraint=time_constraint, + user_guidance=user_guidance, - novel_setting=novel_setting_text, + novel_setting=novel_architecture_text, global_summary=global_summary_text, - character_state=character_state_text + character_state=character_state_text, + + previous_chapter_excerpt=previous_chapter_excerpt, + context_excerpt=relevant_context ) - # 合并检索到的上下文和用户指导 - prompt_text += f"\n\n【检索到的上下文】\n{relevant_context}" - prompt_text += f"\n\n【章节额外指导】\n{user_guidance}\n" - + # 7) 调用 LLM 生成章节正文 model = ChatOpenAI( model=model_name, api_key=api_key, @@ -657,10 +663,8 @@ def generate_chapter_draft( if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 6) 写入 chapters - os.makedirs(chapters_dir, exist_ok=True) + # 8) 写入 chapters chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -668,7 +672,7 @@ def generate_chapter_draft( return chapter_content -# ========== 4) 定稿章节 ========== +# ============ 4) 定稿章节 ============ def finalize_chapter( novel_number: int, word_number: int, @@ -735,7 +739,7 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 3) 更新向量库 (embedding相关) + # 3) 更新向量库 update_vector_store( api_key=embedding_api_key, base_url=embedding_url, @@ -771,6 +775,7 @@ def enrich_chapter_text( # ============ 导入外部知识文本到向量库 ============ + def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> List[str]: diff --git a/prompt_definitions.py b/prompt_definitions.py index 2410ce0..72dc37c 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -1,7 +1,22 @@ # prompt_definitions.py # -*- coding: utf-8 -*- """ -集中存放所有提示词(Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型 +集中存放所有提示词 (Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型等 +并包含新增加的短期摘要/下一章关键字提炼提示词,以及章节正文写作提示词。 +""" + +# =============== 摘要与下一章关键字提炼 =============== +summarize_recent_chapters_prompt = """\ +你是一名资深长篇小说编辑,请分析以下合并文本(可能包含最近几章内容): +{combined_text} + +现在请你基于目前故事的进展,完成以下两件事: +1) 用最多200字,写一个简洁明了的「当前情节短期摘要」。 +2) 提炼「下一章」的关键字(例如关键物品、重要人物、地点、事件、情节等),可以用逗号分隔或条目列出。 + +请按如下格式输出(不需要额外解释): +短期摘要: <这里写短期摘要> +下一章关键字: <这里写下一章关键字> """ # =============== 1. 核心种子设定(雪花第1层)=================== @@ -67,7 +82,7 @@ world_building_prompt = """\ 3. 隐喻维度: - 贯穿全书的视觉符号系统(如反复出现的意象) -- 气候/环境变化映射的心理状态 +- 氣候/环境变化映射的心理状态 - 建筑风格暗示的文明困境 要求: @@ -75,7 +90,7 @@ world_building_prompt = """\ 仅给出最终文本,不要解释任何内容。 """ -# =============== 4. 情节架构(悬念三幕式)=================== +# =============== 4. 情节架构(三幕式悬念)=================== plot_architecture_prompt = """\ 基于以下元素构建三幕式悬念架构: 核心种子:{core_seed} @@ -95,7 +110,7 @@ plot_architecture_prompt = """\ - 虚假胜利:看似解决实则深化危机的转折点 - 灵魂黑夜:世界观认知颠覆时刻 -第三幕(解决) +第三幕(解决) - 代价显现:解决危机必须牺牲的核心价值 - 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) - 余波:留下2个开放式悬念因子 @@ -217,9 +232,9 @@ update_character_state_prompt = """\ 仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 8. 章节正文写作 =================== -scene_dynamics_prompt = """\ -即将创作:第{novel_number}章《{chapter_title}》 +# =============== 8. 章节正文写作(新版) =================== +chapter_draft_prompt = """\ +即将创作:第 {novel_number} 章《{chapter_title}》 本章定位:{chapter_role} 核心作用:{chapter_purpose} 悬念密度:{suspense_level} @@ -234,34 +249,43 @@ scene_dynamics_prompt = """\ - 时间压力(可能未指定):{time_constraint} 参考文档: -- 小说设定:\n{novel_setting}\n -- 全局摘要:\n{global_summary}\n -- 角色状态:\n{character_state}\n +- 小说设定: +{novel_setting} -前章片段(可能为空):\n{previous_chapter_excerpt}\n +- 全局摘要: +{global_summary} -本地向量库检索到的上下文片段(可能为空):\n{context_excerpt}\n +- 角色状态: +{character_state} + +前章片段(可能为空): +{previous_chapter_excerpt} + +本地知识(向量)库检索到的片段(可能为空): +{context_excerpt} 请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: -- 潜台词冲突(表面谈论A,实际博弈B) -- 权力关系变化(使用非对称对话长度控制) -- 至少1处双关语暗示未来危机 + - 潜台词冲突(表面谈论A,实际博弈B) + - 权力关系变化(通过非对称对话长度体现) + - 至少1处双关语暗示未来危机 2. 动作场景: -- 环境交互细节(至少3个感官描写维度) -- 节奏控制(短句加速+比喻减速) -- 动作揭示人物隐藏特质 + - 环境交互细节(至少3个感官描写) + - 节奏控制(短句加速+比喻减速) + - 动作揭示人物隐藏特质 3. 心理场景: -- 认知失调的具体表现(行为矛盾) -- 隐喻系统的运用(连接世界观符号) -- 决策前的价值天平描写 + - 认知失调的具体表现(行为矛盾) + - 隐喻系统的运用(连接世界观符号) + - 决策前的价值天平描写 -文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知预设/神转折等。 +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 格式要求: - 仅返回章节正文文本; - 不使用分章节小标题; - 不要使用markdown格式。 + +用户额外指导(可能未指定):{user_guidance} """ From a2b86a90dc2615745fc0c9c8b57d21ca736cfef4 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 18:35:28 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E4=B8=AD=E9=80=94=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_adapters.py | 122 ++++++++++ llm_adapters.py | 148 ++++++++++++ novel_generator.py | 515 +++++++++++++++++------------------------- tooltips.py | 27 +++ ui.py | 42 ++-- 5 files changed, 523 insertions(+), 331 deletions(-) create mode 100644 embedding_adapters.py create mode 100644 llm_adapters.py create mode 100644 tooltips.py diff --git a/embedding_adapters.py b/embedding_adapters.py new file mode 100644 index 0000000..8c39f51 --- /dev/null +++ b/embedding_adapters.py @@ -0,0 +1,122 @@ +# embedding_adapters.py +# -*- coding: utf-8 -*- +import logging +import requests +import traceback +from typing import List +from langchain_openai import OpenAIEmbeddings + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + +class BaseEmbeddingAdapter: + """ + Embedding 接口统一基类 + """ + def embed_documents(self, texts: List[str]) -> List[List[float]]: + raise NotImplementedError + + def embed_query(self, query: str) -> List[float]: + raise NotImplementedError + +class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 OpenAIEmbeddings(或兼容接口)的适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + self._embedding = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=ensure_openai_base_url_has_v1(base_url), + model=model_name + ) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embedding.embed_documents(texts) + + def embed_query(self, query: str) -> List[float]: + return self._embedding.embed_query(query) + +class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): + """ + Ollama Embedding,示例中和之前的 embedding_ollama.py 类似 + 其接口路径往往为 /api/embeddings + """ + def __init__(self, model_name: str, base_url: str): + self.model_name = model_name + self.base_url = base_url.rstrip("/") + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + embeddings = [] + for text in texts: + vec = self._embed_single(text) + embeddings.append(vec) + return embeddings + + def embed_query(self, query: str) -> List[float]: + return self._embed_single(query) + + def _embed_single(self, text: str) -> List[float]: + """ + 调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding + """ + # 如果 base_url 中已含 /api/embeddings,可直接用;否则拼上 /api/embeddings + url = self.base_url + if "api/embeddings" not in url: + url = f"{url}/api/embeddings" + + data = { + "model": self.model_name, + "prompt": text + } + try: + response = requests.post(url, json=data) + response.raise_for_status() + result = response.json() + if "embedding" not in result: + raise ValueError("No 'embedding' field in Ollama response.") + return result["embedding"] + except requests.exceptions.RequestException as e: + logging.error(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}") + return [] + +class MLStudioEmbeddingAdapter(BaseEmbeddingAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str): + self._embedding = OpenAIEmbeddings( + openai_api_key=api_key, + openai_api_base=ensure_openai_base_url_has_v1(base_url), + model=model_name + ) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + return self._embedding.embed_documents(texts) + + def embed_query(self, query: str) -> List[float]: + return self._embedding.embed_query(query) + +def create_embedding_adapter( + interface_format: str, + api_key: str, + base_url: str, + model_name: str +) -> BaseEmbeddingAdapter: + """ + 工厂函数:根据 interface_format 返回不同的 embedding 适配器实例 + """ + if interface_format.lower() == "openai": + return OpenAIEmbeddingAdapter(api_key, base_url, model_name) + elif interface_format.lower() == "ollama": + return OllamaEmbeddingAdapter(model_name, base_url) + elif interface_format.lower() == "ml studio": + return MLStudioEmbeddingAdapter(api_key, base_url, model_name) + else: + raise ValueError(f"Unknown embedding interface_format: {interface_format}") diff --git a/llm_adapters.py b/llm_adapters.py new file mode 100644 index 0000000..2fa5e37 --- /dev/null +++ b/llm_adapters.py @@ -0,0 +1,148 @@ +# llm_adapters.py +# -*- coding: utf-8 -*- +import logging +from typing import Optional +from langchain_openai import ChatOpenAI + +def ensure_openai_base_url_has_v1(url: str) -> str: + """ + 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 + """ + import re + url = url.strip() + if not url: + return url + if not re.search(r'/v\d+$', url): + if '/v1' not in url: + url = url.rstrip('/') + '/v1' + return url + +class BaseLLMAdapter: + """ + 统一的 LLM 接口基类,为不同后端(OpenAI、Ollama、ML Studio 等)提供一致的方法签名。 + """ + def invoke(self, prompt: str) -> str: + raise NotImplementedError("Subclasses must implement .invoke(prompt) method.") + +class DeepSeekAdapter(BaseLLMAdapter): + """ + 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.content + +class OpenAIAdapter(BaseLLMAdapter): + """ + 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from OpenAIAdapter.") + return "" + return response.content + +class OllamaAdapter(BaseLLMAdapter): + """ + Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 + 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from OllamaAdapter.") + return "" + return response.content + +class MLStudioAdapter(BaseLLMAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + self.base_url = ensure_openai_base_url_has_v1(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + + self._client = ChatOpenAI( + model=self.model_name, + api_key=self.api_key, + base_url=self.base_url, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from MLStudioAdapter.") + return "" + return response.content + +def create_llm_adapter( + interface_format: str, + base_url: str, + model_name: str, + api_key: str, + temperature: float +) -> BaseLLMAdapter: + """ + 工厂函数:根据 interface_format 返回不同的适配器实例。 + """ + if interface_format.lower() == "deepseek": + return DeepSeekAdapter(api_key, base_url, model_name, temperature) + elif interface_format.lower() == "openai": + return OpenAIAdapter(api_key, base_url, model_name, temperature) + elif interface_format.lower() == "ollama": + return OllamaAdapter(api_key, base_url, model_name, temperature) + elif interface_format.lower() == "ml studio": + return MLStudioAdapter(api_key, base_url, model_name, temperature) + else: + raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/novel_generator.py b/novel_generator.py index 2337d5b..9e6206a 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -7,8 +7,6 @@ import time import traceback from typing import List, Optional, Tuple -# langchain 相关 -from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_chroma import Chroma from chromadb.config import Settings from langchain.docstore.document import Document @@ -37,16 +35,16 @@ from prompt_definitions import ( summarize_recent_chapters_prompt ) -# Ollama嵌入 (如使用Ollama时需要) -from embedding_ollama import OllamaEmbeddings - -# 用于目录解析章节标题/简介 +# 章节目录解析 from chapter_directory_parser import get_chapter_info_from_blueprint +from llm_adapters import create_llm_adapter +from embedding_adapters import create_embedding_adapter + 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) @@ -59,128 +57,79 @@ def debug_log(prompt: str, response_content: str): f"\n[######################################### Response #########################################]\n{response_content}\n" ) -def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: - """通用封装:调用模型并移除 ... 文本,记录日志后返回""" - response = model.invoke(prompt) +def invoke_with_cleaning(llm_adapter, prompt: str) -> str: + """通用封装:调用 LLM,并移除 ... 文本,记录日志后返回""" + response = llm_adapter.invoke(prompt) if not response: logging.warning("No response from model.") return "" - cleaned_text = remove_think_tags(response.content) + cleaned_text = remove_think_tags(response) debug_log(prompt, cleaned_text) return cleaned_text.strip() -def ensure_openai_base_url_has_v1(url: str) -> str: - """ - 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 - """ - import re - url = url.strip() - if not url: - return url - 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, - interface_format: str, - embedding_model_name: str -): - """ - 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 - """ - 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,注意 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, - model=embedding_model_name - ) - - -# ============ 向量库相关操作 ============ def clear_vector_store(filepath: str) -> bool: - """ - 返回值表示是否成功清空向量库。 - """ import shutil - store_dir = get_vectorstore_dir(filepath) if not os.path.exists(store_dir): logging.info("No vector store found to clear.") return False - try: shutil.rmtree(store_dir) logging.info(f"Vector store directory '{store_dir}' removed.") return True except Exception as e: - logging.error(f"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") + logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}") traceback.print_exc() return False +# ============ 根据 embedding 接口创建/加载 Chroma ============ + def init_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, + embedding_adapter, texts: List[str], filepath: str ) -> Chroma: """ 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + 这里 embedding_adapter 是一个实现了 embed_documents(texts) 的对象 """ 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, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) + # 将文本封装为 Document documents = [Document(page_content=str(t)) for t in texts] + + # 因为我们是自定义的 embeddings,对接Chroma时需包装一个“langchain兼容对象” + # 这里示例:写一个包装函数 + from langchain.embeddings.base import Embeddings as LCEmbeddings + + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return embedding_adapter.embed_documents(doc_texts) + + def embed_query(self, query_text: str) -> List[float]: + return embedding_adapter.embed_query(query_text) + + chroma_embedding = LCEmbeddingWrapper() + vectorstore = Chroma.from_documents( documents, - embedding=embeddings, + embedding=chroma_embedding, persist_directory=store_dir, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) return vectorstore - def load_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, + embedding_adapter, filepath: str ) -> Optional[Chroma]: """ @@ -191,19 +140,26 @@ def load_vector_store( logging.info("Vector store not found. Will return None.") return None - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) + # 同样要包装embedding_adapter + from langchain.embeddings.base import Embeddings as LCEmbeddings + + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, doc_texts: List[str]) -> List[List[float]]: + return embedding_adapter.embed_documents(doc_texts) + + def embed_query(self, query_text: str) -> List[float]: + return embedding_adapter.embed_query(query_text) + + chroma_embedding = LCEmbeddingWrapper() + return Chroma( persist_directory=store_dir, - embedding_function=embeddings, + embedding_function=chroma_embedding, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) +# ============ 文本分段工具 ============ def split_by_length(text: str, max_length: int = 500) -> List[str]: segments = [] @@ -215,12 +171,12 @@ def split_by_length(text: str, max_length: int = 500) -> List[str]: 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]: """ 对新的章节文本进行分段后,再用于存入向量库。 + 先句子切分 -> 语义相似度合并 -> 再按 max_length 切分。 """ if not chapter_text.strip(): return [] @@ -230,7 +186,6 @@ def split_text_for_vectorstore(chapter_text: str, if not sentences: return [] - # 先对相近句子进行合并 model = SentenceTransformer('paraphrase-MiniLM-L6-v2') embeddings = model.encode(sentences) @@ -251,7 +206,6 @@ def split_text_for_vectorstore(chapter_text: str, if current_sentences: merged_paragraphs.append(" ".join(current_sentences)) - # 再对合并好的段落做 max_length 切分 final_segments = [] for para in merged_paragraphs: if len(para) > max_length: @@ -262,13 +216,11 @@ def split_text_for_vectorstore(chapter_text: str, return final_segments +# ============ 更新向量库 ============ def update_vector_store( - api_key: str, - base_url: str, + embedding_adapter, new_chapter: str, - interface_format: str, - embedding_model_name: str, filepath: str ): """ @@ -279,49 +231,28 @@ def update_vector_store( logging.warning("No valid text to insert into vector store. Skipping.") return - store = load_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("Vector store does not exist. Initializing a new one for new chapter...") - init_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=splitted_texts, - filepath=filepath - ) + init_vector_store(embedding_adapter, splitted_texts, filepath) return 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( - api_key: str, - base_url: str, + embedding_adapter, query: str, - interface_format: str, - embedding_model_name: 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, - filepath=filepath - ) + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("No vector store found. Returning empty context.") return "" @@ -334,8 +265,68 @@ def get_relevant_context_from_vector_store( combined = "\n".join([d.page_content for d in docs]) return combined +# ============ 从目录中获取最近 n 章文本 ============ + +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: + texts = [] + start_chap = max(1, current_chapter_num - n) + for c in range(start_chap, current_chapter_num): + chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") + if os.path.exists(chap_file): + text = read_file(chap_file).strip() + texts.append(text) + else: + texts.append("") + return texts + +# ============ 提炼(短期摘要, 下一章关键字) ============ + +def summarize_recent_chapters( + interface_format: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + chapters_text_list: List[str] +) -> Tuple[str, str]: + """ + 生成 (short_summary, next_chapter_keywords) + 如果解析失败,则返回 (合并文本, "") + """ + combined_text = "\n".join(chapters_text_list).strip() + if not combined_text: + return ("", "") + + # 1) 构造 llm_adapter + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature + ) + + prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) + response_text = invoke_with_cleaning(llm_adapter, prompt) + + short_summary = "" + next_chapter_keywords = "" + + for line in response_text.splitlines(): + line = line.strip() + if line.startswith("短期摘要:"): + short_summary = line.replace("短期摘要:", "").strip() + elif line.startswith("下一章关键字:"): + next_chapter_keywords = line.replace("下一章关键字:", "").strip() + + if not short_summary and not next_chapter_keywords: + short_summary = response_text + + return (short_summary, next_chapter_keywords) + + +# ============ 1) 生成总体架构 ============ -# ============ 1) 生成总体架构 (Novel_architecture.txt) ============ def Novel_architecture_generate( api_key: str, base_url: str, @@ -348,70 +339,68 @@ def Novel_architecture_generate( temperature: float = 0.7 ) -> None: """ - 依次调用: + 依次调用: 1. core_seed_prompt 2. character_dynamics_prompt 3. world_building_prompt 4. plot_architecture_prompt - 将结果整合为“Novel_architecture.txt”。 + 最终输出 Novel_architecture.txt """ os.makedirs(filepath, exist_ok=True) - model = ChatOpenAI( - model=llm_model, + + # 通过工厂函数创建 LLM 适配器 + llm_adapter = create_llm_adapter( + interface_format="openai", # 或根据你的实际:若你在UI中就是 "OpenAI" 就传递过来 + base_url=base_url, + model_name=llm_model, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - # 1) 核心种子 + # Step1: 核心种子 prompt_core = core_seed_prompt.format( topic=topic, genre=genre, number_of_chapters=number_of_chapters, word_number=word_number ) - core_seed_result = invoke_with_cleaning(model, prompt_core) - core_seed_text = core_seed_result.strip() + core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) - # 2) 角色动力学 - prompt_character = character_dynamics_prompt.format(core_seed=core_seed_text) - character_dynamics_result = invoke_with_cleaning(model, prompt_character) - character_dynamics_text = character_dynamics_result.strip() + # Step2: 角色动力学 + prompt_character = character_dynamics_prompt.format(core_seed=core_seed_result.strip()) + character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) - # 3) 世界观 - prompt_world = world_building_prompt.format(core_seed=core_seed_text) - world_building_result = invoke_with_cleaning(model, prompt_world) - world_building_text = world_building_result.strip() + # Step3: 世界观 + prompt_world = world_building_prompt.format(core_seed=core_seed_result.strip()) + world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) - # 4) 三幕式情节架构 + # Step4: 三幕式情节 prompt_plot = plot_architecture_prompt.format( - core_seed=core_seed_text, - character_dynamics=character_dynamics_text, - world_building=world_building_text + core_seed=core_seed_result.strip(), + character_dynamics=character_dynamics_result.strip(), + world_building=world_building_result.strip() ) - plot_arch_result = invoke_with_cleaning(model, prompt_plot) - plot_arch_text = plot_arch_result.strip() + plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) - # 整合并写入 Novel_architecture.txt + # 合并 final_content = ( "#=== 1) 核心种子 ===\n" - f"{core_seed_text}\n\n" + f"{core_seed_result}\n\n" "#=== 2) 角色动力学 ===\n" - f"{character_dynamics_text}\n\n" + f"{character_dynamics_result}\n\n" "#=== 3) 世界观 ===\n" - f"{world_building_text}\n\n" + f"{world_building_result}\n\n" "#=== 4) 三幕式情节架构 ===\n" - f"{plot_arch_text}\n" + f"{plot_arch_result}\n" ) arch_file = os.path.join(filepath, "Novel_architecture.txt") clear_file_content(arch_file) save_string_to_txt(final_content, arch_file) - logging.info("Novel_architecture.txt has been generated successfully.") +# ============ 2) 生成章节蓝图 ============ -# ============ 2) 生成章节蓝图 (Novel_directory.txt) ============ def Chapter_blueprint_generate( api_key: str, base_url: str, @@ -419,10 +408,6 @@ def Chapter_blueprint_generate( filepath: str, temperature: float = 0.7 ) -> None: - """ - 基于“Novel_architecture.txt”中的三幕式情节架构,调用 chapter_blueprint_prompt, - 生成章节蓝图并写入 Novel_directory.txt。 - """ arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): logging.warning("Novel_architecture.txt not found. Please generate architecture first.") @@ -433,12 +418,11 @@ def Chapter_blueprint_generate( logging.warning("Novel_architecture.txt is empty.") return - # 从内容中尽量提取 number_of_chapters match_chaps = re.search(r'约(\d+)章', architecture_text) if match_chaps: number_of_chapters = int(match_chaps.group(1)) else: - number_of_chapters = 10 # fallback + number_of_chapters = 10 # 提取三幕式文本 plot_arch_text = "" @@ -447,10 +431,11 @@ def Chapter_blueprint_generate( if m: plot_arch_text = m.group(1).strip() - model = ChatOpenAI( - model=llm_model, + llm_adapter = create_llm_adapter( + interface_format="openai", # 或实际由UI传入 + base_url=base_url, + model_name=llm_model, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) @@ -458,7 +443,7 @@ def Chapter_blueprint_generate( plot_architecture=plot_arch_text, number_of_chapters=number_of_chapters ) - blueprint_text = invoke_with_cleaning(model, prompt) + blueprint_text = invoke_with_cleaning(llm_adapter, prompt) if not blueprint_text.strip(): logging.warning("Chapter blueprint generation result is empty.") return @@ -469,72 +454,7 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") - -# ============ 工具:获取最近N章内容 ============ - -def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: - """ - 返回从 (current_chapter_num - n) 开始到 (current_chapter_num-1) 的章节文本列表。 - 若缺少文件,则对应位置为空字符串。 - """ - texts = [] - start_chap = max(1, current_chapter_num - n) - for c in range(start_chap, current_chapter_num): - chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") - if os.path.exists(chap_file): - text = read_file(chap_file).strip() - texts.append(text) - else: - texts.append("") - return texts - - -# ============ 新增函数:从合并文本中提炼「当前情节短期摘要」 & 「下一章关键字」 ============ -def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - temperature: float, - chapters_text_list: List[str] -) -> Tuple[str, str]: - """ - 输入若干章节文本,合并后调用 summarize_recent_chapters_prompt, - 返回 (short_summary, next_chapter_keywords) - 如果解析失败,则返回(合并文本, "") - """ - combined_text = "\n".join(chapters_text_list).strip() - if not combined_text: - return ("", "") - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) - response_text = invoke_with_cleaning(model, prompt) - - # 简易解析 - short_summary = "" - next_chapter_keywords = "" - - for line in response_text.splitlines(): - line = line.strip() - if line.startswith("短期摘要:"): - short_summary = line.replace("短期摘要:", "").strip() - elif line.startswith("下一章关键字:"): - next_chapter_keywords = line.replace("下一章关键字:", "").strip() - - # 如果解析失败,就把返回文本当作短期摘要 - if not short_summary and not next_chapter_keywords: - short_summary = response_text - - return (short_summary, next_chapter_keywords) - - -# ============ 3) 生成章节草稿(新版) ============ +# ============ 3) 生成章节草稿 ============ def generate_chapter_draft( api_key: str, @@ -555,16 +475,6 @@ def generate_chapter_draft( embedding_model_name: str, embedding_retrieval_k: int = 2 ) -> str: - """ - 根据新的 chapter_draft_prompt,生成本章草稿。 - - 首先获取最近3章文本 => 提炼短期摘要 & 下一章关键字 - - 使用(短期摘要 + 下一章关键字) 拼成 query => 检索向量库 - - 同时取上一章(或最后一个非空章节)末尾1500字作为 "前章片段" - - 组合所有信息后,调用模型生成章节草稿 - - 最后保存到 chapters/chapter_{novel_number}.txt - """ - - # 1) 读取相关文件 arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -577,7 +487,7 @@ def generate_chapter_draft( character_state_file = os.path.join(filepath, "character_state.txt") character_state_text = read_file(character_state_file) - # 2) 解析 blueprint,得到本章所需的字段 + # 解析本章信息 chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] chapter_role = chapter_info["chapter_role"] @@ -590,43 +500,45 @@ def generate_chapter_draft( chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - # 3) 获取最近3章文本 => 提炼 (短期摘要 & 下一章关键字) + # 获取最近3章 => (短期摘要, 下一章关键字) recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) short_summary, next_chapter_keywords = summarize_recent_chapters( - llm_model=model_name, + interface_format="openai", # 或由UI传进 api_key=api_key, base_url=base_url, + model_name=model_name, temperature=temperature, chapters_text_list=recent_3_texts ) - # 4) 取上一章片段(或最后一个非空章节)的末尾1500字 + # 上一章片段(末尾1500字) previous_chapter_excerpt = "" for text_block in reversed(recent_3_texts): if text_block.strip(): - # 找到最近一个非空章节 if len(text_block) > 1500: previous_chapter_excerpt = text_block[-1500:] else: previous_chapter_excerpt = text_block break - # 如果全为空,则 previous_chapter_excerpt 就是 "" - # 5) 构造向量检索查询: (短期摘要 + 下一章关键字) + # 使用embedding检索上下文 + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name + ) retrieval_query = short_summary + " " + next_chapter_keywords relevant_context = get_relevant_context_from_vector_store( - api_key=embedding_api_key, - base_url=embedding_url, + embedding_adapter=embedding_adapter, query=retrieval_query, - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, filepath=filepath, k=embedding_retrieval_k ) if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 6) 组装 Prompt + # 组装 Prompt prompt_text = chapter_draft_prompt.format( novel_number=novel_number, chapter_title=chapter_title, @@ -646,24 +558,23 @@ def generate_chapter_draft( novel_setting=novel_architecture_text, global_summary=global_summary_text, character_state=character_state_text, - previous_chapter_excerpt=previous_chapter_excerpt, context_excerpt=relevant_context ) - # 7) 调用 LLM 生成章节正文 - model = ChatOpenAI( - model=model_name, + # 调用 LLM 生成 + llm_adapter = create_llm_adapter( + interface_format="openai", # 或由UI传进 + base_url=base_url, + model_name=model_name, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - - chapter_content = invoke_with_cleaning(model, prompt_text) + chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 8) 写入 chapters + # 写入 chapter_X.txt chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -671,8 +582,8 @@ def generate_chapter_draft( logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") return chapter_content - # ============ 4) 定稿章节 ============ + def finalize_chapter( novel_number: int, word_number: int, @@ -686,9 +597,6 @@ def finalize_chapter( embedding_interface_format: str, embedding_model_name: str ): - """ - 定稿:更新全局摘要、角色状态,并将本章文本插入向量库。 - """ 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() @@ -696,7 +604,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 若篇幅过短,可尝试扩写 + # 如果篇幅过短,可以扩写 if len(chapter_text) < 0.6 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) clear_file_content(chapter_file) @@ -708,50 +616,49 @@ def finalize_chapter( character_state_file = os.path.join(filepath, "character_state.txt") old_character_state = read_file(character_state_file) - # 1) 更新全局摘要 - model = ChatOpenAI( - model=model_name, + # 调用 LLM 更新全局摘要 + llm_adapter = create_llm_adapter( + interface_format="openai", + base_url=base_url, + model_name=model_name, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) prompt_summary = summary_prompt.format( chapter_text=chapter_text, global_summary=old_global_summary ) - new_global_summary = invoke_with_cleaning(model, prompt_summary) + new_global_summary = invoke_with_cleaning(llm_adapter, prompt_summary) if not new_global_summary.strip(): new_global_summary = old_global_summary - # 2) 更新角色状态 + # 更新角色状态 prompt_char_state = update_character_state_prompt.format( chapter_text=chapter_text, old_state=old_character_state ) - new_char_state = invoke_with_cleaning(model, prompt_char_state) + new_char_state = invoke_with_cleaning(llm_adapter, prompt_char_state) if not new_char_state.strip(): new_char_state = old_character_state - # 写回文件 + # 写回 clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 3) 更新向量库 - update_vector_store( - api_key=embedding_api_key, - base_url=embedding_url, - new_chapter=chapter_text, - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath + # 更新向量库 + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name ) + update_vector_store(embedding_adapter, chapter_text, filepath) logging.info(f"Chapter {novel_number} has been finalized.") - def enrich_chapter_text( chapter_text: str, word_number: int, @@ -760,28 +667,25 @@ def enrich_chapter_text( model_name: str, temperature: float ) -> str: - model = ChatOpenAI( - model=model_name, + llm_adapter = create_llm_adapter( + interface_format="openai", + base_url=base_url, + model_name=model_name, api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), temperature=temperature ) - prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 - -原章节内容: -{chapter_text}""" - enriched_text = invoke_with_cleaning(model, prompt) + prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: +原内容: +{chapter_text} +""" + enriched_text = invoke_with_cleaning(llm_adapter, prompt) return enriched_text if enriched_text else chapter_text - -# ============ 导入外部知识文本到向量库 ============ +# ============ 导入知识文件到向量库 ============ 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: @@ -837,24 +741,17 @@ def import_knowledge_file( paragraphs = advanced_split_content(content) - # 尝试加载已有的向量库 - store = load_vector_store( + embedding_adapter = create_embedding_adapter( + interface_format=embedding_interface_format, api_key=embedding_api_key, base_url=embedding_url if embedding_url else "http://localhost:11434/api", - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath + model_name=embedding_model_name ) + + store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store( - api_key=embedding_api_key, - base_url=embedding_url if embedding_url else "http://localhost:11434/api", - interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name, - texts=paragraphs, - filepath=filepath - ) + init_vector_store(embedding_adapter, paragraphs, filepath) else: docs = [Document(page_content=str(p)) for p in paragraphs] store.add_documents(docs) diff --git a/tooltips.py b/tooltips.py new file mode 100644 index 0000000..f4e8220 --- /dev/null +++ b/tooltips.py @@ -0,0 +1,27 @@ +# tooltips.py +# -*- coding: utf-8 -*- + +tooltips = { + "api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。", + "base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。", + "interface_format": "指定LLM接口兼容格式,可选OpenAI、Ollama、ML Studio等。", + "model_name": "要使用的模型名称,例如gpt-3.5-turbo、llama2等。如果是Ollama,请填写你下载好的本地模型名。", + "temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。", + "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。", + "embedding_api_key": "调用Embedding模型时所需的API Key。", + "embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。", + "embedding_url": "Embedding模型接口地址。", + "embedding_model_name": "Embedding模型名称,如text-embedding-ada-002。", + "embedding_retrieval_k": "向量检索时返回的Top-K结果数量。", + "topic": "小说的大致主题或主要故事背景描述。", + "genre": "小说的题材类型,如玄幻、都市、科幻等。", + "num_chapters": "小说期望的章节总数。", + "word_number": "每章的目标字数。", + "filepath": "生成文件存储的根目录路径。所有txt文件、向量库等放在该目录下。", + "chapter_num": "当前正在处理的章节号,用于生成草稿或定稿操作。", + "user_guidance": "为本章提供的一些额外指令或写作引导。", + "characters_involved": "本章需要重点描写或影响剧情的角色名单。", + "key_items": "在本章中出现的重要道具、线索或物品。", + "scene_location": "本章主要发生的地点或场景描述。", + "time_constraint": "本章剧情中涉及的时间压力或时限设置。" +} diff --git a/ui.py b/ui.py index 63f6360..3c2a267 100644 --- a/ui.py +++ b/ui.py @@ -10,6 +10,7 @@ import traceback from config_manager import load_config, save_config from utils import read_file, save_string_to_txt, clear_file_content + from novel_generator import ( Novel_architecture_generate, Chapter_blueprint_generate, @@ -17,21 +18,17 @@ from novel_generator import ( finalize_chapter, import_knowledge_file, clear_vector_store, - get_last_n_chapters_text, + get_last_n_chapters_text ) + 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_default_color_theme("blue") - class NovelGeneratorGUI: def __init__(self, master): self.master = master @@ -71,14 +68,14 @@ class NovelGeneratorGUI: self.chapter_num_var = ctk.StringVar(value="1") - # 新增四个可选要素 + # 四个可选要素 self.characters_involved_var = ctk.StringVar(value="") self.key_items_var = ctk.StringVar(value="") self.scene_location_var = ctk.StringVar(value="") self.time_constraint_var = ctk.StringVar(value="") # UI 布局 - self.tabview = ctk.CTkTabview(self.master, width=1200, height=800) + self.tabview = ctk.CTkTabview(self.master) self.tabview.pack(fill="both", expand=True) self.main_tab = self.tabview.add("Main Functions") @@ -197,7 +194,7 @@ class NovelGeneratorGUI: self.build_optional_buttons_area(start_row=2) def build_config_tabview(self): - self.config_tabview = ctk.CTkTabview(self.config_frame, width=600, height=200) + self.config_tabview = ctk.CTkTabview(self.config_frame) self.config_tabview.grid(row=0, column=0, sticky="we", padx=5, pady=5) self.ai_config_tab = self.config_tabview.add("LLM Model settings") @@ -256,8 +253,8 @@ class NovelGeneratorGUI: temp_scale = ctk.CTkSlider( self.ai_config_tab, - from_=0.0, to=1.0, - number_of_steps=100, + from_=0.0, to=2.0, + number_of_steps=200, command=update_temp_label, variable=self.temperature_var ) @@ -335,7 +332,7 @@ class NovelGeneratorGUI: topic_label = ctk.CTkLabel(self.params_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.topic_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") if self.topic_default: self.topic_text.insert("0.0", self.topic_default) @@ -384,7 +381,7 @@ class NovelGeneratorGUI: # 用户指导 guide_label = ctk.CTkLabel(self.params_frame, text="本章指导:", font=("Microsoft YaHei", 12)) guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox(self.params_frame, width=200, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") # 新增:四个可选元素 @@ -528,7 +525,7 @@ class NovelGeneratorGUI: logging.error(full_message) self.safe_log(full_message) - # ------------------ Step1: 生成架构 ------------------ + # ============ Step1: 生成小说架构 ============ def generate_novel_architecture_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -568,7 +565,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ Step2: 生成章节蓝图 ------------------ + # ============ Step2: 生成章节蓝图 ============ def generate_chapter_blueprint_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -599,7 +596,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ Step3: 生成草稿 ------------------ + # ============ Step3: 生成章节草稿 ============ def generate_chapter_draft_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -671,7 +668,7 @@ class NovelGeneratorGUI: self.chapter_result.insert("0.0", text) self.chapter_result.see("end") - # ------------------ Step4: 定稿章节 ------------------ + # ============ Step4: 定稿章节 ============ def finalize_chapter_ui(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -731,7 +728,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() - # ------------------ 一致性审校 ------------------ + # ============ 一致性审校 (可选) ============ def do_consistency_check(self): filepath = self.filepath_var.get().strip() if not filepath: @@ -756,7 +753,7 @@ class NovelGeneratorGUI: self.safe_log("开始一致性审校...") result = check_consistency( - novel_setting="", # 如果需要,可传入最新的 Novel_architecture 内容 + novel_setting="", character_state=read_file(os.path.join(filepath, "character_state.txt")), global_summary=read_file(os.path.join(filepath, "global_summary.txt")), chapter_text=chapter_text, @@ -776,6 +773,7 @@ class NovelGeneratorGUI: threading.Thread(target=task, daemon=True).start() + # ============ 导入知识库 ============ def import_knowledge_handler(self): selected_file = filedialog.askopenfilename( title="选择要导入的知识库文件", @@ -847,7 +845,8 @@ class NovelGeneratorGUI: text_area.insert("0.0", arcs_text) text_area.configure(state="disabled") - # ------------------ 其他标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ------------------ + # ============ 其余标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ============ + def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -1032,7 +1031,7 @@ class NovelGeneratorGUI: save_string_to_txt(content, filename) self.log("已保存对 global_summary.txt 的修改。") - # ------------------ 章节管理标签页 ------------------ + # ============ 章节管理标签页 ============ def build_chapters_tab(self): self.chapters_view_tab.rowconfigure(0, weight=0) self.chapters_view_tab.rowconfigure(1, weight=1) @@ -1165,7 +1164,6 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") - if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From ca0d6e5047eab9b9a05a8ea141c8680413599151 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 19:37:37 +0800 Subject: [PATCH 08/11] add tooltip,max_tokens --- embedding_adapters.py | 1 - llm_adapters.py | 19 +- novel_generator.py | 83 +++++---- tooltips.py | 16 +- ui.py | 403 ++++++++++++++++++++++++++++++++---------- 5 files changed, 378 insertions(+), 144 deletions(-) diff --git a/embedding_adapters.py b/embedding_adapters.py index 8c39f51..a17bb7a 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -69,7 +69,6 @@ class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): """ 调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding """ - # 如果 base_url 中已含 /api/embeddings,可直接用;否则拼上 /api/embeddings url = self.base_url if "api/embeddings" not in url: url = f"{url}/api/embeddings" diff --git a/llm_adapters.py b/llm_adapters.py index 2fa5e37..714e72a 100644 --- a/llm_adapters.py +++ b/llm_adapters.py @@ -28,7 +28,7 @@ class DeepSeekAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -54,7 +54,7 @@ class OpenAIAdapter(BaseLLMAdapter): """ 适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI) """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -81,7 +81,7 @@ class OllamaAdapter(BaseLLMAdapter): Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 但是通常 Ollama 默认本地服务在 http://localhost:11434,如果符合OpenAI风格即可直接传参。 """ - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -104,7 +104,7 @@ class OllamaAdapter(BaseLLMAdapter): return response.content class MLStudioAdapter(BaseLLMAdapter): - def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens:int, temperature: float = 0.7): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7): self.base_url = ensure_openai_base_url_has_v1(base_url) self.api_key = api_key self.model_name = model_name @@ -131,18 +131,19 @@ def create_llm_adapter( base_url: str, model_name: str, api_key: str, - temperature: float + temperature: float, + max_tokens: int ) -> BaseLLMAdapter: """ 工厂函数:根据 interface_format 返回不同的适配器实例。 """ if interface_format.lower() == "deepseek": - return DeepSeekAdapter(api_key, base_url, model_name, temperature) + return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "openai": - return OpenAIAdapter(api_key, base_url, model_name, temperature) + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "ollama": - return OllamaAdapter(api_key, base_url, model_name, temperature) + return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature) elif interface_format.lower() == "ml studio": - return MLStudioAdapter(api_key, base_url, model_name, temperature) + return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature) else: raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/novel_generator.py b/novel_generator.py index 9e6206a..5e2a6ed 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -43,6 +43,7 @@ from embedding_adapters import create_embedding_adapter logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + # ============ 工具函数 ============ def remove_think_tags(text: str) -> str: @@ -67,6 +68,7 @@ def invoke_with_cleaning(llm_adapter, prompt: str) -> str: debug_log(prompt, cleaned_text) return cleaned_text.strip() + # ============ 获取 vectorstore 路径 ============ def get_vectorstore_dir(filepath: str) -> str: @@ -89,6 +91,7 @@ def clear_vector_store(filepath: str) -> bool: traceback.print_exc() return False + # ============ 根据 embedding 接口创建/加载 Chroma ============ def init_vector_store( @@ -103,11 +106,8 @@ def init_vector_store( store_dir = get_vectorstore_dir(filepath) os.makedirs(store_dir, exist_ok=True) - # 将文本封装为 Document documents = [Document(page_content=str(t)) for t in texts] - # 因为我们是自定义的 embeddings,对接Chroma时需包装一个“langchain兼容对象” - # 这里示例:写一个包装函数 from langchain.embeddings.base import Embeddings as LCEmbeddings class LCEmbeddingWrapper(LCEmbeddings): @@ -140,7 +140,6 @@ def load_vector_store( logging.info("Vector store not found. Will return None.") return None - # 同样要包装embedding_adapter from langchain.embeddings.base import Embeddings as LCEmbeddings class LCEmbeddingWrapper(LCEmbeddings): @@ -159,6 +158,7 @@ def load_vector_store( collection_name="novel_collection" ) + # ============ 文本分段工具 ============ def split_by_length(text: str, max_length: int = 500) -> List[str]: @@ -240,7 +240,7 @@ def update_vector_store( 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( @@ -287,6 +287,7 @@ def summarize_recent_chapters( base_url: str, model_name: str, temperature: float, + max_tokens: int, chapters_text_list: List[str] ) -> Tuple[str, str]: """ @@ -297,13 +298,13 @@ def summarize_recent_chapters( if not combined_text: return ("", "") - # 1) 构造 llm_adapter llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = summarize_recent_chapters_prompt.format(combined_text=combined_text) @@ -328,6 +329,7 @@ def summarize_recent_chapters( # ============ 1) 生成总体架构 ============ def Novel_architecture_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, @@ -336,7 +338,8 @@ def Novel_architecture_generate( number_of_chapters: int, word_number: int, filepath: str, - temperature: float = 0.7 + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: """ 依次调用: @@ -348,13 +351,13 @@ def Novel_architecture_generate( """ os.makedirs(filepath, exist_ok=True) - # 通过工厂函数创建 LLM 适配器 llm_adapter = create_llm_adapter( - interface_format="openai", # 或根据你的实际:若你在UI中就是 "OpenAI" 就传递过来 + interface_format=interface_format, base_url=base_url, model_name=llm_model, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) # Step1: 核心种子 @@ -382,7 +385,6 @@ def Novel_architecture_generate( ) plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) - # 合并 final_content = ( "#=== 1) 核心种子 ===\n" f"{core_seed_result}\n\n" @@ -399,14 +401,17 @@ def Novel_architecture_generate( save_string_to_txt(final_content, arch_file) logging.info("Novel_architecture.txt has been generated successfully.") + # ============ 2) 生成章节蓝图 ============ def Chapter_blueprint_generate( + interface_format: str, api_key: str, base_url: str, llm_model: str, filepath: str, - temperature: float = 0.7 + temperature: float = 0.7, + max_tokens: int = 2048 ) -> None: arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): @@ -432,11 +437,12 @@ def Chapter_blueprint_generate( plot_arch_text = m.group(1).strip() llm_adapter = create_llm_adapter( - interface_format="openai", # 或实际由UI传入 + interface_format=interface_format, base_url=base_url, model_name=llm_model, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = chapter_blueprint_prompt.format( @@ -454,6 +460,7 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") + # ============ 3) 生成章节草稿 ============ def generate_chapter_draft( @@ -473,7 +480,9 @@ def generate_chapter_draft( embedding_url: str, embedding_interface_format: str, embedding_model_name: str, - embedding_retrieval_k: int = 2 + embedding_retrieval_k: int = 2, + interface_format: str = "openai", + max_tokens: int = 2048 ) -> str: arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -487,7 +496,6 @@ def generate_chapter_draft( character_state_file = os.path.join(filepath, "character_state.txt") character_state_text = read_file(character_state_file) - # 解析本章信息 chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] chapter_role = chapter_info["chapter_role"] @@ -500,18 +508,17 @@ def generate_chapter_draft( chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - # 获取最近3章 => (短期摘要, 下一章关键字) recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) short_summary, next_chapter_keywords = summarize_recent_chapters( - interface_format="openai", # 或由UI传进 + interface_format=interface_format, api_key=api_key, base_url=base_url, model_name=model_name, temperature=temperature, + max_tokens=max_tokens, chapters_text_list=recent_3_texts ) - # 上一章片段(末尾1500字) previous_chapter_excerpt = "" for text_block in reversed(recent_3_texts): if text_block.strip(): @@ -521,7 +528,6 @@ def generate_chapter_draft( previous_chapter_excerpt = text_block break - # 使用embedding检索上下文 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -538,7 +544,6 @@ def generate_chapter_draft( if not relevant_context.strip(): relevant_context = "(无检索到的上下文)" - # 组装 Prompt prompt_text = chapter_draft_prompt.format( novel_number=novel_number, chapter_title=chapter_title, @@ -562,19 +567,18 @@ def generate_chapter_draft( context_excerpt=relevant_context ) - # 调用 LLM 生成 llm_adapter = create_llm_adapter( - interface_format="openai", # 或由UI传进 + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") - # 写入 chapter_X.txt chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -595,7 +599,9 @@ def finalize_chapter( embedding_api_key: str, embedding_url: str, embedding_interface_format: str, - embedding_model_name: str + embedding_model_name: str, + interface_format: str, + max_tokens: int ): chapters_dir = os.path.join(filepath, "chapters") chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") @@ -604,25 +610,23 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - # 如果篇幅过短,可以扩写 if len(chapter_text) < 0.6 * word_number: - chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature) + chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) - # 读取全局摘要、角色状态 global_summary_file = os.path.join(filepath, "global_summary.txt") old_global_summary = read_file(global_summary_file) character_state_file = os.path.join(filepath, "character_state.txt") old_character_state = read_file(character_state_file) - # 调用 LLM 更新全局摘要 llm_adapter = create_llm_adapter( - interface_format="openai", + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt_summary = summary_prompt.format( chapter_text=chapter_text, @@ -632,7 +636,6 @@ def finalize_chapter( if not new_global_summary.strip(): new_global_summary = old_global_summary - # 更新角色状态 prompt_char_state = update_character_state_prompt.format( chapter_text=chapter_text, old_state=old_character_state @@ -641,14 +644,12 @@ def finalize_chapter( if not new_char_state.strip(): new_char_state = old_character_state - # 写回 clear_file_content(global_summary_file) save_string_to_txt(new_global_summary, global_summary_file) clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) - # 更新向量库 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, @@ -665,14 +666,17 @@ def enrich_chapter_text( api_key: str, base_url: str, model_name: str, - temperature: float + temperature: float, + interface_format: str, + max_tokens: int ) -> str: llm_adapter = create_llm_adapter( - interface_format="openai", + interface_format=interface_format, base_url=base_url, model_name=model_name, api_key=api_key, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: 原内容: @@ -681,6 +685,7 @@ def enrich_chapter_text( enriched_text = invoke_with_cleaning(llm_adapter, prompt) return enriched_text if enriched_text else chapter_text + # ============ 导入知识文件到向量库 ============ def advanced_split_content(content: str, diff --git a/tooltips.py b/tooltips.py index f4e8220..062de2f 100644 --- a/tooltips.py +++ b/tooltips.py @@ -4,10 +4,20 @@ tooltips = { "api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。", "base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。", - "interface_format": "指定LLM接口兼容格式,可选OpenAI、Ollama、ML Studio等。", - "model_name": "要使用的模型名称,例如gpt-3.5-turbo、llama2等。如果是Ollama,请填写你下载好的本地模型名。", + "interface_format": "指定LLM接口兼容格式,可选DeepSeek\OpenAI\Ollama\ML Studio等。\n\n注意:"+ + "OpenAI 兼容是指的可以通过该标准请求的任何接口,不是只允许使用api.openai.com接口\n"+ + "例如Ollama接口格式也兼容OpenAI,可以无需修改直接使用\n"+ + "ML Studio接口格式与OpenAI接口格式也一致。", + "model_name": "要使用的模型名称,例如deepseek-reasoner、gpt-4o等。如果是Ollama等,请填写你下载好的本地模型名。", "temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。", - "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。", + "max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。\n"+ + "以下是一些常见模型的最大值:\n"+ + "o1:100,000\n"+ + "o1-mini:65,536\n"+ + "gpt-4o:16384\n"+ + "gpt-4o-mini:16384\n"+ + "deepseek-reasoner:8192\n"+ + "deepseek-chat:4096\n", "embedding_api_key": "调用Embedding模型时所需的API Key。", "embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。", "embedding_url": "Embedding模型接口地址。", diff --git a/ui.py b/ui.py index 3c2a267..f5b81cc 100644 --- a/ui.py +++ b/ui.py @@ -20,9 +20,11 @@ from novel_generator import ( clear_vector_store, get_last_n_chapters_text ) - from consistency_checker import check_consistency +# ---- Import the tooltip texts ---- +from tooltips import tooltips + def log_error(message: str): logging.error(f"{message}\n{traceback.format_exc()}") @@ -52,6 +54,7 @@ class NovelGeneratorGUI: 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.max_tokens_var = ctk.IntVar(value=self.loaded_config.get("max_tokens", 8192)) # Embedding相关 self.embedding_interface_format_var = ctk.StringVar(value=self.loaded_config.get("embedding_interface_format", "OpenAI")) @@ -92,6 +95,11 @@ class NovelGeneratorGUI: self.build_summary_tab() self.build_chapters_tab() + def show_tooltip(self, key: str): + """Display a popup with tooltip text.""" + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + def safe_get_int(self, var, default=1): try: val_str = str(var.get()).strip() @@ -203,6 +211,27 @@ class NovelGeneratorGUI: self.build_ai_config_tab() self.build_embeddings_config_tab() + # 封装一个小工具函数,用来创建「标签 + 问号按钮」的组合 + def create_label_with_help(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + # frame容器:同一格子里存放 label + "?"按钮 + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + # 先放 label + label = ctk.CTkLabel(frame, text=label_text, font=font) + label.pack(side="left") + # 再放问号按钮 + btn = ctk.CTkButton( + frame, + text="?", + width=22, + height=22, + font=("Microsoft YaHei", 10), + command=lambda: self.show_tooltip(tooltip_key) + ) + btn.pack(side="left", padx=3) + return frame + def build_ai_config_tab(self): def on_interface_format_changed(new_value): if new_value == "Ollama": @@ -211,26 +240,49 @@ class NovelGeneratorGUI: self.base_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.base_url_var.set("https://api.openai.com/v1") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") - for i in range(5): + for i in range(6): self.ai_config_tab.grid_rowconfigure(i, 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(2, weight=0) - api_key_label = ctk.CTkLabel(self.ai_config_tab, text="LLM API Key:", font=("Microsoft YaHei", 12)) - api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + # 1) API Key + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="LLM API Key:", + tooltip_key="api_key", + row=0, + column=0, + font=("Microsoft YaHei", 12) + ) api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var, font=("Microsoft YaHei", 12)) - api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + api_key_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - base_url_label = ctk.CTkLabel(self.ai_config_tab, text="LLM Base URL:", font=("Microsoft YaHei", 12)) - base_url_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + # 2) Base URL + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="LLM Base URL:", + tooltip_key="base_url", + row=1, + column=0, + font=("Microsoft YaHei", 12) + ) base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var, font=("Microsoft YaHei", 12)) - base_url_entry.grid(row=1, column=1, padx=5, pady=5, sticky="nsew") + base_url_entry.grid(row=1, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - interface_label = ctk.CTkLabel(self.ai_config_tab, text="LLM 接口格式:", font=("Microsoft YaHei", 12)) - interface_label.grid(row=2, column=0, padx=5, pady=5, sticky="e") - interface_options = ["OpenAI", "Ollama", "ML Studio"] + # 3) 接口格式 + label_frame = self.create_label_with_help( + parent=self.ai_config_tab, + label_text="LLM 接口格式:", + tooltip_key="interface_format", + row=2, + column=0, + font=("Microsoft YaHei", 12) + ) + interface_options = ["DeepSeek", "OpenAI", "Ollama", "ML Studio"] interface_dropdown = ctk.CTkOptionMenu( self.ai_config_tab, values=interface_options, @@ -238,15 +290,29 @@ class NovelGeneratorGUI: command=on_interface_format_changed, font=("Microsoft YaHei", 12) ) - interface_dropdown.grid(row=2, column=1, padx=5, pady=5, sticky="nsew") + interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - model_name_label = ctk.CTkLabel(self.ai_config_tab, text="Model Name:", font=("Microsoft YaHei", 12)) - model_name_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + # 4) Model Name + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="Model Name:", + tooltip_key="model_name", + row=3, + column=0, + font=("Microsoft YaHei", 12) + ) model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var, font=("Microsoft YaHei", 12)) - model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + model_name_entry.grid(row=3, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") - temp_label = ctk.CTkLabel(self.ai_config_tab, text="Temperature:", font=("Microsoft YaHei", 12)) - temp_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + # 5) Temperature + temp_frame = self.create_label_with_help( + parent=self.ai_config_tab, + label_text="Temperature:", + tooltip_key="temperature", + row=4, + column=0, + font=("Microsoft YaHei", 12) + ) def update_temp_label(value): self.temp_value_label.configure(text=f"{float(value):.2f}") @@ -265,7 +331,37 @@ class NovelGeneratorGUI: text=f"{self.temperature_var.get():.2f}", font=("Microsoft YaHei", 12) ) - self.temp_value_label.grid(row=4, column=2, padx=1, pady=1, sticky="w") + self.temp_value_label.grid(row=4, column=2, padx=5, pady=5, sticky="w") + + # 6) Max Tokens + self.create_label_with_help( + parent=self.ai_config_tab, + label_text="Max Tokens:", + tooltip_key="max_tokens", + row=5, + column=0, + font=("Microsoft YaHei", 12) + ) + + def update_max_tokens_label(value): + self.max_tokens_value_label.configure(text=str(int(float(value)))) + + max_tokens_slider = ctk.CTkSlider( + self.ai_config_tab, + from_=0, + to=102400, + number_of_steps=100, + command=update_max_tokens_label, + variable=self.max_tokens_var + ) + max_tokens_slider.grid(row=5, column=1, padx=5, pady=5, sticky="we") + + self.max_tokens_value_label = ctk.CTkLabel( + self.ai_config_tab, + text=str(self.max_tokens_var.get()), + font=("Microsoft YaHei", 12) + ) + self.max_tokens_value_label.grid(row=5, column=2, padx=5, pady=5, sticky="w") def build_embeddings_config_tab(self): def on_embedding_interface_changed(new_value): @@ -275,20 +371,37 @@ class NovelGeneratorGUI: self.embedding_url_var.set("http://localhost:1234/v1") elif new_value == "OpenAI": self.embedding_url_var.set("https://api.openai.com/v1") - + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + 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) + self.embeddings_config_tab.grid_columnconfigure(2, weight=0) - emb_api_key_label = ctk.CTkLabel(self.embeddings_config_tab, text="Embedding API Key:", font=("Microsoft YaHei", 12)) - emb_api_key_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + # 1) Embedding API Key + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding API Key:", + tooltip_key="embedding_api_key", + row=0, + column=0, + font=("Microsoft YaHei", 12) + ) 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"] + # 2) Embedding 接口格式 + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding 接口格式:", + tooltip_key="embedding_interface_format", + row=1, + column=0, + font=("Microsoft YaHei", 12) + ) + emb_interface_options = ["DeepSeek", "OpenAI", "Ollama", "ML Studio"] emb_interface_dropdown = ctk.CTkOptionMenu( self.embeddings_config_tab, values=emb_interface_options, @@ -298,18 +411,39 @@ class NovelGeneratorGUI: ) 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") + # 3) Embedding Base URL + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding Base URL:", + tooltip_key="embedding_url", + row=2, + column=0, + font=("Microsoft YaHei", 12) + ) emb_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12)) 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=3, column=0, padx=5, pady=5, sticky="e") + # 4) Embedding Model Name + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Embedding Model Name:", + tooltip_key="embedding_model_name", + row=3, + column=0, + font=("Microsoft YaHei", 12) + ) 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=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") + # 5) Retrieval Top-K + self.create_label_with_help( + parent=self.embeddings_config_tab, + label_text="Retrieval Top-K:", + tooltip_key="embedding_retrieval_k", + row=4, + column=0, + font=("Microsoft YaHei", 12) + ) 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") @@ -330,41 +464,72 @@ class NovelGeneratorGUI: self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5) self.params_frame.columnconfigure(1, weight=1) - topic_label = ctk.CTkLabel(self.params_frame, text="主题(Topic):", font=("Microsoft YaHei", 12)) - topic_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") - self.topic_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) + # 1) 主题(Topic) + topic_label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="主题(Topic):", + tooltip_key="topic", + row=0, + column=0, + font=("Microsoft YaHei", 12), + sticky="ne" + ) + self.topic_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") if self.topic_default: self.topic_text.insert("0.0", self.topic_default) - genre_label = ctk.CTkLabel(self.params_frame, text="类型(Genre):", font=("Microsoft YaHei", 12)) - genre_label.grid(row=1, column=0, padx=5, pady=5, sticky="e") + # 2) 类型(Genre) + self.create_label_with_help( + parent=self.params_frame, + label_text="类型(Genre):", + tooltip_key="genre", + row=1, + column=0, + font=("Microsoft YaHei", 12) + ) genre_entry = ctk.CTkEntry(self.params_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12)) genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") + # 3) 章节数 & 每章字数 row_for_chapter_and_word = 2 - num_chapters_label = ctk.CTkLabel(self.params_frame, text="章节数:", font=("Microsoft YaHei", 12)) - num_chapters_label.grid(row=row_for_chapter_and_word, column=0, padx=5, pady=5, sticky="e") + chapter_word_frame = ctk.CTkFrame(self.params_frame) + chapter_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew") + chapter_word_frame.columnconfigure((0, 1, 2, 3), weight=0) - 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, 1, 2, 3), weight=0) + # 左边标签 + label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="章节数 & 每章字数:", + tooltip_key="num_chapters", + row=row_for_chapter_and_word, + column=0, + font=("Microsoft YaHei", 12) + ) - num_chapters_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12)) - num_chapters_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") + # 输入框 + num_chapters_label = ctk.CTkLabel(chapter_word_frame, text="章节数:", font=("Microsoft YaHei", 12)) + num_chapters_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") + num_chapters_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12)) + num_chapters_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w") - word_number_label = ctk.CTkLabel(ch_word_frame, text="每章字数:", font=("Microsoft YaHei", 12)) - word_number_label.grid(row=0, column=1, padx=(15, 5), pady=5, sticky="e") - - word_number_entry = ctk.CTkEntry(ch_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12)) - word_number_entry.grid(row=0, column=2, padx=5, pady=5, sticky="w") - - # 保存路径 - filepath_label = ctk.CTkLabel(self.params_frame, text="保存路径:", font=("Microsoft YaHei", 12)) - filepath_label.grid(row=3, column=0, padx=5, pady=5, sticky="e") + word_number_label = ctk.CTkLabel(chapter_word_frame, text="每章字数:", font=("Microsoft YaHei", 12)) + word_number_label.grid(row=0, column=2, padx=(15, 5), pady=5, sticky="e") + word_number_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12)) + word_number_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w") + # 4) 保存路径 + row_fp = 3 + self.create_label_with_help( + parent=self.params_frame, + label_text="保存路径:", + tooltip_key="filepath", + row=row_fp, + column=0, + font=("Microsoft YaHei", 12) + ) self.filepath_frame = ctk.CTkFrame(self.params_frame) - self.filepath_frame.grid(row=3, column=1, padx=5, pady=5, sticky="nsew") + self.filepath_frame.grid(row=row_fp, column=1, padx=5, pady=5, sticky="nsew") self.filepath_frame.columnconfigure(0, weight=1) filepath_entry = ctk.CTkEntry(self.filepath_frame, textvariable=self.filepath_var, font=("Microsoft YaHei", 12)) @@ -372,43 +537,85 @@ class NovelGeneratorGUI: browse_btn = ctk.CTkButton(self.filepath_frame, text="浏览...", command=self.browse_folder, width=60, font=("Microsoft YaHei", 12)) browse_btn.grid(row=0, column=1, padx=5, pady=5, sticky="e") - # 章节号 - chapter_num_label = ctk.CTkLabel(self.params_frame, text="章节号:", font=("Microsoft YaHei", 12)) - chapter_num_label.grid(row=4, column=0, padx=5, pady=5, sticky="e") + # 5) 章节号 + row_chap_num = 4 + self.create_label_with_help( + parent=self.params_frame, + label_text="章节号:", + tooltip_key="chapter_num", + row=row_chap_num, + column=0, + font=("Microsoft YaHei", 12) + ) chapter_num_entry = ctk.CTkEntry(self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12)) - chapter_num_entry.grid(row=4, column=1, padx=5, pady=5, sticky="w") + chapter_num_entry.grid(row=row_chap_num, column=1, padx=5, pady=5, sticky="w") - # 用户指导 - guide_label = ctk.CTkLabel(self.params_frame, text="本章指导:", font=("Microsoft YaHei", 12)) - guide_label.grid(row=5, column=0, padx=5, pady=5, sticky="ne") - self.user_guide_text = ctk.CTkTextbox(self.params_frame,height=80, wrap="word", font=("Microsoft YaHei", 12)) - self.user_guide_text.grid(row=5, column=1, padx=5, pady=5, sticky="nsew") + # 6) 本章指导 + row_user_guide = 5 + guide_label_frame = self.create_label_with_help( + parent=self.params_frame, + label_text="本章指导:", + tooltip_key="user_guidance", + row=row_user_guide, + column=0, + font=("Microsoft YaHei", 12), + sticky="ne" + ) + self.user_guide_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12)) + self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") - # 新增:四个可选元素 - row_index = 6 - - char_inv_label = ctk.CTkLabel(self.params_frame, text="核心人物:", font=("Microsoft YaHei", 12)) - char_inv_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 7) 可选元素:核心人物/关键道具/空间坐标/时间压力 + row_idx = 6 + # 核心人物 + self.create_label_with_help( + parent=self.params_frame, + label_text="核心人物:", + tooltip_key="characters_involved", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) char_inv_entry = ctk.CTkEntry(self.params_frame, textvariable=self.characters_involved_var, font=("Microsoft YaHei", 12)) - char_inv_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + char_inv_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - key_items_label = ctk.CTkLabel(self.params_frame, text="关键道具:", font=("Microsoft YaHei", 12)) - key_items_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 关键道具 + self.create_label_with_help( + parent=self.params_frame, + label_text="关键道具:", + tooltip_key="key_items", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12)) - key_items_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + key_items_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - scene_loc_label = ctk.CTkLabel(self.params_frame, text="空间坐标:", font=("Microsoft YaHei", 12)) - scene_loc_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 空间坐标 + self.create_label_with_help( + parent=self.params_frame, + label_text="空间坐标:", + tooltip_key="scene_location", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) scene_loc_entry = ctk.CTkEntry(self.params_frame, textvariable=self.scene_location_var, font=("Microsoft YaHei", 12)) - scene_loc_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + scene_loc_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 - row_index += 1 - time_const_label = ctk.CTkLabel(self.params_frame, text="时间压力:", font=("Microsoft YaHei", 12)) - time_const_label.grid(row=row_index, column=0, padx=5, pady=5, sticky="e") + # 时间压力 + self.create_label_with_help( + parent=self.params_frame, + label_text="时间压力:", + tooltip_key="time_constraint", + row=row_idx, + column=0, + font=("Microsoft YaHei", 12) + ) time_const_entry = ctk.CTkEntry(self.params_frame, textvariable=self.time_constraint_var, font=("Microsoft YaHei", 12)) - time_const_entry.grid(row=row_index, column=1, padx=5, pady=5, sticky="ew") + time_const_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") def build_optional_buttons_area(self, start_row=2): self.optional_btn_frame = ctk.CTkFrame(self.right_frame) @@ -456,11 +663,14 @@ class NovelGeneratorGUI: 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)) + self.max_tokens_var.set(cfg.get("max_tokens", 2048)) + 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.embedding_retrieval_k_var.set(str(cfg.get("embedding_retrieval_k", 4))) + self.genre_var.set(cfg.get("genre", "")) self.num_chapters_var.set(str(cfg.get("num_chapters", 10))) self.word_number_var.set(str(cfg.get("word_number", 3000))) @@ -481,6 +691,7 @@ class NovelGeneratorGUI: "interface_format": self.interface_format_var.get(), "model_name": self.model_name_var.get(), "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), "embedding_api_key": self.embedding_api_key_var.get(), "embedding_interface_format": self.embedding_interface_format_var.get(), @@ -535,10 +746,12 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_architecture) try: + interface_format = self.interface_format_var.get().strip() api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + max_tokens = self.max_tokens_var.get() topic = self.topic_text.get("0.0", "end").strip() genre = self.genre_var.get().strip() @@ -547,6 +760,7 @@ class NovelGeneratorGUI: self.safe_log("开始生成小说架构...") Novel_architecture_generate( + interface_format=interface_format, api_key=api_key, base_url=base_url, llm_model=model_name, @@ -555,7 +769,8 @@ class NovelGeneratorGUI: number_of_chapters=num_chapters, word_number=word_number, filepath=filepath, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") except Exception: @@ -575,18 +790,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_directory) try: + interface_format = self.interface_format_var.get().strip() api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + max_tokens = self.max_tokens_var.get() self.safe_log("开始生成章节蓝图...") Chapter_blueprint_generate( + interface_format=interface_format, api_key=api_key, base_url=base_url, llm_model=model_name, filepath=filepath, - temperature=temperature + temperature=temperature, + max_tokens=max_tokens ) self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") except Exception: @@ -606,24 +825,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_generate_chapter) try: - # LLM相关 + interface_format = self.interface_format_var.get().strip() api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + max_tokens = self.max_tokens_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() - # 新增四个可选要素 char_inv = self.characters_involved_var.get().strip() key_items = self.key_items_var.get().strip() scene_loc = self.scene_location_var.get().strip() time_constr = self.time_constraint_var.get().strip() - # Embedding相关 embedding_api_key = self.embedding_api_key_var.get().strip() embedding_url = self.embedding_url_var.get().strip() embedding_interface_format = self.embedding_interface_format_var.get().strip() @@ -648,7 +865,9 @@ class NovelGeneratorGUI: embedding_url=embedding_url, embedding_interface_format=embedding_interface_format, embedding_model_name=embedding_model_name, - embedding_retrieval_k=embedding_k + embedding_retrieval_k=embedding_k, + interface_format=interface_format, + max_tokens=max_tokens ) if draft_text: self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。") @@ -678,24 +897,22 @@ class NovelGeneratorGUI: def task(): self.disable_button_safe(self.btn_finalize_chapter) try: - # LLM相关 + interface_format = self.interface_format_var.get().strip() api_key = self.api_key_var.get().strip() base_url = self.base_url_var.get().strip() model_name = self.model_name_var.get().strip() temperature = self.temperature_var.get() + max_tokens = self.max_tokens_var.get() - # Embedding相关 embedding_api_key = self.embedding_api_key_var.get().strip() embedding_url = self.embedding_url_var.get().strip() embedding_interface_format = self.embedding_interface_format_var.get().strip() embedding_model_name = self.embedding_model_name_var.get().strip() - # 章节参数 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}章...") - # 先保存用户在左侧编辑框中的修改 chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt") @@ -714,7 +931,9 @@ class NovelGeneratorGUI: embedding_api_key=embedding_api_key, embedding_url=embedding_url, embedding_interface_format=embedding_interface_format, - embedding_model_name=embedding_model_name + embedding_model_name=embedding_model_name, + interface_format=interface_format, + max_tokens=max_tokens ) self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新全局摘要、角色状态、向量库)。") @@ -846,7 +1065,6 @@ class NovelGeneratorGUI: text_area.configure(state="disabled") # ============ 其余标签页: Novel Architecture, Chapter Blueprint, Character State, Summary ============ - def build_setting_tab(self): self.setting_tab.rowconfigure(0, weight=0) self.setting_tab.rowconfigure(1, weight=1) @@ -1164,6 +1382,7 @@ class NovelGeneratorGUI: else: messagebox.showinfo("提示", "已经是最后一章了。") + if __name__ == "__main__": app = ctk.CTk() gui = NovelGeneratorGUI(app) From fd50a8130b25624277b8744fb307034b6bd1ad08 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 21:54:06 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=88=86=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator copy.py | 830 ---------------------------------------- novel_generator.py | 114 ++++-- prompt_definitions.py | 170 ++++++-- ui.py | 2 + 4 files changed, 227 insertions(+), 889 deletions(-) delete mode 100644 novel_generator copy.py diff --git a/novel_generator copy.py b/novel_generator copy.py deleted file mode 100644 index 7505948..0000000 --- a/novel_generator copy.py +++ /dev/null @@ -1,830 +0,0 @@ -# novel_generator.py -# -*- coding: utf-8 -*- -import os -import logging -import re -import time -import traceback -from typing import List, Optional - -# langchain 相关 -from langchain_openai import ChatOpenAI, OpenAIEmbeddings -from langchain_chroma import Chroma -from chromadb.config import Settings -from langchain.docstore.document import Document - -# nltk、sentence_transformers 及文本处理相关 -import nltk -import math -from sentence_transformers import SentenceTransformer -from sklearn.metrics.pairwise import cosine_similarity - -# 工具函数 -from utils import ( - read_file, append_text_to_file, clear_file_content, - save_string_to_txt -) - -# prompt模板 -from prompt_definitions import ( - # 设定相关 - set_prompt, character_prompt, dark_lines_prompt, - finalize_setting_prompt, novel_directory_prompt, - - # 写作流程相关 - summary_prompt, update_character_state_prompt, - chapter_outline_prompt, chapter_write_prompt -) - -# Ollama嵌入 (如使用Ollama时需要) -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 debug_log(prompt: str, response_content: str): - logging.info(f"\n[######################################### Prompt #########################################]\n {prompt}\n") - logging.info(f"\n[######################################### Response #########################################]\n {response_content}\n") - -def invoke_with_cleaning(model: ChatOpenAI, prompt: str) -> str: - """通用封装:调用模型并移除 ... 文本,记录日志后返回""" - response = model.invoke(prompt) - if not response: - logging.warning("No response from model.") - return "" - cleaned_text = remove_think_tags(response.content) - debug_log(prompt, cleaned_text) - return cleaned_text.strip() - -def ensure_openai_base_url_has_v1(url: str) -> str: - """ - 若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。 - """ - import re - url = url.strip() - if not url: - return url - 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, - interface_format: str, - embedding_model_name: str -): - """ - 根据 embedding_interface_format,选择 Ollama 或 OpenAIEmbeddings 等不同后端。 - """ - 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,注意 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, - model=embedding_model_name - ) - - -# ============ 向量库相关操作 ============ -def clear_vector_store(filepath: str) -> bool: - """ - 返回值表示是否成功清空向量库。 - """ - import shutil - - store_dir = get_vectorstore_dir(filepath) - if not os.path.exists(store_dir): - logging.info("No vector store found to clear.") - return False - - try: - 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"程序正在运行,无法删除,请在程序关闭后手动前往 {store_dir} 删除目录。\n {str(e)}") - traceback.print_exc() - return False - -def init_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - texts: List[str], - filepath: str -) -> Chroma: - """ - 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 - """ - 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, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) - documents = [Document(page_content=str(t)) for t in texts] - vectorstore = Chroma.from_documents( - documents, - embedding=embeddings, - persist_directory=store_dir, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) - return vectorstore - - -def load_vector_store( - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - filepath: str -) -> Optional[Chroma]: - """ - 读取已存在的 Chroma 向量库。若不存在则返回 None。 - """ - store_dir = get_vectorstore_dir(filepath) - if not os.path.exists(store_dir): - logging.info("Vector store not found. Will return None.") - return None - - embeddings = create_embeddings_object( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name - ) - return Chroma( - persist_directory=store_dir, - embedding_function=embeddings, - client_settings=Settings(anonymized_telemetry=False), - collection_name="novel_collection" - ) - - -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, - new_chapter: str, - interface_format: str, - embedding_model_name: str, - filepath: str -): - """ - 将最新章节文本插入到向量库中。若库不存在则初始化。 - """ - 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, - 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 new chapter...") - init_vector_store( - api_key=api_key, - base_url=base_url, - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=splitted_texts, - filepath=filepath - ) - return - - 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( - api_key: str, - base_url: str, - query: str, - interface_format: str, - embedding_model_name: 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, - filepath=filepath - ) - if not store: - logging.info("No vector store found. Returning empty context.") - return "" - - 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]) - return combined - - -# ============ 1. 生成小说“设定” (Novel_setting.txt) ============ -def Novel_setting_generate( - api_key: str, - base_url: str, - llm_model: str, - topic: str, - genre: str, - number_of_chapters: int, - word_number: int, - filepath: str, - temperature: float = 0.7 -) -> None: - 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), - temperature=temperature - ) - - # Step1: 基础设定 - prompt_base = set_prompt.format( - topic=topic, - genre=genre, - number_of_chapters=number_of_chapters, - word_number=word_number - ) - base_setting = invoke_with_cleaning(model, prompt_base) - - # Step2: 角色设定 - prompt_char = character_prompt.format( - novel_setting=base_setting - ) - character_setting = invoke_with_cleaning(model, prompt_char) - - # Step3: 暗线/雷点 - prompt_dark = dark_lines_prompt.format( - character_info=character_setting - ) - dark_lines = invoke_with_cleaning(model, prompt_dark) - - # Step4: 最终整合 - prompt_final = finalize_setting_prompt.format( - novel_setting_base=base_setting, - character_setting=character_setting, - dark_lines=dark_lines - ) - final_novel_setting = invoke_with_cleaning(model, prompt_final) - - 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) ============ -def Novel_directory_generate( - api_key: str, - base_url: str, - llm_model: str, - number_of_chapters: int, - filepath: str, - temperature: float = 0.7 -) -> None: - filename_set = os.path.join(filepath, "Novel_setting.txt") - final_novel_setting = read_file(filename_set).strip() - if not final_novel_setting: - logging.warning("Novel_setting.txt 内容为空,请先生成小说设定。") - return - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - prompt_dir = novel_directory_prompt.format( - final_novel_setting=final_novel_setting, - number_of_chapters=number_of_chapters - ) - final_novel_directory = invoke_with_cleaning(model, prompt_dir) - if not final_novel_directory.strip(): - logging.warning("Novel_directory生成结果为空。") - return - - filename_dir = os.path.join(filepath, "Novel_directory.txt") - clear_file_content(filename_dir) - - final_novel_directory_cleaned = final_novel_directory.replace('#', '').replace('*', '') - save_string_to_txt(final_novel_directory_cleaned, filename_dir) - - logging.info("Novel_directory.txt has been generated successfully.") - - -# ============ 获取最近 N 章内容,生成短期摘要 ============ -def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> List[str]: - texts = [] - start_chap = max(1, current_chapter_num - n) - for c in range(start_chap, current_chapter_num): - chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt") - if os.path.exists(chap_file): - text = read_file(chap_file).strip() - if text: - texts.append(text) - if len(texts) < n: - texts = [''] * (n - len(texts)) + texts - return texts - -def summarize_recent_chapters( - llm_model: str, - api_key: str, - base_url: str, - 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): - return "暂无摘要。" - - model = ChatOpenAI( - model=llm_model, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - combined_text = "\n".join(chapters_text_list) - prompt = f"""你是一名资深长篇小说写作辅助AI,下面是最近几章的合并文本: -{combined_text} - -请用中文输出不超过500字的摘要,只包含主要剧情进展、角色变化、冲突焦点等要点:""" - - summary_text = invoke_with_cleaning(model, prompt) - if not summary_text: - return (combined_text[:800] + "...") if len(combined_text) > 800 else combined_text - return summary_text - - -# ============ 剧情要点/冲突 ============ -PLOT_ARCS_PROMPT = """\ -下面是新生成的章节内容: -{chapter_text} - -这里是已记录的剧情要点/未解决冲突(可能为空): -{old_plot_arcs} - -请基于新的章节内容,提炼本章引入或延续的悬念、冲突、角色暗线等,将其合并到旧的剧情要点中。 -若有新的冲突则添加,若有已解决/不再重要的冲突可标注或移除。 -最终输出更新后的剧情要点列表,以帮助后续保持故事整体的一致性和悬念延续。 -""" - -def update_plot_arcs( - chapter_text: str, - old_plot_arcs: str, - api_key: str, - base_url: str, - model_name: str, - temperature: float -) -> str: - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - prompt = PLOT_ARCS_PROMPT.format( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs - ) - arcs_text = invoke_with_cleaning(model, prompt) - if not arcs_text: - logging.warning("update_plot_arcs: No response or empty result.") - return old_plot_arcs - return arcs_text - - -# ============ 生成章节草稿 ============ -def generate_chapter_draft( - novel_settings: str, - global_summary: str, - character_state: str, - recent_chapters_summary: str, - user_guidance: str, - api_key: str, - base_url: str, - model_name: str, - novel_number: int, - word_number: int, - temperature: float, - novel_novel_directory: str, - filepath: str, - interface_format: str, - embedding_model_name: 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"] - - # 合并要检索的文本(用户指导 + 章节简介 + 最近摘要) - combined_query_parts = [] - if user_guidance.strip(): - combined_query_parts.append(user_guidance) - if chapter_brief.strip(): - combined_query_parts.append(chapter_brief) - if recent_chapters_summary.strip(): - combined_query_parts.append(recent_chapters_summary) - # 额外加一个关键字 - combined_query_parts.append("回顾剧情") - - 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) 生成本章大纲 - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - - outline_prompt_text = chapter_outline_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - outline_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - outline_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_outline = invoke_with_cleaning(model, outline_prompt_text) - - outlines_dir = os.path.join(filepath, "outlines") - os.makedirs(outlines_dir, exist_ok=True) - outline_file = os.path.join(outlines_dir, f"outline_{novel_number}.txt") - clear_file_content(outline_file) - save_string_to_txt(chapter_outline, outline_file) - - # 4) 生成正文草稿 - writing_prompt_text = chapter_write_prompt.format( - novel_setting=novel_settings, - character_state=character_state + "\n\n【检索到的上下文】\n" + relevant_context, - global_summary=global_summary, - chapter_outline=chapter_outline, - word_number=word_number, - novel_number=novel_number, - chapter_title=chapter_title, - chapter_brief=chapter_brief - ) - writing_prompt_text += f"\n\n【最近几章摘要】\n{recent_chapters_summary}" - writing_prompt_text += f"\n\n【用户指导】\n{user_guidance if user_guidance else '(无)'}" - - chapter_content = invoke_with_cleaning(model, writing_prompt_text) - - chapters_dir = os.path.join(filepath, "chapters") - os.makedirs(chapters_dir, exist_ok=True) - chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") - clear_file_content(chapter_file) - save_string_to_txt(chapter_content, chapter_file) - - logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") - return chapter_content - - -# ============ 定稿章节 ============ -def finalize_chapter( - novel_number: int, - word_number: int, - api_key: str, - base_url: str, - interface_format: str, - embedding_model_name: str, - model_name: str, - temperature: float, - filepath: str, - embedding_base_url: str, - embedding_api_key: str -): - 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() - if not chapter_text: - logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") - return - - character_state_file = os.path.join(filepath, "character_state.txt") - global_summary_file = os.path.join(filepath, "global_summary.txt") - plot_arcs_file = os.path.join(filepath, "plot_arcs.txt") - - old_char_state = read_file(character_state_file) - 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( - chapter_text=chapter_text, - word_number=word_number, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature - ) - clear_file_content(chapter_file) - save_string_to_txt(chapter_text, chapter_file) - - # 更新全局摘要 - model = ChatOpenAI( - model=model_name, - api_key=api_key, - 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, - global_summary=old_summary - ) - return invoke_with_cleaning(model, prompt) or old_summary - - new_global_summary = update_global_summary(chapter_text, old_global_summary) - - # 更新角色状态 - def update_character_state(chapter_text: str, old_state: str) -> str: - prompt = update_character_state_prompt.format( - chapter_text=chapter_text, - old_state=old_state - ) - return invoke_with_cleaning(model, prompt) or old_state - - new_char_state = update_character_state(chapter_text, old_char_state) - - # 更新剧情要点 - new_plot_arcs = update_plot_arcs( - chapter_text=chapter_text, - old_plot_arcs=old_plot_arcs, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature - ) - - # 写回文件 - clear_file_content(character_state_file) - save_string_to_txt(new_char_state, character_state_file) - - clear_file_content(global_summary_file) - save_string_to_txt(new_global_summary, global_summary_file) - - 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=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, - filepath=filepath - ) - - logging.info(f"Chapter {novel_number} has been finalized.") - - -def enrich_chapter_text( - chapter_text: str, - word_number: int, - api_key: str, - base_url: str, - model_name: str, - temperature: float -) -> str: - model = ChatOpenAI( - model=model_name, - api_key=api_key, - base_url=ensure_openai_base_url_has_v1(base_url), - temperature=temperature - ) - prompt = f"""以下是当前章节文本,可能篇幅较短,请在保持剧情连贯的前提下进行扩写,使其更充实、生动,并尽量靠近目标 {word_number} 字数。 - -原章节内容: -{chapter_text}""" - enriched_text = invoke_with_cleaning(model, prompt) - return enriched_text if enriched_text else chapter_text - - -# ============ 导入外部知识文本到向量库 ============ -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 [] - - 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)) - - 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 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 - - paragraphs = advanced_split_content(content) - - # 若向量库不存在则初始化,否则追加 - store = load_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - filepath=filepath - ) - if not store: - logging.info("Vector store does not exist. Initializing a new one for knowledge import...") - init_vector_store( - api_key=api_key, - base_url=base_url if base_url else "http://localhost:11434/v1", - interface_format=interface_format, - embedding_model_name=embedding_model_name, - texts=paragraphs, - filepath=filepath - ) - else: - docs = [Document(page_content=str(p)) for p in paragraphs] - store.add_documents(docs) - logging.info("知识库文件已成功导入至向量库。") diff --git a/novel_generator.py b/novel_generator.py index 5e2a6ed..c02a2a2 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -29,6 +29,7 @@ from prompt_definitions import ( world_building_prompt, plot_architecture_prompt, chapter_blueprint_prompt, + chunked_chapter_blueprint_prompt, summary_prompt, update_character_state_prompt, chapter_draft_prompt, @@ -386,6 +387,8 @@ def Novel_architecture_generate( plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) final_content = ( + "#=== 0) 小说设定 ===\n" + f"主题:{topic},类型:{genre},篇幅:约{number_of_chapters}章(每章{word_number}字)\n\n" "#=== 1) 核心种子 ===\n" f"{core_seed_result}\n\n" "#=== 2) 角色动力学 ===\n" @@ -402,7 +405,29 @@ def Novel_architecture_generate( logging.info("Novel_architecture.txt has been generated successfully.") -# ============ 2) 生成章节蓝图 ============ +# ============ 计算分块大小的工具函数 ============ + +def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: + """ + 基于“每章约100 tokens”的粗略估算, + 再结合当前max_tokens,计算分块大小: + chunk_size = (floor(max_tokens/100/10)*10) - 10 + 并确保 chunk_size 不会小于1或大于实际章节数。 + """ + tokens_per_chapter = 100.0 + ratio = max_tokens / tokens_per_chapter # 8192 / 100 = 81.92 + # 先取到最接近的10倍 + ratio_rounded_to_10 = int(ratio // 10) * 10 # => 80 + # 再减10 + chunk_size = ratio_rounded_to_10 - 10 # => 70 + if chunk_size < 1: + chunk_size = 1 + if chunk_size > number_of_chapters: + chunk_size = number_of_chapters + return chunk_size + + +# ============ 2) 生成章节蓝图(新增分块逻辑) ============ def Chapter_blueprint_generate( interface_format: str, @@ -410,9 +435,18 @@ def Chapter_blueprint_generate( base_url: str, llm_model: str, filepath: str, + number_of_chapters: int, temperature: float = 0.7, max_tokens: int = 2048 ) -> None: + """ + 如果章节数小于等于 chunk_size,则直接使用 chapter_blueprint_prompt 一次性生成。 + 如果章节数较多,则进行分块生成: + 1) 首先说明要生成的总章节数 + 2) 先生成 [1..chunk_size] 的章节 + 3) 将生成的文本作为已有目录传入,继续生成 [chunk_size+1..] 的章节 + 4) 最后汇总全部章节目录写入 Novel_directory.txt + """ arch_file = os.path.join(filepath, "Novel_architecture.txt") if not os.path.exists(arch_file): logging.warning("Novel_architecture.txt not found. Please generate architecture first.") @@ -423,19 +457,6 @@ def Chapter_blueprint_generate( logging.warning("Novel_architecture.txt is empty.") return - match_chaps = re.search(r'约(\d+)章', architecture_text) - if match_chaps: - number_of_chapters = int(match_chaps.group(1)) - else: - number_of_chapters = 10 - - # 提取三幕式文本 - plot_arch_text = "" - pat_plot = r'#=== 4\) 三幕式情节架构 ===\n([\s\S]+)$' - m = re.search(pat_plot, architecture_text) - if m: - plot_arch_text = m.group(1).strip() - llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, @@ -445,20 +466,65 @@ def Chapter_blueprint_generate( max_tokens=max_tokens ) - prompt = chapter_blueprint_prompt.format( - plot_architecture=plot_arch_text, - number_of_chapters=number_of_chapters - ) - blueprint_text = invoke_with_cleaning(llm_adapter, prompt) - if not blueprint_text.strip(): - logging.warning("Chapter blueprint generation result is empty.") + # 计算分块大小 + chunk_size = compute_chunk_size(number_of_chapters, max_tokens) + logging.info(f"Number of chapters = {number_of_chapters}, computed chunk_size = {chunk_size}.") + + # 如果一次就可以生成全部 + if chunk_size >= number_of_chapters: + prompt = chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + number_of_chapters=number_of_chapters + ) + blueprint_text = invoke_with_cleaning(llm_adapter, prompt) + if not blueprint_text.strip(): + logging.warning("Chapter blueprint generation result is empty.") + return + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + clear_file_content(filename_dir) + save_string_to_txt(blueprint_text, filename_dir) + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).") + return + + # 否则,分块生成 + final_blueprint = "" + current_start = 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + + # 分块提示 + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=final_blueprint, # 已有的章节列表文本 + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end + ) + logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") + + chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) + if not chunk_result.strip(): + logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.") + chunk_result = "" + + # 将本次生成的文本拼接到最终结果中 + if final_blueprint.strip(): + final_blueprint += "\n\n" + chunk_result + else: + final_blueprint = chunk_result + + current_start = current_end + 1 + + if not final_blueprint.strip(): + logging.warning("All chunked generation results are empty, cannot create blueprint.") return filename_dir = os.path.join(filepath, "Novel_directory.txt") clear_file_content(filename_dir) - save_string_to_txt(blueprint_text, filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) - logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully.") + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") # ============ 3) 生成章节草稿 ============ @@ -610,7 +676,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return - if len(chapter_text) < 0.6 * word_number: + if len(chapter_text) < 0.7 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) save_string_to_txt(chapter_text, chapter_file) diff --git a/prompt_definitions.py b/prompt_definitions.py index 72dc37c..c7b5661 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -121,39 +121,91 @@ plot_architecture_prompt = """\ # =============== 5. 章节目录生成(悬念节奏曲线)=================== chapter_blueprint_prompt = """\ -根据三幕式架构: -{plot_architecture} +根据小说架构:\n +{novel_architecture} 设计{number_of_chapters}章的节奏分布: -1. 每章需明确: +1. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +2. 每章需明确: +- 章节定位(角色/事件/主题等) - 核心悬念类型(信息差/道德困境/时间压力等) - 情感基调迁移(如从怀疑→恐惧→决绝) - 伏笔操作(埋设/强化/回收) - 认知颠覆强度(1-5级) -2. 章节集群划分: -- 每3-5章构成一个悬念单元,包含完整的小高潮 -- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) -- 关键转折章需预留多视角铺垫 - 输出格式示例: 第n章 - [标题] -本章定位:[角色/事件/主题] -核心作用:[推进/转折/揭示] -悬念密度:[紧凑/渐进/爆发] -伏笔操作:埋设(A线索)→强化(B矛盾) +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... 认知颠覆:★☆☆☆☆ 本章简述:[一句话概括] 第n+1章 - [标题] -本章定位:[角色/事件/主题] -核心作用:[推进/转折/揭示] -悬念密度:[紧凑/渐进/爆发] -伏笔操作:埋设(A线索)→强化(B矛盾) +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... 认知颠覆:★☆☆☆☆ 本章简述:[一句话概括] -使用精炼语言描述,每章字数控制在100字以内。 +要求: +- 使用精炼语言描述,每章字数控制在100字以内。 +- 合理安排节奏,确保整体悬念曲线的连贯性。 +- 在生成{number_of_chapters}章前不要出现结局章节。 + +仅给出最终文本,不要解释任何内容。 +""" + +chunked_chapter_blueprint_prompt = """\ +根据小说架构:\n +{novel_architecture} + +需要生成总共{number_of_chapters}章的节奏分布, + +当前已有章节目录(若未空则说明是初始生成):\n +{chapter_list} + +现在请设计第{n}章到第{m}的节奏分布: +1. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +2. 每章需明确: +- 章节定位(角色/事件/主题等) +- 核心悬念类型(信息差/道德困境/时间压力等) +- 情感基调迁移(如从怀疑→恐惧→决绝) +- 伏笔操作(埋设/强化/回收) +- 认知颠覆强度(1-5级) + +输出格式示例: +第n章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +第n+1章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +要求: +- 使用精炼语言描述,每章字数控制在100字以内。 +- 合理安排节奏,确保整体悬念曲线的连贯性。 +- 在生成{number_of_chapters}章前不要出现结局章节。 + 仅给出最终文本,不要解释任何内容。 """ @@ -183,11 +235,11 @@ update_character_state_prompt = """\ 这是当前的角色状态文档(可为空): {old_state} -请更新角色状态,内容包括: +请更新角色状态,内容格式: 角色A属性: ├──物品: - ├──道具1:描述 - ├──道具2:描述 + ├──某物(道具):描述 + ├──XX长剑(武器):描述 ... ├──能力 ├──技能1:描述 @@ -232,8 +284,10 @@ update_character_state_prompt = """\ 仅返回更新后的角色状态文本,不要解释任何内容。 """ -# =============== 8. 章节正文写作(新版) =================== -chapter_draft_prompt = """\ +# =============== 8. 章节正文写作 =================== + +# 8.1 第一章草稿提示 +first_chapter_draft_prompt = """\ 即将创作:第 {novel_number} 章《{chapter_title}》 本章定位:{chapter_role} 核心作用:{chapter_purpose} @@ -252,18 +306,6 @@ chapter_draft_prompt = """\ - 小说设定: {novel_setting} -- 全局摘要: -{global_summary} - -- 角色状态: -{character_state} - -前章片段(可能为空): -{previous_chapter_excerpt} - -本地知识(向量)库检索到的片段(可能为空): -{context_excerpt} - 请完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: - 潜台词冲突(表面谈论A,实际博弈B) @@ -287,5 +329,63 @@ chapter_draft_prompt = """\ - 不使用分章节小标题; - 不要使用markdown格式。 -用户额外指导(可能未指定):{user_guidance} +额外指导(可能未指定):{user_guidance} """ + +# 8.2 后续章节草稿提示 +next_chapter_draft_prompt = """\ +参考文档: +- 小说设定: +{novel_setting} + +- 全局摘要: +{global_summary} + +- 角色状态: +{character_state} + +本地知识库检索到的片段: +{context_excerpt} + +即将创作:第 {novel_number} 章《{chapter_title}》 +本章定位:{chapter_role} +核心作用:{chapter_purpose} +悬念密度:{suspense_level} +伏笔操作:{foreshadowing} +认知颠覆:{plot_twist_level} +本章简述:{chapter_summary} + +可用元素: +- 核心人物(可能未指定):{characters_involved} +- 关键道具(可能未指定):{key_items} +- 空间坐标(可能未指定):{scene_location} +- 时间压力(可能未指定):{time_constraint} + +前章结尾段: +{previous_chapter_excerpt} + +请从前章结尾处继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +1. 对话场景: + - 潜台词冲突(表面谈论A,实际博弈B) + - 权力关系变化(通过非对称对话长度体现) + - 至少1处双关语暗示未来危机 + +2. 动作场景: + - 环境交互细节(至少3个感官描写) + - 节奏控制(短句加速+比喻减速) + - 动作揭示人物隐藏特质 + +3. 心理场景: + - 认知失调的具体表现(行为矛盾) + - 隐喻系统的运用(连接世界观符号) + - 决策前的价值天平描写 + +文末设置一个"钩链转折":结尾时回收旧悬念/创造新悬念/抛出新危机/颠覆某个认知/神转折等。 + +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 + +额外指导(可能未指定):{user_guidance} +""" \ No newline at end of file diff --git a/ui.py b/ui.py index f5b81cc..22b3bbc 100644 --- a/ui.py +++ b/ui.py @@ -794,6 +794,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() + number_of_chapters = self.safe_get_int(self.num_chapters_var, 10) temperature = self.temperature_var.get() max_tokens = self.max_tokens_var.get() @@ -803,6 +804,7 @@ class NovelGeneratorGUI: api_key=api_key, base_url=base_url, llm_model=model_name, + number_of_chapters=number_of_chapters, filepath=filepath, temperature=temperature, max_tokens=max_tokens From 2a2beac952ac09a245a211526cd520667355a2b5 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 22:38:50 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E6=8B=86=E5=88=86=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E7=AB=A0=E5=92=8C=E5=90=8E=E7=BB=AD=E7=AB=A0=E8=8A=82=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=BF=E6=8E=A5=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- novel_generator.py | 154 ++++++++++++++++++++++++++---------------- prompt_definitions.py | 2 +- 2 files changed, 98 insertions(+), 58 deletions(-) diff --git a/novel_generator.py b/novel_generator.py index c02a2a2..6c4e47f 100644 --- a/novel_generator.py +++ b/novel_generator.py @@ -32,7 +32,8 @@ from prompt_definitions import ( chunked_chapter_blueprint_prompt, summary_prompt, update_character_state_prompt, - chapter_draft_prompt, + first_chapter_draft_prompt, + next_chapter_draft_prompt, summarize_recent_chapters_prompt ) @@ -415,11 +416,11 @@ def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int: 并确保 chunk_size 不会小于1或大于实际章节数。 """ tokens_per_chapter = 100.0 - ratio = max_tokens / tokens_per_chapter # 8192 / 100 = 81.92 + ratio = max_tokens / tokens_per_chapter # 例如:8192 / 100 = 81.92 # 先取到最接近的10倍 ratio_rounded_to_10 = int(ratio // 10) * 10 # => 80 # 再减10 - chunk_size = ratio_rounded_to_10 - 10 # => 70 + chunk_size = ratio_rounded_to_10 - 10 # => 70 if chunk_size < 1: chunk_size = 1 if chunk_size > number_of_chapters: @@ -527,7 +528,7 @@ def Chapter_blueprint_generate( logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") -# ============ 3) 生成章节草稿 ============ +# ============ 3) 生成章节草稿(分「第一章」与「后续章节」) ============ def generate_chapter_draft( api_key: str, @@ -550,6 +551,11 @@ def generate_chapter_draft( interface_format: str = "openai", max_tokens: int = 2048 ) -> str: + """ + 根据 novel_number 判断是否为第一章。 + - 若是第一章,则使用 first_chapter_draft_prompt + - 否则使用 next_chapter_draft_prompt + """ arch_file = os.path.join(filepath, "Novel_architecture.txt") novel_architecture_text = read_file(arch_file) @@ -562,6 +568,7 @@ def generate_chapter_draft( character_state_file = os.path.join(filepath, "character_state.txt") character_state_text = read_file(character_state_file) + # 获取本章在目录中的信息 chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) chapter_title = chapter_info["chapter_title"] chapter_role = chapter_info["chapter_role"] @@ -571,68 +578,97 @@ def generate_chapter_draft( plot_twist_level = chapter_info["plot_twist_level"] chapter_summary = chapter_info["chapter_summary"] + # 准备章节目录文件夹 chapters_dir = os.path.join(filepath, "chapters") os.makedirs(chapters_dir, exist_ok=True) - recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) - short_summary, next_chapter_keywords = summarize_recent_chapters( - interface_format=interface_format, - api_key=api_key, - base_url=base_url, - model_name=model_name, - temperature=temperature, - max_tokens=max_tokens, - chapters_text_list=recent_3_texts - ) + # 如果是第一章,不需要前情检索与前章结尾 + if novel_number == 1: + # 使用第一章提示词 + prompt_text = first_chapter_draft_prompt.format( + novel_number=novel_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, - previous_chapter_excerpt = "" - for text_block in reversed(recent_3_texts): - if text_block.strip(): - if len(text_block) > 1500: - previous_chapter_excerpt = text_block[-1500:] - else: - previous_chapter_excerpt = text_block - break + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, - embedding_adapter = create_embedding_adapter( - embedding_interface_format, - embedding_api_key, - embedding_url, - embedding_model_name - ) - retrieval_query = short_summary + " " + next_chapter_keywords - relevant_context = get_relevant_context_from_vector_store( - embedding_adapter=embedding_adapter, - query=retrieval_query, - filepath=filepath, - k=embedding_retrieval_k - ) - if not relevant_context.strip(): - relevant_context = "(无检索到的上下文)" + novel_setting=novel_architecture_text + ) - prompt_text = chapter_draft_prompt.format( - novel_number=novel_number, - chapter_title=chapter_title, - chapter_role=chapter_role, - chapter_purpose=chapter_purpose, - suspense_level=suspense_level, - foreshadowing=foreshadowing, - plot_twist_level=plot_twist_level, - chapter_summary=chapter_summary, + else: + # 若不是第一章,则先获取最近几章文本,并做摘要与检索 + recent_3_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + short_summary, next_chapter_keywords = summarize_recent_chapters( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + max_tokens=max_tokens, + chapters_text_list=recent_3_texts + ) - characters_involved=characters_involved, - key_items=key_items, - scene_location=scene_location, - time_constraint=time_constraint, - user_guidance=user_guidance, + # 从最近章节中获取最后一段内容作为前章结尾 + previous_chapter_excerpt = "" + for text_block in reversed(recent_3_texts): + if text_block.strip(): + if len(text_block) > 1500: + previous_chapter_excerpt = text_block[-1500:] + else: + previous_chapter_excerpt = text_block + break - novel_setting=novel_architecture_text, - global_summary=global_summary_text, - character_state=character_state_text, - previous_chapter_excerpt=previous_chapter_excerpt, - context_excerpt=relevant_context - ) + # 从向量库检索上下文 + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name + ) + retrieval_query = short_summary + " " + next_chapter_keywords + relevant_context = get_relevant_context_from_vector_store( + embedding_adapter=embedding_adapter, + query=retrieval_query, + filepath=filepath, + k=embedding_retrieval_k + ) + if not relevant_context.strip(): + relevant_context = "(无检索到的上下文)" + # 使用后续章节提示词 + prompt_text = next_chapter_draft_prompt.format( + novel_number=novel_number, + chapter_title=chapter_title, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + suspense_level=suspense_level, + foreshadowing=foreshadowing, + plot_twist_level=plot_twist_level, + chapter_summary=chapter_summary, + + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + user_guidance=user_guidance, + + novel_setting=novel_architecture_text, + global_summary=global_summary_text, + character_state=character_state_text, + context_excerpt=relevant_context, + previous_chapter_excerpt=previous_chapter_excerpt + ) + + # 调用LLM生成 llm_adapter = create_llm_adapter( interface_format=interface_format, base_url=base_url, @@ -645,6 +681,7 @@ def generate_chapter_draft( if not chapter_content.strip(): logging.warning("Generated chapter draft is empty.") + # 保存章节文本 chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt") clear_file_content(chapter_file) save_string_to_txt(chapter_content, chapter_file) @@ -652,6 +689,7 @@ def generate_chapter_draft( logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") return chapter_content + # ============ 4) 定稿章节 ============ def finalize_chapter( @@ -676,6 +714,7 @@ def finalize_chapter( logging.warning(f"Chapter {novel_number} is empty, cannot finalize.") return + # 如果内容过短,则尝试扩写 if len(chapter_text) < 0.7 * word_number: chapter_text = enrich_chapter_text(chapter_text, word_number, api_key, base_url, model_name, temperature, interface_format, max_tokens) clear_file_content(chapter_file) @@ -716,6 +755,7 @@ def finalize_chapter( clear_file_content(character_state_file) save_string_to_txt(new_char_state, character_state_file) + # 更新向量库 embedding_adapter = create_embedding_adapter( embedding_interface_format, embedding_api_key, diff --git a/prompt_definitions.py b/prompt_definitions.py index c7b5661..a6b2d2b 100644 --- a/prompt_definitions.py +++ b/prompt_definitions.py @@ -364,7 +364,7 @@ next_chapter_draft_prompt = """\ 前章结尾段: {previous_chapter_excerpt} -请从前章结尾处继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: +请依据前章结尾片段,继续完成第 {novel_number} 章的正文,至少设计下方2个具有动态张力的场景: 1. 对话场景: - 潜台词冲突(表面谈论A,实际博弈B) - 权力关系变化(通过非对称对话长度体现) From 21d151d4a63691616f0512796a3517081f9c9764 Mon Sep 17 00:00:00 2001 From: YILING0013 Date: Thu, 6 Feb 2025 22:51:11 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=BA=9F=E5=BC=83?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embedding_adapters.py | 3 +-- embedding_ollama.py | 59 ------------------------------------------- 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 embedding_ollama.py diff --git a/embedding_adapters.py b/embedding_adapters.py index a17bb7a..9b0d7a8 100644 --- a/embedding_adapters.py +++ b/embedding_adapters.py @@ -48,8 +48,7 @@ class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter): class OllamaEmbeddingAdapter(BaseEmbeddingAdapter): """ - Ollama Embedding,示例中和之前的 embedding_ollama.py 类似 - 其接口路径往往为 /api/embeddings + 其接口路径为 /api/embeddings """ def __init__(self, model_name: str, base_url: str): self.model_name = model_name diff --git a/embedding_ollama.py b/embedding_ollama.py deleted file mode 100644 index 749a462..0000000 --- a/embedding_ollama.py +++ /dev/null @@ -1,59 +0,0 @@ -# embedding_ollama.py -import requests -import traceback -from typing import List - -class OllamaEmbeddings: - def __init__(self, model_name: str, base_url: str): - self.model_name = model_name - self.base_url = base_url - - def embed(self, texts: List[str]) -> List[List[float]]: - """ - 批量将多段文本转换为embedding向量 - """ - embeddings = [] - for text in texts: - embeddings.append(self.embed_single_document(text)) - return embeddings - - def embed_documents(self, texts: List[str]) -> List[List[float]]: - """ - 兼容langchain的接口写法 - """ - return self.embed(texts) - - def embed_query(self, query: str) -> List[float]: - """ - 将单条 query 转换为 embedding 向量 - """ - return self.embed_single_document(query) - - def embed_single_document(self, text: str) -> List[float]: - """ - 调用 Ollama 本地服务接口,获取文本的 embedding。 - """ - if self.base_url.endswith("/"): - self.base_url = self.base_url.rstrip("/") - if "api/embeddings" in self.base_url: - # 如果 base_url 已经包含 'api/embeddings',则保持不变 - url = f"{self.base_url.rstrip('/')}/api/embeddings" - else: - if "/v1" in self.base_url: - self.base_url = self.base_url.split("/v1")[0] - if "/api" in self.base_url: - self.base_url = self.base_url.split("/api")[0] - url = f"{self.base_url}/api/embeddings" - data = { - "model": self.model_name, - "prompt": text - } - try: - response = requests.post(url, json=data) - response.raise_for_status() - result = response.json() - if "embedding" not in result: - raise ValueError("No 'embedding' field in Ollama response.") - return result["embedding"] - except requests.exceptions.RequestException as e: - raise Exception(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}")