Initial commit

This commit is contained in:
CNlaojing
2025-03-19 13:02:51 +08:00
commit 6752a5cd9f
41 changed files with 7792 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+65
View File
@@ -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,我们保留关闭或忽略的权利。
如果你对上述要求有任何疑问,欢迎在意见区进行讨论。再次感谢你的贡献!
---
+60
View File
@@ -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
+48
View File
@@ -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
View File
@@ -0,0 +1,13 @@
/Novel_Src
/.venv
/build
/dist
/.vscode
/__pycache__
/markdown
/vectorstore
/example
config.json
config_test.json
/novel_generator/__pycache__
/ui/__pycache__
+661
View File
@@ -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/>.
+237
View File
@@ -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** 中提出。
+132
View File
@@ -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": ""
}
+80
View File
@@ -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()
+72
View File
@@ -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
+272
View File
@@ -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}")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+376
View File
@@ -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}")
+12
View File
@@ -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()
+72
View File
@@ -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'
)
+13
View File
@@ -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
+201
View File
@@ -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).")
+173
View File
@@ -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).")
+585
View File
@@ -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
+77
View File
@@ -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
+119
View File
@@ -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
+87
View File
@@ -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()
+244
View File
@@ -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
+662
View File
@@ -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}
<<待分析小说文本结束>>
"""
BIN
View File
Binary file not shown.
+37
View File
@@ -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"+
"o1100,000\n"+
"o1-mini65,536\n"+
"gpt-4o16384\n"+
"gpt-4o-mini16384\n"+
"deepseek-reasoner8192\n"+
"deepseek-chat4096\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": "本章剧情中涉及的时间压力或时限设置。"
}
+2
View File
@@ -0,0 +1,2 @@
# ui/__init__.py
from .main_window import NovelGeneratorGUI
+140
View File
@@ -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("提示", "已经是最后一章了。")
+56
View File
@@ -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 的修改。")
+319
View File
@@ -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("错误", "保存配置失败。")
+54
View File
@@ -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")
+56
View File
@@ -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 的修改。")
+538
View File
@@ -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")
+7
View File
@@ -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
View File
@@ -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 中构建
+367
View File
@@ -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
+146
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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 的修改。")
+57
View File
@@ -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 的修改。")
+53
View File
@@ -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