Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
@@ -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,我们保留关闭或忽略的权利。
|
||||||
|
|
||||||
|
如果你对上述要求有任何疑问,欢迎在意见区进行讨论。再次感谢你的贡献!
|
||||||
|
|
||||||
|
---
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/Novel_Src
|
||||||
|
/.venv
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
/.vscode
|
||||||
|
/__pycache__
|
||||||
|
/markdown
|
||||||
|
/vectorstore
|
||||||
|
/example
|
||||||
|
config.json
|
||||||
|
config_test.json
|
||||||
|
/novel_generator/__pycache__
|
||||||
|
/ui/__pycache__
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# 📖 自动小说生成工具
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
✨ **核心功能** ✨
|
||||||
|
|
||||||
|
| 功能模块 | 关键能力 |
|
||||||
|
|-------------------|----------------------------------|
|
||||||
|
| 🎨 小说设定工坊 | 世界观架构 / 角色设定 / 剧情蓝图 |
|
||||||
|
| 📖 智能章节生成 | 多阶段生成保障剧情连贯性 |
|
||||||
|
| 🧠 状态追踪系统 | 角色发展轨迹 / 伏笔管理系统 |
|
||||||
|
| 🔍 语义检索引擎 | 基于向量的长程上下文一致性维护 |
|
||||||
|
| 📚 知识库集成 | 支持本地文档参考 |
|
||||||
|
| ✅ 自动审校机制 | 检测剧情矛盾与逻辑冲突 |
|
||||||
|
| 🖥 可视化工作台 | 全流程GUI操作,配置/生成/审校一体化 |
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事
|
||||||
|
|
||||||
|
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** 中提出。
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
# llm_adapters.py
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from langchain_openai import ChatOpenAI, AzureChatOpenAI
|
||||||
|
import google.generativeai as genai
|
||||||
|
from azure.ai.inference import ChatCompletionsClient
|
||||||
|
from azure.core.credentials import AzureKeyCredential
|
||||||
|
from azure.ai.inference.models import SystemMessage, UserMessage
|
||||||
|
from openai import OpenAI
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def check_base_url(url: str) -> 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://<endpoint>.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}")
|
||||||
@@ -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()
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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).")
|
||||||
@@ -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).")
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
"""移除 <think>...</think> 包裹的内容"""
|
||||||
|
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||||
|
|
||||||
|
def debug_log(prompt: str, response_content: str):
|
||||||
|
logging.info(
|
||||||
|
f"\n[######################################### Prompt #########################################]\n{prompt}\n"
|
||||||
|
)
|
||||||
|
logging.info(
|
||||||
|
f"\n[######################################### Response #########################################]\n{response_content}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def invoke_with_cleaning(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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
<<待分析小说文本结束>>
|
||||||
|
"""
|
||||||
Binary file not shown.
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# tooltips.py
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
tooltips = {
|
||||||
|
"api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。",
|
||||||
|
"base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。调用Gemini模型则无需填写。",
|
||||||
|
"interface_format": "指定LLM接口兼容格式,可选DeepSeek、OpenAI、Ollama、ML Studio、Gemini等。\n\n注意:"+
|
||||||
|
"OpenAI 兼容是指的可以通过该标准请求的任何接口,不是只允许使用api.openai.com接口\n"+
|
||||||
|
"例如Ollama接口格式也兼容OpenAI,可以无需修改直接使用\n"+
|
||||||
|
"ML Studio接口格式与OpenAI接口格式也一致。",
|
||||||
|
"model_name": "要使用的模型名称,例如deepseek-reasoner、gpt-4o等。如果是Ollama等,请填写你下载好的本地模型名。",
|
||||||
|
"temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。",
|
||||||
|
"max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。\n"+
|
||||||
|
"以下是一些常见模型的最大值:\n"+
|
||||||
|
"o1:100,000\n"+
|
||||||
|
"o1-mini:65,536\n"+
|
||||||
|
"gpt-4o:16384\n"+
|
||||||
|
"gpt-4o-mini:16384\n"+
|
||||||
|
"deepseek-reasoner:8192\n"+
|
||||||
|
"deepseek-chat:4096\n",
|
||||||
|
"embedding_api_key": "调用Embedding模型时所需的API Key。",
|
||||||
|
"embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。",
|
||||||
|
"embedding_url": "Embedding模型接口地址。",
|
||||||
|
"embedding_model_name": "Embedding模型名称,如text-embedding-ada-002。",
|
||||||
|
"embedding_retrieval_k": "向量检索时返回的Top-K结果数量。",
|
||||||
|
"topic": "小说的大致主题或主要故事背景描述。",
|
||||||
|
"genre": "小说的题材类型,如玄幻、都市、科幻等。",
|
||||||
|
"num_chapters": "小说期望的章节总数。",
|
||||||
|
"word_number": "每章的目标字数。",
|
||||||
|
"filepath": "生成文件存储的根目录路径。所有txt文件、向量库等放在该目录下。",
|
||||||
|
"chapter_num": "当前正在处理的章节号,用于生成草稿或定稿操作。",
|
||||||
|
"user_guidance": "为本章提供的一些额外指令或写作引导。",
|
||||||
|
"characters_involved": "本章需要重点描写或影响剧情的角色名单。",
|
||||||
|
"key_items": "在本章中出现的重要道具、线索或物品。",
|
||||||
|
"scene_location": "本章主要发生的地点或场景描述。",
|
||||||
|
"time_constraint": "本章剧情中涉及的时间压力或时限设置。"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# ui/__init__.py
|
||||||
|
from .main_window import NovelGeneratorGUI
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# ui/chapters_tab.py
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
import customtkinter as ctk
|
||||||
|
from tkinter import messagebox
|
||||||
|
from ui.context_menu import TextWidgetContextMenu
|
||||||
|
from utils import read_file, save_string_to_txt, clear_file_content
|
||||||
|
|
||||||
|
def build_chapters_tab(self):
|
||||||
|
self.chapters_view_tab = self.tabview.add("Chapters Manage")
|
||||||
|
self.chapters_view_tab.rowconfigure(0, weight=0)
|
||||||
|
self.chapters_view_tab.rowconfigure(1, weight=1)
|
||||||
|
self.chapters_view_tab.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
top_frame = ctk.CTkFrame(self.chapters_view_tab)
|
||||||
|
top_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
||||||
|
top_frame.columnconfigure(0, weight=0)
|
||||||
|
top_frame.columnconfigure(1, weight=0)
|
||||||
|
top_frame.columnconfigure(2, weight=0)
|
||||||
|
top_frame.columnconfigure(3, weight=0)
|
||||||
|
top_frame.columnconfigure(4, weight=1)
|
||||||
|
|
||||||
|
prev_btn = ctk.CTkButton(top_frame, text="<< 上一章", command=self.prev_chapter, font=("Microsoft YaHei", 12))
|
||||||
|
prev_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
next_btn = ctk.CTkButton(top_frame, text="下一章 >>", command=self.next_chapter, font=("Microsoft YaHei", 12))
|
||||||
|
next_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
self.chapter_select_var = ctk.StringVar(value="")
|
||||||
|
self.chapter_select_menu = ctk.CTkOptionMenu(top_frame, values=[], variable=self.chapter_select_var, command=self.on_chapter_selected, font=("Microsoft YaHei", 12))
|
||||||
|
self.chapter_select_menu.grid(row=0, column=2, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
save_btn = ctk.CTkButton(top_frame, text="保存修改", command=self.save_current_chapter, font=("Microsoft YaHei", 12))
|
||||||
|
save_btn.grid(row=0, column=3, padx=5, pady=5, sticky="w")
|
||||||
|
|
||||||
|
refresh_btn = ctk.CTkButton(top_frame, text="刷新章节列表", command=self.refresh_chapters_list, font=("Microsoft YaHei", 12))
|
||||||
|
refresh_btn.grid(row=0, column=5, padx=5, pady=5, sticky="e")
|
||||||
|
|
||||||
|
self.chapters_word_count_label = ctk.CTkLabel(top_frame, text="字数:0", font=("Microsoft YaHei", 12))
|
||||||
|
self.chapters_word_count_label.grid(row=0, column=4, padx=(0,10), sticky="e")
|
||||||
|
|
||||||
|
self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12))
|
||||||
|
|
||||||
|
def update_word_count(event=None):
|
||||||
|
text = self.chapter_view_text.get("0.0", "end-1c")
|
||||||
|
text_length = len(text)
|
||||||
|
self.chapters_word_count_label.configure(text=f"字数:{text_length}")
|
||||||
|
|
||||||
|
self.chapter_view_text.bind("<KeyRelease>", update_word_count)
|
||||||
|
self.chapter_view_text.bind("<ButtonRelease>", 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("提示", "已经是最后一章了。")
|
||||||
@@ -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("<KeyRelease>", update_word_count)
|
||||||
|
self.character_text.bind("<ButtonRelease>", 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 的修改。")
|
||||||
@@ -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://<your-endpoint>.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("错误", "保存配置失败。")
|
||||||
@@ -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("<Button-3>", 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")
|
||||||
@@ -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("<KeyRelease>", update_word_count)
|
||||||
|
self.directory_text.bind("<ButtonRelease>", 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 的修改。")
|
||||||
@@ -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("<KeyRelease>", update_word_count)
|
||||||
|
text_box.bind("<ButtonRelease>", 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")
|
||||||
@@ -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()}")
|
||||||
+113
@@ -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("<KeyRelease>", update_word_count)
|
||||||
|
self.chapter_result.bind("<ButtonRelease>", 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 中构建
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+1528
File diff suppressed because it is too large
Load Diff
@@ -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("<KeyRelease>", update_word_count)
|
||||||
|
self.setting_text.bind("<ButtonRelease>", 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 的修改。")
|
||||||
@@ -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("<KeyRelease>", update_word_count)
|
||||||
|
self.summary_text.bind("<ButtonRelease>", 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 的修改。")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user