From dc9ff7733b8e72a77318e6bedc30dbb6919fe26a Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 16 Mar 2025 21:53:46 +0800 Subject: [PATCH 1/4] Update blueprint.py --- novel_generator/blueprint.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/novel_generator/blueprint.py b/novel_generator/blueprint.py index a0425df..a0f0f6c 100644 --- a/novel_generator/blueprint.py +++ b/novel_generator/blueprint.py @@ -48,6 +48,7 @@ def Chapter_blueprint_generate( llm_model: str, filepath: str, number_of_chapters: int, + user_guidance: str = "", # 新增参数 temperature: float = 0.7, max_tokens: int = 4096, timeout: int = 600 @@ -106,7 +107,8 @@ def Chapter_blueprint_generate( chapter_list=limited_blueprint, number_of_chapters=number_of_chapters, n=current_start, - m=current_end + m=current_end, + user_guidance=user_guidance # 新增参数 ) logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) @@ -126,7 +128,8 @@ def Chapter_blueprint_generate( if chunk_size >= number_of_chapters: prompt = chapter_blueprint_prompt.format( novel_architecture=architecture_text, - number_of_chapters=number_of_chapters + number_of_chapters=number_of_chapters, + user_guidance=user_guidance # 新增参数 ) blueprint_text = invoke_with_cleaning(llm_adapter, prompt) if not blueprint_text.strip(): @@ -149,7 +152,8 @@ def Chapter_blueprint_generate( chapter_list=limited_blueprint, number_of_chapters=number_of_chapters, n=current_start, - m=current_end + m=current_end, + user_guidance=user_guidance # 新增参数 ) logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...") chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt) From bda3bb9c25b60d39b88fa7bf9836249d695c1d1b Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Sun, 16 Mar 2025 21:54:15 +0800 Subject: [PATCH 2/4] Update generation_handlers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改生成目录弹错 --- ui/generation_handlers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index d1fbadb..9f030a8 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -77,7 +77,7 @@ def generate_chapter_blueprint_ui(self): return def task(): - if not messagebox.askyesno("确认", "确定要生成章节草稿吗?"): + if not messagebox.askyesno("确认", "确定要生成章节目录吗?"): self.enable_button_safe(self.btn_generate_chapter) return self.disable_button_safe(self.btn_generate_directory) @@ -90,6 +90,7 @@ def generate_chapter_blueprint_ui(self): temperature = self.temperature_var.get() max_tokens = self.max_tokens_var.get() timeout_val = self.safe_get_int(self.timeout_var, 600) + user_guidance = self.user_guide_text.get("0.0", "end").strip() # 新增获取用户指导 self.safe_log("开始生成章节蓝图...") Chapter_blueprint_generate( @@ -101,7 +102,8 @@ def generate_chapter_blueprint_ui(self): filepath=filepath, temperature=temperature, max_tokens=max_tokens, - timeout=timeout_val + timeout=timeout_val, + user_guidance=user_guidance # 新增参数 ) self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") except Exception: From 2c55841389da43ea6b4b6ace28407318fce1efb3 Mon Sep 17 00:00:00 2001 From: CNlaojing <154053522+CNlaojing@users.noreply.github.com> Date: Mon, 17 Mar 2025 02:50:22 +0800 Subject: [PATCH 3/4] Update generation_handlers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复导入知识库txt文件为ANSI编码报错问题 --- ui/generation_handlers.py | 53 +++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py index 9f030a8..d394f8d 100644 --- a/ui/generation_handlers.py +++ b/ui/generation_handlers.py @@ -445,20 +445,53 @@ def import_knowledge_handler(self): 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( - embedding_api_key=emb_api_key, - embedding_url=emb_url, - embedding_interface_format=emb_format, - embedding_model_name=emb_model, - file_path=selected_file, - filepath=self.filepath_var.get().strip() - ) - self.safe_log("✅ 知识库文件导入完成。") + # 尝试不同编码读取文件 + content = None + encodings = ['utf-8', 'gbk', 'gb2312', 'ansi'] + for encoding in encodings: + try: + with open(selected_file, 'r', encoding=encoding) as f: + content = f.read() + break + except UnicodeDecodeError: + continue + except Exception as e: + self.safe_log(f"读取文件时发生错误: {str(e)}") + raise + + if content is None: + raise Exception("无法以任何已知编码格式读取文件") + + # 创建临时UTF-8文件 + import tempfile + import os + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, suffix='.txt') as temp: + temp.write(content) + temp_path = temp.name + + try: + self.safe_log(f"开始导入知识库文件: {selected_file}") + import_knowledge_file( + embedding_api_key=emb_api_key, + embedding_url=emb_url, + embedding_interface_format=emb_format, + embedding_model_name=emb_model, + file_path=temp_path, + filepath=self.filepath_var.get().strip() + ) + self.safe_log("✅ 知识库文件导入完成。") + finally: + # 清理临时文件 + try: + os.unlink(temp_path) + except: + pass + except Exception: self.handle_exception("导入知识库时出错") finally: self.enable_button_safe(self.btn_import_knowledge) + try: thread = threading.Thread(target=task, daemon=True) thread.start() From 6752a5cd9f3e21adcc9eff2127126b231866eb0b Mon Sep 17 00:00:00 2001 From: CNlaojing Date: Wed, 19 Mar 2025 13:02:51 +0800 Subject: [PATCH 4/4] Initial commit --- .gitattributes | 2 + .github/CONTRIBUTING.md | 65 ++ .github/ISSUE_TEMPLATE/code_issue.yml | 60 + .github/ISSUE_TEMPLATE/opinion.yml | 48 + .gitignore | 13 + LICENSE | 661 +++++++++++ README.md | 237 ++++ chapter_directory_parser.py | 132 +++ config_manager.py | 80 ++ consistency_checker.py | 72 ++ embedding_adapters.py | 272 +++++ icon.ico | Bin 0 -> 16958 bytes llm_adapters.py | 376 ++++++ main.py | 12 + main.spec | 72 ++ novel_generator/__init__.py | 13 + novel_generator/architecture.py | 201 ++++ novel_generator/blueprint.py | 173 +++ novel_generator/chapter.py | 585 ++++++++++ novel_generator/common.py | 77 ++ novel_generator/finalization.py | 119 ++ novel_generator/knowledge.py | 87 ++ novel_generator/vectorstore_utils.py | 244 ++++ prompt_definitions.py | 662 +++++++++++ requirements.txt | Bin 0 -> 816 bytes tooltips.py | 37 + ui/__init__.py | 2 + ui/chapters_tab.py | 140 +++ ui/character_tab.py | 56 + ui/config_tab.py | 319 ++++++ ui/context_menu.py | 54 + ui/directory_tab.py | 56 + ui/generation_handlers.py | 538 +++++++++ ui/helpers.py | 7 + ui/main_tab.py | 113 ++ ui/main_window.py | 367 ++++++ ui/novel_params_tab.py | 146 +++ ui/role_library.py | 1528 +++++++++++++++++++++++++ ui/setting_tab.py | 56 + ui/summary_tab.py | 57 + utils.py | 53 + 41 files changed, 7792 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/code_issue.yml create mode 100644 .github/ISSUE_TEMPLATE/opinion.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 chapter_directory_parser.py create mode 100644 config_manager.py create mode 100644 consistency_checker.py create mode 100644 embedding_adapters.py create mode 100644 icon.ico create mode 100644 llm_adapters.py create mode 100644 main.py create mode 100644 main.spec create mode 100644 novel_generator/__init__.py create mode 100644 novel_generator/architecture.py create mode 100644 novel_generator/blueprint.py create mode 100644 novel_generator/chapter.py create mode 100644 novel_generator/common.py create mode 100644 novel_generator/finalization.py create mode 100644 novel_generator/knowledge.py create mode 100644 novel_generator/vectorstore_utils.py create mode 100644 prompt_definitions.py create mode 100644 requirements.txt create mode 100644 tooltips.py create mode 100644 ui/__init__.py create mode 100644 ui/chapters_tab.py create mode 100644 ui/character_tab.py create mode 100644 ui/config_tab.py create mode 100644 ui/context_menu.py create mode 100644 ui/directory_tab.py create mode 100644 ui/generation_handlers.py create mode 100644 ui/helpers.py create mode 100644 ui/main_tab.py create mode 100644 ui/main_window.py create mode 100644 ui/novel_params_tab.py create mode 100644 ui/role_library.py create mode 100644 ui/setting_tab.py create mode 100644 ui/summary_tab.py create mode 100644 utils.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..efd594d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to This Project + +首先,感谢你愿意为本项目贡献力量!在提交任何形式的反馈或 Pull Request 之前,请先阅读以下内容。 + +--- + +## 一、反馈类型说明 + +1. **代码问题(Code Issue)** + - 仅限与项目代码本身相关的问题:如编译失败、运行报错、逻辑缺陷等。 + - 反馈之前,请确认该问题与你的环境或配置无关。 + - 如果确认是代码本身导致的错误,请使用 [代码问题反馈模板](?template=code_issue.yml)。 + +2. **意见或建议(Opinion / Enhancement)** + - 如果你有关于功能新增、代码重构、性能优化或其他方面的意见或建议,请使用 [意见/建议模板](?template=opinion.yml)。 + - 我们会积极审阅并讨论可行性,但可能不会立刻实现,视项目计划而定。 + +3. **接口/配置/部署等问题** + - 本项目不针对接口配置、环境部署或第三方服务的参数设置等问题提供支持。 + - 遇到此类问题,请阅读官方文档、社区讨论区或自行搜索相关信息。 + +--- + +## 二、在提交 Issues 之前 + +1. **搜索现有的 Issues** + - 避免重复提交相同问题。 + - 如果发现类似问题可以补充你的信息或在对应 Issue 下评论。 + +2. **提供尽可能详细的信息** + - 提交问题时,尽量提供可复现的步骤、日志信息、环境说明等。 + - 提交意见或建议时,需要清楚说明理由和期望。 + +3. **保持尊重与礼貌** + - 请尊重项目维护者和其他贡献者。 + - 交流中请使用恰当、礼貌的语言。 + +--- + +## 三、Pull Request 提交指南 + +1. **先 Fork 再修改** + - 在你自己的 Fork 中进行修改和测试。 + - 确保修改内容不会引入新的 Bug。 + +2. **遵守代码风格** + - 保持原有代码风格,遵循项目的 Lint 规则(如有)。 + - 减少不必要的格式改动,保证可读性。 + +3. **更新文档或注释** + - 如果你的修改影响到了文档或注释,请及时补充或更新。 + +4. **描述清楚修改内容** + - Pull Request 标题与描述中需包含本次修改的目的、解决的问题以及修改的主要内容。 + +--- + +## 四、其他说明 + +- 我们对所有 Issue 和 Pull Request 均会尽量及时处理,但无法保证立即回复。 +- 对于不符合上述规则的 Issue 或 Pull Request,我们保留关闭或忽略的权利。 + +如果你对上述要求有任何疑问,欢迎在意见区进行讨论。再次感谢你的贡献! + +--- diff --git a/.github/ISSUE_TEMPLATE/code_issue.yml b/.github/ISSUE_TEMPLATE/code_issue.yml new file mode 100644 index 0000000..b3d4793 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/code_issue.yml @@ -0,0 +1,60 @@ +name: "代码问题反馈" +description: "此模板仅用于反馈代码相关问题,例如出现编译错误、运行报错、逻辑缺陷。" +title: "[Code Issue]: " +labels: ["bug", "code issue"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + **⚠ 注意:此处仅受理代码本身的问题,包括但不限于编译错误、运行报错、逻辑异常等。** + **如果是接口配置或环境部署等问题,请自行阅读文档或在讨论区寻求帮助。** + **如果是意见或建议,请使用 [意见模板](?template=opinion.yml)。** + 感谢你的配合! + + - type: textarea + id: description + attributes: + label: "问题描述" + description: "请清晰、简要地描述代码出现的问题。" + placeholder: "例如:运行时报错xxx,或逻辑存在xxx。" + validations: + required: true + + - type: textarea + id: steps + attributes: + label: "复现步骤" + description: "请提供完整的复现步骤,以便我们定位和解决问题。" + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: input + id: environment + attributes: + label: "环境信息" + description: "如编译器、操作系统、依赖版本等。" + placeholder: "示例:Windows 10, Node.js v14, Python 3.9, etc." + + - type: textarea + id: logs + attributes: + label: "日志信息(如适用)" + description: "如果有报错日志或截图,可以贴在此处。" + placeholder: "请粘贴日志内容或相关截图链接(可选)" + validations: + required: false + + - type: textarea + id: additional + attributes: + label: "补充信息" + description: "如果有更多信息,可在此补充。" + placeholder: "任何与问题相关的额外背景说明..." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/opinion.yml b/.github/ISSUE_TEMPLATE/opinion.yml new file mode 100644 index 0000000..90504f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/opinion.yml @@ -0,0 +1,48 @@ +name: "意见或建议" +description: "如果你有对项目的需求、功能建议、或其他意见,请使用此模板。" +title: "[Opinion]: " +labels: ["enhancement", "discussion"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + **⚠ 注意:此处不用于反馈代码报错或编译问题,如果是纯代码报错或逻辑问题,请使用 [代码问题反馈模板](?template=code_issue.yml)。** + 感谢你的宝贵意见或建议,我们会酌情采纳! + + - type: textarea + id: suggestion + attributes: + label: "意见/建议内容" + description: "请简要描述你的想法或建议。" + placeholder: "例如:希望新增xx功能,或者修改xx逻辑。" + validations: + required: true + + - type: textarea + id: reason + attributes: + label: "为什么需要这个功能或修改?" + description: "简单说明你提出此意见/建议的原因或背景需求。" + placeholder: "例如:在实际项目中遇到xx需求场景;希望提升xx效率;等等。" + validations: + required: true + + - type: input + id: relevance + attributes: + label: "相关链接或参考" + description: "如果你有看到类似实现或参考资料,可在此提供链接。" + placeholder: "例如:相关文档链接、RFC、规范文档等" + validations: + required: false + + - type: textarea + id: additional + attributes: + label: "补充信息" + description: "如果有更多信息,可在此补充。" + placeholder: "任何与意见或建议相关的额外说明..." + validations: + required: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b239dd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/Novel_Src +/.venv +/build +/dist +/.vscode +/__pycache__ +/markdown +/vectorstore +/example +config.json +config_test.json +/novel_generator/__pycache__ +/ui/__pycache__ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1240290 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# 📖 自动小说生成工具 + +
+ +✨ **核心功能** ✨ + +| 功能模块 | 关键能力 | +|-------------------|----------------------------------| +| 🎨 小说设定工坊 | 世界观架构 / 角色设定 / 剧情蓝图 | +| 📖 智能章节生成 | 多阶段生成保障剧情连贯性 | +| 🧠 状态追踪系统 | 角色发展轨迹 / 伏笔管理系统 | +| 🔍 语义检索引擎 | 基于向量的长程上下文一致性维护 | +| 📚 知识库集成 | 支持本地文档参考 | +| ✅ 自动审校机制 | 检测剧情矛盾与逻辑冲突 | +| 🖥 可视化工作台 | 全流程GUI操作,配置/生成/审校一体化 | + +
+ +> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事 + +2025-03-05 添加角色库功能 + +2025-03-09 添加字数显示 + +2025-03-13 +1、新增闲云修改; +2、把本章指导改成内容指导; +3、在生成架构中的: 2. 角色动力学设定(角色弧光模型)、 3. 世界构建矩阵(三维度交织法)、 4. 情节架构(三幕式悬念)与生成目录的:5. 章节目录生成(悬念节奏曲线)加入引导词内容指导,以方便生成角色动力学时只以核心种子生成,导致生成的内容与实际需求不符。 +4、在终端加回被删除的LLM提示词与LLM返回内容显示,以便复盘,参考修改提示词。 +--- + +## 📑 目录导航 +1. [环境准备](#-环境准备) +2. [项目架构](#-项目架构) +3. [配置指南](#⚙️-配置指南) +4. [运行说明](#🚀-运行说明) +5. [使用教程](#📘-使用教程) +6. [疑难解答](#❓-疑难解答) + +--- + +## 🛠 环境准备 +确保满足以下运行条件: +- **Python 3.9+** 运行环境(推荐3.10-3.12之间) +- **pip** 包管理工具 +- 有效API密钥: + - 云端服务:OpenAI / DeepSeek 等 + - 本地服务:Ollama 等兼容 OpenAI 的接口 + +--- + + +## 📥 安装说明 +1. **下载项目** + - 通过 [GitHub](https://github.com) 下载项目 ZIP 文件,或使用以下命令克隆本项目: + ```bash + git clone https://github.com/YILING0013/AI_NovelGenerator + ``` + +2. **安装编译工具(可选)** + - 如果对某些包无法正常安装,访问 [Visual Studio Build Tools](https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/) 下载并安装C++编译工具,用于构建部分模块包; + - 安装时,默认只包含 MSBuild 工具,需手动勾选左上角列表栏中的 **C++ 桌面开发** 选项。 + +3. **安装依赖并运行** + - 打开终端,进入项目源文件目录: + ```bash + cd AI_NovelGenerator + ``` + - 安装项目依赖: + ```bash + pip install -r requirements.txt + ``` + - 安装完成后,运行主程序: + ```bash + python main.py + ``` + +>如果缺失部分依赖,后续**手动执行** +>```bash +>pip install XXX +>``` +>进行安装即可 + +## 🗂 项目架构 +``` +novel-generator/ +├── main.py # 入口文件, 运行 GUI +├── ui.py # 图形界面 +├── novel_generator.py # 章节生成核心逻辑 +├── consistency_checker.py # 一致性检查, 防止剧情冲突 +|—— chapter_directory_parser.py # 目录解析 +|—— embedding_adapters.py # Embedding 接口封装 +|—— llm_adapters.py # LLM 接口封装 +├── prompt_definitions.py # 定义 AI 提示词 +├── utils.py # 常用工具函数, 文件操作 +├── config_manager.py # 管理配置 (API Key, Base URL) +├── config.json # 用户配置文件 (可选) +└── vectorstore/ # (可选) 本地向量数据库存储 +``` + +--- + +## ⚙️ 配置指南 +### 📌 基础配置(config.json) +```json +{ + "api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "base_url": "https://api.openai.com/v1", + "interface_format": "OpenAI", + "model_name": "gpt-4o-mini", + "temperature": 0.7, + "max_tokens": 4096, + "embedding_api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "embedding_interface_format": "OpenAI", + "embedding_url": "https://api.openai.com/v1", + "embedding_model_name": "text-embedding-ada-002", + "embedding_retrieval_k": 4, + "topic": "星穹铁道主角星穿越到原神提瓦特大陆,拯救提瓦特大陆,并与其中的角色展开爱恨情仇的小说", + "genre": "玄幻", + "num_chapters": 120, + "word_number": 4000, + "filepath": "D:/AI_NovelGenerator/filepath" +} +``` + +### 🔧 配置说明 +1. **生成模型配置** + - `api_key`: 大模型服务的API密钥 + - `base_url`: API终端地址(本地服务填Ollama等地址) + - `interface_format`: 接口模式 + - `model_name`: 主生成模型名称(如gpt-4, claude-3等) + - `temperature`: 创意度参数(0-1,越高越有创造性) + - `max_tokens`: 模型最大回复长度 + +2. **Embedding模型配置** + - `embedding_model_name`: 模型名称(如Ollama的nomic-embed-text) + - `embedding_url`: 服务地址 + - `embedding_retrieval_k`: + +3. **小说参数配置** + - `topic`: 核心故事主题 + - `genre`: 作品类型 + - `num_chapters`: 总章节数 + - `word_number`: 单章目标字数 + - `filepath`: 生成文件存储路径 + +--- + +## 🚀 运行说明 +### **方式 1:使用 Python 解释器** +```bash +python main.py +``` +执行后,GUI 将会启动,你可以在图形界面中进行各项操作。 + +### **方式 2:打包为可执行文件** +如果你想在无 Python 环境的机器上使用本工具,可以使用 **PyInstaller** 进行打包: + +```bash +pip install pyinstaller +pyinstaller main.spec +``` +打包完成后,会在 `dist/` 目录下生成可执行文件(如 Windows 下的 `main.exe`)。 + +--- + +## 📘 使用教程 +1. **启动后,先完成基本参数设置:** + - **API Key & Base URL**(如 `https://api.openai.com/v1`) + - **模型名称**(如 `gpt-3.5-turbo`、`gpt-4o` 等) + - **Temperature** (0~1,决定文字创意程度) + - **主题(Topic)**(如 “废土世界的 AI 叛乱”) + - **类型(Genre)**(如 “科幻”/“魔幻”/“都市幻想”) + - **章节数**、**每章字数**(如 10 章,每章约 3000 字) + - **保存路径**(建议创建一个新的输出文件夹) + +2. **点击「Step1. 生成设定」** + - 系统将基于主题、类型、章节数等信息,生成: + - `Novel_setting.txt`:包含世界观、角色信息、雷点暗线等。 + - 可以在生成后的 `Novel_setting.txt` 中查看或修改设定内容。 + +3. **点击「Step2. 生成目录」** + - 系统会根据已完成的 `Novel_setting.txt` 内容,为全部章节生成: + - `Novel_directory.txt`:包括每章标题和简要提示。 + - 可以在生成后的文件中查看、修改或补充章节标题和描述。 + +4. **点击「Step3. 生成章节草稿」** + - 在生成章节之前,你可以: + - **设置章节号**(如写第 1 章,就填 `1`) + - **在“本章指导”输入框**中提供对本章剧情的任何期望或提示 + - 点击按钮后,系统将: + - 自动读取前文设定、`Novel_directory.txt`、以及已定稿章节 + - 调用向量检索回顾剧情,保证上下文连贯 + - 生成本章大纲 (`outline_X.txt`) 及正文 (`chapter_X.txt`) + - 生成完成后,你可在左侧的文本框查看、编辑本章草稿内容。 + +5. **点击「Step4. 定稿当前章节」** + - 系统将: + - **更新全局摘要**(写入 `global_summary.txt`) + - **更新角色状态**(写入 `character_state.txt`) + - **更新向量检索库**(保证后续章节可以调用最新信息) + - **更新剧情要点**(如 `plot_arcs.txt`) + - 定稿完成后,你可以在 `chapter_X.txt` 中看到定稿后的文本。 + +6. **一致性检查(可选)** + - 点击「[可选] 一致性审校」按钮,对最新章节进行冲突检测,如角色逻辑、剧情前后矛盾等。 + - 若有冲突,会在日志区输出详细提示。 + +7. **重复第 4-6 步** 直到所有章节生成并定稿! + +> **向量检索配置提示** +> 1. embedding模型需要显示指定接口和模型名称; +> 2. 使用**本地Ollama**的**Embedding**时需提前启动Ollama服务: +> ```bash +> ollama serve # 启动服务 +> ollama pull nomic-embed-text # 下载/启用模型 +> ``` +> 3. 切换不同Embedding模型后建议清空vectorstore目录 +> 4. 云端Embedding需确保对应API权限已开通 + +--- + +## ❓ 疑难解答 +### Q1: Expecting value: line 1 column 1 (char 0) + +该问题大概率由于API未正确响应造成,也许响应了一个html?其它内容,导致出现该报错; + + +### Q2: HTTP/1.1 504 Gateway Timeout? +确认接口是否稳定; + +### Q3: 如何切换不同的Embedding提供商? +在GUI界面中对应输入即可。 + +--- + +如有更多问题或需求,欢迎在**项目 Issues** 中提出。 diff --git a/chapter_directory_parser.py b/chapter_directory_parser.py new file mode 100644 index 0000000..696cbb8 --- /dev/null +++ b/chapter_directory_parser.py @@ -0,0 +1,132 @@ +# chapter_blueprint_parser.py +# -*- coding: utf-8 -*- +import re + +def parse_chapter_blueprint(blueprint_text: str): + """ + 解析整份章节蓝图文本,返回一个列表,每个元素是一个 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 # 本章简述 + } + """ + + # 先按空行进行分块,以免多章之间混淆 + chunks = re.split(r'\n\s*\n', blueprint_text.strip()) + results = [] + + # 兼容是否使用方括号包裹章节标题 + # 例如: + # 第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() + 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 not header_match: + # 不符合“第X章 - 标题”的格式,跳过 + 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_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/config_manager.py b/config_manager.py new file mode 100644 index 0000000..a65be54 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,80 @@ +# config_manager.py +# -*- coding: utf-8 -*- +import json +import os +import threading +from llm_adapters import create_llm_adapter +from embedding_adapters import create_embedding_adapter + + +def load_config(config_file: str) -> dict: + """从指定的 config_file 加载配置,若不存在则返回空字典。""" + if os.path.exists(config_file): + try: + with open(config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + return {} + +def save_config(config_data: dict, config_file: str) -> bool: + """将 config_data 保存到 config_file 中,返回 True/False 表示是否成功。""" + try: + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config_data, f, ensure_ascii=False, indent=4) + return True + except: + return False + +def test_llm_config(interface_format, api_key, base_url, model_name, temperature, max_tokens, timeout, log_func, handle_exception_func): + """测试当前的LLM配置是否可用""" + def task(): + try: + log_func("开始测试LLM配置...") + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + test_prompt = "Please reply 'OK'" + response = llm_adapter.invoke(test_prompt) + if response: + log_func("✅ LLM配置测试成功!") + log_func(f"测试回复: {response}") + else: + log_func("❌ LLM配置测试失败:未获取到响应") + except Exception as e: + log_func(f"❌ LLM配置测试出错: {str(e)}") + handle_exception_func("测试LLM配置时出错") + + threading.Thread(target=task, daemon=True).start() + +def test_embedding_config(api_key, base_url, interface_format, model_name, log_func, handle_exception_func): + """测试当前的Embedding配置是否可用""" + def task(): + try: + log_func("开始测试Embedding配置...") + embedding_adapter = create_embedding_adapter( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name + ) + + test_text = "测试文本" + embeddings = embedding_adapter.embed_query(test_text) + if embeddings and len(embeddings) > 0: + log_func("✅ Embedding配置测试成功!") + log_func(f"生成的向量维度: {len(embeddings)}") + else: + log_func("❌ Embedding配置测试失败:未获取到向量") + except Exception as e: + log_func(f"❌ Embedding配置测试出错: {str(e)}") + handle_exception_func("测试Embedding配置时出错") + + threading.Thread(target=task, daemon=True).start() \ No newline at end of file diff --git a/consistency_checker.py b/consistency_checker.py new file mode 100644 index 0000000..28380b0 --- /dev/null +++ b/consistency_checker.py @@ -0,0 +1,72 @@ +# consistency_checker.py +# -*- coding: utf-8 -*- +from llm_adapters import create_llm_adapter + +# ============== 增加对“剧情要点/未解决冲突”进行检查的可选引导 ============== +CONSISTENCY_PROMPT = """\ +请检查下面的小说设定与最新章节是否存在明显冲突或不一致之处,如有请列出: +- 小说设定: +{novel_setting} + +- 角色状态(可能包含重要信息): +{character_state} + +- 前文摘要: +{global_summary} + +- 已记录的未解决冲突或剧情要点: +{plot_arcs} # 若为空可能不输出 + +- 最新章节内容: +{chapter_text} + +如果存在冲突或不一致,请说明;如果在未解决冲突中有被忽略或需要推进的地方,也请提及;否则请返回“无明显冲突”。 +""" + +def check_consistency( + novel_setting: str, + character_state: str, + global_summary: str, + chapter_text: str, + api_key: str, + base_url: str, + model_name: str, + temperature: float = 0.3, + plot_arcs: str = "", + interface_format: str = "OpenAI", + max_tokens: int = 2048, + timeout: int = 600 +) -> str: + """ + 调用模型做简单的一致性检查。可扩展更多提示或校验规则。 + 新增: 会额外检查对“未解决冲突或剧情要点”(plot_arcs)的衔接情况。 + """ + prompt = CONSISTENCY_PROMPT.format( + novel_setting=novel_setting, + character_state=character_state, + global_summary=global_summary, + plot_arcs=plot_arcs, + chapter_text=chapter_text + ) + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + # 调试日志 + print("\n[ConsistencyChecker] Prompt >>>", prompt) + + response = llm_adapter.invoke(prompt) + if not response: + return "审校Agent无回复" + + # 调试日志 + print("[ConsistencyChecker] Response <<<", response) + + return response diff --git a/embedding_adapters.py b/embedding_adapters.py new file mode 100644 index 0000000..2294de9 --- /dev/null +++ b/embedding_adapters.py @@ -0,0 +1,272 @@ +# embedding_adapters.py +# -*- coding: utf-8 -*- +import logging +import traceback +from typing import List +import requests +from langchain_openai import AzureOpenAIEmbeddings, 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 AzureOpenAIEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 AzureOpenAIEmbeddings(或兼容接口)的适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + import re + match = re.match(r'https://(.+?)/openai/deployments/(.+?)/embeddings\?api-version=(.+)', base_url) + if match: + self.azure_endpoint = f"https://{match.group(1)}" + self.azure_deployment = match.group(2) + self.api_version = match.group(3) + else: + raise ValueError("Invalid Azure OpenAI base_url format") + + self._embedding = AzureOpenAIEmbeddings( + azure_endpoint=self.azure_endpoint, + azure_deployment=self.azure_deployment, + openai_api_key=api_key, + api_version=self.api_version, + ) + + 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): + """ + 其接口路径为 /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 + """ + url = self.base_url.rstrip("/") + if "/api/embeddings" not in url: + if "/api" in url: + url = f"{url}/embeddings" + else: + if "/v1" in url: + url = url[:url.index("/v1")] + 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) + +class GeminiEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 Google Generative AI (Gemini) 接口的 Embedding 适配器 + 使用直接 POST 请求方式,URL 示例: + https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=YOUR_API_KEY + """ + def __init__(self, api_key: str, model_name: str, base_url: str): + """ + :param api_key: 传入的 Google API Key + :param model_name: 这里一般是 "text-embedding-004" + :param base_url: e.g. https://generativelanguage.googleapis.com/v1beta/models + """ + self.api_key = api_key + 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]: + """ + 直接调用 Google Generative Language API (Gemini) 接口,获取文本 embedding + """ + url = f"{self.base_url}/{self.model_name}:embedContent?key={self.api_key}" + payload = { + "model": self.model_name, + "content": { + "parts": [ + {"text": text} + ] + } + } + + try: + response = requests.post(url, json=payload) + print(response.text) + response.raise_for_status() + result = response.json() + embedding_data = result.get("embedding", {}) + return embedding_data.get("values", []) + except requests.exceptions.RequestException as e: + logging.error(f"Gemini embed_content request error: {e}\n{traceback.format_exc()}") + return [] + except Exception as e: + logging.error(f"Gemini embed_content parse error: {e}\n{traceback.format_exc()}") + return [] + +class SiliconFlowEmbeddingAdapter(BaseEmbeddingAdapter): + """ + 基于 SiliconFlow 的 embedding 适配器 + """ + def __init__(self, api_key: str, base_url: str, model_name: str): + # 自动为 base_url 添加 scheme(如果缺失) + if not base_url.startswith("http://") and not base_url.startswith("https://"): + base_url = "https://" + base_url + self.url = base_url if base_url else "https://api.siliconflow.cn/v1/embeddings" + + self.payload = { + "model": model_name, + "input": "Silicon flow embedding online: fast, affordable, and high-quality embedding services. come try it out!", + "encoding_format": "float" + } + self.headers = { + "Authorization": "Bearer {api_key}".format(api_key=api_key), + "Content-Type": "application/json" + } + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + embeddings = [] + for text in texts: + try: + self.payload["input"] = text + response = requests.post(self.url, json=self.payload, headers=self.headers) + response.raise_for_status() + result = response.json() + if not result or "data" not in result or not result["data"]: + logging.error(f"Invalid response format from SiliconFlow API: {result}") + embeddings.append([]) + continue + emb = result["data"][0].get("embedding", []) + embeddings.append(emb) + except requests.exceptions.RequestException as e: + logging.error(f"SiliconFlow API request failed: {str(e)}") + embeddings.append([]) + except (KeyError, IndexError, ValueError, TypeError) as e: + logging.error(f"Error parsing SiliconFlow API response: {str(e)}") + embeddings.append([]) + return embeddings + + def embed_query(self, query: str) -> List[float]: + try: + self.payload["input"] = query + response = requests.post(self.url, json=self.payload, headers=self.headers) + response.raise_for_status() + result = response.json() + if not result or "data" not in result or not result["data"]: + logging.error(f"Invalid response format from SiliconFlow API: {result}") + return [] + return result["data"][0].get("embedding", []) + except requests.exceptions.RequestException as e: + logging.error(f"SiliconFlow API request failed: {str(e)}") + return [] + except (KeyError, IndexError, ValueError, TypeError) as e: + logging.error(f"Error parsing SiliconFlow API response: {str(e)}") + return [] + +def create_embedding_adapter( + interface_format: str, + api_key: str, + base_url: str, + model_name: str +) -> BaseEmbeddingAdapter: + """ + 工厂函数:根据 interface_format 返回不同的 embedding 适配器实例 + """ + fmt = interface_format.strip().lower() + if fmt == "openai": + return OpenAIEmbeddingAdapter(api_key, base_url, model_name) + elif fmt == "azure openai": + return AzureOpenAIEmbeddingAdapter(api_key, base_url, model_name) + elif fmt == "ollama": + return OllamaEmbeddingAdapter(model_name, base_url) + elif fmt == "ml studio": + return MLStudioEmbeddingAdapter(api_key, base_url, model_name) + elif fmt == "gemini": + return GeminiEmbeddingAdapter(api_key, model_name, base_url) + elif fmt == "siliconflow": + return SiliconFlowEmbeddingAdapter(api_key, base_url, model_name) + else: + raise ValueError(f"Unknown embedding interface_format: {interface_format}") diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d7491c7875d39feeebddff1880dc7f2cc25318e5 GIT binary patch literal 16958 zcmeHuX>?TQnWp0L$;|QO^z_N;Guw|&;>n~l(`lb+cN{x3v4O;njRD)NW-+V4h(&-v z>_|dF5+F7KLP%&u3))MyNwuj;CDo$RzDuQj7g|dDzT}zbUJ1u`5}V*S&73o`zxt|f z-FxeM-tS$W_kAx;PT%2w%a%Fu^@mPBUE$>PpPihXe#*f^PF@_saq(~2!u0=NpI-?y zH}-6?S9R@K?H>M(5xdn}=g`9$npo2=n%uy>!^+`_IoxpKc|4Ki>Lx z?t5I9`5!*L;_~4WOLu>~;l)37<;q_je$igjqwXJ=!@w}H3;0DD%t0BOJnOl8```Sj zYoEvCKD+mQ{P5D%VcGpq$ZiKB`SNAlI(?z6$WrQNudEMfsBOMpU)^{<-&DLd`d<9^ z%!c~F*48<+bHzn@w|@pPfgz_ax*UEUVgAuI`8e-?CHVNM*#{TA5aa8I z9H|0L_9l#sjA49i0<*Jon4O!&9LJzz0G8~M;l{dYG}vd++%k*aKH?vm$K&1^RM+-2 zo_4$T-9P$6&*?$e4I6%Z^WdTR02deN)1(*~9udbV@z2f(xaSrI4wko<3IU4*1(|4FZo@%xa{-Cg!eTQBTlDw zIfwgf+YXOi`!PH;g8n`Sx;nei+|&eOK|@^w6loS1RPE4b^rEVK5|1Cx@tkS2G)%){ z7(&RERAi*d)f&{{{Y5D$1yWCgK3VT!C`cF zc2g!TP$cIfSKEnV+Zc3;MwAsgP+v2Lrp8(NfiJ~`#n{kK|Jc~vU-J3i|F+)yA33rt z{NmZf&);9Gmb$7llf`4z<0DQS`)kHWM{CAMP&+XSw=H{dZtFp)l4W?@)$@D1e`5cO z^YrvILe88-%H6w=B@|KL2k8rD(NH^!rY7=V0sqK6`k%zaCo_3MX-TW2r?LB6kBuL! zT=CM4V~3hgI`0-zO7TyZDfK9{Y3CE;!p>F>^gkkg2!s2Nyw)c8dwOBt^0f%^4nTWr z2YvrIeg7BzpYXY-yBn9CcOdA<0fc*|LF@t-5cp8fz&LE?BHZ@$#o>?EBS)=8eOWb3C1q8V`?t!G|J#50k50!o zt>5jwb<@fB-+bLEQ>#t1Saat~igJ;kr$?SykGk4Y+H5)M>?J5FG{WoXA#D8RTX0^v z3ftaz13OnNLv?vMGSgDvy6z*mtXU1$jT^D$jn}bz=`V5kw<~b?-QVEEhL2#<7oo^H z1jq0knw$F|iOa^Gw^mT6-o&BRYjAAcdR#f|0+p?gof4SFP@Bj8^F3#?5Hy`TSH90vj=jGCG*E5dQqouh9&5f04Y$!)b ziHY$$4qJb<49Vd!IJR*Uw!Z#zoY=7)kvDH+)j#|Le*HgQ#`2ecgylc_A=bR|3bwrV zFK}M|7F1a zq#HQ+(T6=d|Mi!{XV$L9ZP)#XJm)?e<#qn!zZ80jvF^oq%VXzQYyYsyT#WGGAjs0w zpwXyc%QK;(stkoy<&dc}VK!&OY|_BvkPB{ldLz)|B6btomS6k}3N1E--@1(t{^=F` z=0`umvLE~aZ*jasnQVLgHSAfw46g6L3y6DQ zPTW6w9AU>#AwBp$&)tO`uf2vHZ~OvB-hCUsJGUY5(5HBC=EUgPJDF@%(o0ZdJk$IZ{fsL>$y{yp^dEcp1s0vmPTf(&g2Jog{Op_OZJ ze)~?iZQ71KE8fPAB};Mf(@#-ZSPF|S8#~`zinagvzwi#_u$<%VpZo-C|M^w8{CYW# zu6hqAH>|~jtHDsGm*d!nA7kIr<#1oK0atc@iYRvvBzWIIqPHKi($aDC-S@GJc6Ruk zx8d!)4*~o4;MNfr{@xy+XT>jEr>Yx#H?7iPm)4Cy#*tjshhE1B@Vnpf|KBl|58}bK ztH_Y1BTJ`3Lwy74dLE1TF7DWaOS|{smg_N`Ub_wl-hK}UR;+;Ax^?*E_1CfewSUIe zpTCOrulx)vfBF;r=70W}I`J|#P}f~QT8HynHskchb%+bPkG9q}l;#(qEUy5%G&$=7 z3h0v*Fi0|>h)+Rm;5~f0^jA3X{z_cfyaku`QU(Y1!+qPPFL0MznBY7)#rKosO(lrBdI!hfUj@INJ8|XU5uDhx*}iM_>Tety24|<2 zR@PLZsI&}~ZM~3}Jx1>Yxg2d^in&};QWEpl5d{1BVsKy(_Uc*@e^XT*Gzk(sIC23& zuBUNh7yZHBgSfi)5KhsLyRTja*Y|!4*Hx>rd)adATk#I|Ec*?%EqN2Wmj9aZb1Ra< zBT;TELa`+ujWzY;)WaBG#4pHVfO%vIV?a|)BZNGwwxSyGA)!bJ3m!aq$n9^RWxv0C zP4)O_rlqJ5rt0d?cuvLGsQ6x4kcaT7NF+*AkswJy=)Jq-1x09UY!=rltl1>jAndd+ zV%)tD?&^+vltsXvPvQH?P6#$~Zp(I@+xRiu)~v;$_g3R9_di#gg_)Tdac}FZ?5HEI z{2YrYUt!OMoT|O04MpaBl$2JYqOBc;jSa|DNny=VFu%%1Xwbbs$kD!x?dv|1*Zq{- zcHU&kLrzfvs>$&Lo+k|9eZW4--)Xds}2`0*8U}y;Q^h3g!n4Dx@U5PxC5;ax%Fz7YxgnY9<;=fWpiDjKGADeRS1B3>aU7#bWBWgy@eMtdvwminJ>&O-@xtGu`j1q}@trR@nmSojm> ziHQlyrU{l@CG53jj8l{0&x-z_Z}Ru9rc^AGly$U5=hzW#Y(h+K8^W^MaVMh{K^ZLw zO0yw2Bm!lF{g4>-NYSbgo05o!!NH&57v#`St}Ea!&7&PtUmGfFMgIF_f9F^a5p;cE zV1Q$gx;+I!4o~nq`W&$3SW#b9OFmSI!qyhi_9o}&zJOi$t&-wGVlPEQL#-f_KA!N@ zb%Ik@6yLp?(y~5C)1Mk?9T|R9F^!~Z`nScAP(FkE#P4|{1&4MYhx6MjahmZ>oo7Ws zM;oFO<8hX`j7A~E@GoYAWKd`TYc;2zMsR$(5Se_LapG5Z-0Vcz$Cmc z&MQP4ZM@`h7j3fxLE;%Z0=JiTwib;myJ{q6hM|L$t+U$p_QyWQaLAB?!LU?e6b z!qV6Pd5!`3^cBK>3TqeYekBD(&}1199~X|WkUQvV?c#T{T;mKnT02GTqpTGP{#&RM z2r>{b3)esKf#t=ODC_D#<yMmvzPobmr{pJ}zOxdazPAQ0t3Jd5`eT>XAK}3IZ8*B!1t(oE;P|m? z@bG(t>k)eR1ZLo2Y%26xDf2)XEctoJEh|M^d*^5C@9FG=N^5|H`)4v*7$5FHrBI@$ zvlk83b!cUr5d8TdZCC7I5Ql_EG05e*@@T6z^8C)W z$7pTtLMd%_pnsToft7g@ZMU=ng;pEv6*XcUbM$e)X!`<}9HtBey%6vVx+B!kdLH*e zAad^y>$PzUmvcVsHlp*3bfFUr0MdDUgepy0SqP3v~W+X2WdbmRzK*> zPKL>1ntl)x{Zu*7+rM7@(up&Hoi{?1@V%7;5C155T#kg-)mU7(5{olF;qdki#l?&F z;CbODwyfEV(w<&1_KAFe@vyMF1F?z8NR>!YTv7&&K9~7%#~0$kW8y8LJ#@8oi@s05 zIMCmZ{@y;ZKhQ{-R+d$f^S7g~w}hv8;m5|J=B z@P*fLADnePkInC`g^gH5F2MCvkSC7f6=*TB8ojKy&cg-gle&=uE!Z9!UJ#Tn; zN8r?%Ah;j8fK$${aNFsE^($6FT`u|uf=_G2}yJSR56~=CHO&p-y5owSUd{9 zhez?zP%zgv(f%{hOMj!#`LJq_N01j1Sz@Zr_O=y%43{Py9adox%uGI5f7Z( zdj#%o*Ks-A1pf%u>yBT+@m+^;aMNyluw*&q+$Zk4p|YGl*vfi-8Dbws!DKB!3;B7D zF`F?e8%^~sBCo5fZ9!2%3;G6U=>rFm8rjGGR|n;@&>Ir`UQtmAT3cF>MUI=ROGURW z5M8!g;;1uRgCr#aD&8|bT|%5A$YL!@BTYuOBpNxY6qK~pe}Sh{KmV*e$a&YNOFj3V zLV&wJF8f3v;7&R&)8~1gxrz(SA-7+BMvbC6Y4#`>!Xm6U%$XB%<d8sRdCn*t1M|q$+i_%TI%4k(QoqNMZ(`nD+ly=i`K_%L zS(>;myQle_!zBIu7&^OVXfLy@ zlg~2On`13yjyca59!92-`{-d*grTP-9KGdnXfKn&oKs9c$!A0gnCB6HeI1_}sjd10 z{EQRP&w}6gnCJIzxcRnTId~pHCw+1C&`DhQWH*iz|E?9k#j)LcVQHvGZoQrHE{*(U zgnK^#yWI|bRyGn6(r7zZ@h~(UhRk;6zcY+^v#bxZ&)qvK%D^!=gFg1W`zeo3_B({{ zDvD>2pF0b)b{1BhAcG;KvDPJLeXY4L9*%k?hT5!7w(z_Z{Nt_cpj|HGtY*=(TyHC#S?8s~TH#nH8!5F8wW8v0e?{;}yY$XLtl z;ob|i7?o0m z+S&m$H_WhxLmjZqL9gsYY zC~@>a$i?%T8lf#NhK~F?zvuBQ&x&8Tu6A4VgK8Cru9jSR0u(h}I z_w|q|^k`{pp^tAwTC$4$pF}7!)Cdf?iRfS*Ze5EMLgXtf$hB}r(l%fq;%o<1iL5f5&O zoUpXA1o~_(Y>b!5QYrN1r6?cjM^0)aFQW-O=@F<%6USnp?coXU5K^aOJ^lBN#hdWSU z$)Zf}lV_6;=H(zNG6b4z14??yd&v6)-K-iL@eek3IwJBqB?(nsM`KGn4yD?AzV}sl zzx>^us7&X!p1$#G_u}BX*Nr*w0qonf3y~5Xq~zowA&;Px8BkqP#lBrJEaqyYOXOU) z0Vzp2tc6a~R~Mk4HRD0{Z40c!2)ma8g)ARgSw-TA4pTy_%cm@gSidttlB#C^r=59K z7CDp_52HfyDB%%pN{zInN92m_^b$3@&PT>w1c923;l#04`U)IBN-AXlc@MO*oOx{ z&wG*+3kts%TiG)iX6xD?S5W(BaiGu3<&MYAJJ&B0BXV=gmQ>d?ZXa^yRVieYp&rFUX zmt3{1poabUBGw^Tqt0u9)l!YbM=6L)NJT_w1X9v;$jPZD$Ll4hiD!*w0h=UMhOp33 zD2Y9h@$wO`Nlca?ls(gQse(CwCgqgDYwpp$BcRJRB2$+|`$)#<=m_m1438LV{H#3dn~=gMW&dkzI_ z1`^_y%ZZ=4q*AFQ&PeL|L+Ayb&;0i;^Fc@d5bg#Bit@-Z8X=`_uuf0xqutfxW8ca% z5v->!>Feuf34RJyLeGIRsN!>48Xfzd1a~lMPAi#S;5`&G^KsZ?w^0bMef~w5w2;=xpyoc~Lp?X$Mu02VkP# zFE8yxO_flu=z>mD$h?eoO6qUm&Fj#K_1{#ar7!GlT&z$dP;q}%^!Zw7Sr5ukYk9ts z7%3BCS7c^Do{>S{AJ3dx#{1>M`}A3aMMNVaSRv7NJ#c}!km6>%ByAoL#hT`J;8PDw=y zhf1Bx8j*sWfc{LAh18UEB&DPwF;zkvNoL$rKy4tmBL1K|x5T}dXQ*)Qh$~dADcz4u zgwbNbIrfecQ>7^FfBdLsd|Z4!=bIz^>O0s+94}Um4TYCEdbuxJl(TMM-#5ZOTnl@0 zGvt9|%;8#4Mt@*nEki<17ET|o$RuZviXv7ebOz?QHAD0T^oIr2=*9PD7p78Vb#q}#UaOw0J#!Csj zy{|Yw-KzgGXO$MUywF(R^^5AtmKCi%1J}#S8^*KsCG5itF^BI#@8fyOV3Pizi*$mn;Tj8Ddo9YIn7RV1-_s--sC)r!IYaXCV6@q)7$B139jmV>anO*&6cZq!X;JXEurs$fA zKGr6x$AQbm~aP7oFc%F231l@@E#?Lo=vt9q=SC<#Joo#O!pKr7e(+^DZnFRK) zS>MbxRzR<}Qs)iyne0`vb|S>Tj0`?UO6)4ydIB@a6k6B{dZy4LN7Vxn`<|=HjWOrHc#<+=3)A}E>Q>cS;fqGEzD&#?yL=S5#Kkm!+{R`{R;-zK&{?`9HQ- zI}9z&lc=ufVLWW5{@3%lloHXONx9FN)PFVYU*NqN%wvS1(z2#ZpRXg=H*)XI)O}lF zA$^3+Xwcez_ecw14SvCDtlwEy*xfF~Loq)z+u6se6yv?XZw1Vm8V>cs{G`t- z+CAf=LZ&K;iAeqMqYpOyRruXkBYL)P4?FvRSDIwqrc;$@4Z8A4o3)WO%R1^e<3DxW zV6x7#-!rV$=Zq^dw1er=Oe=Y!@5QqLum0mdJ(q6o*#yC>-v0G_e|u#Ag*Wa7Bzzbh zqFkFCuUZoOQ2Afo4*Goey>~u*Y3Z+Ce{N#hGlTqp=tA*9p)b7*FMhuGC+6>n$A{MZ zJ8^Dt`tt0wcwGF#Guug=&v`A(XDJ-v2I+#3I1dT)sl}OhY7stv2fq_0;e;o%m+*!c ziZjm>J_ str: + """ + 处理base_url的规则: + 1. 如果url以#结尾,则移除#并直接使用用户提供的url + 2. 否则检查是否需要添加/v1后缀 + """ + import re + url = url.strip() + if not url: + return url + + if url.endswith('#'): + return url.rstrip('#') + + 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、Gemini等)提供一致的方法签名。 + """ + 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, timeout: Optional[int] = 600): + self.base_url = check_base_url(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + 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, + timeout=self.timeout + ) + + 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, timeout: Optional[int] = 600): + self.base_url = check_base_url(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + 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, + timeout=self.timeout + ) + + 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 GeminiAdapter(BaseLLMAdapter): + """ + 适配 Google Gemini (Google Generative AI) 接口 + """ + def __init__(self, api_key: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = genai.Client(api_key=self.api_key) + + def invoke(self, prompt: str) -> str: + try: + response = self._client.models.generate_content( + model = self.model_name, + contents = prompt, + config = genai.types.GenerateContentConfig( + max_output_tokens=self.max_tokens, + temperature=self.temperature, + ), + timeout=self.timeout # 添加超时参数 + ) + if response and response.text: + return response.text + else: + logging.warning("No text response from Gemini API.") + return "" + except Exception as e: + logging.error(f"Gemini API 调用失败: {e}") + return "" + +class AzureOpenAIAdapter(BaseLLMAdapter): + """ + 适配 Azure OpenAI 接口(使用 langchain.ChatOpenAI) + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + import re + match = re.match(r'https://(.+?)/openai/deployments/(.+?)/chat/completions\?api-version=(.+)', base_url) + if match: + self.azure_endpoint = f"https://{match.group(1)}" + self.azure_deployment = match.group(2) + self.api_version = match.group(3) + else: + raise ValueError("Invalid Azure OpenAI base_url format") + + self.api_key = api_key + self.model_name = self.azure_deployment + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = AzureChatOpenAI( + azure_endpoint=self.azure_endpoint, + azure_deployment=self.azure_deployment, + api_version=self.api_version, + api_key=self.api_key, + max_tokens=self.max_tokens, + temperature=self.temperature, + timeout=self.timeout + ) + + def invoke(self, prompt: str) -> str: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from AzureOpenAIAdapter.") + return "" + return response.content + +class OllamaAdapter(BaseLLMAdapter): + """ + Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。 + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + self.base_url = check_base_url(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + if self.api_key == '': + self.api_key= 'ollama' + + 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, + timeout=self.timeout + ) + + 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, timeout: Optional[int] = 600): + self.base_url = check_base_url(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + 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, + timeout=self.timeout + ) + + def invoke(self, prompt: str) -> str: + try: + response = self._client.invoke(prompt) + if not response: + logging.warning("No response from MLStudioAdapter.") + return "" + return response.content + except Exception as e: + logging.error(f"ML Studio API 调用超时或失败: {e}") + return "" + +class AzureAIAdapter(BaseLLMAdapter): + """ + 适配 Azure AI Inference 接口,用于访问Azure AI服务部署的模型 + 使用 azure-ai-inference 库进行API调用 + """ + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + import re + # 匹配形如 https://xxx.services.ai.azure.com/models/chat/completions?api-version=xxx 的URL + match = re.match(r'https://(.+?)\.services\.ai\.azure\.com(?:/models)?(?:/chat/completions)?(?:\?api-version=(.+))?', base_url) + if match: + # endpoint需要是形如 https://xxx.services.ai.azure.com/models 的格式 + self.endpoint = f"https://{match.group(1)}.services.ai.azure.com/models" + # 如果URL中包含api-version参数,使用它;否则使用默认值 + self.api_version = match.group(2) if match.group(2) else "2024-05-01-preview" + else: + raise ValueError("Invalid Azure AI base_url format. Expected format: https://.services.ai.azure.com/models/chat/completions?api-version=xxx") + + self.base_url = self.endpoint # 存储处理后的endpoint URL + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = ChatCompletionsClient( + endpoint=self.endpoint, + credential=AzureKeyCredential(self.api_key), + model=self.model_name, + temperature=self.temperature, + max_tokens=self.max_tokens, + timeout=self.timeout + ) + + def invoke(self, prompt: str) -> str: + try: + response = self._client.complete( + messages=[ + SystemMessage("You are a helpful assistant."), + UserMessage(prompt) + ] + ) + if response and response.choices: + return response.choices[0].message.content + else: + logging.warning("No response from AzureAIAdapter.") + return "" + except Exception as e: + logging.error(f"Azure AI Inference API 调用失败: {e}") + return "" + +# 火山引擎实现 +class VolcanoEngineAIAdapter(BaseLLMAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + self.base_url = check_base_url(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = OpenAI( + base_url=base_url, + api_key=api_key, + timeout=timeout # 添加超时配置 + ) + def invoke(self, prompt: str) -> str: + try: + response = self._client.chat.completions.create( + model=self.model_name, + messages=[ + {"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"}, + {"role": "user", "content": prompt}, + ], + timeout=self.timeout # 添加超时参数 + ) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.choices[0].message.content + except Exception as e: + logging.error(f"火山引擎API调用超时或失败: {e}") + return "" + +class SiliconFlowAdapter(BaseLLMAdapter): + def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600): + self.base_url = check_base_url(base_url) + self.api_key = api_key + self.model_name = model_name + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + self._client = OpenAI( + base_url=base_url, + api_key=api_key, + timeout=timeout # 添加超时配置 + ) + def invoke(self, prompt: str) -> str: + try: + response = self._client.chat.completions.create( + model=self.model_name, + messages=[ + {"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"}, + {"role": "user", "content": prompt}, + ], + timeout=self.timeout # 添加超时参数 + ) + if not response: + logging.warning("No response from DeepSeekAdapter.") + return "" + return response.choices[0].message.content + except Exception as e: + logging.error(f"硅基流动API调用超时或失败: {e}") + return "" + +def create_llm_adapter( + interface_format: str, + base_url: str, + model_name: str, + api_key: str, + temperature: float, + max_tokens: int, + timeout: int +) -> BaseLLMAdapter: + """ + 工厂函数:根据 interface_format 返回不同的适配器实例。 + """ + fmt = interface_format.strip().lower() + if fmt == "deepseek": + return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "openai": + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "azure openai": + return AzureOpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "azure ai": + return AzureAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "ollama": + return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "ml studio": + return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "gemini": + # base_url 对 Gemini 暂无用处,可忽略 + return GeminiAdapter(api_key, model_name, max_tokens, temperature, timeout) + elif fmt == "阿里云百炼": + return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "火山引擎": + return VolcanoEngineAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + elif fmt == "硅基流动": + return SiliconFlowAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout) + else: + raise ValueError(f"Unknown interface_format: {interface_format}") diff --git a/main.py b/main.py new file mode 100644 index 0000000..c968f64 --- /dev/null +++ b/main.py @@ -0,0 +1,12 @@ +# main.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from ui import NovelGeneratorGUI + +def main(): + app = ctk.CTk() + gui = NovelGeneratorGUI(app) + app.mainloop() + +if __name__ == "__main__": + main() diff --git a/main.spec b/main.spec new file mode 100644 index 0000000..5935660 --- /dev/null +++ b/main.spec @@ -0,0 +1,72 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = ['typing_extensions', + 'langchain-openai', + 'langgraph', + 'openai', + 'google-genai', + 'google', + 'nltk', + 'sentence_transformers', + 'scikit-learn', + 'langchain-community', + 'pydantic', + 'pydantic.deprecated.decorator', + 'tiktoken_ext.openai_public', + 'tiktoken_ext', + 'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2' + ] + +tmp_ret = collect_all('chromadb') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + +customtkinter_dir = r'c:/Users/xieli/Desktop/AI_NovelGenerator/.venv/Lib/site-packages/customtkinter' +datas.append((customtkinter_dir, 'customtkinter')) + +a = Analysis( + ['main.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='AI_NovelGenerator_V1.4.2', + debug=True, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['icon.ico'] +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='AI_NovelGenerator_V1.4.2' +) diff --git a/novel_generator/__init__.py b/novel_generator/__init__.py new file mode 100644 index 0000000..4b6dbcd --- /dev/null +++ b/novel_generator/__init__.py @@ -0,0 +1,13 @@ +#novel_generator/__init__.py +from .architecture import Novel_architecture_generate +from .blueprint import Chapter_blueprint_generate +from .chapter import ( + get_last_n_chapters_text, + summarize_recent_chapters, + get_filtered_knowledge_context, + build_chapter_prompt, + generate_chapter_draft +) +from .finalization import finalize_chapter, enrich_chapter_text +from .knowledge import import_knowledge_file +from .vectorstore_utils import clear_vector_store \ No newline at end of file diff --git a/novel_generator/architecture.py b/novel_generator/architecture.py new file mode 100644 index 0000000..7e06a07 --- /dev/null +++ b/novel_generator/architecture.py @@ -0,0 +1,201 @@ +#novel_generator/architecture.py +# -*- coding: utf-8 -*- +""" +小说总体架构生成(Novel_architecture_generate 及相关辅助函数) +""" +import os +import json +import logging +import traceback +from novel_generator.common import invoke_with_cleaning +from llm_adapters import create_llm_adapter +from prompt_definitions import ( + core_seed_prompt, + character_dynamics_prompt, + world_building_prompt, + plot_architecture_prompt, + create_character_state_prompt +) +from utils import clear_file_content, save_string_to_txt + +def load_partial_architecture_data(filepath: str) -> dict: + """ + 从 filepath 下的 partial_architecture.json 读取已有的阶段性数据。 + 如果文件不存在或无法解析,返回空 dict。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + if not os.path.exists(partial_file): + return {} + try: + with open(partial_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data + except Exception as e: + logging.warning(f"Failed to load partial_architecture.json: {e}") + return {} + +def save_partial_architecture_data(filepath: str, data: dict): + """ + 将阶段性数据写入 partial_architecture.json。 + """ + partial_file = os.path.join(filepath, "partial_architecture.json") + try: + with open(partial_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + logging.warning(f"Failed to save partial_architecture.json: {e}") + +def Novel_architecture_generate( + interface_format: str, + api_key: str, + base_url: str, + llm_model: str, + topic: str, + genre: str, + number_of_chapters: int, + word_number: int, + filepath: str, + user_guidance: str = "", # 新增参数 + temperature: float = 0.7, + max_tokens: int = 2048, + timeout: int = 600 +) -> None: + """ + 依次调用: + 1. core_seed_prompt + 2. character_dynamics_prompt + 3. world_building_prompt + 4. plot_architecture_prompt + 若在中间任何一步报错且重试多次失败,则将已经生成的内容写入 partial_architecture.json 并退出; + 下次调用时可从该步骤继续。 + 最终输出 Novel_architecture.txt + + 新增: + - 在完成角色动力学设定后,依据该角色体系,使用 create_character_state_prompt 生成初始角色状态表, + 并存储到 character_state.txt,后续维护更新。 + """ + os.makedirs(filepath, exist_ok=True) + partial_data = load_partial_architecture_data(filepath) + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=llm_model, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + # Step1: 核心种子 + if "core_seed_result" not in partial_data: + logging.info("Step1: Generating core_seed_prompt (核心种子) ...") + prompt_core = core_seed_prompt.format( + topic=topic, + genre=genre, + number_of_chapters=number_of_chapters, + word_number=word_number, + user_guidance=user_guidance # 修复:添加内容指导 + ) + core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core) + if not core_seed_result.strip(): + logging.warning("core_seed_prompt generation failed and returned empty.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["core_seed_result"] = core_seed_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step1 already done. Skipping...") + # Step2: 角色动力学 + if "character_dynamics_result" not in partial_data: + logging.info("Step2: Generating character_dynamics_prompt ...") + prompt_character = character_dynamics_prompt.format( + core_seed=partial_data["core_seed_result"].strip(), + user_guidance=user_guidance + ) + character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character) + if not character_dynamics_result.strip(): + logging.warning("character_dynamics_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["character_dynamics_result"] = character_dynamics_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step2 already done. Skipping...") + # 生成初始角色状态 + if "character_dynamics_result" in partial_data and "character_state_result" not in partial_data: + logging.info("Generating initial character state from character dynamics ...") + prompt_char_state_init = create_character_state_prompt.format( + character_dynamics=partial_data["character_dynamics_result"].strip() + ) + character_state_init = invoke_with_cleaning(llm_adapter, prompt_char_state_init) + if not character_state_init.strip(): + logging.warning("create_character_state_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["character_state_result"] = character_state_init + character_state_file = os.path.join(filepath, "character_state.txt") + clear_file_content(character_state_file) + save_string_to_txt(character_state_init, character_state_file) + save_partial_architecture_data(filepath, partial_data) + logging.info("Initial character state created and saved.") + # Step3: 世界观 + if "world_building_result" not in partial_data: + logging.info("Step3: Generating world_building_prompt ...") + prompt_world = world_building_prompt.format( + core_seed=partial_data["core_seed_result"].strip(), + user_guidance=user_guidance # 修复:添加用户指导 + ) + world_building_result = invoke_with_cleaning(llm_adapter, prompt_world) + if not world_building_result.strip(): + logging.warning("world_building_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["world_building_result"] = world_building_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step3 already done. Skipping...") + # Step4: 三幕式情节 + if "plot_arch_result" not in partial_data: + logging.info("Step4: Generating plot_architecture_prompt ...") + prompt_plot = plot_architecture_prompt.format( + core_seed=partial_data["core_seed_result"].strip(), + character_dynamics=partial_data["character_dynamics_result"].strip(), + world_building=partial_data["world_building_result"].strip(), + user_guidance=user_guidance # 修复:添加用户指导 + ) + plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot) + if not plot_arch_result.strip(): + logging.warning("plot_architecture_prompt generation failed.") + save_partial_architecture_data(filepath, partial_data) + return + partial_data["plot_arch_result"] = plot_arch_result + save_partial_architecture_data(filepath, partial_data) + else: + logging.info("Step4 already done. Skipping...") + + core_seed_result = partial_data["core_seed_result"] + character_dynamics_result = partial_data["character_dynamics_result"] + world_building_result = partial_data["world_building_result"] + plot_arch_result = partial_data["plot_arch_result"] + + final_content = ( + "#=== 0) 小说设定 ===\n" + f"主题:{topic},类型:{genre},篇幅:约{number_of_chapters}章(每章{word_number}字)\n\n" + "#=== 1) 核心种子 ===\n" + f"{core_seed_result}\n\n" + "#=== 2) 角色动力学 ===\n" + f"{character_dynamics_result}\n\n" + "#=== 3) 世界观 ===\n" + f"{world_building_result}\n\n" + "#=== 4) 三幕式情节架构 ===\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.") + + partial_arch_file = os.path.join(filepath, "partial_architecture.json") + if os.path.exists(partial_arch_file): + os.remove(partial_arch_file) + logging.info("partial_architecture.json removed (all steps completed).") diff --git a/novel_generator/blueprint.py b/novel_generator/blueprint.py new file mode 100644 index 0000000..a0f0f6c --- /dev/null +++ b/novel_generator/blueprint.py @@ -0,0 +1,173 @@ +#novel_generator/blueprint.py +# -*- coding: utf-8 -*- +""" +章节蓝图生成(Chapter_blueprint_generate 及辅助函数) +""" +import os +import re +import logging +from novel_generator.common import invoke_with_cleaning +from llm_adapters import create_llm_adapter +from prompt_definitions import chapter_blueprint_prompt, chunked_chapter_blueprint_prompt +from utils import read_file, clear_file_content, save_string_to_txt + +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 + ratio_rounded_to_10 = int(ratio // 10) * 10 + chunk_size = ratio_rounded_to_10 - 10 + if chunk_size < 1: + chunk_size = 1 + if chunk_size > number_of_chapters: + chunk_size = number_of_chapters + return chunk_size + +def limit_chapter_blueprint(blueprint_text: str, limit_chapters: int = 100) -> str: + """ + 从已有章节目录中只取最近的 limit_chapters 章,以避免 prompt 超长。 + """ + pattern = r"(第\s*\d+\s*章.*?)(?=第\s*\d+\s*章|$)" + chapters = re.findall(pattern, blueprint_text, flags=re.DOTALL) + if not chapters: + return blueprint_text + if len(chapters) <= limit_chapters: + return blueprint_text + selected = chapters[-limit_chapters:] + return "\n\n".join(selected).strip() + +def Chapter_blueprint_generate( + interface_format: str, + api_key: str, + base_url: str, + llm_model: str, + filepath: str, + number_of_chapters: int, + user_guidance: str = "", # 新增参数 + temperature: float = 0.7, + max_tokens: int = 4096, + timeout: int = 600 +) -> None: + """ + 若 Novel_directory.txt 已存在且内容非空,则表示可能是之前的部分生成结果; + 解析其中已有的章节数,从下一个章节继续分块生成; + 对于已有章节目录,传入时仅保留最近100章目录,避免prompt过长。 + 否则: + - 若章节数 <= chunk_size,直接一次性生成 + - 若章节数 > chunk_size,进行分块生成 + 生成完成后输出至 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 + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=llm_model, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + filename_dir = os.path.join(filepath, "Novel_directory.txt") + if not os.path.exists(filename_dir): + open(filename_dir, "w", encoding="utf-8").close() + + existing_blueprint = read_file(filename_dir).strip() + 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 existing_blueprint: + logging.info("Detected existing blueprint content. Will resume chunked generation from that point.") + pattern = r"第\s*(\d+)\s*章" + existing_chapter_numbers = re.findall(pattern, existing_blueprint) + existing_chapter_numbers = [int(x) for x in existing_chapter_numbers if x.isdigit()] + max_existing_chap = max(existing_chapter_numbers) if existing_chapter_numbers else 0 + logging.info(f"Existing blueprint indicates up to chapter {max_existing_chap} has been generated.") + final_blueprint = existing_blueprint + current_start = max_existing_chap + 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=limited_blueprint, + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end, + user_guidance=user_guidance # 新增参数 + ) + 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.") + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return + final_blueprint += "\n\n" + chunk_result.strip() + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + current_start = current_end + 1 + + logging.info("All chapters blueprint have been generated (resumed chunked).") + return + + if chunk_size >= number_of_chapters: + prompt = chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + number_of_chapters=number_of_chapters, + user_guidance=user_guidance # 新增参数 + ) + blueprint_text = invoke_with_cleaning(llm_adapter, prompt) + if not blueprint_text.strip(): + logging.warning("Chapter blueprint generation result is empty.") + return + + 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 + + logging.info("Will generate chapter blueprint in chunked mode from scratch.") + final_blueprint = "" + current_start = 1 + while current_start <= number_of_chapters: + current_end = min(current_start + chunk_size - 1, number_of_chapters) + limited_blueprint = limit_chapter_blueprint(final_blueprint, 100) + chunk_prompt = chunked_chapter_blueprint_prompt.format( + novel_architecture=architecture_text, + chapter_list=limited_blueprint, + number_of_chapters=number_of_chapters, + n=current_start, + m=current_end, + user_guidance=user_guidance # 新增参数 + ) + 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.") + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + return + if final_blueprint.strip(): + final_blueprint += "\n\n" + chunk_result.strip() + else: + final_blueprint = chunk_result.strip() + clear_file_content(filename_dir) + save_string_to_txt(final_blueprint.strip(), filename_dir) + current_start = current_end + 1 + + logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).") diff --git a/novel_generator/chapter.py b/novel_generator/chapter.py new file mode 100644 index 0000000..f2e84d2 --- /dev/null +++ b/novel_generator/chapter.py @@ -0,0 +1,585 @@ +# novel_generator/chapter.py +# -*- coding: utf-8 -*- +""" +章节草稿生成及获取历史章节文本、当前章节摘要等 +""" +import os +import json +import logging +import re # 添加re模块导入 +from llm_adapters import create_llm_adapter +from prompt_definitions import ( + first_chapter_draft_prompt, + next_chapter_draft_prompt, + summarize_recent_chapters_prompt, + knowledge_filter_prompt, + knowledge_search_prompt +) +from chapter_directory_parser import get_chapter_info_from_blueprint +from novel_generator.common import invoke_with_cleaning +from utils import read_file, clear_file_content, save_string_to_txt +from novel_generator.vectorstore_utils import ( + get_relevant_context_from_vector_store, + load_vector_store # 添加导入 +) + +def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> list: + """ + 从目录 chapters_dir 中获取最近 n 章的文本内容,返回文本列表。 + """ + 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, + max_tokens: int, + chapters_text_list: list, + novel_number: int, # 新增参数 + chapter_info: dict, # 新增参数 + next_chapter_info: dict, # 新增参数 + timeout: int = 600 +) -> str: # 修改返回值类型为 str,不再是 tuple + """ + 根据前三章内容生成当前章节的精准摘要。 + 如果解析失败,则返回空字符串。 + """ + try: + combined_text = "\n".join(chapters_text_list).strip() + if not combined_text: + return "" + + # 限制组合文本长度 + max_combined_length = 4000 + if len(combined_text) > max_combined_length: + combined_text = combined_text[-max_combined_length:] + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + # 确保所有参数都有默认值 + chapter_info = chapter_info or {} + next_chapter_info = next_chapter_info or {} + + prompt = summarize_recent_chapters_prompt.format( + combined_text=combined_text, + novel_number=novel_number, + chapter_title=chapter_info.get("chapter_title", "未命名"), + chapter_role=chapter_info.get("chapter_role", "常规章节"), + chapter_purpose=chapter_info.get("chapter_purpose", "内容推进"), + suspense_level=chapter_info.get("suspense_level", "中等"), + foreshadowing=chapter_info.get("foreshadowing", "无"), + plot_twist_level=chapter_info.get("plot_twist_level", "★☆☆☆☆"), + chapter_summary=chapter_info.get("chapter_summary", ""), + next_chapter_number=novel_number + 1, + next_chapter_title=next_chapter_info.get("chapter_title", "(未命名)"), + next_chapter_role=next_chapter_info.get("chapter_role", "过渡章节"), + next_chapter_purpose=next_chapter_info.get("chapter_purpose", "承上启下"), + next_chapter_summary=next_chapter_info.get("chapter_summary", "衔接过渡内容"), + next_chapter_suspense_level=next_chapter_info.get("suspense_level", "中等"), + next_chapter_foreshadowing=next_chapter_info.get("foreshadowing", "无特殊伏笔"), + next_chapter_plot_twist_level=next_chapter_info.get("plot_twist_level", "★☆☆☆☆") + ) + + response_text = invoke_with_cleaning(llm_adapter, prompt) + summary = extract_summary_from_response(response_text) + + if not summary: + logging.warning("Failed to extract summary, using full response") + return response_text[:2000] # 限制长度 + + return summary[:2000] # 限制摘要长度 + + except Exception as e: + logging.error(f"Error in summarize_recent_chapters: {str(e)}") + return "" + +def extract_summary_from_response(response_text: str) -> str: + """从响应文本中提取摘要部分""" + if not response_text: + return "" + + # 查找摘要标记 + summary_markers = [ + "当前章节摘要:", + "章节摘要:", + "摘要:", + "本章摘要:" + ] + + for marker in summary_markers: + if (marker in response_text): + parts = response_text.split(marker, 1) + if len(parts) > 1: + return parts[1].strip() + + return response_text.strip() + +def format_chapter_info(chapter_info: dict) -> str: + """将章节信息字典格式化为文本""" + template = """ +章节编号:第{number}章 +章节标题:《{title}》 +章节定位:{role} +核心作用:{purpose} +主要人物:{characters} +关键道具:{items} +场景地点:{location} +伏笔设计:{foreshadow} +悬念密度:{suspense} +转折程度:{twist} +章节简述:{summary} +""" + return template.format( + number=chapter_info.get('chapter_number', '未知'), + title=chapter_info.get('chapter_title', '未知'), + role=chapter_info.get('chapter_role', '未知'), + purpose=chapter_info.get('chapter_purpose', '未知'), + characters=chapter_info.get('characters_involved', '未指定'), + items=chapter_info.get('key_items', '未指定'), + location=chapter_info.get('scene_location', '未指定'), + foreshadow=chapter_info.get('foreshadowing', '无'), + suspense=chapter_info.get('suspense_level', '一般'), + twist=chapter_info.get('plot_twist_level', '★☆☆☆☆'), + summary=chapter_info.get('chapter_summary', '未提供') + ) + +def parse_search_keywords(response_text: str) -> list: + """解析新版关键词格式(示例输入:'科技公司·数据泄露\n地下实验室·基因编辑')""" + return [ + line.strip().replace('·', ' ') + for line in response_text.strip().split('\n') + if '·' in line + ][:5] # 最多取5组 + +def apply_content_rules(texts: list, novel_number: int) -> list: + """应用内容处理规则""" + processed = [] + for text in texts: + if re.search(r'第[\d]+章', text) or re.search(r'chapter_[\d]+', text): + chap_nums = list(map(int, re.findall(r'\d+', text))) + recent_chap = max(chap_nums) if chap_nums else 0 + time_distance = novel_number - recent_chap + + if time_distance <= 2: + processed.append(f"[SKIP] 跳过近章内容:{text[:120]}...") + elif 3 <= time_distance <= 5: + processed.append(f"[MOD40%] {text}(需修改≥40%)") + else: + processed.append(f"[OK] {text}(可引用核心)") + else: + processed.append(f"[PRIOR] {text}(优先使用)") + return processed + +def apply_knowledge_rules(contexts: list, chapter_num: int) -> list: + """应用知识库使用规则""" + processed = [] + for text in contexts: + # 检测历史章节内容 + if "第" in text and "章" in text: + # 提取章节号判断时间远近 + chap_nums = [int(s) for s in text.split() if s.isdigit()] + recent_chap = max(chap_nums) if chap_nums else 0 + time_distance = chapter_num - recent_chap + + # 相似度处理规则 + if time_distance <= 3: # 近三章内容 + processed.append(f"[历史章节限制] 跳过近期内容: {text[:50]}...") + continue + + # 允许引用但需要转换 + processed.append(f"[历史参考] {text} (需进行30%以上改写)") + else: + # 第三方知识优先处理 + processed.append(f"[外部知识] {text}") + return processed + +def get_filtered_knowledge_context( + api_key: str, + base_url: str, + model_name: str, + interface_format: str, + embedding_adapter, + filepath: str, + chapter_info: dict, + retrieved_texts: list, + max_tokens: int = 2048, + timeout: int = 600 +) -> str: + """优化后的知识过滤处理""" + if not retrieved_texts: + return "(无相关知识库内容)" + + try: + processed_texts = apply_knowledge_rules(retrieved_texts, chapter_info.get('chapter_number', 0)) + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=0.3, + max_tokens=max_tokens, + timeout=timeout + ) + + # 限制检索文本长度并格式化 + formatted_texts = [] + max_text_length = 600 + for i, text in enumerate(processed_texts, 1): + if len(text) > max_text_length: + text = text[:max_text_length] + "..." + formatted_texts.append(f"[预处理结果{i}]\n{text}") + + # 使用格式化函数处理章节信息 + formatted_chapter_info = ( + f"当前章节定位:{chapter_info.get('chapter_role', '')}\n" + f"核心目标:{chapter_info.get('chapter_purpose', '')}\n" + f"关键要素:{chapter_info.get('characters_involved', '')} | " + f"{chapter_info.get('key_items', '')} | " + f"{chapter_info.get('scene_location', '')}" + ) + + prompt = knowledge_filter_prompt.format( + chapter_info=formatted_chapter_info, + retrieved_texts="\n\n".join(formatted_texts) if formatted_texts else "(无检索结果)" + ) + + filtered_content = invoke_with_cleaning(llm_adapter, prompt) + return filtered_content if filtered_content else "(知识内容过滤失败)" + + except Exception as e: + logging.error(f"Error in knowledge filtering: {str(e)}") + return "(内容过滤过程出错)" + +def build_chapter_prompt( + api_key: str, + base_url: str, + model_name: str, + filepath: str, + novel_number: int, + word_number: int, + temperature: float, + user_guidance: str, + characters_involved: str, + 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, + interface_format: str = "openai", + max_tokens: int = 2048, + timeout: int = 600 +) -> str: + """ + 构造当前章节的请求提示词(完整实现版) + 修改重点: + 1. 优化知识库检索流程 + 2. 新增内容重复检测机制 + 3. 集成提示词应用规则 + """ + # 读取基础文件 + 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) + + # 获取章节信息 + chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number) + chapter_title = chapter_info["chapter_title"] + 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"] + + # 获取下一章节信息 + next_chapter_number = novel_number + 1 + next_chapter_info = get_chapter_info_from_blueprint(blueprint_text, next_chapter_number) + next_chapter_title = next_chapter_info.get("chapter_title", "(未命名)") + next_chapter_role = next_chapter_info.get("chapter_role", "过渡章节") + next_chapter_purpose = next_chapter_info.get("chapter_purpose", "承上启下") + next_chapter_suspense = next_chapter_info.get("suspense_level", "中等") + next_chapter_foreshadow = next_chapter_info.get("foreshadowing", "无特殊伏笔") + next_chapter_twist = next_chapter_info.get("plot_twist_level", "★☆☆☆☆") + next_chapter_summary = next_chapter_info.get("chapter_summary", "衔接过渡内容") + + # 创建章节目录 + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + + # 第一章特殊处理 + if novel_number == 1: + return first_chapter_draft_prompt.format( + novel_number=novel_number, + word_number=word_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 + ) + + # 获取前文内容和摘要 + recent_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3) + + try: + logging.info("Attempting to generate summary") + short_summary = 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_texts, + novel_number=novel_number, + chapter_info=chapter_info, + next_chapter_info=next_chapter_info, + timeout=timeout + ) + logging.info("Summary generated successfully") + except Exception as e: + logging.error(f"Error in summarize_recent_chapters: {str(e)}") + short_summary = "(摘要生成失败)" + + # 获取前一章结尾 + previous_excerpt = "" + for text in reversed(recent_texts): + if text.strip(): + previous_excerpt = text[-800:] if len(text) > 800 else text + break + + # 知识库检索和处理 + try: + # 生成检索关键词 + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=0.3, + max_tokens=max_tokens, + timeout=timeout + ) + + search_prompt = knowledge_search_prompt.format( + chapter_number=novel_number, + chapter_title=chapter_title, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + chapter_role=chapter_role, + chapter_purpose=chapter_purpose, + foreshadowing=foreshadowing, + short_summary=short_summary, + user_guidance=user_guidance, + time_constraint=time_constraint + ) + + search_response = invoke_with_cleaning(llm_adapter, search_prompt) + keyword_groups = parse_search_keywords(search_response) + + # 执行向量检索 + all_contexts = [] + from embedding_adapters import create_embedding_adapter + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name + ) + + store = load_vector_store(embedding_adapter, filepath) + if store: + collection_size = store._collection.count() + actual_k = min(embedding_retrieval_k, max(1, collection_size)) + + for group in keyword_groups: + context = get_relevant_context_from_vector_store( + embedding_adapter=embedding_adapter, + query=group, + filepath=filepath, + k=actual_k + ) + if context: + if any(kw in group.lower() for kw in ["技法", "手法", "模板"]): + all_contexts.append(f"[TECHNIQUE] {context}") + elif any(kw in group.lower() for kw in ["设定", "技术", "世界观"]): + all_contexts.append(f"[SETTING] {context}") + else: + all_contexts.append(f"[GENERAL] {context}") + + # 应用内容规则 + processed_contexts = apply_content_rules(all_contexts, novel_number) + + # 执行知识过滤 + chapter_info_for_filter = { + "chapter_number": novel_number, + "chapter_title": chapter_title, + "chapter_role": chapter_role, + "chapter_purpose": chapter_purpose, + "characters_involved": characters_involved, + "key_items": key_items, + "scene_location": scene_location, + "foreshadowing": foreshadowing, # 修复拼写错误 + "suspense_level": suspense_level, + "plot_twist_level": plot_twist_level, + "chapter_summary": chapter_summary, + "time_constraint": time_constraint + } + + filtered_context = get_filtered_knowledge_context( + api_key=api_key, + base_url=base_url, + model_name=model_name, + interface_format=interface_format, + embedding_adapter=embedding_adapter, + filepath=filepath, + chapter_info=chapter_info_for_filter, + retrieved_texts=processed_contexts, + max_tokens=max_tokens, + timeout=timeout + ) + + except Exception as e: + logging.error(f"知识处理流程异常:{str(e)}") + filtered_context = "(知识库处理失败)" + + # 返回最终提示词 + return next_chapter_draft_prompt.format( + user_guidance=user_guidance if user_guidance else "无特殊指导", + global_summary=global_summary_text, + previous_chapter_excerpt=previous_excerpt, + character_state=character_state_text, + short_summary=short_summary, + 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, + word_number=word_number, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + next_chapter_number=next_chapter_number, + next_chapter_title=next_chapter_title, + next_chapter_role=next_chapter_role, + next_chapter_purpose=next_chapter_purpose, + next_chapter_suspense_level=next_chapter_suspense, + next_chapter_foreshadowing=next_chapter_foreshadow, + next_chapter_plot_twist_level=next_chapter_twist, + next_chapter_summary=next_chapter_summary, + filtered_context=filtered_context + ) + +def generate_chapter_draft( + api_key: str, + base_url: str, + model_name: str, + filepath: str, + novel_number: int, + word_number: int, + temperature: float, + user_guidance: str, + characters_involved: str, + 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, + interface_format: str = "openai", + max_tokens: int = 2048, + timeout: int = 600, + custom_prompt_text: str = None +) -> str: + """ + 生成章节草稿,支持自定义提示词 + """ + if custom_prompt_text is None: + prompt_text = build_chapter_prompt( + api_key=api_key, + base_url=base_url, + model_name=model_name, + filepath=filepath, + novel_number=novel_number, + word_number=word_number, + temperature=temperature, + user_guidance=user_guidance, + characters_involved=characters_involved, + key_items=key_items, + scene_location=scene_location, + time_constraint=time_constraint, + 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_retrieval_k, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout + ) + else: + prompt_text = custom_prompt_text + + chapters_dir = os.path.join(filepath, "chapters") + os.makedirs(chapters_dir, exist_ok=True) + + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + chapter_content = invoke_with_cleaning(llm_adapter, prompt_text) + 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) + logging.info(f"[Draft] Chapter {novel_number} generated as a draft.") + return chapter_content diff --git a/novel_generator/common.py b/novel_generator/common.py new file mode 100644 index 0000000..f61b665 --- /dev/null +++ b/novel_generator/common.py @@ -0,0 +1,77 @@ +#novel_generator/common.py +# -*- coding: utf-8 -*- +""" +通用重试、清洗、日志工具 +""" +import logging +import re +import time +import traceback + +def call_with_retry(func, max_retries=3, sleep_time=2, fallback_return=None, **kwargs): + """ + 通用的重试机制封装。 + :param func: 要执行的函数 + :param max_retries: 最大重试次数 + :param sleep_time: 重试前的等待秒数 + :param fallback_return: 如果多次重试仍失败时的返回值 + :param kwargs: 传给func的命名参数 + :return: func的结果,若失败则返回 fallback_return + """ + for attempt in range(1, max_retries + 1): + try: + return func(**kwargs) + except Exception as e: + logging.warning(f"[call_with_retry] Attempt {attempt} failed with error: {e}") + traceback.print_exc() + if attempt < max_retries: + time.sleep(sleep_time) + else: + logging.error("Max retries reached, returning fallback_return.") + return fallback_return + +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(llm_adapter, prompt: str, max_retries: int = 3) -> str: + """调用 LLM 并清理返回结果""" + print("\n" + "="*50) + print("发送到 LLM 的提示词:") + print("-"*50) + print(prompt) + print("="*50 + "\n") + + result = "" + retry_count = 0 + + while retry_count < max_retries: + try: + result = llm_adapter.invoke(prompt) + print("\n" + "="*50) + print("LLM 返回的内容:") + print("-"*50) + print(result) + print("="*50 + "\n") + + # 清理结果中的特殊格式标记 + result = result.replace("```", "").strip() + if result: + return result + retry_count += 1 + except Exception as e: + print(f"调用失败 ({retry_count + 1}/{max_retries}): {str(e)}") + retry_count += 1 + if retry_count >= max_retries: + raise e + + return result + diff --git a/novel_generator/finalization.py b/novel_generator/finalization.py new file mode 100644 index 0000000..a708fda --- /dev/null +++ b/novel_generator/finalization.py @@ -0,0 +1,119 @@ +#novel_generator/finalization.py +# -*- coding: utf-8 -*- +""" +定稿章节和扩写章节(finalize_chapter、enrich_chapter_text) +""" +import os +import logging +from llm_adapters import create_llm_adapter +from embedding_adapters import create_embedding_adapter +from prompt_definitions import summary_prompt, update_character_state_prompt +from novel_generator.common import invoke_with_cleaning +from utils import read_file, clear_file_content, save_string_to_txt +from novel_generator.vectorstore_utils import update_vector_store + +def finalize_chapter( + novel_number: int, + word_number: int, + api_key: str, + base_url: str, + model_name: str, + temperature: float, + filepath: str, + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + interface_format: str, + max_tokens: int, + timeout: int = 600 +): + """ + 对指定章节做最终处理:更新前文摘要、更新角色状态、插入向量库等。 + 默认无需再做扩写操作,若有需要可在外部调用 enrich_chapter_text 处理后再定稿。 + """ + 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 + + 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_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + + prompt_summary = summary_prompt.format( + chapter_text=chapter_text, + global_summary=old_global_summary + ) + new_global_summary = invoke_with_cleaning(llm_adapter, prompt_summary) + 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 + ) + 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) + + update_vector_store( + embedding_adapter=create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url, + embedding_model_name + ), + new_chapter=chapter_text, + 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, + interface_format: str, + max_tokens: int, + timeout: int=600 +) -> str: + """ + 对章节文本进行扩写,使其更接近 word_number 字数,保持剧情连贯。 + """ + llm_adapter = create_llm_adapter( + interface_format=interface_format, + base_url=base_url, + model_name=model_name, + api_key=api_key, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout + ) + prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右: +原内容: +{chapter_text} +""" + enriched_text = invoke_with_cleaning(llm_adapter, prompt) + return enriched_text if enriched_text else chapter_text diff --git a/novel_generator/knowledge.py b/novel_generator/knowledge.py new file mode 100644 index 0000000..c54a969 --- /dev/null +++ b/novel_generator/knowledge.py @@ -0,0 +1,87 @@ +#novel_generator/knowledge.py +# -*- coding: utf-8 -*- +""" +知识文件导入至向量库(advanced_split_content、import_knowledge_file) +""" +import os +import logging +import re +import traceback +import nltk +import warnings +from utils import read_file +from novel_generator.vectorstore_utils import load_vector_store, init_vector_store +from langchain.docstore.document import Document + +# 禁用特定的Torch警告 +warnings.filterwarnings('ignore', message='.*Torch was not compiled with flash attention.*') +os.environ["TOKENIZERS_PARALLELISM"] = "false" + +def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> list: + """使用基本分段策略""" + nltk.download('punkt', quiet=True) + nltk.download('punkt_tab', quiet=True) + sentences = nltk.sent_tokenize(content) + if not sentences: + return [] + + final_segments = [] + current_segment = [] + current_length = 0 + + for sentence in sentences: + sentence_length = len(sentence) + if current_length + sentence_length > max_length: + if current_segment: + final_segments.append(" ".join(current_segment)) + current_segment = [sentence] + current_length = sentence_length + else: + current_segment.append(sentence) + current_length += sentence_length + + if current_segment: + final_segments.append(" ".join(current_segment)) + + return final_segments + +def import_knowledge_file( + embedding_api_key: str, + embedding_url: str, + embedding_interface_format: str, + embedding_model_name: str, + file_path: str, + filepath: str +): + logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_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) + from embedding_adapters import create_embedding_adapter + embedding_adapter = create_embedding_adapter( + embedding_interface_format, + embedding_api_key, + embedding_url if embedding_url else "http://localhost:11434/api", + embedding_model_name + ) + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("Vector store does not exist or load failed. Initializing a new one for knowledge import...") + store = init_vector_store(embedding_adapter, paragraphs, filepath) + if store: + logging.info("知识库文件已成功导入至向量库(新初始化)。") + else: + logging.warning("知识库导入失败,跳过。") + else: + try: + docs = [Document(page_content=str(p)) for p in paragraphs] + store.add_documents(docs) + logging.info("知识库文件已成功导入至向量库(追加模式)。") + except Exception as e: + logging.warning(f"知识库导入失败: {e}") + traceback.print_exc() diff --git a/novel_generator/vectorstore_utils.py b/novel_generator/vectorstore_utils.py new file mode 100644 index 0000000..2898925 --- /dev/null +++ b/novel_generator/vectorstore_utils.py @@ -0,0 +1,244 @@ +#novel_generator/vectorstore_utils.py +# -*- coding: utf-8 -*- +""" +向量库相关操作(初始化、更新、检索、清空、文本切分等) +""" +import os +import logging +import traceback +import nltk +import numpy as np +import re +import ssl +import requests +import warnings +from langchain_chroma import Chroma + +# 禁用特定的Torch警告 +warnings.filterwarnings('ignore', message='.*Torch was not compiled with flash attention.*') +os.environ["TOKENIZERS_PARALLELISM"] = "false" # 禁用tokenizer并行警告 + +from chromadb.config import Settings +from langchain.docstore.document import Document +from sklearn.metrics.pairwise import cosine_similarity +from .common import call_with_retry + +def get_vectorstore_dir(filepath: str) -> str: + """获取 vectorstore 路径""" + return os.path.join(filepath, "vectorstore") + +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)}") + traceback.print_exc() + return False + +def init_vector_store(embedding_adapter, texts, filepath: str): + """ + 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 + 如果Embedding失败,则返回 None,不中断任务。 + """ + from langchain.embeddings.base import Embeddings as LCEmbeddings + + store_dir = get_vectorstore_dir(filepath) + os.makedirs(store_dir, exist_ok=True) + documents = [Document(page_content=str(t)) for t in texts] + + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, texts): + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + texts=texts + ) + def embed_query(self, query: str): + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query=query + ) + return res + + chroma_embedding = LCEmbeddingWrapper() + vectorstore = Chroma.from_documents( + documents, + embedding=chroma_embedding, + persist_directory=store_dir, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + return vectorstore + except Exception as e: + logging.warning(f"Init vector store failed: {e}") + traceback.print_exc() + return None + +def load_vector_store(embedding_adapter, filepath: str): + """ + 读取已存在的 Chroma 向量库。若不存在则返回 None。 + 如果加载失败(embedding 或IO问题),则返回 None。 + """ + from langchain.embeddings.base import Embeddings as LCEmbeddings + store_dir = get_vectorstore_dir(filepath) + if not os.path.exists(store_dir): + logging.info("Vector store not found. Will return None.") + return None + + try: + class LCEmbeddingWrapper(LCEmbeddings): + def embed_documents(self, texts): + return call_with_retry( + func=embedding_adapter.embed_documents, + max_retries=3, + fallback_return=[], + texts=texts + ) + def embed_query(self, query: str): + res = call_with_retry( + func=embedding_adapter.embed_query, + max_retries=3, + fallback_return=[], + query=query + ) + return res + + chroma_embedding = LCEmbeddingWrapper() + return Chroma( + persist_directory=store_dir, + embedding_function=chroma_embedding, + client_settings=Settings(anonymized_telemetry=False), + collection_name="novel_collection" + ) + except Exception as e: + logging.warning(f"Failed to load vector store: {e}") + traceback.print_exc() + return None + +def split_by_length(text: str, max_length: int = 500): + """按照 max_length 切分文本""" + 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): + """ + 对新的章节文本进行分段后,再用于存入向量库。 + 使用 embedding 进行文本相似度计算。 + """ + 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 [] + + # 直接按长度分段,不做相似度合并 + final_segments = [] + current_segment = [] + current_length = 0 + + for sentence in sentences: + sentence_length = len(sentence) + if current_length + sentence_length > max_length: + if current_segment: + final_segments.append(" ".join(current_segment)) + current_segment = [sentence] + current_length = sentence_length + else: + current_segment.append(sentence) + current_length += sentence_length + + if current_segment: + final_segments.append(" ".join(current_segment)) + + return final_segments + +def update_vector_store(embedding_adapter, new_chapter: str, filepath: str): + """ + 将最新章节文本插入到向量库中。 + 若库不存在则初始化;若初始化/更新失败,则跳过。 + """ + from utils import read_file, clear_file_content, save_string_to_txt + 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(embedding_adapter, filepath) + if not store: + logging.info("Vector store does not exist or failed to load. Initializing a new one for new chapter...") + store = init_vector_store(embedding_adapter, splitted_texts, filepath) + if not store: + logging.warning("Init vector store failed, skip embedding.") + else: + logging.info("New vector store created successfully.") + return + + try: + 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.") + except Exception as e: + logging.warning(f"Failed to update vector store: {e}") + traceback.print_exc() + +def get_relevant_context_from_vector_store(embedding_adapter, query: str, filepath: str, k: int = 2) -> str: + """ + 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 + 如果向量库加载/检索失败,则返回空字符串。 + 最终只返回最多2000字符的检索片段。 + """ + store = load_vector_store(embedding_adapter, filepath) + if not store: + logging.info("No vector store found or load failed. Returning empty context.") + return "" + + try: + 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]) + if len(combined) > 2000: + combined = combined[:2000] + return combined + except Exception as e: + logging.warning(f"Similarity search failed: {e}") + traceback.print_exc() + return "" + +def _get_sentence_transformer(model_name: str = 'paraphrase-MiniLM-L6-v2'): + """获取sentence transformer模型,处理SSL问题""" + try: + # 设置torch环境变量 + os.environ["TORCH_ALLOW_TF32_CUBLAS_OVERRIDE"] = "0" + os.environ["TORCH_CUDNN_V8_API_ENABLED"] = "0" + + # 禁用SSL验证 + ssl._create_default_https_context = ssl._create_unverified_context + + # ...existing code... + except Exception as e: + logging.error(f"Failed to load sentence transformer model: {e}") + traceback.print_exc() + return None diff --git a/prompt_definitions.py b/prompt_definitions.py new file mode 100644 index 0000000..c7173d5 --- /dev/null +++ b/prompt_definitions.py @@ -0,0 +1,662 @@ +# prompt_definitions.py +# -*- coding: utf-8 -*- +""" +集中存放所有提示词 (Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型等 +并包含新增加的前三章摘要/下一章关键字提炼提示词,以及章节正文写作提示词。 +""" + +# =============== 生成草稿提示词当前章节摘要、知识库提炼 =============== +# 当前章节摘要生成提示词 +summarize_recent_chapters_prompt = """\ +作为一名专业的小说编辑和知识管理专家,正在基于已完成的前三章内容和本章信息生成当前章节的精准摘要。请严格遵循以下工作流程: +前三章内容: +{combined_text} + +当前章节信息: +第{novel_number}章《{chapter_title}》: +├── 本章定位:{chapter_role} +├── 核心作用:{chapter_purpose} +├── 悬念密度:{suspense_level} +├── 伏笔操作:{foreshadowing} +├── 认知颠覆:{plot_twist_level} +└── 本章简述:{chapter_summary} + +下一章信息: +第{next_chapter_number}章《{next_chapter_title}》: +├── 本章定位:{next_chapter_role} +├── 核心作用:{next_chapter_purpose} +├── 悬念密度:{next_chapter_suspense_level} +├── 伏笔操作:{next_chapter_foreshadowing} +├── 认知颠覆:{next_chapter_plot_twist_level} +└── 本章简述:{next_chapter_summary} + +**上下文分析阶段**: +1. 回顾前三章核心内容: + - 第一章核心要素:[章节标题]→[核心冲突/理论]→[关键人物/概念] + - 第二章发展路径:[已建立的人物关系]→[技术/情节进展]→[遗留伏笔] + - 第三章转折点:[新出现的变量]→[世界观扩展]→[待解决问题] +2. 提取延续性要素: + - 必继承要素:列出前3章中必须延续的3个核心设定 + - 可调整要素:识别2个允许适度变化的辅助设定 + +**当前章节摘要生成规则**: +1. 内容架构: + - 继承权重:70%内容需与前3章形成逻辑递进 + - 创新空间:30%内容可引入新要素,但需标注创新类型(如:技术突破/人物黑化) +2. 结构控制: + - 采用"承继→发展→铺垫"三段式结构 + - 每段含1个前文呼应点+1个新进展 +3. 预警机制: + - 若检测到与前3章设定冲突,用[!]标记并说明 + - 对开放式发展路径,提供2种合理演化方向 + +现在请你基于目前故事的进展,完成以下两件事: +用最多800字,写一个简洁明了的「当前章节摘要」; + +请按如下格式输出(不需要额外解释): +当前章节摘要: <这里写当前章节摘要> +""" + +# 知识库相关性检索提示词 +knowledge_search_prompt = """\ +请基于以下当前写作需求,生成合适的知识库检索关键词: + +章节元数据: +- 准备创作:第{chapter_number}章 +- 章节主题:{chapter_title} +- 核心人物:{characters_involved} +- 关键道具:{key_items} +- 场景位置:{scene_location} + +写作目标: +- 本章定位:{chapter_role} +- 核心作用:{chapter_purpose} +- 伏笔操作:{foreshadowing} + +当前摘要: +{short_summary} + +- 用户指导: +{user_guidance} + +- 核心人物(可能未指定):{characters_involved} +- 关键道具(可能未指定):{key_items} +- 空间坐标(可能未指定):{scene_location} +- 时间压力(可能未指定):{time_constraint} + +生成规则: + +1.关键词组合逻辑: +-类型1:[实体]+[属性](如"量子计算机 故障日志") +-类型2:[事件]+[后果](如"实验室爆炸 辐射泄漏") +-类型3:[地点]+[特征](如"地下城 氧气循环系统") + +2.优先级: +-首选用户指导中明确提及的术语 +-次选当前章节涉及的核心道具/地点 +-最后补充可能关联的扩展概念 + +3.过滤机制: +-排除抽象程度高于"中级"的概念 +-排除与前3章重复率超60%的词汇 + +请生成3-5组检索词,按优先级降序排列。 +格式:每组用"·"连接2-3个关键词,每组占一行 + +示例: +科技公司·数据泄露 +地下实验室·基因编辑·禁忌实验 +""" + +# 知识库内容过滤提示词 +knowledge_filter_prompt = """\ +对知识库内容进行三级过滤: + +待过滤内容: +{retrieved_texts} + +当前叙事需求: +{chapter_info} + +过滤流程: + +冲突检测: + +删除与已有摘要重复度>40%的内容 + +标记存在世界观矛盾的内容(使用▲前缀) + +价值评估: + +关键价值点(❗标记): +· 提供新的角色关系可能性 +· 包含可转化的隐喻素材 +· 存在至少2个可延伸的细节锚点 + +次级价值点(·标记): +· 补充环境细节 +· 提供技术/流程描述 + +结构重组: + +按"情节燃料/人物维度/世界碎片/叙事技法"分类 + +为每个分类添加适用场景提示(如"可用于XX类型伏笔") + +输出格式: +[分类名称]→[适用场景] +❗/· [内容片段] (▲冲突提示) +... + +示例: +[情节燃料]→可用于时间压力类悬念 +❗ 地下氧气系统剩余23%储量(可制造生存危机) +▲ 与第三章提到的"永久生态循环系统"存在设定冲突 +""" + +# =============== 1. 核心种子设定(雪花第1层)=================== +core_seed_prompt = """\ +作为专业作家,请用"雪花写作法"第一步构建故事核心: +主题:{topic} +类型:{genre} +篇幅:约{number_of_chapters}章(每章{word_number}字) + +请用单句公式概括故事本质,例如: +"当[主角]遭遇[核心事件],必须[关键行动],否则[灾难后果];与此同时,[隐藏的更大危机]正在发酵。" + +要求: +1. 必须包含显性冲突与潜在危机 +2. 体现人物核心驱动力 +3. 暗示世界观关键矛盾 +4. 使用25-100字精准表达 + +仅返回故事核心文本,不要解释任何内容。 +""" + +# =============== 2. 角色动力学设定(角色弧光模型)=================== +character_dynamics_prompt = """\ +基于以下元素: +- 内容指导:{user_guidance} +- 核心种子:{core_seed} + +请设计3-6个具有动态变化潜力的核心角色,每个角色需包含: +特征: +- 背景、外貌、性别、年龄、职业等 +- 暗藏的秘密或潜在弱点(可与世界观或其他角色有关) + +核心驱动力三角: +- 表面追求(物质目标) +- 深层渴望(情感需求) +- 灵魂需求(哲学层面) + +角色弧线设计: +初始状态 → 触发事件 → 认知失调 → 蜕变节点 → 最终状态 + +关系冲突网: +- 与其他角色的关系或对立点 +- 与至少两个其他角色的价值观冲突 +- 一个合作纽带 +- 一个隐藏的背叛可能性 + +要求: +仅给出最终文本,不要解释任何内容。 +""" + +# =============== 3. 世界构建矩阵(三维度交织法)=================== +world_building_prompt = """\ +基于以下元素: +- 内容指导:{user_guidance} +- 核心冲突:"{core_seed}" + +为服务上述内容,请构建三维交织的世界观: + +1. 物理维度: +- 空间结构(地理×社会阶层分布图) +- 时间轴(关键历史事件年表) +- 法则体系(物理/魔法/社会规则的漏洞点) + +2. 社会维度: +- 权力结构断层线(可引发冲突的阶层/种族/组织矛盾) +- 文化禁忌(可被打破的禁忌及其后果) +- 经济命脉(资源争夺焦点) + +3. 隐喻维度: +- 贯穿全书的视觉符号系统(如反复出现的意象) +- 氣候/环境变化映射的心理状态 +- 建筑风格暗示的文明困境 + +要求: +每个维度至少包含3个可与角色决策产生互动的动态元素。 +仅给出最终文本,不要解释任何内容。 +""" + +# =============== 4. 情节架构(三幕式悬念)=================== +plot_architecture_prompt = """\ +基于以下元素: +- 内容指导:{user_guidance} +- 核心种子:{core_seed} +- 角色体系:{character_dynamics} +- 世界观:{world_building} + +要求按以下结构设计: +第一幕(触发) +- 日常状态中的异常征兆(3处铺垫) +- 引出故事:展示主线、暗线、副线的开端 +- 关键事件:打破平衡的催化剂(需改变至少3个角色的关系) +- 错误抉择:主角的认知局限导致的错误反应 + +第二幕(对抗) +- 剧情升级:主线+副线的交叉点 +- 双重压力:外部障碍升级+内部挫折 +- 虚假胜利:看似解决实则深化危机的转折点 +- 灵魂黑夜:世界观认知颠覆时刻 + +第三幕(解决) +- 代价显现:解决危机必须牺牲的核心价值 +- 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择) +- 余波:留下2个开放式悬念因子 + +每个阶段需包含3个关键转折点及其对应的伏笔回收方案。 +仅给出最终文本,不要解释任何内容。 +""" + +# =============== 5. 章节目录生成(悬念节奏曲线)=================== +chapter_blueprint_prompt = """\ +基于以下元素: +- 内容指导:{user_guidance} +- 小说架构: +{novel_architecture} + +设计{number_of_chapters}章的节奏分布: +1. 章节集群划分: +- 每3-5章构成一个悬念单元,包含完整的小高潮 +- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲) +- 关键转折章需预留多视角铺垫 + +2. 每章需明确: +- 章节定位(角色/事件/主题等) +- 核心悬念类型(信息差/道德困境/时间压力等) +- 情感基调迁移(如从怀疑→恐惧→决绝) +- 伏笔操作(埋设/强化/回收) +- 认知颠覆强度(1-5级) + +输出格式示例: +第n章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +第n+1章 - [标题] +本章定位:[角色/事件/主题/...] +核心作用:[推进/转折/揭示/...] +悬念密度:[紧凑/渐进/爆发/...] +伏笔操作:埋设(A线索)→强化(B矛盾)... +认知颠覆:★☆☆☆☆ +本章简述:[一句话概括] + +要求: +- 使用精炼语言描述,每章字数控制在100字以内。 +- 合理安排节奏,确保整体悬念曲线的连贯性。 +- 在生成{number_of_chapters}章前不要出现结局章节。 + +仅给出最终文本,不要解释任何内容。 +""" + +chunked_chapter_blueprint_prompt = """\ +基于以下元素: +- 内容指导:{user_guidance} +- 小说架构: +{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}章前不要出现结局章节。 + +仅给出最终文本,不要解释任何内容。 +""" + +# =============== 6. 前文摘要更新 =================== +summary_prompt = """\ +以下是新完成的章节文本: +{chapter_text} + +这是当前的前文摘要(可为空): +{global_summary} + +请根据本章新增内容,更新前文摘要。 +要求: +- 保留既有重要信息,同时融入新剧情要点 +- 以简洁、连贯的语言描述全书进展 +- 客观描绘,不展开联想或解释 +- 总字数控制在2000字以内 + +仅返回前文摘要文本,不要解释任何内容。 +""" + +# =============== 7. 角色状态更新 =================== +create_character_state_prompt = """\ +依据当前角色动力学设定:{character_dynamics} + +请生成一个角色状态文档,内容格式: +例: +张三: +├──物品: +│ ├──青衫:一件破损的青色长袍,带有暗红色的污渍 +│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文 +├──能力 +│ ├──技能1:强大的精神感知能力:能够察觉到周围人的心中活动 +│ └──技能2:无形攻击:能够释放一种无法被视觉捕捉的精神攻击 +├──状态 +│ ├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻 +│ └──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安 +├──主要角色间关系网 +│ ├──李四:张三从小就与她有关联,对她的成长一直保持关注 +│ └──王二:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁 +├──触发或加深的事件 +│ ├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件 +│ └──李四被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍 + +角色名: +├──物品: +│ ├──某物(道具):描述 +│ └──XX长剑(武器):描述 +│ ... +├──能力 +│ ├──技能1:描述 +│ └──技能2:描述 +│ ... +├──状态 +│ ├──身体状态: +│ └──心理状态:描述 +│ +├──主要角色间关系网 +│ ├──李四:描述 +│ └──王二:描述 +│ ... +├──触发或加深的事件 +│ ├──事件1:描述 +│ └──事件2:描述 + ... + +新出场角色: +- (此处填写未来任何新增角色或临时出场人物的基本信息) + +要求: +仅返回编写好的角色状态文本,不要解释任何内容。 +""" + +update_character_state_prompt = """\ +以下是新完成的章节文本: +{chapter_text} + +这是当前的角色状态文档: +{old_state} + +请更新主要角色状态,内容格式: +例: +张三: +├──物品: +│ ├──青衫:一件破损的青色长袍,带有暗红色的污渍 +│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文 +├──能力 +│ ├──技能1:强大的精神感知能力:能够察觉到周围人的心中活动 +│ └──技能2:无形攻击:能够释放一种无法被视觉捕捉的精神攻击 +├──状态 +│ ├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻 +│ └──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安 +├──主要角色间关系网 +│ ├──李四:张三从小就与她有关联,对她的成长一直保持关注 +│ └──王二:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁 +├──触发或加深的事件 +│ ├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件 +│ └──李四被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍 + +角色名: +├──物品: +│ ├──某物(道具):描述 +│ └──XX长剑(武器):描述 +│ ... +├──能力 +│ ├──技能1:描述 +│ └──技能2:描述 +│ ... +├──状态 +│ ├──身体状态: +│ └──心理状态:描述 +│ +├──主要角色间关系网 +│ ├──李四:描述 +│ └──王二:描述 +│ ... +├──触发或加深的事件 +│ ├──事件1:描述 +│ └──事件2:描述 + ... + +...... + +新出场角色: +- 任何新增角色或临时出场人物的基本信息,简要描述即可,不要展开,淡出视线的角色可删除。 + +要求: +- 请直接在已有文档基础上进行增删 +- 不改变原有结构,语言尽量简洁、有条理 + +仅返回更新后的角色状态文本,不要解释任何内容。 +""" + +# =============== 8. 章节正文写作 =================== + +# 8.1 第一章草稿提示 +first_chapter_draft_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} + +参考文档: +- 小说设定: +{novel_setting} + +完成第 {novel_number} 章的正文,字数要求{word_number}字,至少设计下方2个或以上具有动态张力的场景: +1. 对话场景: + - 潜台词冲突(表面谈论A,实际博弈B) + - 权力关系变化(通过非对称对话长度体现) + +2. 动作场景: + - 环境交互细节(至少3个感官描写) + - 节奏控制(短句加速+比喻减速) + - 动作揭示人物隐藏特质 + +3. 心理场景: + - 认知失调的具体表现(行为矛盾) + - 隐喻系统的运用(连接世界观符号) + - 决策前的价值天平描写 + +4. 环境场景: + - 空间透视变化(宏观→微观→异常焦点) + - 非常规感官组合(如"听见阳光的重量") + - 动态环境反映心理(环境与人物心理对应) + +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 + +额外指导(可能未指定):{user_guidance} +""" + +# 8.2 后续章节草稿提示 +next_chapter_draft_prompt = """\ +参考文档: +└── 前文摘要: + {global_summary} + +└── 前章结尾段: + {previous_chapter_excerpt} + +└── 用户指导: + {user_guidance} + +└── 角色状态: + {character_state} + +└── 当前章节摘要: + {short_summary} + +当前章节信息: +第{novel_number}章《{chapter_title}》: +├── 章节定位:{chapter_role} +├── 核心作用:{chapter_purpose} +├── 悬念密度:{suspense_level} +├── 伏笔设计:{foreshadowing} +├── 转折程度:{plot_twist_level} +├── 章节简述:{chapter_summary} +├── 字数要求:{word_number}字 +├── 核心人物:{characters_involved} +├── 关键道具:{key_items} +├── 场景地点:{scene_location} +└── 时间压力:{time_constraint} + +下一章节目录 +第{next_chapter_number}章《{next_chapter_title}》: +├── 章节定位:{next_chapter_role} +├── 核心作用:{next_chapter_purpose} +├── 悬念密度:{next_chapter_suspense_level} +├── 伏笔设计:{next_chapter_foreshadowing} +├── 转折程度:{next_chapter_plot_twist_level} +└── 章节简述:{next_chapter_summary} + +知识库参考:(按优先级应用) +{filtered_context} + +🎯 知识库应用规则: +1. 内容分级: + - 写作技法类(优先): + ▸ 场景构建模板 + ▸ 对话写作技巧 + ▸ 悬念营造手法 + - 设定资料类(选择性): + ▸ 独特世界观元素 + ▸ 未使用过的技术细节 + - 禁忌项类(必须规避): + ▸ 已在前文出现过的特定情节 + ▸ 重复的人物关系发展 + +2. 使用限制: + ● 禁止直接复制已有章节的情节模式 + ● 历史章节内容仅允许: + → 参照叙事节奏(不超过20%相似度) + → 延续必要的人物反应模式(需改编30%以上) + ● 第三方写作知识优先用于: + → 增强场景表现力(占知识应用的60%以上) + → 创新悬念设计(至少1处新技巧) + +3. 冲突检测: + ⚠️ 若检测到与历史章节重复: + - 相似度>40%:必须重构叙事角度 + - 相似度20-40%:替换至少3个关键要素 + - 相似度<20%:允许保留核心概念但改变表现形式 + +依据前面所有设定,开始完成第 {novel_number} 章的正文,字数要求{word_number}字, +内容生成严格遵循: +-用户指导 +-当前章节摘要 +-当前章节信息 +-无逻辑漏洞, +确保章节内容与前文摘要、前章结尾段衔接流畅、下一章目录保证上下文完整性, + +格式要求: +- 仅返回章节正文文本; +- 不使用分章节小标题; +- 不要使用markdown格式。 +""" + +Character_Import_Prompt = """\ +根据以下文本内容,分析出所有角色及其属性信息,严格按照以下格式要求: + +<<角色状态格式要求>> +1. 必须包含以下五个分类(按顺序): + ● 物品 ● 能力 ● 状态 ● 主要角色间关系网 ● 触发或加深的事件 +2. 每个属性条目必须用【名称: 描述】格式 + 例:├──青衫: 一件破损的青色长袍,带有暗红色的污渍 +3. 状态必须包含: + ● 身体状态: [当前身体状况] + ● 心理状态: [当前心理状况] +4. 关系网格式: + ● [角色名称]: [关系类型,如"竞争对手"/"盟友"] +5. 触发事件格式: + ● [事件名称]: [简要描述及影响] + +<<示例>> +李员外: +├──物品: +│ ├──青衫: 一件破损的青色长袍,带有暗红色污渍 +│ └──寒铁长剑: 剑身有裂痕,刻有「青云」符文 +├──能力: +│ ├──精神感知: 能感知半径30米内的生命体 +│ └──剑气压制: 通过目光释放精神威压 +├──状态: +│ ├──身体状态: 右臂有未愈合的刀伤 +│ └──心理状态: 对苏明远的实力感到忌惮 +├──主要角色间关系网: +│ ├──苏明远: 竞争对手,十年前的同僚 +│ └──林婉儿: 暗中培养的继承人 +├──触发或加深的事件: +│ ├──兵器库遇袭: 丢失三把传家宝剑,影响战力 +│ └──匿名威胁信: 信纸带有檀香味,暗示内部泄密 +│ + +请严格按上述格式分析以下内容: +<<待分析小说文本开始>> +{content} +<<待分析小说文本结束>> +""" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..5ca51023d88a6f6ebc1808a62620746d7b65c705 GIT binary patch literal 816 zcmaKr%}T>S6ot>y3NCyDq3i5jDz1d0mHG!=l$fS%Xqwb?T3eq$7lN)_xfFa4aqUC+ z0B*&F^_=Mh60H(K?%bd6o;x#{e^s%8&y%g`!~XHj8dfnx#8=h`Aq>V`8Kit-dgQ-A zkCBjTt@Mhq*?Vr~jEvE9R)(xFuF7C$*$KMv7@0B>B4IQlTi+sW4>i>BF&Q1mo3NKb z-)2Zv;%Woh9_X7g6;hqPpK+k9", update_word_count) + self.chapter_view_text.bind("", update_word_count) + TextWidgetContextMenu(self.chapter_view_text) + self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=6) + + self.chapters_list = [] + refresh_chapters_list(self) + +def refresh_chapters_list(self): + filepath = self.filepath_var.get().strip() + chapters_dir = os.path.join(filepath, "chapters") + if not os.path.exists(chapters_dir): + self.safe_log("尚未找到 chapters 文件夹,请先生成章节或检查保存路径。") + self.chapter_select_menu.configure(values=[]) + return + + all_files = os.listdir(chapters_dir) + chapter_nums = [] + for f in all_files: + if f.startswith("chapter_") and f.endswith(".txt"): + number_part = f.replace("chapter_", "").replace(".txt", "") + if number_part.isdigit(): + chapter_nums.append(number_part) + chapter_nums.sort(key=lambda x: int(x)) + self.chapters_list = chapter_nums + self.chapter_select_menu.configure(values=self.chapters_list) + current_selected = self.chapter_select_var.get() + if current_selected not in self.chapters_list: + if self.chapters_list: + self.chapter_select_var.set(self.chapters_list[0]) + load_chapter_content(self, self.chapters_list[0]) + else: + self.chapter_select_var.set("") + self.chapter_view_text.delete("0.0", "end") + +def on_chapter_selected(self, value): + load_chapter_content(self, value) + +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): + self.safe_log(f"章节文件 {chapter_file} 不存在!") + return + content = read_file(chapter_file) + self.chapter_view_text.delete("0.0", "end") + self.chapter_view_text.insert("0.0", content) + +def save_current_chapter(self): + chapter_number_str = self.chapter_select_var.get() + if not chapter_number_str: + messagebox.showwarning("警告", "尚未选择章节,无法保存。") + return + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径") + return + chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt") + content = self.chapter_view_text.get("0.0", "end").strip() + clear_file_content(chapter_file) + save_string_to_txt(content, chapter_file) + self.safe_log(f"已保存对第 {chapter_number_str} 章的修改。") + +def prev_chapter(self): + if not self.chapters_list: + return + current = self.chapter_select_var.get() + if current not in self.chapters_list: + return + idx = self.chapters_list.index(current) + if idx > 0: + new_idx = idx - 1 + self.chapter_select_var.set(self.chapters_list[new_idx]) + load_chapter_content(self, self.chapters_list[new_idx]) + else: + messagebox.showinfo("提示", "已经是第一章了。") + +def next_chapter(self): + if not self.chapters_list: + return + current = self.chapter_select_var.get() + if current not in self.chapters_list: + return + idx = self.chapters_list.index(current) + if idx < len(self.chapters_list) - 1: + new_idx = idx + 1 + self.chapter_select_var.set(self.chapters_list[new_idx]) + load_chapter_content(self, self.chapters_list[new_idx]) + else: + messagebox.showinfo("提示", "已经是最后一章了。") diff --git a/ui/character_tab.py b/ui/character_tab.py new file mode 100644 index 0000000..eefd781 --- /dev/null +++ b/ui/character_tab.py @@ -0,0 +1,56 @@ +# ui/character_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_character_tab(self): + self.character_tab = self.tabview.add("Character State") + self.character_tab.rowconfigure(0, weight=0) + self.character_tab.rowconfigure(1, weight=1) + self.character_tab.columnconfigure(0, weight=1) + + load_btn = ctk.CTkButton(self.character_tab, text="加载 character_state.txt", command=self.load_character_state, font=("Microsoft YaHei", 12)) + load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + self.character_wordcount_label = ctk.CTkLabel(self.character_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.character_wordcount_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(self.character_tab, text="保存修改", command=self.save_character_state, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") + + self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12)) + + def update_word_count(event=None): + text = self.character_text.get("0.0", "end-1c") + text_length = len(text) + self.character_wordcount_label.configure(text=f"字数:{text_length}") + + self.character_text.bind("", update_word_count) + self.character_text.bind("", update_word_count) + TextWidgetContextMenu(self.character_text) + self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) + +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 的修改。") diff --git a/ui/config_tab.py b/ui/config_tab.py new file mode 100644 index 0000000..2840679 --- /dev/null +++ b/ui/config_tab.py @@ -0,0 +1,319 @@ +# ui/config_tab.py +# -*- coding: utf-8 -*- +from tkinter import messagebox + +import customtkinter as ctk + +from config_manager import load_config, save_config +from tooltips import tooltips + + +def create_label_with_help(self, parent, label_text, tooltip_key, row, column, + font=None, sticky="e", padx=5, pady=5): + """ + 封装一个带"?"按钮的Label,用于展示提示信息。 + """ + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + + 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: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明")) + ) + btn.pack(side="left", padx=3) + + return frame + +def build_config_tabview(self): + """ + 创建包含 LLM Model settings 和 Embedding settings 的选项卡。 + """ + 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") + self.embeddings_config_tab = self.config_tabview.add("Embedding settings") + + build_ai_config_tab(self) + build_embeddings_config_tab(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_ai_config_tab(self): + def on_interface_format_changed(new_value): + self.interface_format_var.set(new_value) + config_data = load_config(self.config_file) + if config_data: + config_data["last_interface_format"] = new_value + save_config(config_data, self.config_file) + if self.loaded_config and "llm_configs" in self.loaded_config and new_value in self.loaded_config["llm_configs"]: + llm_conf = self.loaded_config["llm_configs"][new_value] + self.api_key_var.set(llm_conf.get("api_key", "")) + self.base_url_var.set(llm_conf.get("base_url", self.base_url_var.get())) + self.model_name_var.set(llm_conf.get("model_name", "")) + self.temperature_var.set(llm_conf.get("temperature", 0.7)) + self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) + self.timeout_var.set(llm_conf.get("timeout", 600)) + else: + 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") + self.model_name_var.set("gpt-4o-mini") + elif new_value == "Azure OpenAI": + self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview") + elif new_value == "DeepSeek": + self.base_url_var.set("https://api.deepseek.com/v1") + self.model_name_var.set("deepseek-chat") + elif new_value == "Gemini": + self.base_url_var.set("") + elif new_value == "Azure AI": + self.base_url_var.set("https://.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview") + elif new_value == "阿里云百炼": + self.base_url_var.set("https://dashscope.aliyuncs.com/compatible-mode/v1") + self.model_name_var.set("qwen-plus") + elif new_value == "硅基流动": + self.base_url_var.set("https://api.siliconflow.cn/v1") + self.model_name_var.set("deepseek-ai/DeepSeek-V3") + + for i in range(7): + 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) + + # 1) API Key + create_label_with_help(self, 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),show="*") + api_key_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=2, sticky="nsew") + + # 2) Base URL + create_label_with_help(self, 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, columnspan=2, sticky="nsew") + + # 3) 接口格式 + create_label_with_help(self, parent=self.ai_config_tab, label_text="LLM 接口格式:", tooltip_key="interface_format", row=2, column=0, font=("Microsoft YaHei", 12)) + # 在这里的接口选项列表中添加 "硅基流动" + interface_options = ["DeepSeek", "阿里云百炼", "OpenAI", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini", "火山引擎", "硅基流动"] + 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, columnspan=2, sticky="nsew") + + # 4) Model Name + create_label_with_help(self, 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, columnspan=2, sticky="nsew") + + # 5) Temperature + create_label_with_help(self, 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}") + temp_scale = ctk.CTkSlider(self.ai_config_tab, from_=0.0, to=2.0, number_of_steps=200, 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=5, pady=5, sticky="w") + + # 6) Max Tokens + create_label_with_help(self, 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") + + # 7) Timeout (sec) + create_label_with_help(self, parent=self.ai_config_tab, label_text="Timeout (sec):", tooltip_key="timeout", row=6, column=0, font=("Microsoft YaHei", 12)) + def update_timeout_label(value): + integer_val = int(float(value)) + self.timeout_value_label.configure(text=str(integer_val)) + timeout_slider = ctk.CTkSlider(self.ai_config_tab, from_=0, to=3600, number_of_steps=3600, command=update_timeout_label, variable=self.timeout_var) + timeout_slider.grid(row=6, column=1, padx=5, pady=5, sticky="we") + self.timeout_value_label = ctk.CTkLabel(self.ai_config_tab, text=str(self.timeout_var.get()), font=("Microsoft YaHei", 12)) + self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w") + + # 添加测试按钮 + test_btn = ctk.CTkButton(self.ai_config_tab, text="测试配置", command=self.test_llm_config, font=("Microsoft YaHei", 12)) + test_btn.grid(row=7, column=0, columnspan=3, padx=5, pady=5, sticky="ew") + +def build_embeddings_config_tab(self): + def on_embedding_interface_changed(new_value): + self.embedding_interface_format_var.set(new_value) + config_data = load_config(self.config_file) + if config_data: + config_data["last_embedding_interface_format"] = new_value + save_config(config_data, self.config_file) + if self.loaded_config and "embedding_configs" in self.loaded_config and new_value in self.loaded_config["embedding_configs"]: + emb_conf = self.loaded_config["embedding_configs"][new_value] + self.embedding_api_key_var.set(emb_conf.get("api_key", "")) + self.embedding_url_var.set(emb_conf.get("base_url", self.embedding_url_var.get())) + self.embedding_model_name_var.set(emb_conf.get("model_name", "")) + self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) + else: + 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") + self.embedding_model_name_var.set("text-embedding-ada-002") + elif new_value == "Azure OpenAI": + self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15") + elif new_value == "DeepSeek": + self.embedding_url_var.set("https://api.deepseek.com/v1") + elif new_value == "Gemini": + self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/") + self.embedding_model_name_var.set("models/text-embedding-004") + elif new_value == "SiliconFlow": + self.embedding_url_var.set("https://api.siliconflow.cn/v1/embeddings") + self.embedding_model_name_var.set("BAAI/bge-m3") + + 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) + + # 1) Embedding API Key + create_label_with_help(self, 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") + + # 2) Embedding 接口格式 + create_label_with_help(self, 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", "Azure OpenAI", "Gemini", "Ollama", "ML Studio","SiliconFlow"] + + 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") + + # 3) Embedding Base URL + create_label_with_help(self, 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") + + # 4) Embedding Model Name + create_label_with_help(self, 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") + + # 5) Retrieval Top-K + create_label_with_help(self, 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") + + # 添加测试按钮 + test_btn = ctk.CTkButton(self.embeddings_config_tab, text="测试配置", command=self.test_embedding_config, font=("Microsoft YaHei", 12)) + test_btn.grid(row=5, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + +def load_config_btn(self): + cfg = load_config(self.config_file) + if cfg: + last_llm = cfg.get("last_interface_format", "OpenAI") + last_embedding = cfg.get("last_embedding_interface_format", "OpenAI") + self.interface_format_var.set(last_llm) + self.embedding_interface_format_var.set(last_embedding) + llm_configs = cfg.get("llm_configs", {}) + if last_llm in llm_configs: + llm_conf = llm_configs[last_llm] + self.api_key_var.set(llm_conf.get("api_key", "")) + self.base_url_var.set(llm_conf.get("base_url", "https://api.openai.com/v1")) + self.model_name_var.set(llm_conf.get("model_name", "gpt-4o-mini")) + self.temperature_var.set(llm_conf.get("temperature", 0.7)) + self.max_tokens_var.set(llm_conf.get("max_tokens", 8192)) + self.timeout_var.set(llm_conf.get("timeout", 600)) + embedding_configs = cfg.get("embedding_configs", {}) + if last_embedding in embedding_configs: + emb_conf = embedding_configs[last_embedding] + self.embedding_api_key_var.set(emb_conf.get("api_key", "")) + self.embedding_url_var.set(emb_conf.get("base_url", "https://api.openai.com/v1")) + self.embedding_model_name_var.set(emb_conf.get("model_name", "text-embedding-ada-002")) + self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4))) + other_params = cfg.get("other_params", {}) + self.topic_text.delete("0.0", "end") + self.topic_text.insert("0.0", other_params.get("topic", "")) + self.genre_var.set(other_params.get("genre", "玄幻")) + self.num_chapters_var.set(str(other_params.get("num_chapters", 10))) + self.word_number_var.set(str(other_params.get("word_number", 3000))) + self.filepath_var.set(other_params.get("filepath", "")) + self.chapter_num_var.set(str(other_params.get("chapter_num", "1"))) + self.user_guide_text.delete("0.0", "end") + self.user_guide_text.insert("0.0", other_params.get("user_guidance", "")) + self.characters_involved_var.set(other_params.get("characters_involved", "")) + self.key_items_var.set(other_params.get("key_items", "")) + self.scene_location_var.set(other_params.get("scene_location", "")) + self.time_constraint_var.set(other_params.get("time_constraint", "")) + self.log("已加载配置。") + else: + messagebox.showwarning("提示", "未找到或无法读取配置文件。") + +def save_config_btn(self): + current_llm_interface = self.interface_format_var.get().strip() + current_embedding_interface = self.embedding_interface_format_var.get().strip() + llm_config = { + "api_key": self.api_key_var.get(), + "base_url": self.base_url_var.get(), + "model_name": self.model_name_var.get(), + "temperature": self.temperature_var.get(), + "max_tokens": self.max_tokens_var.get(), + "timeout": self.safe_get_int(self.timeout_var, 600) + } + embedding_config = { + "api_key": self.embedding_api_key_var.get(), + "base_url": self.embedding_url_var.get(), + "model_name": self.embedding_model_name_var.get(), + "retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4) + } + other_params = { + "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(), + "chapter_num": self.chapter_num_var.get(), + "user_guidance": self.user_guide_text.get("0.0", "end").strip(), + "characters_involved": self.characters_involved_var.get(), + "key_items": self.key_items_var.get(), + "scene_location": self.scene_location_var.get(), + "time_constraint": self.time_constraint_var.get() + } + existing_config = load_config(self.config_file) + if not existing_config: + existing_config = {} + existing_config["last_interface_format"] = current_llm_interface + existing_config["last_embedding_interface_format"] = current_embedding_interface + if "llm_configs" not in existing_config: + existing_config["llm_configs"] = {} + existing_config["llm_configs"][current_llm_interface] = llm_config + + if "embedding_configs" not in existing_config: + existing_config["embedding_configs"] = {} + existing_config["embedding_configs"][current_embedding_interface] = embedding_config + + existing_config["other_params"] = other_params + + if save_config(existing_config, self.config_file): + messagebox.showinfo("提示", "配置已保存至 config.json") + self.log("配置已保存。") + else: + messagebox.showerror("错误", "保存配置失败。") diff --git a/ui/context_menu.py b/ui/context_menu.py new file mode 100644 index 0000000..438081c --- /dev/null +++ b/ui/context_menu.py @@ -0,0 +1,54 @@ +# ui/context_menu.py +# -*- coding: utf-8 -*- +import tkinter as tk +import customtkinter as ctk + +class TextWidgetContextMenu: + """ + 为 customtkinter.TextBox 或 tkinter.Text 提供右键复制/剪切/粘贴/全选的功能。 + """ + def __init__(self, widget): + self.widget = widget + self.menu = tk.Menu(widget, tearoff=0) + self.menu.add_command(label="复制", command=self.copy) + self.menu.add_command(label="粘贴", command=self.paste) + self.menu.add_command(label="剪切", command=self.cut) + self.menu.add_separator() + self.menu.add_command(label="全选", command=self.select_all) + + # 绑定右键事件 + self.widget.bind("", self.show_menu) + + def show_menu(self, event): + if isinstance(self.widget, ctk.CTkTextbox): + try: + self.menu.tk_popup(event.x_root, event.y_root) + finally: + self.menu.grab_release() + + def copy(self): + try: + text = self.widget.get("sel.first", "sel.last") + self.widget.clipboard_clear() + self.widget.clipboard_append(text) + except tk.TclError: + pass # 没有选中文本时忽略错误 + + def paste(self): + try: + text = self.widget.clipboard_get() + self.widget.insert("insert", text) + except tk.TclError: + pass # 剪贴板为空时忽略错误 + + def cut(self): + try: + text = self.widget.get("sel.first", "sel.last") + self.widget.delete("sel.first", "sel.last") + self.widget.clipboard_clear() + self.widget.clipboard_append(text) + except tk.TclError: + pass # 没有选中文本时忽略错误 + + def select_all(self): + self.widget.tag_add("sel", "1.0", "end") diff --git a/ui/directory_tab.py b/ui/directory_tab.py new file mode 100644 index 0000000..2a26c64 --- /dev/null +++ b/ui/directory_tab.py @@ -0,0 +1,56 @@ +# ui/directory_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_directory_tab(self): + self.directory_tab = self.tabview.add("Chapter Blueprint") + self.directory_tab.rowconfigure(0, weight=0) + self.directory_tab.rowconfigure(1, weight=1) + self.directory_tab.columnconfigure(0, weight=1) + + load_btn = ctk.CTkButton(self.directory_tab, text="加载 Novel_directory.txt", command=self.load_chapter_blueprint, font=("Microsoft YaHei", 12)) + load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + self.directory_word_count_label = ctk.CTkLabel(self.directory_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.directory_word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(self.directory_tab, text="保存修改", command=self.save_chapter_blueprint, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") + + self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12)) + + def update_word_count(event=None): + text = self.directory_text.get("0.0", "end") + count = len(text) - 1 + self.directory_word_count_label.configure(text=f"字数:{count}") + + self.directory_text.bind("", update_word_count) + self.directory_text.bind("", update_word_count) + TextWidgetContextMenu(self.directory_text) + self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) + +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 的修改。") diff --git a/ui/generation_handlers.py b/ui/generation_handlers.py new file mode 100644 index 0000000..0016d08 --- /dev/null +++ b/ui/generation_handlers.py @@ -0,0 +1,538 @@ +# ui/generation_handlers.py +# -*- coding: utf-8 -*- +import os +import threading +import tkinter as tk +from tkinter import messagebox +import customtkinter as ctk +import traceback +from utils import read_file, save_string_to_txt, clear_file_content +from novel_generator import ( + Novel_architecture_generate, + Chapter_blueprint_generate, + generate_chapter_draft, + finalize_chapter, + import_knowledge_file, + clear_vector_store, + enrich_chapter_text +) +from consistency_checker import check_consistency + +def generate_novel_architecture_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + def task(): + confirm = messagebox.askyesno("确认", "确定要生成小说架构吗?") + if not confirm: + self.enable_button_safe(self.btn_generate_architecture) + return + + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + 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) + # 获取内容指导 + user_guidance = self.user_guide_text.get("0.0", "end").strip() + + self.safe_log("开始生成小说架构...") + Novel_architecture_generate( + interface_format=interface_format, + 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, + max_tokens=max_tokens, + timeout=timeout_val, + user_guidance=user_guidance # 添加内容指导参数 + ) + self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。") + except Exception: + self.handle_exception("生成小说架构时出错") + finally: + self.enable_button_safe(self.btn_generate_architecture) + threading.Thread(target=task, daemon=True).start() + +def generate_chapter_blueprint_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先选择保存文件路径") + return + + def task(): + if not messagebox.askyesno("确认", "确定要生成章节目录吗?"): + self.enable_button_safe(self.btn_generate_chapter) + return + 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() + number_of_chapters = self.safe_get_int(self.num_chapters_var, 10) + temperature = self.temperature_var.get() + max_tokens = self.max_tokens_var.get() + timeout_val = self.safe_get_int(self.timeout_var, 600) + user_guidance = self.user_guide_text.get("0.0", "end").strip() # 新增获取用户指导 + + self.safe_log("开始生成章节蓝图...") + Chapter_blueprint_generate( + interface_format=interface_format, + 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, + timeout=timeout_val, + user_guidance=user_guidance # 新增参数 + ) + self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。") + 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): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + self.disable_button_safe(self.btn_generate_chapter) + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + 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_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) + + self.safe_log(f"生成第{chap_num}章草稿:准备生成请求提示词...") + + # 调用新添加的 build_chapter_prompt 函数构造初始提示词 + from novel_generator.chapter import build_chapter_prompt + prompt_text = build_chapter_prompt( + 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_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, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + + # 弹出可编辑提示词对话框,等待用户确认或取消 + result = {"prompt": None} + event = threading.Event() + + def create_dialog(): + dialog = ctk.CTkToplevel(self.master) + dialog.title("当前章节请求提示词(可编辑)") + dialog.geometry("600x400") + text_box = ctk.CTkTextbox(dialog, wrap="word", font=("Microsoft YaHei", 12)) + text_box.pack(fill="both", expand=True, padx=10, pady=10) + + # 字数统计标签 + wordcount_label = ctk.CTkLabel(dialog, text="字数:0", font=("Microsoft YaHei", 12)) + wordcount_label.pack(side="left", padx=(10,0), pady=5) + + # 插入角色内容 + final_prompt = prompt_text + role_names = [name.strip() for name in self.char_inv_text.get("0.0", "end").strip().split(',') if name.strip()] + role_lib_path = os.path.join(filepath, "角色库") + role_contents = [] + + if os.path.exists(role_lib_path): + for root, dirs, files in os.walk(role_lib_path): + for file in files: + if file.endswith(".txt") and os.path.splitext(file)[0] in role_names: + file_path = os.path.join(root, file) + try: + with open(file_path, 'r', encoding='utf-8') as f: + role_contents.append(f.read().strip()) # 直接使用文件内容,不添加重复名字 + except Exception as e: + self.safe_log(f"读取角色文件 {file} 失败: {str(e)}") + + if role_contents: + role_content_str = "\n".join(role_contents) + # 更精确的替换逻辑,处理不同情况下的占位符 + placeholder_variations = [ + "核心人物(可能未指定):{characters_involved}", + "核心人物:{characters_involved}", + "核心人物(可能未指定):{characters_involved}", + "核心人物:{characters_involved}" + ] + + for placeholder in placeholder_variations: + if placeholder in final_prompt: + final_prompt = final_prompt.replace( + placeholder, + f"核心人物:\n{role_content_str}" + ) + break + else: # 如果没有找到任何已知占位符变体 + lines = final_prompt.split('\n') + for i, line in enumerate(lines): + if "核心人物" in line and ":" in line: + lines[i] = f"核心人物:\n{role_content_str}" + break + final_prompt = '\n'.join(lines) + + text_box.insert("0.0", final_prompt) + # 更新字数函数 + def update_word_count(event=None): + text = text_box.get("0.0", "end-1c") + text_length = len(text) + wordcount_label.configure(text=f"字数:{text_length}") + + text_box.bind("", update_word_count) + text_box.bind("", update_word_count) + update_word_count() # 初始化统计 + + button_frame = ctk.CTkFrame(dialog) + button_frame.pack(pady=10) + def on_confirm(): + result["prompt"] = text_box.get("1.0", "end").strip() + dialog.destroy() + event.set() + def on_cancel(): + result["prompt"] = None + dialog.destroy() + event.set() + btn_confirm = ctk.CTkButton(button_frame, text="确认使用", font=("Microsoft YaHei", 12), command=on_confirm) + btn_confirm.pack(side="left", padx=10) + btn_cancel = ctk.CTkButton(button_frame, text="取消请求", font=("Microsoft YaHei", 12), command=on_cancel) + btn_cancel.pack(side="left", padx=10) + # 若用户直接关闭弹窗,则调用 on_cancel 处理 + dialog.protocol("WM_DELETE_WINDOW", on_cancel) + dialog.grab_set() + self.master.after(0, create_dialog) + event.wait() # 等待用户操作完成 + edited_prompt = result["prompt"] + if edited_prompt is None: + self.safe_log("❌ 用户取消了草稿生成请求。") + return + + self.safe_log("开始生成章节草稿...") + from novel_generator.chapter import generate_chapter_draft + 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_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, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val, + custom_prompt_text=edited_prompt # 使用用户编辑后的提示词 + ) + 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 finalize_chapter_ui(self): + filepath = self.filepath_var.get().strip() + if not filepath: + messagebox.showwarning("警告", "请先配置保存文件路径。") + return + + def task(): + if not messagebox.askyesno("确认", "确定要定稿当前章节吗?"): + self.enable_button_safe(self.btn_finalize_chapter) + return + + self.disable_button_safe(self.btn_finalize_chapter) + 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() + timeout_val = self.safe_get_int(self.timeout_var, 600) + + 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") + + edited_text = self.chapter_result.get("0.0", "end").strip() + + if len(edited_text) < 0.7 * word_number: + ask = messagebox.askyesno("字数不足", f"当前章节字数 ({len(edited_text)}) 低于目标字数({word_number})的70%,是否要尝试扩写?") + if ask: + self.safe_log("正在扩写章节内容...") + enriched = enrich_chapter_text( + chapter_text=edited_text, + word_number=word_number, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + edited_text = enriched + self.master.after(0, lambda: self.chapter_result.delete("0.0", "end")) + self.master.after(0, lambda: self.chapter_result.insert("0.0", edited_text)) + 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, + 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, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout_val + ) + self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新前文摘要、角色状态、向量库)。") + + 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() + interface_format = self.interface_format_var.get() + max_tokens = self.max_tokens_var.get() + timeout = self.timeout_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="", + 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, + interface_format=interface_format, + max_tokens=max_tokens, + timeout=timeout, + 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 = tk.filedialog.askopenfilename( + title="选择要导入的知识库文件", + filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")] + ) + if selected_file: + def task(): + self.disable_button_safe(self.btn_import_knowledge) + try: + 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() + + # 尝试不同编码读取文件 + content = None + encodings = ['utf-8', 'gbk', 'gb2312', 'ansi'] + for encoding in encodings: + try: + with open(selected_file, 'r', encoding=encoding) as f: + content = f.read() + break + except UnicodeDecodeError: + continue + except Exception as e: + self.safe_log(f"读取文件时发生错误: {str(e)}") + raise + + if content is None: + raise Exception("无法以任何已知编码格式读取文件") + + # 创建临时UTF-8文件 + import tempfile + import os + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, suffix='.txt') as temp: + temp.write(content) + temp_path = temp.name + + try: + self.safe_log(f"开始导入知识库文件: {selected_file}") + import_knowledge_file( + embedding_api_key=emb_api_key, + embedding_url=emb_url, + embedding_interface_format=emb_format, + embedding_model_name=emb_model, + file_path=temp_path, + filepath=self.filepath_var.get().strip() + ) + self.safe_log("✅ 知识库文件导入完成。") + finally: + # 清理临时文件 + try: + os.unlink(temp_path) + except: + pass + + except Exception: + self.handle_exception("导入知识库时出错") + finally: + self.enable_button_safe(self.btn_import_knowledge) + + try: + thread = threading.Thread(target=task, daemon=True) + thread.start() + except Exception as e: + self.enable_button_safe(self.btn_import_knowledge) + messagebox.showerror("错误", f"线程启动失败: {str(e)}") + +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") diff --git a/ui/helpers.py b/ui/helpers.py new file mode 100644 index 0000000..870549e --- /dev/null +++ b/ui/helpers.py @@ -0,0 +1,7 @@ +# ui/helpers.py +# -*- coding: utf-8 -*- +import logging +import traceback + +def log_error(message: str): + logging.error(f"{message}\n{traceback.format_exc()}") diff --git a/ui/main_tab.py b/ui/main_tab.py new file mode 100644 index 0000000..a5f66d4 --- /dev/null +++ b/ui/main_tab.py @@ -0,0 +1,113 @@ +# ui/main_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import messagebox +from ui.context_menu import TextWidgetContextMenu + +def build_main_tab(self): + """ + 主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区 + """ + self.main_tab = self.tabview.add("Main Functions") + self.main_tab.rowconfigure(0, weight=1) + self.main_tab.columnconfigure(0, weight=1) + self.main_tab.columnconfigure(1, weight=0) + + self.left_frame = ctk.CTkFrame(self.main_tab) + self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) + + self.right_frame = ctk.CTkFrame(self.main_tab) + self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2) + + build_left_layout(self) + build_right_layout(self) + +def build_left_layout(self): + """ + 左侧区域:本章内容(可编辑) + Step流程按钮 + 输出日志(只读) + """ + 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.columnconfigure(0, weight=1) + + self.chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容(可编辑) 字数:0", font=("Microsoft YaHei", 12)) + self.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)) + TextWidgetContextMenu(self.chapter_result) + self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + + + + def update_word_count(event=None): + text = self.chapter_result.get("0.0", "end") + count = len(text) - 1 # 减去最后一个换行符 + self.chapter_label.configure(text=f"本章内容(可编辑) 字数:{count}") + + self.chapter_result.bind("", update_word_count) + self.chapter_result.bind("", update_word_count) + + # 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_architecture = ctk.CTkButton( + self.step_buttons_frame, + text="Step1. 生成架构", + command=self.generate_novel_architecture_ui, + font=("Microsoft YaHei", 12) + ) + 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_chapter_blueprint_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_directory.grid(row=0, column=1, padx=5, pady=2, sticky="ew") + + self.btn_generate_chapter = ctk.CTkButton( + self.step_buttons_frame, + text="Step3. 生成草稿", + command=self.generate_chapter_draft_ui, + font=("Microsoft YaHei", 12) + ) + self.btn_generate_chapter.grid(row=0, column=2, padx=5, pady=2, sticky="ew") + + self.btn_finalize_chapter = ctk.CTkButton( + self.step_buttons_frame, + text="Step4. 定稿章节", + command=self.finalize_chapter_ui, + font=("Microsoft YaHei", 12) + ) + 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)) + TextWidgetContextMenu(self.log_text) + 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): + """ + 右侧区域:配置区(tabview) + 小说主参数 + 可选功能按钮 + """ + 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) + + # 配置区(AI/Embedding) + 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) + # 其余部分将在 config_tab.py 与 novel_params_tab.py 中构建 diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..8f8f75a --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,367 @@ +# ui/main_window.py +# -*- coding: utf-8 -*- +import os +import threading +import logging +import traceback +import customtkinter as ctk +import tkinter as tk +from tkinter import filedialog, messagebox +from .role_library import RoleLibrary +from llm_adapters import create_llm_adapter + +from config_manager import load_config, save_config, test_llm_config, test_embedding_config +from utils import read_file, save_string_to_txt, clear_file_content +from tooltips import tooltips + +from ui.context_menu import TextWidgetContextMenu +from ui.main_tab import build_main_tab, build_left_layout, build_right_layout +from ui.config_tab import build_config_tabview, load_config_btn, save_config_btn +from ui.novel_params_tab import build_novel_params_area, build_optional_buttons_area +from ui.generation_handlers import ( + generate_novel_architecture_ui, + generate_chapter_blueprint_ui, + generate_chapter_draft_ui, + finalize_chapter_ui, + do_consistency_check, + import_knowledge_handler, + clear_vectorstore_handler, + show_plot_arcs_ui +) +from ui.setting_tab import build_setting_tab, load_novel_architecture, save_novel_architecture +from ui.directory_tab import build_directory_tab, load_chapter_blueprint, save_chapter_blueprint +from ui.character_tab import build_character_tab, load_character_state, save_character_state +from ui.summary_tab import build_summary_tab, load_global_summary, save_global_summary +from ui.chapters_tab import build_chapters_tab, refresh_chapters_list, on_chapter_selected, load_chapter_content, save_current_chapter, prev_chapter, next_chapter + +class NovelGeneratorGUI: + """ + 小说生成器的主GUI类,包含所有的界面布局、事件处理、与后端逻辑的交互等。 + """ + def __init__(self, master): + self.master = master + self.master.title("Novel Generator GUI") + 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) + + if self.loaded_config: + last_llm = self.loaded_config.get("last_interface_format", "OpenAI") + last_embedding = self.loaded_config.get("last_embedding_interface_format", "OpenAI") + else: + last_llm = "OpenAI" + last_embedding = "OpenAI" + + if self.loaded_config and "llm_configs" in self.loaded_config and last_llm in self.loaded_config["llm_configs"]: + llm_conf = self.loaded_config["llm_configs"][last_llm] + else: + llm_conf = { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model_name": "gpt-4o-mini", + "temperature": 0.7, + "max_tokens": 8192, + "timeout": 600 + } + + if self.loaded_config and "embedding_configs" in self.loaded_config and last_embedding in self.loaded_config["embedding_configs"]: + emb_conf = self.loaded_config["embedding_configs"][last_embedding] + else: + emb_conf = { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model_name": "text-embedding-ada-002", + "retrieval_k": 4 + } + + # -- LLM通用参数 -- + self.api_key_var = ctk.StringVar(value=llm_conf.get("api_key", "")) + self.base_url_var = ctk.StringVar(value=llm_conf.get("base_url", "https://api.openai.com/v1")) + self.interface_format_var = ctk.StringVar(value=last_llm) + self.model_name_var = ctk.StringVar(value=llm_conf.get("model_name", "gpt-4o-mini")) + self.temperature_var = ctk.DoubleVar(value=llm_conf.get("temperature", 0.7)) + self.max_tokens_var = ctk.IntVar(value=llm_conf.get("max_tokens", 8192)) + self.timeout_var = ctk.IntVar(value=llm_conf.get("timeout", 600)) + + # -- Embedding相关 -- + self.embedding_interface_format_var = ctk.StringVar(value=last_embedding) + self.embedding_api_key_var = ctk.StringVar(value=emb_conf.get("api_key", "")) + self.embedding_url_var = ctk.StringVar(value=emb_conf.get("base_url", "https://api.openai.com/v1")) + self.embedding_model_name_var = ctk.StringVar(value=emb_conf.get("model_name", "text-embedding-ada-002")) + self.embedding_retrieval_k_var = ctk.StringVar(value=str(emb_conf.get("retrieval_k", 4))) + + # -- 小说参数相关 -- + if self.loaded_config and "other_params" in self.loaded_config: + op = self.loaded_config["other_params"] + self.topic_default = op.get("topic", "") + self.genre_var = ctk.StringVar(value=op.get("genre", "玄幻")) + self.num_chapters_var = ctk.StringVar(value=str(op.get("num_chapters", 10))) + self.word_number_var = ctk.StringVar(value=str(op.get("word_number", 3000))) + self.filepath_var = ctk.StringVar(value=op.get("filepath", "")) + self.chapter_num_var = ctk.StringVar(value=str(op.get("chapter_num", "1"))) + self.characters_involved_var = ctk.StringVar(value=op.get("characters_involved", "")) + self.key_items_var = ctk.StringVar(value=op.get("key_items", "")) + self.scene_location_var = ctk.StringVar(value=op.get("scene_location", "")) + self.time_constraint_var = ctk.StringVar(value=op.get("time_constraint", "")) + self.user_guidance_default = op.get("user_guidance", "") + else: + self.topic_default = "" + self.genre_var = ctk.StringVar(value="玄幻") + self.num_chapters_var = ctk.StringVar(value="10") + self.word_number_var = ctk.StringVar(value="3000") + self.filepath_var = ctk.StringVar(value="") + 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="") + self.user_guidance_default = "" + + # --------------- 整体Tab布局 --------------- + self.tabview = ctk.CTkTabview(self.master) + self.tabview.pack(fill="both", expand=True) + + # 创建各个标签页 + build_main_tab(self) + build_config_tabview(self) + build_novel_params_area(self, start_row=1) + build_optional_buttons_area(self, start_row=2) + build_setting_tab(self) + build_directory_tab(self) + build_character_tab(self) + build_summary_tab(self) + build_chapters_tab(self) + + # ----------------- 通用辅助函数 ----------------- + def show_tooltip(self, key: str): + info_text = tooltips.get(key, "暂无说明") + messagebox.showinfo("参数说明", info_text) + + def safe_get_int(self, var, default=1): + try: + val_str = str(var.get()).strip() + return int(val_str) + except: + var.set(str(default)) + return default + + 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 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 test_llm_config(self): + """ + 测试当前的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() + timeout = self.timeout_var.get() + + test_llm_config( + interface_format=interface_format, + api_key=api_key, + base_url=base_url, + model_name=model_name, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + log_func=self.safe_log, + handle_exception_func=self.handle_exception + ) + + def test_embedding_config(self): + """ + 测试当前的Embedding配置是否可用 + """ + 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() + model_name = self.embedding_model_name_var.get().strip() + + test_embedding_config( + api_key=api_key, + base_url=base_url, + interface_format=interface_format, + model_name=model_name, + log_func=self.safe_log, + handle_exception_func=self.handle_exception + ) + + def browse_folder(self): + selected_dir = filedialog.askdirectory() + if selected_dir: + self.filepath_var.set(selected_dir) + + def show_character_import_window(self): + """显示角色导入窗口""" + import_window = ctk.CTkToplevel(self.master) + import_window.title("导入角色信息") + import_window.geometry("600x500") + import_window.transient(self.master) # 设置为父窗口的临时窗口 + import_window.grab_set() # 保持窗口在顶层 + + # 主容器 + main_frame = ctk.CTkFrame(import_window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 滚动容器 + scroll_frame = ctk.CTkScrollableFrame(main_frame) + scroll_frame.pack(fill="both", expand=True, padx=5, pady=5) + + # 获取角色库路径 + role_lib_path = os.path.join(self.filepath_var.get().strip(), "角色库") + self.selected_roles = [] # 存储选中的角色名称 + + # 动态加载角色分类 + if os.path.exists(role_lib_path): + # 配置网格布局参数 + scroll_frame.columnconfigure(0, weight=1) + max_roles_per_row = 4 + current_row = 0 + + for category in os.listdir(role_lib_path): + category_path = os.path.join(role_lib_path, category) + if os.path.isdir(category_path): + # 创建分类容器 + category_frame = ctk.CTkFrame(scroll_frame) + category_frame.grid(row=current_row, column=0, sticky="w", pady=(10,5), padx=5) + + # 添加分类标签 + category_label = ctk.CTkLabel(category_frame, text=f"【{category}】", + font=("Microsoft YaHei", 12, "bold")) + category_label.grid(row=0, column=0, padx=(0,10), sticky="w") + + # 初始化角色排列参数 + role_count = 0 + row_num = 0 + col_num = 1 # 从第1列开始(第0列是分类标签) + + # 添加角色复选框 + for role_file in os.listdir(category_path): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + if not any(name == role_name for _, name in self.selected_roles): + chk = ctk.CTkCheckBox(category_frame, text=role_name) + chk.grid(row=row_num, column=col_num, padx=5, pady=2, sticky="w") + self.selected_roles.append((chk, role_name)) + + # 更新行列位置 + role_count += 1 + col_num += 1 + if col_num > max_roles_per_row: + col_num = 1 + row_num += 1 + + # 如果没有角色,调整分类标签占满整行 + if role_count == 0: + category_label.grid(columnspan=max_roles_per_row+1, sticky="w") + + # 更新主布局的行号 + current_row += 1 + + # 添加分隔线 + separator = ctk.CTkFrame(scroll_frame, height=1, fg_color="gray") + separator.grid(row=current_row, column=0, sticky="ew", pady=5) + current_row += 1 + + # 底部按钮框架 + btn_frame = ctk.CTkFrame(main_frame) + btn_frame.pack(fill="x", pady=10) + + # 选择按钮 + def confirm_selection(): + selected = [name for chk, name in self.selected_roles if chk.get() == 1] + self.char_inv_text.delete("0.0", "end") + self.char_inv_text.insert("0.0", ", ".join(selected)) + import_window.destroy() + + btn_confirm = ctk.CTkButton(btn_frame, text="选择", command=confirm_selection) + btn_confirm.pack(side="left", padx=20) + + # 取消按钮 + btn_cancel = ctk.CTkButton(btn_frame, text="取消", command=import_window.destroy) + btn_cancel.pack(side="right", padx=20) + + def show_role_library(self): + save_path = self.filepath_var.get().strip() + if not save_path: + messagebox.showwarning("警告", "请先设置保存路径") + return + + # 初始化LLM适配器 + llm_adapter = create_llm_adapter( + interface_format=self.interface_format_var.get(), + base_url=self.base_url_var.get(), + model_name=self.model_name_var.get(), + api_key=self.api_key_var.get(), + temperature=self.temperature_var.get(), + max_tokens=self.max_tokens_var.get(), + timeout=self.timeout_var.get() + ) + + # 传递LLM适配器实例到角色库 + if hasattr(self, '_role_lib'): + if self._role_lib.window and self._role_lib.window.winfo_exists(): + self._role_lib.window.destroy() + + self._role_lib = RoleLibrary(self.master, save_path, llm_adapter) # 新增参数 + + # ----------------- 将导入的各模块函数直接赋给类方法 ----------------- + generate_novel_architecture_ui = generate_novel_architecture_ui + generate_chapter_blueprint_ui = generate_chapter_blueprint_ui + generate_chapter_draft_ui = generate_chapter_draft_ui + finalize_chapter_ui = finalize_chapter_ui + do_consistency_check = do_consistency_check + import_knowledge_handler = import_knowledge_handler + clear_vectorstore_handler = clear_vectorstore_handler + show_plot_arcs_ui = show_plot_arcs_ui + load_config_btn = load_config_btn + save_config_btn = save_config_btn + load_novel_architecture = load_novel_architecture + save_novel_architecture = save_novel_architecture + load_chapter_blueprint = load_chapter_blueprint + save_chapter_blueprint = save_chapter_blueprint + load_character_state = load_character_state + save_character_state = save_character_state + load_global_summary = load_global_summary + save_global_summary = save_global_summary + refresh_chapters_list = refresh_chapters_list + on_chapter_selected = on_chapter_selected + save_current_chapter = save_current_chapter + prev_chapter = prev_chapter + next_chapter = next_chapter + test_llm_config = test_llm_config + test_embedding_config = test_embedding_config + browse_folder = browse_folder diff --git a/ui/novel_params_tab.py b/ui/novel_params_tab.py new file mode 100644 index 0000000..84369bb --- /dev/null +++ b/ui/novel_params_tab.py @@ -0,0 +1,146 @@ +# ui/novel_params_tab.py +# -*- coding: utf-8 -*- +import customtkinter as ctk +from tkinter import filedialog, messagebox +from ui.context_menu import TextWidgetContextMenu +from tooltips import tooltips + +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) + + # 1) 主题(Topic) + create_label_with_help_for_novel_params(self, 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)) + TextWidgetContextMenu(self.topic_text) + self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew") + if hasattr(self, 'topic_default') and self.topic_default: + self.topic_text.insert("0.0", self.topic_default) + + # 2) 类型(Genre) + create_label_with_help_for_novel_params(self, 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 + create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="章节数 & 每章字数:", tooltip_key="num_chapters", row=row_for_chapter_and_word, column=0, font=("Microsoft YaHei", 12)) + 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) + 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(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 + create_label_with_help_for_novel_params(self, 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=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)) + 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") + + # 5) 章节号 + row_chap_num = 4 + create_label_with_help_for_novel_params(self, 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=row_chap_num, column=1, padx=5, pady=5, sticky="w") + + # 6) 内容指导 + row_user_guide = 5 + create_label_with_help_for_novel_params(self, 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)) + TextWidgetContextMenu(self.user_guide_text) + self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew") + if hasattr(self, 'user_guidance_default') and self.user_guidance_default: + self.user_guide_text.insert("0.0", self.user_guidance_default) + + # 7) 可选元素:核心人物/关键道具/空间坐标/时间压力 + row_idx = 6 + create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="核心人物:", tooltip_key="characters_involved", row=row_idx, column=0, font=("Microsoft YaHei", 12)) + + # 核心人物输入框+按钮容器 + char_inv_frame = ctk.CTkFrame(self.params_frame) + char_inv_frame.grid(row=row_idx, column=1, padx=5, pady=5, sticky="nsew") + char_inv_frame.columnconfigure(0, weight=1) + char_inv_frame.rowconfigure(0, weight=1) + + # 三行文本输入框 + self.char_inv_text = ctk.CTkTextbox(char_inv_frame, height=60, wrap="word", font=("Microsoft YaHei", 12)) + self.char_inv_text.grid(row=0, column=0, padx=(0,5), pady=5, sticky="nsew") + if hasattr(self, 'characters_involved_var'): + self.char_inv_text.insert("0.0", self.characters_involved_var.get()) + + # 导入按钮 + import_btn = ctk.CTkButton(char_inv_frame, text="导入", width=60, + command=self.show_character_import_window, + font=("Microsoft YaHei", 12)) + import_btn.grid(row=0, column=1, padx=(0,5), pady=5, sticky="e") + row_idx += 1 + create_label_with_help_for_novel_params(self, 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_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + create_label_with_help_for_novel_params(self, 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_idx, column=1, padx=5, pady=5, sticky="ew") + row_idx += 1 + create_label_with_help_for_novel_params(self, 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_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) + 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, 4), weight=1) + + self.btn_check_consistency = ctk.CTkButton( + self.optional_btn_frame, text="一致性审校", command=self.do_consistency_check, + font=("Microsoft YaHei", 12), width=100 # 固定宽度 + ) + self.btn_check_consistency.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + self.btn_import_knowledge = ctk.CTkButton( + self.optional_btn_frame, text="导入知识库", command=self.import_knowledge_handler, + font=("Microsoft YaHei", 12), width=100 + ) + self.btn_import_knowledge.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + self.btn_clear_vectorstore = ctk.CTkButton( + self.optional_btn_frame, text="清空向量库", fg_color="red", + command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12), width=100 + ) + self.btn_clear_vectorstore.grid(row=0, column=2, padx=5, pady=5, sticky="ew") + + self.plot_arcs_btn = ctk.CTkButton( + self.optional_btn_frame, text="查看剧情要点", command=self.show_plot_arcs_ui, + font=("Microsoft YaHei", 12), width=100 + ) + self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew") + + # 新增角色库按钮 + self.role_library_btn = ctk.CTkButton( + self.optional_btn_frame, text="角色库", command=self.show_role_library, + font=("Microsoft YaHei", 12), width=100 + ) + self.role_library_btn.grid(row=0, column=4, padx=5, pady=5, sticky="ew") + +def create_label_with_help_for_novel_params(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5): + frame = ctk.CTkFrame(parent) + frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky) + frame.columnconfigure(0, weight=0) + 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: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明"))) + btn.pack(side="left", padx=3) + return frame diff --git a/ui/role_library.py b/ui/role_library.py new file mode 100644 index 0000000..0f53d58 --- /dev/null +++ b/ui/role_library.py @@ -0,0 +1,1528 @@ +# ui/role_library.py +import os +import tkinter as tk +from tkinter import filedialog +import shutil +import re +import customtkinter as ctk +from tkinter import messagebox, BooleanVar +from customtkinter import CTkScrollableFrame, CTkTextbox, END +from utils import read_file, save_string_to_txt # 导入 utils 中的函数 +from novel_generator.common import invoke_with_cleaning # 新增导入 +from prompt_definitions import Character_Import_Prompt + +class RoleLibrary: + def __init__(self, master, save_path, llm_adapter): # 新增llm_adapter参数 + self.master = master + self.save_path = os.path.join(save_path, "角色库") + self.selected_category = None + self.current_roles = [] + self.selected_del = [] + self.llm_adapter = llm_adapter # 保存LLM适配器实例 + + # 初始化窗口 + self.window = ctk.CTkToplevel(master) + self.window.title("角色库管理") + self.window.geometry("1200x800") + self.window.protocol("WM_DELETE_WINDOW", self.on_close) + + # 创建目录结构 + self.create_library_structure() + # 构建UI + self.create_ui() + # 窗口居中 + self.center_window() + # 窗口模态设置 + self.window.grab_set() + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + + def create_library_structure(self): + """创建必要的目录结构""" + os.makedirs(self.save_path, exist_ok=True) + all_dir = os.path.join(self.save_path, "全部") + os.makedirs(all_dir, exist_ok=True) + + def create_ui(self): + """创建主界面""" + # 分类按钮区 + self.create_category_bar() + + # 主内容区 + main_frame = ctk.CTkFrame(self.window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左侧面板(保持不变) + left_panel = ctk.CTkFrame(main_frame, width=300) + left_panel.pack(side="left", fill="both", padx=5, pady=5) + + # 上部角色列表区(保持不变) + role_list_container = ctk.CTkFrame(left_panel) + role_list_container.pack(fill="both", expand=True, pady=(0, 5)) + + self.role_list_frame = ctk.CTkScrollableFrame(role_list_container) + self.role_list_frame.pack(fill="both", expand=True) + + # 下部内容预览区(保持不变) + preview_container = ctk.CTkFrame(left_panel) + preview_container.pack(fill="both", expand=True, pady=(5, 0)) + + self.preview_text = ctk.CTkTextbox(preview_container, wrap="word", + font=("Microsoft YaHei", 12)) + scrollbar = ctk.CTkScrollbar( + preview_container, command=self.preview_text.yview) + self.preview_text.configure(yscrollcommand=scrollbar.set) + + self.preview_text.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # 右侧面板(信息编辑区) + right_panel = ctk.CTkFrame(main_frame) + right_panel.pack(side="right", fill="both", expand=True, padx=5, pady=5) + + # 分类选择行 + category_frame = ctk.CTkFrame(right_panel) + category_frame.pack(fill="x", padx=5, pady=5) + + # 分类选择标签 + ctk.CTkLabel(category_frame, text="分类选择").pack(side="left", padx=(0, 5)) + + # 分类选择框 + self.category_combobox = ctk.CTkComboBox( + category_frame, + values=self._get_all_categories(), + width=200 + ) + self.category_combobox.pack(side="left", padx=0) + + # 分类保存按钮 + self.save_category_btn = ctk.CTkButton( + category_frame, + text="保存分类", + width=80, + command=self._move_to_category + ) + self.save_category_btn.pack(side="left", padx=(0, 5)) + + # 打开文件夹按钮 + ctk.CTkButton( + category_frame, + text="打开文件夹", + width=80, + command=lambda: os.startfile( + os.path.join(self.save_path, self.category_combobox.get())) + ).pack(side="left", padx=0) + + # 角色名编辑行 + name_frame = ctk.CTkFrame(right_panel) + name_frame.pack(fill="x", padx=5, pady=5) + + # 角色名称标签 + ctk.CTkLabel(name_frame, text="角色名称").pack(side="left", padx=(0, 5)) + + self.role_name_var = tk.StringVar() + self.role_name_entry = ctk.CTkEntry( + name_frame, + textvariable=self.role_name_var, + placeholder_text="角色名称", + width=200 + ) + self.role_name_entry.pack(side="left", padx=0) + + ctk.CTkButton( + name_frame, + text="修改", + width=60, + command=self._rename_role_file + ).pack(side="left", padx=(0, 5)) + + ctk.CTkButton( + name_frame, + text="新增", + width=60, + command=lambda: self._create_new_role("全部") + ).pack(side="left", padx=0) + + # 属性编辑区(基础框架) + self.attributes_frame = ctk.CTkScrollableFrame(right_panel) + self.attributes_frame.pack(fill="both", expand=True, padx=5, pady=5) + # 设置统一的列权重 + self.attributes_frame.grid_columnconfigure(1, weight=1) + + button_frame = ctk.CTkFrame(right_panel) + button_frame.pack(fill="x", padx=5, pady=5) + + ctk.CTkButton(button_frame, text="导入角色", + command=self.import_roles).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="删除", + command=self.delete_current_role).pack(side="left", padx=5) + ctk.CTkButton(button_frame, text="保存", + command=self.save_current_role).pack(side="left", padx=5) + + def _get_all_categories(self): + """获取所有有效分类(包括动态更新)""" + categories = ["全部"] + for d in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部": + categories.append(d) + return categories + + def _move_to_category(self): + """分类转移功能""" + if not hasattr(self, 'current_role') or not self.current_role: + messagebox.showwarning("警告", "请先选择一个角色", parent=self.window) + return + + new_category = self.category_combobox.get() + + # 如果当前在"全部"分类下,需要找到角色实际所在分类 + if self.selected_category == "全部": + # 遍历所有分类查找实际存储位置(包含全部目录) + actual_category = None + for category in os.listdir(self.save_path): + test_path = os.path.join( + self.save_path, category, f"{self.current_role}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + msg = messagebox.showerror("错误", f"找不到角色 {self.current_role} 的实际存储位置", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + old_path = os.path.join( + self.save_path, actual_category, f"{self.current_role}.txt") + else: + old_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + + # 如果目标分类是"全部",则实际移动到"全部"分类 + if new_category == "全部": + new_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + else: + new_path = os.path.join( + self.save_path, new_category, f"{self.current_role}.txt") + + # 检查是否已经在目标分类 + if os.path.exists(new_path): + msg = messagebox.showinfo("提示", "角色已在目标分类中", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + confirm = messagebox.askyesno( + "确认", f"确定要将角色 {self.current_role} 移动到 {new_category} 分类吗?", parent=self.window) + if not confirm: + return + + try: + # 确保目标目录存在 + os.makedirs(os.path.dirname(new_path), exist_ok=True) + + try: + # 执行移动操作 + shutil.move(old_path, new_path) + + # 更新显示 + self.selected_category = new_category if new_category != "全部" else "全部" + self.show_category(self.selected_category) + self.category_combobox.set(new_category) + + # 成功提示 + messagebox.showinfo("成功", "分类已更新", parent=self.window) + return # 成功时直接返回 + + except Exception as e: + # 失败时恢复原分类显示 + self.category_combobox.set(self.selected_category) + raise e + except Exception as e: + msg = messagebox.showerror("错误", f"分类转移失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + self.category_combobox.set(self.selected_category) + + def import_roles(self): + """导入角色窗口""" + import_window = ctk.CTkToplevel(self.window) + import_window.title("角色导入") + import_window.geometry("800x600") + import_window.transient(self.window) # 设置为子窗口 + import_window.grab_set() # 模态窗口 + import_window.lift() # 置于父窗口前面 + + # 窗口居中计算 + import_window.update_idletasks() + i_width = import_window.winfo_width() + i_height = import_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - i_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - i_height) // 2 + import_window.geometry(f"+{x}+{y}") + + # 主内容区 + main_frame = ctk.CTkFrame(import_window) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 左右面板容器 + content_frame = ctk.CTkFrame(main_frame) + content_frame.pack(fill="both", expand=True, pady=(0, 10)) + content_frame.grid_columnconfigure(0, weight=1) # 左侧面板权重 + content_frame.grid_columnconfigure(1, weight=1) # 右侧面板权重 + + # 左侧面板 - 使用权重让控件占满空间 + left_panel = ctk.CTkFrame(content_frame) + left_panel.grid(row=0, column=0, sticky="nsew", padx=(0, 5), pady=5) + left_panel.grid_rowconfigure(0, weight=1) + left_panel.grid_columnconfigure(0, weight=1) + left_panel.grid_propagate(False) # 防止子控件改变父容器大小 + + # 右侧面板(2份宽度) - 添加初始可编辑文本框 + right_panel = ctk.CTkFrame(content_frame) + right_panel.grid(row=0, column=1, sticky="nsew", padx=(5, 0), pady=5) + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + # 创建初始可编辑文本框 + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + text_box.configure(state="normal") # 保持可编辑状态 + + # 初始化角色列表 + self.import_roles_list = [] + + # 底部按钮区 + btn_frame = ctk.CTkFrame(main_frame) + btn_frame.pack(fill="x", pady=(0, 10)) + + # 导入按钮 + ctk.CTkButton( + btn_frame, + text="导入临时角色库", + width=120, + command=lambda: self.confirm_import(import_window) + ).pack(side="left", padx=10) + + # 分析文件按钮 + ctk.CTkButton( + btn_frame, + text="分析文件", + width=100, + command=lambda: self.analyze_character_state(right_panel, left_panel) + ).pack(side="left", padx=10) + + # 加载character_state.txt按钮 + ctk.CTkButton( + btn_frame, + text="加载character_state.txt", + width=160, + command=lambda: self.load_default_character_state(right_panel) + ).pack(side="right", padx=10) + + # 从文件导入按钮 + ctk.CTkButton( + btn_frame, + text="从文件导入", + width=100, + command=lambda: self.import_from_file(right_panel) + ).pack(side="right", padx=10) + + # 设置内容区权重 + content_frame.grid_rowconfigure(0, weight=1) + + def analyze_character_state(self, right_panel, left_panel): + """分析角色状态文件,使用LLM提取角色信息并保存到临时角色库""" + content = "" + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + content = widget.get("1.0", "end").strip() + break + + if not content: + messagebox.showwarning("警告", "未找到可分析的内容", parent=self.window) + return + + try: + # 创建临时角色库目录 + target_dir = os.path.join(self.save_path, "临时角色库") + # 清空现有临时角色库 + if os.path.exists(target_dir): + for filename in os.listdir(target_dir): + file_path = os.path.join(target_dir, filename) + try: + if os.path.isfile(file_path): + os.unlink(file_path) + except Exception as e: + print(f"删除文件{file_path}时出错: {e}") + os.makedirs(target_dir, exist_ok=True) + + # 调用LLM进行分析 + prompt = f"{Character_Import_Prompt}\n<<待分析小说文本开始>>\n{content}\n<<待分析小说文本结束>>" + response = invoke_with_cleaning( + self.llm_adapter, + prompt + ) + + # 解析LLM响应 + roles = self._parse_llm_response(response) + + if not roles: + messagebox.showwarning("警告", "未解析到有效角色信息", parent=self.window) + return + + # 直接显示分析结果而不保存到文件 + self._display_analyzed_roles(left_panel, roles) + + except Exception as e: + messagebox.showerror("分析失败", f"LLM分析出错:{str(e)}", parent=self.window) + + def _display_temp_roles(self, parent, temp_dir): + """显示临时角色库中的角色""" + # 清空左侧面板 + for widget in parent.winfo_children(): + widget.destroy() + + # 创建滚动容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True) + + # 读取所有临时角色文件 + self.character_checkboxes = {} + for file_name in os.listdir(temp_dir): + if file_name.endswith(".txt"): + role_name = os.path.splitext(file_name)[0] + file_path = os.path.join(temp_dir, file_name) + + # 解析角色属性 + attributes = self._parse_temp_role_file(file_path) + + # 创建带勾选框的条目 + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称 + lbl = ctk.CTkLabel(frame, text=role_name, + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in attributes.items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role_name] = { + 'var': var, + 'data': {'name': role_name, 'attributes': attributes} + } + + # 添加操作按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="取消选择", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _parse_temp_role_file(self, file_path): + """解析临时角色文件""" + attributes = {} + current_attr = None + try: + with open(file_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # 统一解析├──和└──两种前缀 + if any(prefix in line for prefix in ['├──', '└──']) and ':' in line: + prefix = '├──' if '├──' in line else '└──' + current_attr = line.split(prefix)[1].split(':')[0].strip() + attributes[current_attr] = [] + elif any(prefix in line for prefix in ['│ ├──', '│ └──']): + prefix = '│ ├──' if '│ ├──' in line else '│ └──' + if current_attr: + item = line.split(prefix)[1].strip() + attributes[current_attr].append(item) + except Exception as e: + messagebox.showerror("解析错误", f"解析临时文件失败:{str(e)}", parent=self.window) + return attributes + + def _parse_llm_response(self, response): + """解析LLM返回的角色数据""" + roles = [] + current_role = None + current_attr = None + current_subattr = None + + attribute_pattern = re.compile(r'^([├└]──)([\w\u4e00-\u9fa5]+)\s*[::]') + item_pattern = re.compile(r'^│\s+([├└]──)\s*(.*)') + + for line in response.split('\n'): + line = line.rstrip() + + # 检测角色名称行(兼容中英文冒号和前后空格) + role_match = re.match(r'^\s*([\u4e00-\u9fa5a-zA-Z0-9]+)\s*[::]\s*$', line) + if role_match: + current_role = role_match.group(1).strip() + roles.append({'name': current_role, 'attributes': {}}) + continue + + if not current_role: + continue + + # 解析属性(支持子属性) + attr_match = attribute_pattern.match(line) + if attr_match: + prefix, attr_name = attr_match.groups() + current_attr = attr_name.strip() + roles[-1]['attributes'][current_attr] = [] + current_subattr = None + continue + + # 解析属性条目(支持多级结构) + item_match = item_pattern.match(line) + if item_match and current_attr: + prefix, content = item_match.groups() + content = content.strip() + + # 解析子属性(例如"身体状态: xxx") + if ':' in content or ':' in content: + subattr_match = re.split(r'[::]', content, 1) + if len(subattr_match) > 1: + current_subattr = subattr_match[0].strip() + value = subattr_match[1].strip() + if value: # 值不为空时才添加 + roles[-1]['attributes'][current_attr].append( + f"{current_subattr}: {value}" + ) + continue + + # 普通条目处理 + if content: + if current_subattr: + # 子属性的延续条目 + roles[-1]['attributes'][current_attr][-1] += f",{content}" + else: + roles[-1]['attributes'][current_attr].append(content) + return roles + + def _display_analyzed_roles(self, parent, roles): + """显示分析后的角色列表""" + self.character_checkboxes = {} + + # 创建带滚动条的容器 + scroll_frame = ctk.CTkScrollableFrame(parent) + scroll_frame.pack(fill="both", expand=True, padx=5, pady=5) + scroll_frame.grid_rowconfigure(0, weight=1) + scroll_frame.grid_columnconfigure(0, weight=1) + + # 为每个角色创建带勾选框的条目 + for role in roles: + frame = ctk.CTkFrame(scroll_frame) + frame.pack(fill="x", pady=2, padx=5) + + # 勾选框 + var = BooleanVar(value=True) + cb = ctk.CTkCheckBox(frame, text="", variable=var, width=20) + cb.pack(side="left", padx=5) + + # 角色名称标签 + lbl = ctk.CTkLabel(frame, text=role['name'], + font=("Microsoft YaHei", 11, "bold")) + lbl.pack(side="left", padx=5) + + # 属性摘要 + attrs = [f"{k}({len(v)})" for k,v in role['attributes'].items()] + summary = ctk.CTkLabel(frame, text=" | ".join(attrs), + text_color="gray") + summary.pack(side="right", padx=10) + + self.character_checkboxes[role['name']] = { + 'var': var, + 'data': role + } + + # 添加全选/反选按钮 + btn_frame = ctk.CTkFrame(scroll_frame) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="全选", + command=lambda: self._toggle_all(True)).pack(side="left") + ctk.CTkButton(btn_frame, text="反选", + command=lambda: self._toggle_all(False)).pack(side="left") + + def _toggle_all(self, select): + """全选/反选操作""" + for role in self.character_checkboxes.values(): + current_state = role['var'].get() + # 如果是反选操作,则设置相反状态 + if isinstance(select, bool): + role['var'].set(select) + else: + role['var'].set(not current_state) + + + def import_from_file(self, right_panel): + """从文件导入内容到右侧窗口""" + filetypes = ( + ('文本文件', '*.txt'), + ('Word文档', '*.docx'), + ('所有文件', '*.*') + ) + + file_path = filedialog.askopenfilename( + title="选择要导入的文件", + initialdir=os.path.expanduser("~"), + filetypes=filetypes + ) + + if not file_path: + return + + try: + content = "" + if file_path.endswith('.docx'): + # 处理Word文档 + from docx import Document + doc = Document(file_path) + content = "\n".join([para.text for para in doc.paragraphs]) + else: + # 处理普通文本文件 + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 更新右侧文本框中 + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + widget.delete("1.0", "end") + widget.insert("1.0", content) + break + + except Exception as e: + messagebox.showerror("导入失败", f"无法读取文件:{str(e)}", parent=self.window) + + def load_default_character_state(self, right_panel): + """加载character_state.txt文件到右侧窗口""" + # 获取保存路径 + save_path = os.path.dirname(self.save_path) + file_path = os.path.join(save_path, "character_state.txt") + + if not os.path.exists(file_path): + messagebox.showwarning("警告", f"未找到文件: {file_path}", parent=self.window) + return + + try: + # 读取文件内容 + content = read_file(file_path) + + # 清空右侧面板中可能存在的旧控件 + for widget in right_panel.winfo_children(): + widget.destroy() + + # 查找或创建文本框 + text_box = None + for widget in right_panel.winfo_children(): + if isinstance(widget, ctk.CTkTextbox): + text_box = widget + break + + if not text_box: + text_box = ctk.CTkTextbox(right_panel, wrap="word") + text_box.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + text_box.configure(state="normal") + text_box.delete("1.0", "end") + text_box.insert("1.0", content) + + # 设置右边面板的布局权重 + right_panel.grid_rowconfigure(0, weight=1) + right_panel.grid_columnconfigure(0, weight=1) + + except Exception as e: + messagebox.showerror("错误", f"加载文件失败: {str(e)}", parent=self.window) + + def confirm_import(self, import_window): + """从临时角色库导入选中的角色""" + # 创建必要的目录 + target_dir = os.path.join(self.save_path, "临时角色库") + os.makedirs(target_dir, exist_ok=True) + + try: + # 获取选中的角色 + selected_roles = [role_data['data'] for role_data in self.character_checkboxes.values() + if role_data['var'].get()] + + if not selected_roles: + # 创建错误提示窗口 + error_window = ctk.CTkToplevel(import_window) + error_window.title("错误") + error_window.transient(import_window) + error_window.grab_set() + + # 窗口内容 + ctk.CTkLabel(error_window, text="请至少选择一个角色").pack(padx=20, pady=10) + ctk.CTkButton(error_window, text="确定", command=error_window.destroy).pack(pady=10) + + # 窗口居中 + error_window.update_idletasks() + e_width = error_window.winfo_width() + e_height = error_window.winfo_height() + x = import_window.winfo_x() + (import_window.winfo_width() - e_width) // 2 + y = import_window.winfo_y() + (import_window.winfo_height() - e_height) // 2 + error_window.geometry(f"+{x}+{y}") + error_window.attributes('-topmost', 1) + return + + # 从内存数据直接保存角色 + for role in selected_roles: + dest_path = os.path.join(target_dir, f"{role['name']}.txt") + + # 构建角色内容 + content_lines = [f"{role['name']}:"] + for attr, items in role['attributes'].items(): + content_lines.append(f"├──{attr}:") + for i, item in enumerate(items): + prefix = "├──" if i < len(items)-1 else "└──" + content_lines.append(f"│ {prefix}{item}") + + # 直接写入文件,覆盖已存在的文件 + with open(dest_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content_lines)) + + # 刷新分类显示 + self.load_categories() + import_window.destroy() + + except Exception as e: + # 静默处理错误 + import_window.destroy() + + + + def delete_current_role(self): + """删除当前角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + confirm = messagebox.askyesno( + "确认删除", f"确定要删除角色 {self.current_role} 吗?", parent=self.window) + if not confirm: + return + + role_path = os.path.join( + self.save_path, self.selected_category, f"{self.current_role}.txt") + try: + os.remove(role_path) + # 从"全部"分类也删除 + all_path = os.path.join( + self.save_path, "全部", f"{self.current_role}.txt") + if os.path.exists(all_path): + os.remove(all_path) + self.show_category(self.selected_category) + self.preview_text.delete("1.0", "end") + msg = messagebox.showinfo("成功", "角色已删除", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + except Exception as e: + msg = messagebox.showerror("错误", f"删除失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _build_role_content(self): + """构建角色文件内容""" + content = [f"{self.role_name_var.get()}:"] + attributes_order = ["物品", "能力", "状态", "主要角色间关系网", "触发或加深的事件"] + + for attr_name in attributes_order: + content.append(f"├──{attr_name}:") + # 找到对应的 attribute_block + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + # 遍历该 block 中的所有 CTkEntry + for child in block.winfo_children(): + if isinstance(child, ctk.CTkFrame): # 条目行 + for item in child.winfo_children(): + if isinstance(item, ctk.CTkEntry): + entry_text = item.get().strip() + if entry_text: # 只添加非空条目 + content.append(f"│ ├──{entry_text}") + break # 找到对应属性后跳出循环 + return content + + def _save_role_file(self, content, save_path): + """保存角色文件""" + with open(save_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(content)) + + def _check_role_name_conflict(self, new_name): + """检查角色名是否重复,遍历整个角色文件夹""" + conflicts = [] + # 遍历所有分类目录 + for category in os.listdir(self.save_path): + if os.path.isdir(os.path.join(self.save_path, category)): + # 检查该分类下是否有同名角色 + role_path = os.path.join( + self.save_path, category, f"{new_name}.txt") + if os.path.exists(role_path): + # 如果是"全部"分类,需要进一步检查是否是实际文件 + if category == "全部": + # 检查"全部"目录下的文件是否是实际文件 + all_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + if os.path.isfile(all_path): + # 如果是实际文件,则认为是冲突 + conflicts.append(category) + else: + # 普通分类直接记录冲突 + conflicts.append(category) + return conflicts + + def save_current_role(self): + """保存当前编辑的角色""" + if not hasattr(self, 'current_role') or not self.current_role: + return + + new_name = self.role_name_var.get().strip() + if not new_name: + msg = messagebox.showwarning("警告", "角色名称不能为空", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 检查角色名是否重复 + if new_name != self.current_role: + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + content = self._build_role_content() + save_path = os.path.join(self.save_path, self.selected_category, + f"{new_name}.txt") + + try: + self._save_role_file(content, save_path) + # 如果修改了角色名,更新文件名 + if new_name != self.current_role: + old_path = os.path.join(self.save_path, self.selected_category, + f"{self.current_role}.txt") + os.rename(old_path, save_path) + + # 更新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.show_role(new_name) # 刷新角色显示 + messagebox.showinfo("成功", "角色已保存", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"保存失败:{str(e)}", parent=self.window) + + def _rename_role_file(self): + """修改角色名称""" + old_name = self.current_role + new_name = self.role_name_var.get().strip() + + if not old_name or not new_name: + return + + # 处理中英文冒号 + for colon in [":", ":"]: + old_name = old_name.split(colon)[0] + new_name = new_name.split(colon)[0] + + # 如果角色名没有改变,直接返回 + if new_name == old_name: + return + + # 检查角色名是否重复 + conflicts = self._check_role_name_conflict(new_name) + if conflicts: + messagebox.showerror("错误", + f"角色名称 '{new_name}' 已存在于以下分类中:\n" + + "\n".join(conflicts) + + "\n请使用不同的角色名称", parent=self.window) + return + + try: + # 如果是"全部"分类,需要找到实际存储的分类 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + if os.path.exists(all_path): + # 如果"全部"目录下有文件,则直接操作 + actual_category = "全部" + else: + # 遍历所有分类查找实际存储位置 + actual_category = None + for category in os.listdir(self.save_path): + if category == "全部": + continue + test_path = os.path.join( + self.save_path, category, f"{old_name}.txt") + if os.path.exists(test_path): + actual_category = category + break + + if not actual_category: + raise FileNotFoundError( + f"找不到角色 {old_name} 的实际存储位置") + else: + actual_category = self.selected_category + + # 读取旧文件内容并更新角色名 + old_path = os.path.join( + self.save_path, actual_category, f"{old_name}.txt") + with open(old_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 获取第一行内容 + first_line = content.split('\n')[0].strip() + # 提取内容中的角色名 + content_role_name = first_line.split(':')[0].split(':')[0].strip() + # 如果内容中的角色名与旧文件名不同,更新内容 + if content_role_name != old_name: + content = content.replace( + f"{content_role_name}:", f"{new_name}:", 1) + else: + content = content.replace(f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + new_path = os.path.join( + self.save_path, actual_category, f"{new_name}.txt") + with open(new_path, 'w', encoding='utf-8') as f: + f.write(content) + + # 删除旧文件 + os.remove(old_path) + + # 处理"全部"目录 + all_old_path = os.path.join( + self.save_path, "全部", f"{old_name}.txt") + all_new_path = os.path.join( + self.save_path, "全部", f"{new_name}.txt") + + # 如果"全部"目录存在旧文件 + if os.path.exists(all_old_path): + try: + # 更新"全部"目录中的文件内容 + with open(all_old_path, 'r', encoding='utf-8') as f: + all_content = f.read() + updated_all_content = all_content.replace( + f"{old_name}:", f"{new_name}:", 1) + + # 写入新文件 + with open(all_new_path, 'w', encoding='utf-8') as f: + f.write(updated_all_content) + + # 删除旧文件 + os.remove(all_old_path) + except Exception as e: + messagebox.showerror("错误", f"更新全部目录失败: {str(e)}", parent=self.window) + # 回滚重命名操作 + os.rename(new_path, old_path) + return + + # 刷新显示 + self.current_role = new_name + self.show_category(self.selected_category) + self.role_name_var.set(new_name) + self.show_role(new_name) # 刷新角色显示区域 + + except Exception as e: + msg = messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + + def _create_new_role(self, category): + """在指定分类创建新角色""" + role_dir = os.path.join(self.save_path, category) + base_name = "未命名" + counter = 1 + + # 生成唯一文件名 + while os.path.exists(os.path.join(role_dir, f"{base_name}.txt")): + base_name = f"未命名{counter}" + counter += 1 + + # 创建基础文件结构(包含初始条目) + content = f"{base_name}:\n" + "\n".join([ + "├──物品:", + "│ └──待补充", + "├──能力:", + "│ └──待补充", + "├──状态:", + "│ └──待补充", + "├──主要角色间关系网:", + "│ └──待补充", + "├──触发或加深的事件:", + "│ └──待补充" + ]) + + with open(os.path.join(role_dir, f"{base_name}.txt"), "w", encoding="utf-8") as f: + f.write(content) + + # 刷新显示 + self.show_category(category) + self.role_name_var.set(base_name) + self.current_role = base_name + + def create_category_bar(self): + """创建分类按钮区""" + category_frame = ctk.CTkFrame(self.window) + category_frame.pack(fill="x", padx=10, pady=5) + + # 操作提示 + ctk.CTkLabel(category_frame, + text="右键分类名即可重命名", + font=("Microsoft YaHei", 10), + text_color="gray").pack(side="top", anchor="w", padx=5) + + # 固定按钮 + ctk.CTkButton(category_frame, text="全部", width=50, + command=lambda: self.show_category("全部")).pack(side="left", padx=2) + + # 滚动分类区 + self.scroll_frame = ctk.CTkScrollableFrame( + category_frame, orientation="horizontal", height=30) + self.scroll_frame.pack(side="left", fill="x", expand=True, padx=5) + + # 操作按钮 + ctk.CTkButton(category_frame, text="新增", width=50, + command=self.add_category).pack(side="right", padx=2) + ctk.CTkButton(category_frame, text="删除", width=50, + command=self.delete_category).pack(side="right", padx=2) + + self.load_categories() + + def center_window(self): + """窗口居中""" + self.window.update_idletasks() + parent_x = self.master.winfo_x() + parent_y = self.master.winfo_y() + parent_width = self.master.winfo_width() + parent_height = self.master.winfo_height() + win_width = 1200 + win_height = 800 + x = parent_x + (parent_width - win_width) // 2 + y = parent_y + (parent_height - win_height) // 2 + self.window.geometry(f"{win_width}x{win_height}+{x}+{y}") + + def load_categories(self): + """加载分类按钮""" + for widget in self.scroll_frame.winfo_children(): + widget.destroy() + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + + for category in categories: + btn = ctk.CTkButton(self.scroll_frame, text=category, width=80) + btn.bind("", lambda e, c=category: self.show_category(c)) + btn.bind("", lambda e, c=category: self.rename_category(c)) + btn.pack(side="left", padx=2) + + def _create_category_directory(self, category_name): + """创建分类目录""" + new_dir = os.path.join(self.save_path, category_name) + if not os.path.exists(new_dir): + os.makedirs(new_dir) + return new_dir + + def add_category(self): + """新增分类""" + self._create_category_directory("未命名") + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + + def delete_category(self): + """删除分类对话框""" + if not self.window.winfo_exists(): + return + + del_window = ctk.CTkToplevel(self.window) + del_window.title("删除分类") + del_window.transient(self.window) + del_window.grab_set() + del_window.attributes('-topmost', 1) + + # 居中计算 + parent_x = self.window.winfo_x() + parent_y = self.window.winfo_y() + parent_width = self.window.winfo_width() + parent_height = self.window.winfo_height() + del_window.geometry( + f"300x400+{parent_x + (parent_width-300)//2}+{parent_y + (parent_height-400)//2}") + + scroll_frame = ctk.CTkScrollableFrame(del_window) + scroll_frame.pack(fill="both", expand=True) + + categories = [d for d in os.listdir(self.save_path) + if os.path.isdir(os.path.join(self.save_path, d)) and d != "全部"] + self.selected_del = [] + + for cat in categories: + var = tk.BooleanVar() + chk = ctk.CTkCheckBox(scroll_frame, text=cat, variable=var) + chk.pack(anchor="w") + self.selected_del.append((cat, var)) + + # 操作按钮 + btn_frame = ctk.CTkFrame(del_window) + btn_frame.pack(fill="x", pady=5) + + ctk.CTkButton(btn_frame, text="删除选中", + command=lambda: self.confirm_delete(del_window)).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="取消", + command=del_window.destroy).pack(side="right", padx=5) + + self.category_combobox.configure(values=self._get_all_categories()) + self.category_combobox.set("全部") + + def confirm_delete(self, original_window): + """确认删除操作""" + selected = [item[0] for item in self.selected_del if item[1].get()] + if not selected: + msg = messagebox.showwarning("警告", "请至少选择一个分类", parent=self.window) + self.window.attributes('-topmost', 1) + self.window.after(200, lambda: self.window.attributes('-topmost', 0)) + return + + # 创建选择窗口时添加前置设置 + choice_window = ctk.CTkToplevel(self.window) + choice_window.transient(self.window) # 设置为子窗口 + choice_window.grab_set() # 模态窗口 + choice_window.lift() # 置顶 + choice_window.attributes('-topmost', 1) # 强制置顶 + + # 添加居中计算 + choice_window.update_idletasks() + c_width = choice_window.winfo_width() + c_height = choice_window.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - c_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - c_height) // 2 + choice_window.geometry(f"+{x}+{y}") + + ctk.CTkLabel(choice_window, text="请选择删除方式:").pack(pady=10) + btn_frame = ctk.CTkFrame(choice_window) + btn_frame.pack(pady=10) + + def perform_delete(mode): + all_dir = os.path.join(self.save_path, "全部") + for cat in selected: + cat_path = os.path.join(self.save_path, cat) + if mode == "move": + for role_file in os.listdir(cat_path): + if role_file.endswith(".txt"): + src = os.path.join(cat_path, role_file) + dst = os.path.join(all_dir, role_file) + try: + shutil.move(src, dst) + except: + os.remove(dst) + shutil.move(src, dst) + shutil.rmtree(cat_path) + self.load_categories() + # 刷新分类选择下拉框 + self.category_combobox.configure(values=self._get_all_categories()) + original_window.destroy() + choice_window.destroy() + + ctk.CTkButton(btn_frame, text="全部删除", + command=lambda: perform_delete("all")).pack(side="left", padx=5) + ctk.CTkButton(btn_frame, text="移动角色", + command=lambda: perform_delete("move")).pack(side="left", padx=5) + + def count_roles(self, categories): + """统计角色数量""" + count = 0 + for cat in categories: + cat_path = os.path.join(self.save_path, cat) + count += len([f for f in os.listdir(cat_path) if f.endswith(".txt")]) + return count + + def show_category(self, category): + """显示分类内容""" + self.selected_category = category + self.category_combobox.set(category) + for widget in self.role_list_frame.winfo_children(): + widget.destroy() + + # 如果是"全部"分类,显示所有角色 + if category == "全部": + # 获取所有分类目录 + categories = [d for d in os.listdir(self.save_path) + + if os.path.isdir(os.path.join(self.save_path, d))] + # 用于去重的角色集合 + unique_roles = set() + + for cat in categories: + role_dir = os.path.join(self.save_path, cat) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + # 去重 + if role_name not in unique_roles: + unique_roles.add(role_name) + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + continue + else: + # 普通分类显示 + role_dir = os.path.join(self.save_path, category) + try: + for role_file in os.listdir(role_dir): + if role_file.endswith(".txt"): + role_name = os.path.splitext(role_file)[0] + btn = ctk.CTkButton( + self.role_list_frame, + text=role_name, + command=lambda r=role_name: self.show_role(r) + ) + btn.pack(fill="x", pady=2) + except FileNotFoundError: + messagebox.showerror("错误", "分类目录不存在", parent=self.window) + + def show_role(self, role_name): + """显示角色详细信息(支持UTF-8/ANSI编码)""" + try: + # 清空现有属性控件 + self.preview_text.delete('1.0', tk.END) + for widget in self.attributes_frame.winfo_children(): + widget.destroy() + + # 更新角色名称显示 + self.current_role = role_name.split(":")[0].split(":")[0] + self.role_name_var.set(self.current_role) + + # 查找角色实际所在目录 + if self.selected_category == "全部": + # 首先检查"全部"目录下是否有该角色文件 + all_path = os.path.join( + self.save_path, "全部", f"{role_name}.txt") + if os.path.exists(all_path): + file_path = all_path + actual_category = "全部" + else: + # 如果"全部"目录下没有,则遍历其他分类查找 + file_path = None + for cat in os.listdir(self.save_path): + if cat == "全部": + continue + test_path = os.path.join( + self.save_path, cat, f"{role_name}.txt") + if os.path.exists(test_path): + file_path = test_path + actual_category = cat + # 保存实际分类 + self.actual_category = cat + break + if file_path is None: + raise FileNotFoundError(f"找不到角色文件:{role_name}") + + # 只更新分类选择框的显示值,不改变当前选中的分类 + self.category_combobox.set(actual_category) + else: + # 普通分类直接使用当前路径 + file_path = os.path.join( + self.save_path, self.selected_category, f"{role_name}.txt") + + content, _ = self._read_file_with_fallback_encoding(file_path) + + # 解析属性结构 + attributes = { + "物品": [], + "能力": [], + "状态": [], + "主要角色间关系网": [], + "触发或加深的事件": [] + } + current_attribute = None + for line in content[1:]: + # 改进属性名称识别 + if line.startswith(("├──", "├──")): + # 提取属性名称(兼容冒号和空格) + attr_part = line.split("──")[1].strip() + attr_name = re.split(r'[::]', attr_part, 1)[0].strip() + + # 匹配预设属性 + for preset_attr in attributes: + if attr_name == preset_attr: + current_attribute = preset_attr + indent_level = line.find( + "├") if "├" in line else line.find("├") + break + else: + current_attribute = None + + # 改进条目内容提取 + elif current_attribute and line.startswith(("│ ", " ")): + # 提取整个条目内容 + item_content = line.strip() + # 去掉前面的符号和空格 + item_content = re.sub(r'^[│├└─\s]*', '', item_content) + attributes[current_attribute].append(item_content) + + # 显示原始文件内容 + self.preview_text.insert(tk.END, '\n'.join(content)) + + # 重构属性编辑区 + for attr_name, items in attributes.items(): + self._create_attribute_section(attr_name, items) + + except FileNotFoundError as e: + messagebox.showerror("错误", f"文件不存在:{str(e)}", parent=self.window) + except Exception as e: + messagebox.showerror("错误", f"读取文件失败:{str(e)}", parent=self.window) + + def _create_attribute_section(self, attr_name, items): + """创建单个属性的编辑区域""" + + # 属性块 (attribute_block) + attribute_block = ctk.CTkFrame(self.attributes_frame) + attribute_block.pack(fill="x", pady=5) + attribute_block.attribute_name = attr_name # 存储属性名称 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + attribute_block.grid_columnconfigure(1, weight=1) # 设置第二列权重 + + # 属性名称标签 + label = ctk.CTkLabel(attribute_block, text=attr_name) + label.grid(row=0, column=0, sticky="w", padx=(5, 10), pady=2) + + # 第一个条目和“增加”按钮的容器 + first_item_frame = ctk.CTkFrame(attribute_block) + first_item_frame.grid(row=0, column=1, sticky="ew", padx=5, pady=2) + first_item_frame.grid_columnconfigure(0, weight=1) + + # 第一个条目输入框 + first_entry = ctk.CTkEntry(first_item_frame) + first_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + if items: + first_entry.insert(0, items[0]) # 填充第一个条目的内容 + + # “增加”按钮容器 + add_button_frame = ctk.CTkFrame(first_item_frame, fg_color="transparent") + add_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + + # “增加”按钮 + add_button = ctk.CTkButton( + add_button_frame, + text="+", + width=30, + command=lambda: self._add_item(attr_name) + ) + add_button.grid(row=0, column=0) + + # 创建剩余的条目(如果有) + for i, item_text in enumerate(items[1:]): + self._add_item(attr_name, item_text) # 传入初始文本 + + def _add_item(self, attr_name, initial_text=""): + """为指定属性添加一个新条目""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 计算新条目的行号 + row_number = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + row_number += 1 + + # 条目容器 + item_frame = ctk.CTkFrame(attribute_block) + item_frame.grid(row=row_number, column=1, sticky="ew", padx=5, pady=2) + item_frame.grid_columnconfigure(0, weight=1) + + # 条目输入框 + new_entry = ctk.CTkEntry(item_frame) + new_entry.grid(row=0, column=0, sticky="ew", padx=(0, 5), ipadx=5, ipady=3) + new_entry.insert(0, initial_text) # 设置初始文本 + + # 删除按钮容器 + del_button_frame = ctk.CTkFrame(item_frame, fg_color="transparent") + del_button_frame.grid(row=0, column=1, sticky="e", padx=(5, 0)) + # “删除”按钮 + del_button = ctk.CTkButton( + del_button_frame, + text="-", + width=30, + command=lambda f=item_frame: self._remove_item(f, attr_name) + ) + del_button.grid(row=0, column=0) + + + def _remove_item(self, item_frame, attr_name): + """移除指定的条目,并重新调整布局""" + + # 找到对应的 attribute_block + attribute_block = None + for block in self.attributes_frame.winfo_children(): + if isinstance(block, ctk.CTkFrame) and block.attribute_name == attr_name: + attribute_block = block + break + + if attribute_block is None: + return + + # 确认不是删除带"+"号的原始条目 + for child in item_frame.winfo_children(): + if isinstance(child, ctk.CTkFrame): + for btn in child.winfo_children(): + if isinstance(btn, ctk.CTkButton) and btn.cget("text") == "+": + msg = messagebox.showinfo("提示", "不能删除带'+'号的原始条目", parent=self.window) + self.window.attributes('-topmost', 1) + msg.attributes('-topmost', 1) + self.window.after(200, lambda: [self.window.attributes('-topmost', 0), msg.attributes('-topmost', 0)]) + return + + # 移除条目 + item_frame.destroy() + + # 重新调整剩余条目的行号 + current_row = 0 + for child in attribute_block.winfo_children(): + if isinstance(child, ctk.CTkFrame): + if current_row == 0: # 找到属性标签 + current_row += 1 + continue + ctk.CTkFrame.grid_configure(child, row=current_row) + current_row += 1 + + def _read_file_with_fallback_encoding(self, file_path): + """带编码回退的文件读取,支持UTF-8、GBK(ANSI)和BOM""" + encodings = ['utf-8-sig', 'utf-8', 'gbk', 'latin1'] # 增加更多编码支持 + + for encoding in encodings: + try: + with open(file_path, "r", encoding=encoding) as f: + content = f.read() + # 检查内容是否包含乱码 + if any(ord(char) > 127 and not char.isprintable() for char in content): + continue # 如果包含乱码,尝试下一个编码 + return content.splitlines(), encoding + except UnicodeDecodeError: + continue + except Exception as e: + raise + + # 如果所有编码尝试都失败,尝试二进制读取 + try: + with open(file_path, "rb") as f: + raw_data = f.read() + # 尝试UTF-8解码 + try: + return raw_data.decode('utf-8').splitlines(), 'utf-8' + except UnicodeDecodeError: + # 尝试GBK解码 + try: + return raw_data.decode('gbk').splitlines(), 'gbk' + except UnicodeDecodeError: + # 最后尝试latin1解码 + return raw_data.decode('latin1').splitlines(), 'latin1' + except Exception as e: + raise ValueError(f"无法识别的文件编码:{file_path}") + + def rename_category(self, old_name): + """分类重命名(带居中功能)""" + new_name = None # 初始化变量 + + # 创建对话框窗口 + dialog = ctk.CTkToplevel(self.window) + dialog.title("重命名分类") + dialog.transient(self.window) + dialog.grab_set() + + # 窗口内容 + content_frame = ctk.CTkFrame(dialog) + content_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # 顶部提示 + ctk.CTkLabel(content_frame, text=f"当前分类:{old_name}").pack(pady=(10, 5)) + + # 输入框 + input_frame = ctk.CTkFrame(content_frame) + input_frame.pack(fill="x", pady=5) + ctk.CTkLabel(input_frame, text="新名称:").pack(side="left", padx=5) + name_var = tk.StringVar() + name_entry = ctk.CTkEntry(input_frame, textvariable=name_var, width=150) + name_entry.pack(side="left", padx=5) + + # 按钮区 + button_frame = ctk.CTkFrame(content_frame) + button_frame.pack(fill="x", pady=(10, 5)) + + def confirm_rename(): + nonlocal new_name # 引用外部变量 + new_name = name_var.get().strip() + if not new_name: + messagebox.showwarning("警告", "分类名称不能为空", parent=self.window) + return + if new_name == old_name: + dialog.destroy() + return + if os.path.exists(os.path.join(self.save_path, new_name)): + messagebox.showerror("错误", "分类名称已存在", parent=self.window) + return + + try: + os.rename(os.path.join(self.save_path, old_name), + os.path.join(self.save_path, new_name)) + self.load_categories() + # 更新分类选择框 + self.category_combobox.configure( + values=self._get_all_categories()) + self.category_combobox.set(new_name) + dialog.destroy() + except Exception as e: + messagebox.showerror("错误", f"重命名失败:{str(e)}", parent=self.window) + + ctk.CTkButton(button_frame, text="确认", + command=confirm_rename).pack(side="left", padx=10) + ctk.CTkButton(button_frame, text="取消", + command=dialog.destroy).pack(side="right", padx=10) + + # 窗口居中 + dialog.update_idletasks() + d_width = dialog.winfo_width() + d_height = dialog.winfo_height() + x = self.window.winfo_x() + (self.window.winfo_width() - d_width) // 2 + y = self.window.winfo_y() + (self.window.winfo_height() - d_height) // 2 + dialog.geometry(f"+{x}+{y}") + dialog.attributes('-topmost', 1) + + def on_close(self): + """关闭窗口""" + self.window.destroy() + + diff --git a/ui/setting_tab.py b/ui/setting_tab.py new file mode 100644 index 0000000..a5b6548 --- /dev/null +++ b/ui/setting_tab.py @@ -0,0 +1,56 @@ +# ui/setting_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_setting_tab(self): + self.setting_tab = self.tabview.add("Novel Architecture") + self.setting_tab.rowconfigure(0, weight=0) + self.setting_tab.rowconfigure(1, weight=1) + self.setting_tab.columnconfigure(0, weight=1) + + load_btn = ctk.CTkButton(self.setting_tab, 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") + + self.setting_word_count_label = ctk.CTkLabel(self.setting_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.setting_word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(self.setting_tab, text="保存修改", command=self.save_novel_architecture, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") + + self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12)) + TextWidgetContextMenu(self.setting_text) + self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) + + def update_word_count(event=None): + text = self.setting_text.get("0.0", "end") + count = len(text) - 1 + self.setting_word_count_label.configure(text=f"字数:{count}") + + self.setting_text.bind("", update_word_count) + self.setting_text.bind("", update_word_count) + +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 的修改。") diff --git a/ui/summary_tab.py b/ui/summary_tab.py new file mode 100644 index 0000000..9a66d7c --- /dev/null +++ b/ui/summary_tab.py @@ -0,0 +1,57 @@ +# ui/summary_tab.py +# -*- coding: utf-8 -*- +import os +import customtkinter as ctk +from tkinter import messagebox +from utils import read_file, save_string_to_txt, clear_file_content +from ui.context_menu import TextWidgetContextMenu + +def build_summary_tab(self): + self.summary_tab = self.tabview.add("Global Summary") + self.summary_tab.rowconfigure(0, weight=0) + self.summary_tab.rowconfigure(1, weight=1) + self.summary_tab.columnconfigure(0, weight=1) + self.summary_tab.columnconfigure(1, weight=0) + self.summary_tab.columnconfigure(2, weight=0) + + load_btn = ctk.CTkButton(self.summary_tab, text="加载 global_summary.txt", command=self.load_global_summary, font=("Microsoft YaHei", 12)) + load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + self.word_count_label = ctk.CTkLabel(self.summary_tab, text="字数:0", font=("Microsoft YaHei", 12)) + self.word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w") + + save_btn = ctk.CTkButton(self.summary_tab, text="保存修改", command=self.save_global_summary, font=("Microsoft YaHei", 12)) + save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") + + self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12)) + TextWidgetContextMenu(self.summary_text) + self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3) + + def update_word_count(event=None): + text = self.summary_text.get("0.0", "end") + count = len(text) - 1 + self.word_count_label.configure(text=f"字数:{count}") + + self.summary_text.bind("", update_word_count) + self.summary_text.bind("", update_word_count) +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 的修改。") diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..96b2e0b --- /dev/null +++ b/utils.py @@ -0,0 +1,53 @@ +# utils.py +# -*- coding: utf-8 -*- +import os +import json + +def read_file(filename: str) -> str: + """读取文件的全部内容,若文件不存在或异常则返回空字符串。""" + try: + with open(filename, 'r', encoding='utf-8') as file: + content = file.read() + return content + except FileNotFoundError: + return "" + except Exception as e: + print(f"[read_file] 读取文件时发生错误: {e}") + return "" + +def append_text_to_file(text_to_append: str, file_path: str): + """在文件末尾追加文本(带换行)。若文本非空且无换行,则自动加换行。""" + if text_to_append and not text_to_append.startswith('\n'): + text_to_append = '\n' + text_to_append + + try: + with open(file_path, 'a', encoding='utf-8') as file: + file.write(text_to_append) + except IOError as e: + print(f"[append_text_to_file] 发生错误:{e}") + +def clear_file_content(filename: str): + """清空指定文件内容。""" + try: + with open(filename, 'w', encoding='utf-8') as file: + pass + except IOError as e: + print(f"[clear_file_content] 无法清空文件 '{filename}' 的内容:{e}") + +def save_string_to_txt(content: str, filename: str): + """将字符串保存为 txt 文件(覆盖写)。""" + try: + with open(filename, 'w', encoding='utf-8') as file: + file.write(content) + except Exception as e: + print(f"[save_string_to_txt] 保存文件时发生错误: {e}") + +def save_data_to_json(data: dict, file_path: str) -> bool: + """将数据保存到 JSON 文件。""" + try: + with open(file_path, 'w', encoding='utf-8') as json_file: + json.dump(data, json_file, ensure_ascii=False, indent=4) + return True + except Exception as e: + print(f"[save_data_to_json] 保存数据到JSON文件时出错: {e}") + return False