Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,65 @@
|
||||
# Contributing to This Project
|
||||
|
||||
首先,感谢你愿意为本项目贡献力量!在提交任何形式的反馈或 Pull Request 之前,请先阅读以下内容。
|
||||
|
||||
---
|
||||
|
||||
## 一、反馈类型说明
|
||||
|
||||
1. **代码问题(Code Issue)**
|
||||
- 仅限与项目代码本身相关的问题:如编译失败、运行报错、逻辑缺陷等。
|
||||
- 反馈之前,请确认该问题与你的环境或配置无关。
|
||||
- 如果确认是代码本身导致的错误,请使用 [代码问题反馈模板](?template=code_issue.yml)。
|
||||
|
||||
2. **意见或建议(Opinion / Enhancement)**
|
||||
- 如果你有关于功能新增、代码重构、性能优化或其他方面的意见或建议,请使用 [意见/建议模板](?template=opinion.yml)。
|
||||
- 我们会积极审阅并讨论可行性,但可能不会立刻实现,视项目计划而定。
|
||||
|
||||
3. **接口/配置/部署等问题**
|
||||
- 本项目不针对接口配置、环境部署或第三方服务的参数设置等问题提供支持。
|
||||
- 遇到此类问题,请阅读官方文档、社区讨论区或自行搜索相关信息。
|
||||
|
||||
---
|
||||
|
||||
## 二、在提交 Issues 之前
|
||||
|
||||
1. **搜索现有的 Issues**
|
||||
- 避免重复提交相同问题。
|
||||
- 如果发现类似问题可以补充你的信息或在对应 Issue 下评论。
|
||||
|
||||
2. **提供尽可能详细的信息**
|
||||
- 提交问题时,尽量提供可复现的步骤、日志信息、环境说明等。
|
||||
- 提交意见或建议时,需要清楚说明理由和期望。
|
||||
|
||||
3. **保持尊重与礼貌**
|
||||
- 请尊重项目维护者和其他贡献者。
|
||||
- 交流中请使用恰当、礼貌的语言。
|
||||
|
||||
---
|
||||
|
||||
## 三、Pull Request 提交指南
|
||||
|
||||
1. **先 Fork 再修改**
|
||||
- 在你自己的 Fork 中进行修改和测试。
|
||||
- 确保修改内容不会引入新的 Bug。
|
||||
|
||||
2. **遵守代码风格**
|
||||
- 保持原有代码风格,遵循项目的 Lint 规则(如有)。
|
||||
- 减少不必要的格式改动,保证可读性。
|
||||
|
||||
3. **更新文档或注释**
|
||||
- 如果你的修改影响到了文档或注释,请及时补充或更新。
|
||||
|
||||
4. **描述清楚修改内容**
|
||||
- Pull Request 标题与描述中需包含本次修改的目的、解决的问题以及修改的主要内容。
|
||||
|
||||
---
|
||||
|
||||
## 四、其他说明
|
||||
|
||||
- 我们对所有 Issue 和 Pull Request 均会尽量及时处理,但无法保证立即回复。
|
||||
- 对于不符合上述规则的 Issue 或 Pull Request,我们保留关闭或忽略的权利。
|
||||
|
||||
如果你对上述要求有任何疑问,欢迎在意见区进行讨论。再次感谢你的贡献!
|
||||
|
||||
---
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "代码问题反馈"
|
||||
description: "此模板仅用于反馈代码相关问题,例如出现编译错误、运行报错、逻辑缺陷。"
|
||||
title: "[Code Issue]: "
|
||||
labels: ["bug", "code issue"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**⚠ 注意:此处仅受理代码本身的问题,包括但不限于编译错误、运行报错、逻辑异常等。**
|
||||
**如果是接口配置或环境部署等问题,请自行阅读文档或在讨论区寻求帮助。**
|
||||
**如果是意见或建议,请使用 [意见模板](?template=opinion.yml)。**
|
||||
感谢你的配合!
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "问题描述"
|
||||
description: "请清晰、简要地描述代码出现的问题。"
|
||||
placeholder: "例如:运行时报错xxx,或逻辑存在xxx。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: "复现步骤"
|
||||
description: "请提供完整的复现步骤,以便我们定位和解决问题。"
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: "环境信息"
|
||||
description: "如编译器、操作系统、依赖版本等。"
|
||||
placeholder: "示例:Windows 10, Node.js v14, Python 3.9, etc."
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: "日志信息(如适用)"
|
||||
description: "如果有报错日志或截图,可以贴在此处。"
|
||||
placeholder: "请粘贴日志内容或相关截图链接(可选)"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: "补充信息"
|
||||
description: "如果有更多信息,可在此补充。"
|
||||
placeholder: "任何与问题相关的额外背景说明..."
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,48 @@
|
||||
name: "意见或建议"
|
||||
description: "如果你有对项目的需求、功能建议、或其他意见,请使用此模板。"
|
||||
title: "[Opinion]: "
|
||||
labels: ["enhancement", "discussion"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**⚠ 注意:此处不用于反馈代码报错或编译问题,如果是纯代码报错或逻辑问题,请使用 [代码问题反馈模板](?template=code_issue.yml)。**
|
||||
感谢你的宝贵意见或建议,我们会酌情采纳!
|
||||
|
||||
- type: textarea
|
||||
id: suggestion
|
||||
attributes:
|
||||
label: "意见/建议内容"
|
||||
description: "请简要描述你的想法或建议。"
|
||||
placeholder: "例如:希望新增xx功能,或者修改xx逻辑。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reason
|
||||
attributes:
|
||||
label: "为什么需要这个功能或修改?"
|
||||
description: "简单说明你提出此意见/建议的原因或背景需求。"
|
||||
placeholder: "例如:在实际项目中遇到xx需求场景;希望提升xx效率;等等。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: relevance
|
||||
attributes:
|
||||
label: "相关链接或参考"
|
||||
description: "如果你有看到类似实现或参考资料,可在此提供链接。"
|
||||
placeholder: "例如:相关文档链接、RFC、规范文档等"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: "补充信息"
|
||||
description: "如果有更多信息,可在此补充。"
|
||||
placeholder: "任何与意见或建议相关的额外说明..."
|
||||
validations:
|
||||
required: false
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/Novel_Src
|
||||
/.venv
|
||||
/build
|
||||
/dist
|
||||
/.vscode
|
||||
/__pycache__
|
||||
/markdown
|
||||
/vectorstore
|
||||
/example
|
||||
config.json
|
||||
config_test.json
|
||||
/novel_generator/__pycache__
|
||||
/ui/__pycache__
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,237 @@
|
||||
# 📖 自动小说生成工具
|
||||
|
||||
<div align="center">
|
||||
|
||||
✨ **核心功能** ✨
|
||||
|
||||
| 功能模块 | 关键能力 |
|
||||
|-------------------|----------------------------------|
|
||||
| 🎨 小说设定工坊 | 世界观架构 / 角色设定 / 剧情蓝图 |
|
||||
| 📖 智能章节生成 | 多阶段生成保障剧情连贯性 |
|
||||
| 🧠 状态追踪系统 | 角色发展轨迹 / 伏笔管理系统 |
|
||||
| 🔍 语义检索引擎 | 基于向量的长程上下文一致性维护 |
|
||||
| 📚 知识库集成 | 支持本地文档参考 |
|
||||
| ✅ 自动审校机制 | 检测剧情矛盾与逻辑冲突 |
|
||||
| 🖥 可视化工作台 | 全流程GUI操作,配置/生成/审校一体化 |
|
||||
|
||||
</div>
|
||||
|
||||
> 一款基于大语言模型的多功能小说生成器,助您高效创作逻辑严谨、设定统一的长篇故事
|
||||
|
||||
2025-03-05 添加角色库功能
|
||||
|
||||
2025-03-09 添加字数显示
|
||||
|
||||
2025-03-13
|
||||
1、新增闲云修改;
|
||||
2、把本章指导改成内容指导;
|
||||
3、在生成架构中的: 2. 角色动力学设定(角色弧光模型)、 3. 世界构建矩阵(三维度交织法)、 4. 情节架构(三幕式悬念)与生成目录的:5. 章节目录生成(悬念节奏曲线)加入引导词内容指导,以方便生成角色动力学时只以核心种子生成,导致生成的内容与实际需求不符。
|
||||
4、在终端加回被删除的LLM提示词与LLM返回内容显示,以便复盘,参考修改提示词。
|
||||
---
|
||||
|
||||
## 📑 目录导航
|
||||
1. [环境准备](#-环境准备)
|
||||
2. [项目架构](#-项目架构)
|
||||
3. [配置指南](#⚙️-配置指南)
|
||||
4. [运行说明](#🚀-运行说明)
|
||||
5. [使用教程](#📘-使用教程)
|
||||
6. [疑难解答](#❓-疑难解答)
|
||||
|
||||
---
|
||||
|
||||
## 🛠 环境准备
|
||||
确保满足以下运行条件:
|
||||
- **Python 3.9+** 运行环境(推荐3.10-3.12之间)
|
||||
- **pip** 包管理工具
|
||||
- 有效API密钥:
|
||||
- 云端服务:OpenAI / DeepSeek 等
|
||||
- 本地服务:Ollama 等兼容 OpenAI 的接口
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 📥 安装说明
|
||||
1. **下载项目**
|
||||
- 通过 [GitHub](https://github.com) 下载项目 ZIP 文件,或使用以下命令克隆本项目:
|
||||
```bash
|
||||
git clone https://github.com/YILING0013/AI_NovelGenerator
|
||||
```
|
||||
|
||||
2. **安装编译工具(可选)**
|
||||
- 如果对某些包无法正常安装,访问 [Visual Studio Build Tools](https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/) 下载并安装C++编译工具,用于构建部分模块包;
|
||||
- 安装时,默认只包含 MSBuild 工具,需手动勾选左上角列表栏中的 **C++ 桌面开发** 选项。
|
||||
|
||||
3. **安装依赖并运行**
|
||||
- 打开终端,进入项目源文件目录:
|
||||
```bash
|
||||
cd AI_NovelGenerator
|
||||
```
|
||||
- 安装项目依赖:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
- 安装完成后,运行主程序:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
>如果缺失部分依赖,后续**手动执行**
|
||||
>```bash
|
||||
>pip install XXX
|
||||
>```
|
||||
>进行安装即可
|
||||
|
||||
## 🗂 项目架构
|
||||
```
|
||||
novel-generator/
|
||||
├── main.py # 入口文件, 运行 GUI
|
||||
├── ui.py # 图形界面
|
||||
├── novel_generator.py # 章节生成核心逻辑
|
||||
├── consistency_checker.py # 一致性检查, 防止剧情冲突
|
||||
|—— chapter_directory_parser.py # 目录解析
|
||||
|—— embedding_adapters.py # Embedding 接口封装
|
||||
|—— llm_adapters.py # LLM 接口封装
|
||||
├── prompt_definitions.py # 定义 AI 提示词
|
||||
├── utils.py # 常用工具函数, 文件操作
|
||||
├── config_manager.py # 管理配置 (API Key, Base URL)
|
||||
├── config.json # 用户配置文件 (可选)
|
||||
└── vectorstore/ # (可选) 本地向量数据库存储
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置指南
|
||||
### 📌 基础配置(config.json)
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"interface_format": "OpenAI",
|
||||
"model_name": "gpt-4o-mini",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
"embedding_api_key": "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"embedding_interface_format": "OpenAI",
|
||||
"embedding_url": "https://api.openai.com/v1",
|
||||
"embedding_model_name": "text-embedding-ada-002",
|
||||
"embedding_retrieval_k": 4,
|
||||
"topic": "星穹铁道主角星穿越到原神提瓦特大陆,拯救提瓦特大陆,并与其中的角色展开爱恨情仇的小说",
|
||||
"genre": "玄幻",
|
||||
"num_chapters": 120,
|
||||
"word_number": 4000,
|
||||
"filepath": "D:/AI_NovelGenerator/filepath"
|
||||
}
|
||||
```
|
||||
|
||||
### 🔧 配置说明
|
||||
1. **生成模型配置**
|
||||
- `api_key`: 大模型服务的API密钥
|
||||
- `base_url`: API终端地址(本地服务填Ollama等地址)
|
||||
- `interface_format`: 接口模式
|
||||
- `model_name`: 主生成模型名称(如gpt-4, claude-3等)
|
||||
- `temperature`: 创意度参数(0-1,越高越有创造性)
|
||||
- `max_tokens`: 模型最大回复长度
|
||||
|
||||
2. **Embedding模型配置**
|
||||
- `embedding_model_name`: 模型名称(如Ollama的nomic-embed-text)
|
||||
- `embedding_url`: 服务地址
|
||||
- `embedding_retrieval_k`:
|
||||
|
||||
3. **小说参数配置**
|
||||
- `topic`: 核心故事主题
|
||||
- `genre`: 作品类型
|
||||
- `num_chapters`: 总章节数
|
||||
- `word_number`: 单章目标字数
|
||||
- `filepath`: 生成文件存储路径
|
||||
|
||||
---
|
||||
|
||||
## 🚀 运行说明
|
||||
### **方式 1:使用 Python 解释器**
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
执行后,GUI 将会启动,你可以在图形界面中进行各项操作。
|
||||
|
||||
### **方式 2:打包为可执行文件**
|
||||
如果你想在无 Python 环境的机器上使用本工具,可以使用 **PyInstaller** 进行打包:
|
||||
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
pyinstaller main.spec
|
||||
```
|
||||
打包完成后,会在 `dist/` 目录下生成可执行文件(如 Windows 下的 `main.exe`)。
|
||||
|
||||
---
|
||||
|
||||
## 📘 使用教程
|
||||
1. **启动后,先完成基本参数设置:**
|
||||
- **API Key & Base URL**(如 `https://api.openai.com/v1`)
|
||||
- **模型名称**(如 `gpt-3.5-turbo`、`gpt-4o` 等)
|
||||
- **Temperature** (0~1,决定文字创意程度)
|
||||
- **主题(Topic)**(如 “废土世界的 AI 叛乱”)
|
||||
- **类型(Genre)**(如 “科幻”/“魔幻”/“都市幻想”)
|
||||
- **章节数**、**每章字数**(如 10 章,每章约 3000 字)
|
||||
- **保存路径**(建议创建一个新的输出文件夹)
|
||||
|
||||
2. **点击「Step1. 生成设定」**
|
||||
- 系统将基于主题、类型、章节数等信息,生成:
|
||||
- `Novel_setting.txt`:包含世界观、角色信息、雷点暗线等。
|
||||
- 可以在生成后的 `Novel_setting.txt` 中查看或修改设定内容。
|
||||
|
||||
3. **点击「Step2. 生成目录」**
|
||||
- 系统会根据已完成的 `Novel_setting.txt` 内容,为全部章节生成:
|
||||
- `Novel_directory.txt`:包括每章标题和简要提示。
|
||||
- 可以在生成后的文件中查看、修改或补充章节标题和描述。
|
||||
|
||||
4. **点击「Step3. 生成章节草稿」**
|
||||
- 在生成章节之前,你可以:
|
||||
- **设置章节号**(如写第 1 章,就填 `1`)
|
||||
- **在“本章指导”输入框**中提供对本章剧情的任何期望或提示
|
||||
- 点击按钮后,系统将:
|
||||
- 自动读取前文设定、`Novel_directory.txt`、以及已定稿章节
|
||||
- 调用向量检索回顾剧情,保证上下文连贯
|
||||
- 生成本章大纲 (`outline_X.txt`) 及正文 (`chapter_X.txt`)
|
||||
- 生成完成后,你可在左侧的文本框查看、编辑本章草稿内容。
|
||||
|
||||
5. **点击「Step4. 定稿当前章节」**
|
||||
- 系统将:
|
||||
- **更新全局摘要**(写入 `global_summary.txt`)
|
||||
- **更新角色状态**(写入 `character_state.txt`)
|
||||
- **更新向量检索库**(保证后续章节可以调用最新信息)
|
||||
- **更新剧情要点**(如 `plot_arcs.txt`)
|
||||
- 定稿完成后,你可以在 `chapter_X.txt` 中看到定稿后的文本。
|
||||
|
||||
6. **一致性检查(可选)**
|
||||
- 点击「[可选] 一致性审校」按钮,对最新章节进行冲突检测,如角色逻辑、剧情前后矛盾等。
|
||||
- 若有冲突,会在日志区输出详细提示。
|
||||
|
||||
7. **重复第 4-6 步** 直到所有章节生成并定稿!
|
||||
|
||||
> **向量检索配置提示**
|
||||
> 1. embedding模型需要显示指定接口和模型名称;
|
||||
> 2. 使用**本地Ollama**的**Embedding**时需提前启动Ollama服务:
|
||||
> ```bash
|
||||
> ollama serve # 启动服务
|
||||
> ollama pull nomic-embed-text # 下载/启用模型
|
||||
> ```
|
||||
> 3. 切换不同Embedding模型后建议清空vectorstore目录
|
||||
> 4. 云端Embedding需确保对应API权限已开通
|
||||
|
||||
---
|
||||
|
||||
## ❓ 疑难解答
|
||||
### Q1: Expecting value: line 1 column 1 (char 0)
|
||||
|
||||
该问题大概率由于API未正确响应造成,也许响应了一个html?其它内容,导致出现该报错;
|
||||
|
||||
|
||||
### Q2: HTTP/1.1 504 Gateway Timeout?
|
||||
确认接口是否稳定;
|
||||
|
||||
### Q3: 如何切换不同的Embedding提供商?
|
||||
在GUI界面中对应输入即可。
|
||||
|
||||
---
|
||||
|
||||
如有更多问题或需求,欢迎在**项目 Issues** 中提出。
|
||||
@@ -0,0 +1,132 @@
|
||||
# chapter_blueprint_parser.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
|
||||
def parse_chapter_blueprint(blueprint_text: str):
|
||||
"""
|
||||
解析整份章节蓝图文本,返回一个列表,每个元素是一个 dict:
|
||||
{
|
||||
"chapter_number": int,
|
||||
"chapter_title": str,
|
||||
"chapter_role": str, # 本章定位
|
||||
"chapter_purpose": str, # 核心作用
|
||||
"suspense_level": str, # 悬念密度
|
||||
"foreshadowing": str, # 伏笔操作
|
||||
"plot_twist_level": str, # 认知颠覆
|
||||
"chapter_summary": str # 本章简述
|
||||
}
|
||||
"""
|
||||
|
||||
# 先按空行进行分块,以免多章之间混淆
|
||||
chunks = re.split(r'\n\s*\n', blueprint_text.strip())
|
||||
results = []
|
||||
|
||||
# 兼容是否使用方括号包裹章节标题
|
||||
# 例如:
|
||||
# 第1章 - 紫极光下的预兆
|
||||
# 或
|
||||
# 第1章 - [紫极光下的预兆]
|
||||
chapter_number_pattern = re.compile(r'^第\s*(\d+)\s*章\s*-\s*\[?(.*?)\]?$')
|
||||
|
||||
role_pattern = re.compile(r'^本章定位:\s*\[?(.*)\]?$')
|
||||
purpose_pattern = re.compile(r'^核心作用:\s*\[?(.*)\]?$')
|
||||
suspense_pattern = re.compile(r'^悬念密度:\s*\[?(.*)\]?$')
|
||||
foreshadow_pattern = re.compile(r'^伏笔操作:\s*\[?(.*)\]?$')
|
||||
twist_pattern = re.compile(r'^认知颠覆:\s*\[?(.*)\]?$')
|
||||
summary_pattern = re.compile(r'^本章简述:\s*\[?(.*)\]?$')
|
||||
|
||||
for chunk in chunks:
|
||||
lines = chunk.strip().splitlines()
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
chapter_number = None
|
||||
chapter_title = ""
|
||||
chapter_role = ""
|
||||
chapter_purpose = ""
|
||||
suspense_level = ""
|
||||
foreshadowing = ""
|
||||
plot_twist_level = ""
|
||||
chapter_summary = ""
|
||||
|
||||
# 先匹配第一行(或前几行),找到章号和标题
|
||||
header_match = chapter_number_pattern.match(lines[0].strip())
|
||||
if not header_match:
|
||||
# 不符合“第X章 - 标题”的格式,跳过
|
||||
continue
|
||||
|
||||
chapter_number = int(header_match.group(1))
|
||||
chapter_title = header_match.group(2).strip()
|
||||
|
||||
# 从后面的行匹配其他字段
|
||||
for line in lines[1:]:
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
continue
|
||||
|
||||
m_role = role_pattern.match(line_stripped)
|
||||
if m_role:
|
||||
chapter_role = m_role.group(1).strip()
|
||||
continue
|
||||
|
||||
m_purpose = purpose_pattern.match(line_stripped)
|
||||
if m_purpose:
|
||||
chapter_purpose = m_purpose.group(1).strip()
|
||||
continue
|
||||
|
||||
m_suspense = suspense_pattern.match(line_stripped)
|
||||
if m_suspense:
|
||||
suspense_level = m_suspense.group(1).strip()
|
||||
continue
|
||||
|
||||
m_foreshadow = foreshadow_pattern.match(line_stripped)
|
||||
if m_foreshadow:
|
||||
foreshadowing = m_foreshadow.group(1).strip()
|
||||
continue
|
||||
|
||||
m_twist = twist_pattern.match(line_stripped)
|
||||
if m_twist:
|
||||
plot_twist_level = m_twist.group(1).strip()
|
||||
continue
|
||||
|
||||
m_summary = summary_pattern.match(line_stripped)
|
||||
if m_summary:
|
||||
chapter_summary = m_summary.group(1).strip()
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"chapter_number": chapter_number,
|
||||
"chapter_title": chapter_title,
|
||||
"chapter_role": chapter_role,
|
||||
"chapter_purpose": chapter_purpose,
|
||||
"suspense_level": suspense_level,
|
||||
"foreshadowing": foreshadowing,
|
||||
"plot_twist_level": plot_twist_level,
|
||||
"chapter_summary": chapter_summary
|
||||
})
|
||||
|
||||
# 按照 chapter_number 排序后返回
|
||||
results.sort(key=lambda x: x["chapter_number"])
|
||||
return results
|
||||
|
||||
|
||||
def get_chapter_info_from_blueprint(blueprint_text: str, target_chapter_number: int):
|
||||
"""
|
||||
在已经加载好的章节蓝图文本中,找到对应章号的结构化信息,返回一个 dict。
|
||||
若找不到则返回一个默认的结构。
|
||||
"""
|
||||
all_chapters = parse_chapter_blueprint(blueprint_text)
|
||||
for ch in all_chapters:
|
||||
if ch["chapter_number"] == target_chapter_number:
|
||||
return ch
|
||||
# 默认返回
|
||||
return {
|
||||
"chapter_number": target_chapter_number,
|
||||
"chapter_title": f"第{target_chapter_number}章",
|
||||
"chapter_role": "",
|
||||
"chapter_purpose": "",
|
||||
"suspense_level": "",
|
||||
"foreshadowing": "",
|
||||
"plot_twist_level": "",
|
||||
"chapter_summary": ""
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# config_manager.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from llm_adapters import create_llm_adapter
|
||||
from embedding_adapters import create_embedding_adapter
|
||||
|
||||
|
||||
def load_config(config_file: str) -> dict:
|
||||
"""从指定的 config_file 加载配置,若不存在则返回空字典。"""
|
||||
if os.path.exists(config_file):
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_config(config_data: dict, config_file: str) -> bool:
|
||||
"""将 config_data 保存到 config_file 中,返回 True/False 表示是否成功。"""
|
||||
try:
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(config_data, f, ensure_ascii=False, indent=4)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def test_llm_config(interface_format, api_key, base_url, model_name, temperature, max_tokens, timeout, log_func, handle_exception_func):
|
||||
"""测试当前的LLM配置是否可用"""
|
||||
def task():
|
||||
try:
|
||||
log_func("开始测试LLM配置...")
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
test_prompt = "Please reply 'OK'"
|
||||
response = llm_adapter.invoke(test_prompt)
|
||||
if response:
|
||||
log_func("✅ LLM配置测试成功!")
|
||||
log_func(f"测试回复: {response}")
|
||||
else:
|
||||
log_func("❌ LLM配置测试失败:未获取到响应")
|
||||
except Exception as e:
|
||||
log_func(f"❌ LLM配置测试出错: {str(e)}")
|
||||
handle_exception_func("测试LLM配置时出错")
|
||||
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def test_embedding_config(api_key, base_url, interface_format, model_name, log_func, handle_exception_func):
|
||||
"""测试当前的Embedding配置是否可用"""
|
||||
def task():
|
||||
try:
|
||||
log_func("开始测试Embedding配置...")
|
||||
embedding_adapter = create_embedding_adapter(
|
||||
interface_format=interface_format,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name
|
||||
)
|
||||
|
||||
test_text = "测试文本"
|
||||
embeddings = embedding_adapter.embed_query(test_text)
|
||||
if embeddings and len(embeddings) > 0:
|
||||
log_func("✅ Embedding配置测试成功!")
|
||||
log_func(f"生成的向量维度: {len(embeddings)}")
|
||||
else:
|
||||
log_func("❌ Embedding配置测试失败:未获取到向量")
|
||||
except Exception as e:
|
||||
log_func(f"❌ Embedding配置测试出错: {str(e)}")
|
||||
handle_exception_func("测试Embedding配置时出错")
|
||||
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
@@ -0,0 +1,72 @@
|
||||
# consistency_checker.py
|
||||
# -*- coding: utf-8 -*-
|
||||
from llm_adapters import create_llm_adapter
|
||||
|
||||
# ============== 增加对“剧情要点/未解决冲突”进行检查的可选引导 ==============
|
||||
CONSISTENCY_PROMPT = """\
|
||||
请检查下面的小说设定与最新章节是否存在明显冲突或不一致之处,如有请列出:
|
||||
- 小说设定:
|
||||
{novel_setting}
|
||||
|
||||
- 角色状态(可能包含重要信息):
|
||||
{character_state}
|
||||
|
||||
- 前文摘要:
|
||||
{global_summary}
|
||||
|
||||
- 已记录的未解决冲突或剧情要点:
|
||||
{plot_arcs} # 若为空可能不输出
|
||||
|
||||
- 最新章节内容:
|
||||
{chapter_text}
|
||||
|
||||
如果存在冲突或不一致,请说明;如果在未解决冲突中有被忽略或需要推进的地方,也请提及;否则请返回“无明显冲突”。
|
||||
"""
|
||||
|
||||
def check_consistency(
|
||||
novel_setting: str,
|
||||
character_state: str,
|
||||
global_summary: str,
|
||||
chapter_text: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
temperature: float = 0.3,
|
||||
plot_arcs: str = "",
|
||||
interface_format: str = "OpenAI",
|
||||
max_tokens: int = 2048,
|
||||
timeout: int = 600
|
||||
) -> str:
|
||||
"""
|
||||
调用模型做简单的一致性检查。可扩展更多提示或校验规则。
|
||||
新增: 会额外检查对“未解决冲突或剧情要点”(plot_arcs)的衔接情况。
|
||||
"""
|
||||
prompt = CONSISTENCY_PROMPT.format(
|
||||
novel_setting=novel_setting,
|
||||
character_state=character_state,
|
||||
global_summary=global_summary,
|
||||
plot_arcs=plot_arcs,
|
||||
chapter_text=chapter_text
|
||||
)
|
||||
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 调试日志
|
||||
print("\n[ConsistencyChecker] Prompt >>>", prompt)
|
||||
|
||||
response = llm_adapter.invoke(prompt)
|
||||
if not response:
|
||||
return "审校Agent无回复"
|
||||
|
||||
# 调试日志
|
||||
print("[ConsistencyChecker] Response <<<", response)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,272 @@
|
||||
# embedding_adapters.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import traceback
|
||||
from typing import List
|
||||
import requests
|
||||
from langchain_openai import AzureOpenAIEmbeddings, OpenAIEmbeddings
|
||||
|
||||
def ensure_openai_base_url_has_v1(url: str) -> str:
|
||||
"""
|
||||
若用户输入的 url 不包含 '/v1',则在末尾追加 '/v1'。
|
||||
"""
|
||||
import re
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return url
|
||||
if not re.search(r'/v\d+$', url):
|
||||
if '/v1' not in url:
|
||||
url = url.rstrip('/') + '/v1'
|
||||
return url
|
||||
|
||||
class BaseEmbeddingAdapter:
|
||||
"""
|
||||
Embedding 接口统一基类
|
||||
"""
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
raise NotImplementedError
|
||||
|
||||
def embed_query(self, query: str) -> List[float]:
|
||||
raise NotImplementedError
|
||||
|
||||
class OpenAIEmbeddingAdapter(BaseEmbeddingAdapter):
|
||||
"""
|
||||
基于 OpenAIEmbeddings(或兼容接口)的适配器
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str):
|
||||
self._embedding = OpenAIEmbeddings(
|
||||
openai_api_key=api_key,
|
||||
openai_api_base=ensure_openai_base_url_has_v1(base_url),
|
||||
model=model_name
|
||||
)
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
return self._embedding.embed_documents(texts)
|
||||
|
||||
def embed_query(self, query: str) -> List[float]:
|
||||
return self._embedding.embed_query(query)
|
||||
|
||||
class AzureOpenAIEmbeddingAdapter(BaseEmbeddingAdapter):
|
||||
"""
|
||||
基于 AzureOpenAIEmbeddings(或兼容接口)的适配器
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str):
|
||||
import re
|
||||
match = re.match(r'https://(.+?)/openai/deployments/(.+?)/embeddings\?api-version=(.+)', base_url)
|
||||
if match:
|
||||
self.azure_endpoint = f"https://{match.group(1)}"
|
||||
self.azure_deployment = match.group(2)
|
||||
self.api_version = match.group(3)
|
||||
else:
|
||||
raise ValueError("Invalid Azure OpenAI base_url format")
|
||||
|
||||
self._embedding = AzureOpenAIEmbeddings(
|
||||
azure_endpoint=self.azure_endpoint,
|
||||
azure_deployment=self.azure_deployment,
|
||||
openai_api_key=api_key,
|
||||
api_version=self.api_version,
|
||||
)
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
return self._embedding.embed_documents(texts)
|
||||
|
||||
def embed_query(self, query: str) -> List[float]:
|
||||
return self._embedding.embed_query(query)
|
||||
|
||||
class OllamaEmbeddingAdapter(BaseEmbeddingAdapter):
|
||||
"""
|
||||
其接口路径为 /api/embeddings
|
||||
"""
|
||||
def __init__(self, model_name: str, base_url: str):
|
||||
self.model_name = model_name
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
vec = self._embed_single(text)
|
||||
embeddings.append(vec)
|
||||
return embeddings
|
||||
|
||||
def embed_query(self, query: str) -> List[float]:
|
||||
return self._embed_single(query)
|
||||
|
||||
def _embed_single(self, text: str) -> List[float]:
|
||||
"""
|
||||
调用 Ollama 本地服务 /api/embeddings 接口,获取文本 embedding
|
||||
"""
|
||||
url = self.base_url.rstrip("/")
|
||||
if "/api/embeddings" not in url:
|
||||
if "/api" in url:
|
||||
url = f"{url}/embeddings"
|
||||
else:
|
||||
if "/v1" in url:
|
||||
url = url[:url.index("/v1")]
|
||||
url = f"{url}/api/embeddings"
|
||||
|
||||
data = {
|
||||
"model": self.model_name,
|
||||
"prompt": text
|
||||
}
|
||||
try:
|
||||
response = requests.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if "embedding" not in result:
|
||||
raise ValueError("No 'embedding' field in Ollama response.")
|
||||
return result["embedding"]
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Ollama embeddings request error: {e}\n{traceback.format_exc()}")
|
||||
return []
|
||||
|
||||
class MLStudioEmbeddingAdapter(BaseEmbeddingAdapter):
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str):
|
||||
self._embedding = OpenAIEmbeddings(
|
||||
openai_api_key=api_key,
|
||||
openai_api_base=ensure_openai_base_url_has_v1(base_url),
|
||||
model=model_name
|
||||
)
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
return self._embedding.embed_documents(texts)
|
||||
|
||||
def embed_query(self, query: str) -> List[float]:
|
||||
return self._embedding.embed_query(query)
|
||||
|
||||
class GeminiEmbeddingAdapter(BaseEmbeddingAdapter):
|
||||
"""
|
||||
基于 Google Generative AI (Gemini) 接口的 Embedding 适配器
|
||||
使用直接 POST 请求方式,URL 示例:
|
||||
https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=YOUR_API_KEY
|
||||
"""
|
||||
def __init__(self, api_key: str, model_name: str, base_url: str):
|
||||
"""
|
||||
:param api_key: 传入的 Google API Key
|
||||
:param model_name: 这里一般是 "text-embedding-004"
|
||||
:param base_url: e.g. https://generativelanguage.googleapis.com/v1beta/models
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
vec = self._embed_single(text)
|
||||
embeddings.append(vec)
|
||||
return embeddings
|
||||
|
||||
def embed_query(self, query: str) -> List[float]:
|
||||
return self._embed_single(query)
|
||||
|
||||
def _embed_single(self, text: str) -> List[float]:
|
||||
"""
|
||||
直接调用 Google Generative Language API (Gemini) 接口,获取文本 embedding
|
||||
"""
|
||||
url = f"{self.base_url}/{self.model_name}:embedContent?key={self.api_key}"
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": text}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload)
|
||||
print(response.text)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
embedding_data = result.get("embedding", {})
|
||||
return embedding_data.get("values", [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"Gemini embed_content request error: {e}\n{traceback.format_exc()}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logging.error(f"Gemini embed_content parse error: {e}\n{traceback.format_exc()}")
|
||||
return []
|
||||
|
||||
class SiliconFlowEmbeddingAdapter(BaseEmbeddingAdapter):
|
||||
"""
|
||||
基于 SiliconFlow 的 embedding 适配器
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str):
|
||||
# 自动为 base_url 添加 scheme(如果缺失)
|
||||
if not base_url.startswith("http://") and not base_url.startswith("https://"):
|
||||
base_url = "https://" + base_url
|
||||
self.url = base_url if base_url else "https://api.siliconflow.cn/v1/embeddings"
|
||||
|
||||
self.payload = {
|
||||
"model": model_name,
|
||||
"input": "Silicon flow embedding online: fast, affordable, and high-quality embedding services. come try it out!",
|
||||
"encoding_format": "float"
|
||||
}
|
||||
self.headers = {
|
||||
"Authorization": "Bearer {api_key}".format(api_key=api_key),
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
try:
|
||||
self.payload["input"] = text
|
||||
response = requests.post(self.url, json=self.payload, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if not result or "data" not in result or not result["data"]:
|
||||
logging.error(f"Invalid response format from SiliconFlow API: {result}")
|
||||
embeddings.append([])
|
||||
continue
|
||||
emb = result["data"][0].get("embedding", [])
|
||||
embeddings.append(emb)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"SiliconFlow API request failed: {str(e)}")
|
||||
embeddings.append([])
|
||||
except (KeyError, IndexError, ValueError, TypeError) as e:
|
||||
logging.error(f"Error parsing SiliconFlow API response: {str(e)}")
|
||||
embeddings.append([])
|
||||
return embeddings
|
||||
|
||||
def embed_query(self, query: str) -> List[float]:
|
||||
try:
|
||||
self.payload["input"] = query
|
||||
response = requests.post(self.url, json=self.payload, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if not result or "data" not in result or not result["data"]:
|
||||
logging.error(f"Invalid response format from SiliconFlow API: {result}")
|
||||
return []
|
||||
return result["data"][0].get("embedding", [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"SiliconFlow API request failed: {str(e)}")
|
||||
return []
|
||||
except (KeyError, IndexError, ValueError, TypeError) as e:
|
||||
logging.error(f"Error parsing SiliconFlow API response: {str(e)}")
|
||||
return []
|
||||
|
||||
def create_embedding_adapter(
|
||||
interface_format: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str
|
||||
) -> BaseEmbeddingAdapter:
|
||||
"""
|
||||
工厂函数:根据 interface_format 返回不同的 embedding 适配器实例
|
||||
"""
|
||||
fmt = interface_format.strip().lower()
|
||||
if fmt == "openai":
|
||||
return OpenAIEmbeddingAdapter(api_key, base_url, model_name)
|
||||
elif fmt == "azure openai":
|
||||
return AzureOpenAIEmbeddingAdapter(api_key, base_url, model_name)
|
||||
elif fmt == "ollama":
|
||||
return OllamaEmbeddingAdapter(model_name, base_url)
|
||||
elif fmt == "ml studio":
|
||||
return MLStudioEmbeddingAdapter(api_key, base_url, model_name)
|
||||
elif fmt == "gemini":
|
||||
return GeminiEmbeddingAdapter(api_key, model_name, base_url)
|
||||
elif fmt == "siliconflow":
|
||||
return SiliconFlowEmbeddingAdapter(api_key, base_url, model_name)
|
||||
else:
|
||||
raise ValueError(f"Unknown embedding interface_format: {interface_format}")
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
# llm_adapters.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from typing import Optional
|
||||
from langchain_openai import ChatOpenAI, AzureChatOpenAI
|
||||
import google.generativeai as genai
|
||||
from azure.ai.inference import ChatCompletionsClient
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.ai.inference.models import SystemMessage, UserMessage
|
||||
from openai import OpenAI
|
||||
import requests
|
||||
|
||||
|
||||
def check_base_url(url: str) -> str:
|
||||
"""
|
||||
处理base_url的规则:
|
||||
1. 如果url以#结尾,则移除#并直接使用用户提供的url
|
||||
2. 否则检查是否需要添加/v1后缀
|
||||
"""
|
||||
import re
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return url
|
||||
|
||||
if url.endswith('#'):
|
||||
return url.rstrip('#')
|
||||
|
||||
if not re.search(r'/v\d+$', url):
|
||||
if '/v1' not in url:
|
||||
url = url.rstrip('/') + '/v1'
|
||||
return url
|
||||
|
||||
class BaseLLMAdapter:
|
||||
"""
|
||||
统一的 LLM 接口基类,为不同后端(OpenAI、Ollama、ML Studio、Gemini等)提供一致的方法签名。
|
||||
"""
|
||||
def invoke(self, prompt: str) -> str:
|
||||
raise NotImplementedError("Subclasses must implement .invoke(prompt) method.")
|
||||
|
||||
class DeepSeekAdapter(BaseLLMAdapter):
|
||||
"""
|
||||
适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI)
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
self.base_url = check_base_url(base_url)
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = ChatOpenAI(
|
||||
model=self.model_name,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
response = self._client.invoke(prompt)
|
||||
if not response:
|
||||
logging.warning("No response from DeepSeekAdapter.")
|
||||
return ""
|
||||
return response.content
|
||||
|
||||
class OpenAIAdapter(BaseLLMAdapter):
|
||||
"""
|
||||
适配官方/OpenAI兼容接口(使用 langchain.ChatOpenAI)
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
self.base_url = check_base_url(base_url)
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = ChatOpenAI(
|
||||
model=self.model_name,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
response = self._client.invoke(prompt)
|
||||
if not response:
|
||||
logging.warning("No response from OpenAIAdapter.")
|
||||
return ""
|
||||
return response.content
|
||||
|
||||
class GeminiAdapter(BaseLLMAdapter):
|
||||
"""
|
||||
适配 Google Gemini (Google Generative AI) 接口
|
||||
"""
|
||||
def __init__(self, api_key: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = genai.Client(api_key=self.api_key)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
try:
|
||||
response = self._client.models.generate_content(
|
||||
model = self.model_name,
|
||||
contents = prompt,
|
||||
config = genai.types.GenerateContentConfig(
|
||||
max_output_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
),
|
||||
timeout=self.timeout # 添加超时参数
|
||||
)
|
||||
if response and response.text:
|
||||
return response.text
|
||||
else:
|
||||
logging.warning("No text response from Gemini API.")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logging.error(f"Gemini API 调用失败: {e}")
|
||||
return ""
|
||||
|
||||
class AzureOpenAIAdapter(BaseLLMAdapter):
|
||||
"""
|
||||
适配 Azure OpenAI 接口(使用 langchain.ChatOpenAI)
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
import re
|
||||
match = re.match(r'https://(.+?)/openai/deployments/(.+?)/chat/completions\?api-version=(.+)', base_url)
|
||||
if match:
|
||||
self.azure_endpoint = f"https://{match.group(1)}"
|
||||
self.azure_deployment = match.group(2)
|
||||
self.api_version = match.group(3)
|
||||
else:
|
||||
raise ValueError("Invalid Azure OpenAI base_url format")
|
||||
|
||||
self.api_key = api_key
|
||||
self.model_name = self.azure_deployment
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = AzureChatOpenAI(
|
||||
azure_endpoint=self.azure_endpoint,
|
||||
azure_deployment=self.azure_deployment,
|
||||
api_version=self.api_version,
|
||||
api_key=self.api_key,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
response = self._client.invoke(prompt)
|
||||
if not response:
|
||||
logging.warning("No response from AzureOpenAIAdapter.")
|
||||
return ""
|
||||
return response.content
|
||||
|
||||
class OllamaAdapter(BaseLLMAdapter):
|
||||
"""
|
||||
Ollama 同样有一个 OpenAI-like /v1/chat 接口,可直接使用 ChatOpenAI。
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
self.base_url = check_base_url(base_url)
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
if self.api_key == '':
|
||||
self.api_key= 'ollama'
|
||||
|
||||
self._client = ChatOpenAI(
|
||||
model=self.model_name,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
response = self._client.invoke(prompt)
|
||||
if not response:
|
||||
logging.warning("No response from OllamaAdapter.")
|
||||
return ""
|
||||
return response.content
|
||||
|
||||
class MLStudioAdapter(BaseLLMAdapter):
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
self.base_url = check_base_url(base_url)
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = ChatOpenAI(
|
||||
model=self.model_name,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
max_tokens=self.max_tokens,
|
||||
temperature=self.temperature,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
try:
|
||||
response = self._client.invoke(prompt)
|
||||
if not response:
|
||||
logging.warning("No response from MLStudioAdapter.")
|
||||
return ""
|
||||
return response.content
|
||||
except Exception as e:
|
||||
logging.error(f"ML Studio API 调用超时或失败: {e}")
|
||||
return ""
|
||||
|
||||
class AzureAIAdapter(BaseLLMAdapter):
|
||||
"""
|
||||
适配 Azure AI Inference 接口,用于访问Azure AI服务部署的模型
|
||||
使用 azure-ai-inference 库进行API调用
|
||||
"""
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
import re
|
||||
# 匹配形如 https://xxx.services.ai.azure.com/models/chat/completions?api-version=xxx 的URL
|
||||
match = re.match(r'https://(.+?)\.services\.ai\.azure\.com(?:/models)?(?:/chat/completions)?(?:\?api-version=(.+))?', base_url)
|
||||
if match:
|
||||
# endpoint需要是形如 https://xxx.services.ai.azure.com/models 的格式
|
||||
self.endpoint = f"https://{match.group(1)}.services.ai.azure.com/models"
|
||||
# 如果URL中包含api-version参数,使用它;否则使用默认值
|
||||
self.api_version = match.group(2) if match.group(2) else "2024-05-01-preview"
|
||||
else:
|
||||
raise ValueError("Invalid Azure AI base_url format. Expected format: https://<endpoint>.services.ai.azure.com/models/chat/completions?api-version=xxx")
|
||||
|
||||
self.base_url = self.endpoint # 存储处理后的endpoint URL
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = ChatCompletionsClient(
|
||||
endpoint=self.endpoint,
|
||||
credential=AzureKeyCredential(self.api_key),
|
||||
model=self.model_name,
|
||||
temperature=self.temperature,
|
||||
max_tokens=self.max_tokens,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
try:
|
||||
response = self._client.complete(
|
||||
messages=[
|
||||
SystemMessage("You are a helpful assistant."),
|
||||
UserMessage(prompt)
|
||||
]
|
||||
)
|
||||
if response and response.choices:
|
||||
return response.choices[0].message.content
|
||||
else:
|
||||
logging.warning("No response from AzureAIAdapter.")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logging.error(f"Azure AI Inference API 调用失败: {e}")
|
||||
return ""
|
||||
|
||||
# 火山引擎实现
|
||||
class VolcanoEngineAIAdapter(BaseLLMAdapter):
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
self.base_url = check_base_url(base_url)
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = OpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
timeout=timeout # 添加超时配置
|
||||
)
|
||||
def invoke(self, prompt: str) -> str:
|
||||
try:
|
||||
response = self._client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
timeout=self.timeout # 添加超时参数
|
||||
)
|
||||
if not response:
|
||||
logging.warning("No response from DeepSeekAdapter.")
|
||||
return ""
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logging.error(f"火山引擎API调用超时或失败: {e}")
|
||||
return ""
|
||||
|
||||
class SiliconFlowAdapter(BaseLLMAdapter):
|
||||
def __init__(self, api_key: str, base_url: str, model_name: str, max_tokens: int, temperature: float = 0.7, timeout: Optional[int] = 600):
|
||||
self.base_url = check_base_url(base_url)
|
||||
self.api_key = api_key
|
||||
self.model_name = model_name
|
||||
self.max_tokens = max_tokens
|
||||
self.temperature = temperature
|
||||
self.timeout = timeout
|
||||
|
||||
self._client = OpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
timeout=timeout # 添加超时配置
|
||||
)
|
||||
def invoke(self, prompt: str) -> str:
|
||||
try:
|
||||
response = self._client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": "你是DeepSeek,是一个 AI 人工智能助手"},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
timeout=self.timeout # 添加超时参数
|
||||
)
|
||||
if not response:
|
||||
logging.warning("No response from DeepSeekAdapter.")
|
||||
return ""
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logging.error(f"硅基流动API调用超时或失败: {e}")
|
||||
return ""
|
||||
|
||||
def create_llm_adapter(
|
||||
interface_format: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
api_key: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
timeout: int
|
||||
) -> BaseLLMAdapter:
|
||||
"""
|
||||
工厂函数:根据 interface_format 返回不同的适配器实例。
|
||||
"""
|
||||
fmt = interface_format.strip().lower()
|
||||
if fmt == "deepseek":
|
||||
return DeepSeekAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "openai":
|
||||
return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "azure openai":
|
||||
return AzureOpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "azure ai":
|
||||
return AzureAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "ollama":
|
||||
return OllamaAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "ml studio":
|
||||
return MLStudioAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "gemini":
|
||||
# base_url 对 Gemini 暂无用处,可忽略
|
||||
return GeminiAdapter(api_key, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "阿里云百炼":
|
||||
return OpenAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "火山引擎":
|
||||
return VolcanoEngineAIAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
elif fmt == "硅基流动":
|
||||
return SiliconFlowAdapter(api_key, base_url, model_name, max_tokens, temperature, timeout)
|
||||
else:
|
||||
raise ValueError(f"Unknown interface_format: {interface_format}")
|
||||
@@ -0,0 +1,12 @@
|
||||
# main.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import customtkinter as ctk
|
||||
from ui import NovelGeneratorGUI
|
||||
|
||||
def main():
|
||||
app = ctk.CTk()
|
||||
gui = NovelGeneratorGUI(app)
|
||||
app.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,72 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = ['typing_extensions',
|
||||
'langchain-openai',
|
||||
'langgraph',
|
||||
'openai',
|
||||
'google-genai',
|
||||
'google',
|
||||
'nltk',
|
||||
'sentence_transformers',
|
||||
'scikit-learn',
|
||||
'langchain-community',
|
||||
'pydantic',
|
||||
'pydantic.deprecated.decorator',
|
||||
'tiktoken_ext.openai_public',
|
||||
'tiktoken_ext',
|
||||
'chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2'
|
||||
]
|
||||
|
||||
tmp_ret = collect_all('chromadb')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
customtkinter_dir = r'c:/Users/xieli/Desktop/AI_NovelGenerator/.venv/Lib/site-packages/customtkinter'
|
||||
datas.append((customtkinter_dir, 'customtkinter'))
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='AI_NovelGenerator_V1.4.2',
|
||||
debug=True,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=['icon.ico']
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='AI_NovelGenerator_V1.4.2'
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
#novel_generator/__init__.py
|
||||
from .architecture import Novel_architecture_generate
|
||||
from .blueprint import Chapter_blueprint_generate
|
||||
from .chapter import (
|
||||
get_last_n_chapters_text,
|
||||
summarize_recent_chapters,
|
||||
get_filtered_knowledge_context,
|
||||
build_chapter_prompt,
|
||||
generate_chapter_draft
|
||||
)
|
||||
from .finalization import finalize_chapter, enrich_chapter_text
|
||||
from .knowledge import import_knowledge_file
|
||||
from .vectorstore_utils import clear_vector_store
|
||||
@@ -0,0 +1,201 @@
|
||||
#novel_generator/architecture.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小说总体架构生成(Novel_architecture_generate 及相关辅助函数)
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from novel_generator.common import invoke_with_cleaning
|
||||
from llm_adapters import create_llm_adapter
|
||||
from prompt_definitions import (
|
||||
core_seed_prompt,
|
||||
character_dynamics_prompt,
|
||||
world_building_prompt,
|
||||
plot_architecture_prompt,
|
||||
create_character_state_prompt
|
||||
)
|
||||
from utils import clear_file_content, save_string_to_txt
|
||||
|
||||
def load_partial_architecture_data(filepath: str) -> dict:
|
||||
"""
|
||||
从 filepath 下的 partial_architecture.json 读取已有的阶段性数据。
|
||||
如果文件不存在或无法解析,返回空 dict。
|
||||
"""
|
||||
partial_file = os.path.join(filepath, "partial_architecture.json")
|
||||
if not os.path.exists(partial_file):
|
||||
return {}
|
||||
try:
|
||||
with open(partial_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to load partial_architecture.json: {e}")
|
||||
return {}
|
||||
|
||||
def save_partial_architecture_data(filepath: str, data: dict):
|
||||
"""
|
||||
将阶段性数据写入 partial_architecture.json。
|
||||
"""
|
||||
partial_file = os.path.join(filepath, "partial_architecture.json")
|
||||
try:
|
||||
with open(partial_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to save partial_architecture.json: {e}")
|
||||
|
||||
def Novel_architecture_generate(
|
||||
interface_format: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
llm_model: str,
|
||||
topic: str,
|
||||
genre: str,
|
||||
number_of_chapters: int,
|
||||
word_number: int,
|
||||
filepath: str,
|
||||
user_guidance: str = "", # 新增参数
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
timeout: int = 600
|
||||
) -> None:
|
||||
"""
|
||||
依次调用:
|
||||
1. core_seed_prompt
|
||||
2. character_dynamics_prompt
|
||||
3. world_building_prompt
|
||||
4. plot_architecture_prompt
|
||||
若在中间任何一步报错且重试多次失败,则将已经生成的内容写入 partial_architecture.json 并退出;
|
||||
下次调用时可从该步骤继续。
|
||||
最终输出 Novel_architecture.txt
|
||||
|
||||
新增:
|
||||
- 在完成角色动力学设定后,依据该角色体系,使用 create_character_state_prompt 生成初始角色状态表,
|
||||
并存储到 character_state.txt,后续维护更新。
|
||||
"""
|
||||
os.makedirs(filepath, exist_ok=True)
|
||||
partial_data = load_partial_architecture_data(filepath)
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=llm_model,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
# Step1: 核心种子
|
||||
if "core_seed_result" not in partial_data:
|
||||
logging.info("Step1: Generating core_seed_prompt (核心种子) ...")
|
||||
prompt_core = core_seed_prompt.format(
|
||||
topic=topic,
|
||||
genre=genre,
|
||||
number_of_chapters=number_of_chapters,
|
||||
word_number=word_number,
|
||||
user_guidance=user_guidance # 修复:添加内容指导
|
||||
)
|
||||
core_seed_result = invoke_with_cleaning(llm_adapter, prompt_core)
|
||||
if not core_seed_result.strip():
|
||||
logging.warning("core_seed_prompt generation failed and returned empty.")
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
return
|
||||
partial_data["core_seed_result"] = core_seed_result
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
else:
|
||||
logging.info("Step1 already done. Skipping...")
|
||||
# Step2: 角色动力学
|
||||
if "character_dynamics_result" not in partial_data:
|
||||
logging.info("Step2: Generating character_dynamics_prompt ...")
|
||||
prompt_character = character_dynamics_prompt.format(
|
||||
core_seed=partial_data["core_seed_result"].strip(),
|
||||
user_guidance=user_guidance
|
||||
)
|
||||
character_dynamics_result = invoke_with_cleaning(llm_adapter, prompt_character)
|
||||
if not character_dynamics_result.strip():
|
||||
logging.warning("character_dynamics_prompt generation failed.")
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
return
|
||||
partial_data["character_dynamics_result"] = character_dynamics_result
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
else:
|
||||
logging.info("Step2 already done. Skipping...")
|
||||
# 生成初始角色状态
|
||||
if "character_dynamics_result" in partial_data and "character_state_result" not in partial_data:
|
||||
logging.info("Generating initial character state from character dynamics ...")
|
||||
prompt_char_state_init = create_character_state_prompt.format(
|
||||
character_dynamics=partial_data["character_dynamics_result"].strip()
|
||||
)
|
||||
character_state_init = invoke_with_cleaning(llm_adapter, prompt_char_state_init)
|
||||
if not character_state_init.strip():
|
||||
logging.warning("create_character_state_prompt generation failed.")
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
return
|
||||
partial_data["character_state_result"] = character_state_init
|
||||
character_state_file = os.path.join(filepath, "character_state.txt")
|
||||
clear_file_content(character_state_file)
|
||||
save_string_to_txt(character_state_init, character_state_file)
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
logging.info("Initial character state created and saved.")
|
||||
# Step3: 世界观
|
||||
if "world_building_result" not in partial_data:
|
||||
logging.info("Step3: Generating world_building_prompt ...")
|
||||
prompt_world = world_building_prompt.format(
|
||||
core_seed=partial_data["core_seed_result"].strip(),
|
||||
user_guidance=user_guidance # 修复:添加用户指导
|
||||
)
|
||||
world_building_result = invoke_with_cleaning(llm_adapter, prompt_world)
|
||||
if not world_building_result.strip():
|
||||
logging.warning("world_building_prompt generation failed.")
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
return
|
||||
partial_data["world_building_result"] = world_building_result
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
else:
|
||||
logging.info("Step3 already done. Skipping...")
|
||||
# Step4: 三幕式情节
|
||||
if "plot_arch_result" not in partial_data:
|
||||
logging.info("Step4: Generating plot_architecture_prompt ...")
|
||||
prompt_plot = plot_architecture_prompt.format(
|
||||
core_seed=partial_data["core_seed_result"].strip(),
|
||||
character_dynamics=partial_data["character_dynamics_result"].strip(),
|
||||
world_building=partial_data["world_building_result"].strip(),
|
||||
user_guidance=user_guidance # 修复:添加用户指导
|
||||
)
|
||||
plot_arch_result = invoke_with_cleaning(llm_adapter, prompt_plot)
|
||||
if not plot_arch_result.strip():
|
||||
logging.warning("plot_architecture_prompt generation failed.")
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
return
|
||||
partial_data["plot_arch_result"] = plot_arch_result
|
||||
save_partial_architecture_data(filepath, partial_data)
|
||||
else:
|
||||
logging.info("Step4 already done. Skipping...")
|
||||
|
||||
core_seed_result = partial_data["core_seed_result"]
|
||||
character_dynamics_result = partial_data["character_dynamics_result"]
|
||||
world_building_result = partial_data["world_building_result"]
|
||||
plot_arch_result = partial_data["plot_arch_result"]
|
||||
|
||||
final_content = (
|
||||
"#=== 0) 小说设定 ===\n"
|
||||
f"主题:{topic},类型:{genre},篇幅:约{number_of_chapters}章(每章{word_number}字)\n\n"
|
||||
"#=== 1) 核心种子 ===\n"
|
||||
f"{core_seed_result}\n\n"
|
||||
"#=== 2) 角色动力学 ===\n"
|
||||
f"{character_dynamics_result}\n\n"
|
||||
"#=== 3) 世界观 ===\n"
|
||||
f"{world_building_result}\n\n"
|
||||
"#=== 4) 三幕式情节架构 ===\n"
|
||||
f"{plot_arch_result}\n"
|
||||
)
|
||||
|
||||
arch_file = os.path.join(filepath, "Novel_architecture.txt")
|
||||
clear_file_content(arch_file)
|
||||
save_string_to_txt(final_content, arch_file)
|
||||
logging.info("Novel_architecture.txt has been generated successfully.")
|
||||
|
||||
partial_arch_file = os.path.join(filepath, "partial_architecture.json")
|
||||
if os.path.exists(partial_arch_file):
|
||||
os.remove(partial_arch_file)
|
||||
logging.info("partial_architecture.json removed (all steps completed).")
|
||||
@@ -0,0 +1,173 @@
|
||||
#novel_generator/blueprint.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
章节蓝图生成(Chapter_blueprint_generate 及辅助函数)
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from novel_generator.common import invoke_with_cleaning
|
||||
from llm_adapters import create_llm_adapter
|
||||
from prompt_definitions import chapter_blueprint_prompt, chunked_chapter_blueprint_prompt
|
||||
from utils import read_file, clear_file_content, save_string_to_txt
|
||||
|
||||
def compute_chunk_size(number_of_chapters: int, max_tokens: int) -> int:
|
||||
"""
|
||||
基于“每章约100 tokens”的粗略估算,
|
||||
再结合当前max_tokens,计算分块大小:
|
||||
chunk_size = (floor(max_tokens/100/10)*10) - 10
|
||||
并确保 chunk_size 不会小于1或大于实际章节数。
|
||||
"""
|
||||
tokens_per_chapter = 100.0
|
||||
ratio = max_tokens / tokens_per_chapter
|
||||
ratio_rounded_to_10 = int(ratio // 10) * 10
|
||||
chunk_size = ratio_rounded_to_10 - 10
|
||||
if chunk_size < 1:
|
||||
chunk_size = 1
|
||||
if chunk_size > number_of_chapters:
|
||||
chunk_size = number_of_chapters
|
||||
return chunk_size
|
||||
|
||||
def limit_chapter_blueprint(blueprint_text: str, limit_chapters: int = 100) -> str:
|
||||
"""
|
||||
从已有章节目录中只取最近的 limit_chapters 章,以避免 prompt 超长。
|
||||
"""
|
||||
pattern = r"(第\s*\d+\s*章.*?)(?=第\s*\d+\s*章|$)"
|
||||
chapters = re.findall(pattern, blueprint_text, flags=re.DOTALL)
|
||||
if not chapters:
|
||||
return blueprint_text
|
||||
if len(chapters) <= limit_chapters:
|
||||
return blueprint_text
|
||||
selected = chapters[-limit_chapters:]
|
||||
return "\n\n".join(selected).strip()
|
||||
|
||||
def Chapter_blueprint_generate(
|
||||
interface_format: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
llm_model: str,
|
||||
filepath: str,
|
||||
number_of_chapters: int,
|
||||
user_guidance: str = "", # 新增参数
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 4096,
|
||||
timeout: int = 600
|
||||
) -> None:
|
||||
"""
|
||||
若 Novel_directory.txt 已存在且内容非空,则表示可能是之前的部分生成结果;
|
||||
解析其中已有的章节数,从下一个章节继续分块生成;
|
||||
对于已有章节目录,传入时仅保留最近100章目录,避免prompt过长。
|
||||
否则:
|
||||
- 若章节数 <= chunk_size,直接一次性生成
|
||||
- 若章节数 > chunk_size,进行分块生成
|
||||
生成完成后输出至 Novel_directory.txt。
|
||||
"""
|
||||
arch_file = os.path.join(filepath, "Novel_architecture.txt")
|
||||
if not os.path.exists(arch_file):
|
||||
logging.warning("Novel_architecture.txt not found. Please generate architecture first.")
|
||||
return
|
||||
|
||||
architecture_text = read_file(arch_file).strip()
|
||||
if not architecture_text:
|
||||
logging.warning("Novel_architecture.txt is empty.")
|
||||
return
|
||||
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=llm_model,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
filename_dir = os.path.join(filepath, "Novel_directory.txt")
|
||||
if not os.path.exists(filename_dir):
|
||||
open(filename_dir, "w", encoding="utf-8").close()
|
||||
|
||||
existing_blueprint = read_file(filename_dir).strip()
|
||||
chunk_size = compute_chunk_size(number_of_chapters, max_tokens)
|
||||
logging.info(f"Number of chapters = {number_of_chapters}, computed chunk_size = {chunk_size}.")
|
||||
|
||||
if existing_blueprint:
|
||||
logging.info("Detected existing blueprint content. Will resume chunked generation from that point.")
|
||||
pattern = r"第\s*(\d+)\s*章"
|
||||
existing_chapter_numbers = re.findall(pattern, existing_blueprint)
|
||||
existing_chapter_numbers = [int(x) for x in existing_chapter_numbers if x.isdigit()]
|
||||
max_existing_chap = max(existing_chapter_numbers) if existing_chapter_numbers else 0
|
||||
logging.info(f"Existing blueprint indicates up to chapter {max_existing_chap} has been generated.")
|
||||
final_blueprint = existing_blueprint
|
||||
current_start = max_existing_chap + 1
|
||||
while current_start <= number_of_chapters:
|
||||
current_end = min(current_start + chunk_size - 1, number_of_chapters)
|
||||
limited_blueprint = limit_chapter_blueprint(final_blueprint, 100)
|
||||
chunk_prompt = chunked_chapter_blueprint_prompt.format(
|
||||
novel_architecture=architecture_text,
|
||||
chapter_list=limited_blueprint,
|
||||
number_of_chapters=number_of_chapters,
|
||||
n=current_start,
|
||||
m=current_end,
|
||||
user_guidance=user_guidance # 新增参数
|
||||
)
|
||||
logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...")
|
||||
chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt)
|
||||
if not chunk_result.strip():
|
||||
logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.")
|
||||
clear_file_content(filename_dir)
|
||||
save_string_to_txt(final_blueprint.strip(), filename_dir)
|
||||
return
|
||||
final_blueprint += "\n\n" + chunk_result.strip()
|
||||
clear_file_content(filename_dir)
|
||||
save_string_to_txt(final_blueprint.strip(), filename_dir)
|
||||
current_start = current_end + 1
|
||||
|
||||
logging.info("All chapters blueprint have been generated (resumed chunked).")
|
||||
return
|
||||
|
||||
if chunk_size >= number_of_chapters:
|
||||
prompt = chapter_blueprint_prompt.format(
|
||||
novel_architecture=architecture_text,
|
||||
number_of_chapters=number_of_chapters,
|
||||
user_guidance=user_guidance # 新增参数
|
||||
)
|
||||
blueprint_text = invoke_with_cleaning(llm_adapter, prompt)
|
||||
if not blueprint_text.strip():
|
||||
logging.warning("Chapter blueprint generation result is empty.")
|
||||
return
|
||||
|
||||
clear_file_content(filename_dir)
|
||||
save_string_to_txt(blueprint_text, filename_dir)
|
||||
logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (single-shot).")
|
||||
return
|
||||
|
||||
logging.info("Will generate chapter blueprint in chunked mode from scratch.")
|
||||
final_blueprint = ""
|
||||
current_start = 1
|
||||
while current_start <= number_of_chapters:
|
||||
current_end = min(current_start + chunk_size - 1, number_of_chapters)
|
||||
limited_blueprint = limit_chapter_blueprint(final_blueprint, 100)
|
||||
chunk_prompt = chunked_chapter_blueprint_prompt.format(
|
||||
novel_architecture=architecture_text,
|
||||
chapter_list=limited_blueprint,
|
||||
number_of_chapters=number_of_chapters,
|
||||
n=current_start,
|
||||
m=current_end,
|
||||
user_guidance=user_guidance # 新增参数
|
||||
)
|
||||
logging.info(f"Generating chapters [{current_start}..{current_end}] in a chunk...")
|
||||
chunk_result = invoke_with_cleaning(llm_adapter, chunk_prompt)
|
||||
if not chunk_result.strip():
|
||||
logging.warning(f"Chunk generation for chapters [{current_start}..{current_end}] is empty.")
|
||||
clear_file_content(filename_dir)
|
||||
save_string_to_txt(final_blueprint.strip(), filename_dir)
|
||||
return
|
||||
if final_blueprint.strip():
|
||||
final_blueprint += "\n\n" + chunk_result.strip()
|
||||
else:
|
||||
final_blueprint = chunk_result.strip()
|
||||
clear_file_content(filename_dir)
|
||||
save_string_to_txt(final_blueprint.strip(), filename_dir)
|
||||
current_start = current_end + 1
|
||||
|
||||
logging.info("Novel_directory.txt (chapter blueprint) has been generated successfully (chunked).")
|
||||
@@ -0,0 +1,585 @@
|
||||
# novel_generator/chapter.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
章节草稿生成及获取历史章节文本、当前章节摘要等
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import re # 添加re模块导入
|
||||
from llm_adapters import create_llm_adapter
|
||||
from prompt_definitions import (
|
||||
first_chapter_draft_prompt,
|
||||
next_chapter_draft_prompt,
|
||||
summarize_recent_chapters_prompt,
|
||||
knowledge_filter_prompt,
|
||||
knowledge_search_prompt
|
||||
)
|
||||
from chapter_directory_parser import get_chapter_info_from_blueprint
|
||||
from novel_generator.common import invoke_with_cleaning
|
||||
from utils import read_file, clear_file_content, save_string_to_txt
|
||||
from novel_generator.vectorstore_utils import (
|
||||
get_relevant_context_from_vector_store,
|
||||
load_vector_store # 添加导入
|
||||
)
|
||||
|
||||
def get_last_n_chapters_text(chapters_dir: str, current_chapter_num: int, n: int = 3) -> list:
|
||||
"""
|
||||
从目录 chapters_dir 中获取最近 n 章的文本内容,返回文本列表。
|
||||
"""
|
||||
texts = []
|
||||
start_chap = max(1, current_chapter_num - n)
|
||||
for c in range(start_chap, current_chapter_num):
|
||||
chap_file = os.path.join(chapters_dir, f"chapter_{c}.txt")
|
||||
if os.path.exists(chap_file):
|
||||
text = read_file(chap_file).strip()
|
||||
texts.append(text)
|
||||
else:
|
||||
texts.append("")
|
||||
return texts
|
||||
|
||||
def summarize_recent_chapters(
|
||||
interface_format: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
chapters_text_list: list,
|
||||
novel_number: int, # 新增参数
|
||||
chapter_info: dict, # 新增参数
|
||||
next_chapter_info: dict, # 新增参数
|
||||
timeout: int = 600
|
||||
) -> str: # 修改返回值类型为 str,不再是 tuple
|
||||
"""
|
||||
根据前三章内容生成当前章节的精准摘要。
|
||||
如果解析失败,则返回空字符串。
|
||||
"""
|
||||
try:
|
||||
combined_text = "\n".join(chapters_text_list).strip()
|
||||
if not combined_text:
|
||||
return ""
|
||||
|
||||
# 限制组合文本长度
|
||||
max_combined_length = 4000
|
||||
if len(combined_text) > max_combined_length:
|
||||
combined_text = combined_text[-max_combined_length:]
|
||||
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 确保所有参数都有默认值
|
||||
chapter_info = chapter_info or {}
|
||||
next_chapter_info = next_chapter_info or {}
|
||||
|
||||
prompt = summarize_recent_chapters_prompt.format(
|
||||
combined_text=combined_text,
|
||||
novel_number=novel_number,
|
||||
chapter_title=chapter_info.get("chapter_title", "未命名"),
|
||||
chapter_role=chapter_info.get("chapter_role", "常规章节"),
|
||||
chapter_purpose=chapter_info.get("chapter_purpose", "内容推进"),
|
||||
suspense_level=chapter_info.get("suspense_level", "中等"),
|
||||
foreshadowing=chapter_info.get("foreshadowing", "无"),
|
||||
plot_twist_level=chapter_info.get("plot_twist_level", "★☆☆☆☆"),
|
||||
chapter_summary=chapter_info.get("chapter_summary", ""),
|
||||
next_chapter_number=novel_number + 1,
|
||||
next_chapter_title=next_chapter_info.get("chapter_title", "(未命名)"),
|
||||
next_chapter_role=next_chapter_info.get("chapter_role", "过渡章节"),
|
||||
next_chapter_purpose=next_chapter_info.get("chapter_purpose", "承上启下"),
|
||||
next_chapter_summary=next_chapter_info.get("chapter_summary", "衔接过渡内容"),
|
||||
next_chapter_suspense_level=next_chapter_info.get("suspense_level", "中等"),
|
||||
next_chapter_foreshadowing=next_chapter_info.get("foreshadowing", "无特殊伏笔"),
|
||||
next_chapter_plot_twist_level=next_chapter_info.get("plot_twist_level", "★☆☆☆☆")
|
||||
)
|
||||
|
||||
response_text = invoke_with_cleaning(llm_adapter, prompt)
|
||||
summary = extract_summary_from_response(response_text)
|
||||
|
||||
if not summary:
|
||||
logging.warning("Failed to extract summary, using full response")
|
||||
return response_text[:2000] # 限制长度
|
||||
|
||||
return summary[:2000] # 限制摘要长度
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in summarize_recent_chapters: {str(e)}")
|
||||
return ""
|
||||
|
||||
def extract_summary_from_response(response_text: str) -> str:
|
||||
"""从响应文本中提取摘要部分"""
|
||||
if not response_text:
|
||||
return ""
|
||||
|
||||
# 查找摘要标记
|
||||
summary_markers = [
|
||||
"当前章节摘要:",
|
||||
"章节摘要:",
|
||||
"摘要:",
|
||||
"本章摘要:"
|
||||
]
|
||||
|
||||
for marker in summary_markers:
|
||||
if (marker in response_text):
|
||||
parts = response_text.split(marker, 1)
|
||||
if len(parts) > 1:
|
||||
return parts[1].strip()
|
||||
|
||||
return response_text.strip()
|
||||
|
||||
def format_chapter_info(chapter_info: dict) -> str:
|
||||
"""将章节信息字典格式化为文本"""
|
||||
template = """
|
||||
章节编号:第{number}章
|
||||
章节标题:《{title}》
|
||||
章节定位:{role}
|
||||
核心作用:{purpose}
|
||||
主要人物:{characters}
|
||||
关键道具:{items}
|
||||
场景地点:{location}
|
||||
伏笔设计:{foreshadow}
|
||||
悬念密度:{suspense}
|
||||
转折程度:{twist}
|
||||
章节简述:{summary}
|
||||
"""
|
||||
return template.format(
|
||||
number=chapter_info.get('chapter_number', '未知'),
|
||||
title=chapter_info.get('chapter_title', '未知'),
|
||||
role=chapter_info.get('chapter_role', '未知'),
|
||||
purpose=chapter_info.get('chapter_purpose', '未知'),
|
||||
characters=chapter_info.get('characters_involved', '未指定'),
|
||||
items=chapter_info.get('key_items', '未指定'),
|
||||
location=chapter_info.get('scene_location', '未指定'),
|
||||
foreshadow=chapter_info.get('foreshadowing', '无'),
|
||||
suspense=chapter_info.get('suspense_level', '一般'),
|
||||
twist=chapter_info.get('plot_twist_level', '★☆☆☆☆'),
|
||||
summary=chapter_info.get('chapter_summary', '未提供')
|
||||
)
|
||||
|
||||
def parse_search_keywords(response_text: str) -> list:
|
||||
"""解析新版关键词格式(示例输入:'科技公司·数据泄露\n地下实验室·基因编辑')"""
|
||||
return [
|
||||
line.strip().replace('·', ' ')
|
||||
for line in response_text.strip().split('\n')
|
||||
if '·' in line
|
||||
][:5] # 最多取5组
|
||||
|
||||
def apply_content_rules(texts: list, novel_number: int) -> list:
|
||||
"""应用内容处理规则"""
|
||||
processed = []
|
||||
for text in texts:
|
||||
if re.search(r'第[\d]+章', text) or re.search(r'chapter_[\d]+', text):
|
||||
chap_nums = list(map(int, re.findall(r'\d+', text)))
|
||||
recent_chap = max(chap_nums) if chap_nums else 0
|
||||
time_distance = novel_number - recent_chap
|
||||
|
||||
if time_distance <= 2:
|
||||
processed.append(f"[SKIP] 跳过近章内容:{text[:120]}...")
|
||||
elif 3 <= time_distance <= 5:
|
||||
processed.append(f"[MOD40%] {text}(需修改≥40%)")
|
||||
else:
|
||||
processed.append(f"[OK] {text}(可引用核心)")
|
||||
else:
|
||||
processed.append(f"[PRIOR] {text}(优先使用)")
|
||||
return processed
|
||||
|
||||
def apply_knowledge_rules(contexts: list, chapter_num: int) -> list:
|
||||
"""应用知识库使用规则"""
|
||||
processed = []
|
||||
for text in contexts:
|
||||
# 检测历史章节内容
|
||||
if "第" in text and "章" in text:
|
||||
# 提取章节号判断时间远近
|
||||
chap_nums = [int(s) for s in text.split() if s.isdigit()]
|
||||
recent_chap = max(chap_nums) if chap_nums else 0
|
||||
time_distance = chapter_num - recent_chap
|
||||
|
||||
# 相似度处理规则
|
||||
if time_distance <= 3: # 近三章内容
|
||||
processed.append(f"[历史章节限制] 跳过近期内容: {text[:50]}...")
|
||||
continue
|
||||
|
||||
# 允许引用但需要转换
|
||||
processed.append(f"[历史参考] {text} (需进行30%以上改写)")
|
||||
else:
|
||||
# 第三方知识优先处理
|
||||
processed.append(f"[外部知识] {text}")
|
||||
return processed
|
||||
|
||||
def get_filtered_knowledge_context(
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
interface_format: str,
|
||||
embedding_adapter,
|
||||
filepath: str,
|
||||
chapter_info: dict,
|
||||
retrieved_texts: list,
|
||||
max_tokens: int = 2048,
|
||||
timeout: int = 600
|
||||
) -> str:
|
||||
"""优化后的知识过滤处理"""
|
||||
if not retrieved_texts:
|
||||
return "(无相关知识库内容)"
|
||||
|
||||
try:
|
||||
processed_texts = apply_knowledge_rules(retrieved_texts, chapter_info.get('chapter_number', 0))
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 限制检索文本长度并格式化
|
||||
formatted_texts = []
|
||||
max_text_length = 600
|
||||
for i, text in enumerate(processed_texts, 1):
|
||||
if len(text) > max_text_length:
|
||||
text = text[:max_text_length] + "..."
|
||||
formatted_texts.append(f"[预处理结果{i}]\n{text}")
|
||||
|
||||
# 使用格式化函数处理章节信息
|
||||
formatted_chapter_info = (
|
||||
f"当前章节定位:{chapter_info.get('chapter_role', '')}\n"
|
||||
f"核心目标:{chapter_info.get('chapter_purpose', '')}\n"
|
||||
f"关键要素:{chapter_info.get('characters_involved', '')} | "
|
||||
f"{chapter_info.get('key_items', '')} | "
|
||||
f"{chapter_info.get('scene_location', '')}"
|
||||
)
|
||||
|
||||
prompt = knowledge_filter_prompt.format(
|
||||
chapter_info=formatted_chapter_info,
|
||||
retrieved_texts="\n\n".join(formatted_texts) if formatted_texts else "(无检索结果)"
|
||||
)
|
||||
|
||||
filtered_content = invoke_with_cleaning(llm_adapter, prompt)
|
||||
return filtered_content if filtered_content else "(知识内容过滤失败)"
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in knowledge filtering: {str(e)}")
|
||||
return "(内容过滤过程出错)"
|
||||
|
||||
def build_chapter_prompt(
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
filepath: str,
|
||||
novel_number: int,
|
||||
word_number: int,
|
||||
temperature: float,
|
||||
user_guidance: str,
|
||||
characters_involved: str,
|
||||
key_items: str,
|
||||
scene_location: str,
|
||||
time_constraint: str,
|
||||
embedding_api_key: str,
|
||||
embedding_url: str,
|
||||
embedding_interface_format: str,
|
||||
embedding_model_name: str,
|
||||
embedding_retrieval_k: int = 2,
|
||||
interface_format: str = "openai",
|
||||
max_tokens: int = 2048,
|
||||
timeout: int = 600
|
||||
) -> str:
|
||||
"""
|
||||
构造当前章节的请求提示词(完整实现版)
|
||||
修改重点:
|
||||
1. 优化知识库检索流程
|
||||
2. 新增内容重复检测机制
|
||||
3. 集成提示词应用规则
|
||||
"""
|
||||
# 读取基础文件
|
||||
arch_file = os.path.join(filepath, "Novel_architecture.txt")
|
||||
novel_architecture_text = read_file(arch_file)
|
||||
directory_file = os.path.join(filepath, "Novel_directory.txt")
|
||||
blueprint_text = read_file(directory_file)
|
||||
global_summary_file = os.path.join(filepath, "global_summary.txt")
|
||||
global_summary_text = read_file(global_summary_file)
|
||||
character_state_file = os.path.join(filepath, "character_state.txt")
|
||||
character_state_text = read_file(character_state_file)
|
||||
|
||||
# 获取章节信息
|
||||
chapter_info = get_chapter_info_from_blueprint(blueprint_text, novel_number)
|
||||
chapter_title = chapter_info["chapter_title"]
|
||||
chapter_role = chapter_info["chapter_role"]
|
||||
chapter_purpose = chapter_info["chapter_purpose"]
|
||||
suspense_level = chapter_info["suspense_level"]
|
||||
foreshadowing = chapter_info["foreshadowing"]
|
||||
plot_twist_level = chapter_info["plot_twist_level"]
|
||||
chapter_summary = chapter_info["chapter_summary"]
|
||||
|
||||
# 获取下一章节信息
|
||||
next_chapter_number = novel_number + 1
|
||||
next_chapter_info = get_chapter_info_from_blueprint(blueprint_text, next_chapter_number)
|
||||
next_chapter_title = next_chapter_info.get("chapter_title", "(未命名)")
|
||||
next_chapter_role = next_chapter_info.get("chapter_role", "过渡章节")
|
||||
next_chapter_purpose = next_chapter_info.get("chapter_purpose", "承上启下")
|
||||
next_chapter_suspense = next_chapter_info.get("suspense_level", "中等")
|
||||
next_chapter_foreshadow = next_chapter_info.get("foreshadowing", "无特殊伏笔")
|
||||
next_chapter_twist = next_chapter_info.get("plot_twist_level", "★☆☆☆☆")
|
||||
next_chapter_summary = next_chapter_info.get("chapter_summary", "衔接过渡内容")
|
||||
|
||||
# 创建章节目录
|
||||
chapters_dir = os.path.join(filepath, "chapters")
|
||||
os.makedirs(chapters_dir, exist_ok=True)
|
||||
|
||||
# 第一章特殊处理
|
||||
if novel_number == 1:
|
||||
return first_chapter_draft_prompt.format(
|
||||
novel_number=novel_number,
|
||||
word_number=word_number,
|
||||
chapter_title=chapter_title,
|
||||
chapter_role=chapter_role,
|
||||
chapter_purpose=chapter_purpose,
|
||||
suspense_level=suspense_level,
|
||||
foreshadowing=foreshadowing,
|
||||
plot_twist_level=plot_twist_level,
|
||||
chapter_summary=chapter_summary,
|
||||
characters_involved=characters_involved,
|
||||
key_items=key_items,
|
||||
scene_location=scene_location,
|
||||
time_constraint=time_constraint,
|
||||
user_guidance=user_guidance,
|
||||
novel_setting=novel_architecture_text
|
||||
)
|
||||
|
||||
# 获取前文内容和摘要
|
||||
recent_texts = get_last_n_chapters_text(chapters_dir, novel_number, n=3)
|
||||
|
||||
try:
|
||||
logging.info("Attempting to generate summary")
|
||||
short_summary = summarize_recent_chapters(
|
||||
interface_format=interface_format,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
chapters_text_list=recent_texts,
|
||||
novel_number=novel_number,
|
||||
chapter_info=chapter_info,
|
||||
next_chapter_info=next_chapter_info,
|
||||
timeout=timeout
|
||||
)
|
||||
logging.info("Summary generated successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"Error in summarize_recent_chapters: {str(e)}")
|
||||
short_summary = "(摘要生成失败)"
|
||||
|
||||
# 获取前一章结尾
|
||||
previous_excerpt = ""
|
||||
for text in reversed(recent_texts):
|
||||
if text.strip():
|
||||
previous_excerpt = text[-800:] if len(text) > 800 else text
|
||||
break
|
||||
|
||||
# 知识库检索和处理
|
||||
try:
|
||||
# 生成检索关键词
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
search_prompt = knowledge_search_prompt.format(
|
||||
chapter_number=novel_number,
|
||||
chapter_title=chapter_title,
|
||||
characters_involved=characters_involved,
|
||||
key_items=key_items,
|
||||
scene_location=scene_location,
|
||||
chapter_role=chapter_role,
|
||||
chapter_purpose=chapter_purpose,
|
||||
foreshadowing=foreshadowing,
|
||||
short_summary=short_summary,
|
||||
user_guidance=user_guidance,
|
||||
time_constraint=time_constraint
|
||||
)
|
||||
|
||||
search_response = invoke_with_cleaning(llm_adapter, search_prompt)
|
||||
keyword_groups = parse_search_keywords(search_response)
|
||||
|
||||
# 执行向量检索
|
||||
all_contexts = []
|
||||
from embedding_adapters import create_embedding_adapter
|
||||
embedding_adapter = create_embedding_adapter(
|
||||
embedding_interface_format,
|
||||
embedding_api_key,
|
||||
embedding_url,
|
||||
embedding_model_name
|
||||
)
|
||||
|
||||
store = load_vector_store(embedding_adapter, filepath)
|
||||
if store:
|
||||
collection_size = store._collection.count()
|
||||
actual_k = min(embedding_retrieval_k, max(1, collection_size))
|
||||
|
||||
for group in keyword_groups:
|
||||
context = get_relevant_context_from_vector_store(
|
||||
embedding_adapter=embedding_adapter,
|
||||
query=group,
|
||||
filepath=filepath,
|
||||
k=actual_k
|
||||
)
|
||||
if context:
|
||||
if any(kw in group.lower() for kw in ["技法", "手法", "模板"]):
|
||||
all_contexts.append(f"[TECHNIQUE] {context}")
|
||||
elif any(kw in group.lower() for kw in ["设定", "技术", "世界观"]):
|
||||
all_contexts.append(f"[SETTING] {context}")
|
||||
else:
|
||||
all_contexts.append(f"[GENERAL] {context}")
|
||||
|
||||
# 应用内容规则
|
||||
processed_contexts = apply_content_rules(all_contexts, novel_number)
|
||||
|
||||
# 执行知识过滤
|
||||
chapter_info_for_filter = {
|
||||
"chapter_number": novel_number,
|
||||
"chapter_title": chapter_title,
|
||||
"chapter_role": chapter_role,
|
||||
"chapter_purpose": chapter_purpose,
|
||||
"characters_involved": characters_involved,
|
||||
"key_items": key_items,
|
||||
"scene_location": scene_location,
|
||||
"foreshadowing": foreshadowing, # 修复拼写错误
|
||||
"suspense_level": suspense_level,
|
||||
"plot_twist_level": plot_twist_level,
|
||||
"chapter_summary": chapter_summary,
|
||||
"time_constraint": time_constraint
|
||||
}
|
||||
|
||||
filtered_context = get_filtered_knowledge_context(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
interface_format=interface_format,
|
||||
embedding_adapter=embedding_adapter,
|
||||
filepath=filepath,
|
||||
chapter_info=chapter_info_for_filter,
|
||||
retrieved_texts=processed_contexts,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"知识处理流程异常:{str(e)}")
|
||||
filtered_context = "(知识库处理失败)"
|
||||
|
||||
# 返回最终提示词
|
||||
return next_chapter_draft_prompt.format(
|
||||
user_guidance=user_guidance if user_guidance else "无特殊指导",
|
||||
global_summary=global_summary_text,
|
||||
previous_chapter_excerpt=previous_excerpt,
|
||||
character_state=character_state_text,
|
||||
short_summary=short_summary,
|
||||
novel_number=novel_number,
|
||||
chapter_title=chapter_title,
|
||||
chapter_role=chapter_role,
|
||||
chapter_purpose=chapter_purpose,
|
||||
suspense_level=suspense_level,
|
||||
foreshadowing=foreshadowing,
|
||||
plot_twist_level=plot_twist_level,
|
||||
chapter_summary=chapter_summary,
|
||||
word_number=word_number,
|
||||
characters_involved=characters_involved,
|
||||
key_items=key_items,
|
||||
scene_location=scene_location,
|
||||
time_constraint=time_constraint,
|
||||
next_chapter_number=next_chapter_number,
|
||||
next_chapter_title=next_chapter_title,
|
||||
next_chapter_role=next_chapter_role,
|
||||
next_chapter_purpose=next_chapter_purpose,
|
||||
next_chapter_suspense_level=next_chapter_suspense,
|
||||
next_chapter_foreshadowing=next_chapter_foreshadow,
|
||||
next_chapter_plot_twist_level=next_chapter_twist,
|
||||
next_chapter_summary=next_chapter_summary,
|
||||
filtered_context=filtered_context
|
||||
)
|
||||
|
||||
def generate_chapter_draft(
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
filepath: str,
|
||||
novel_number: int,
|
||||
word_number: int,
|
||||
temperature: float,
|
||||
user_guidance: str,
|
||||
characters_involved: str,
|
||||
key_items: str,
|
||||
scene_location: str,
|
||||
time_constraint: str,
|
||||
embedding_api_key: str,
|
||||
embedding_url: str,
|
||||
embedding_interface_format: str,
|
||||
embedding_model_name: str,
|
||||
embedding_retrieval_k: int = 2,
|
||||
interface_format: str = "openai",
|
||||
max_tokens: int = 2048,
|
||||
timeout: int = 600,
|
||||
custom_prompt_text: str = None
|
||||
) -> str:
|
||||
"""
|
||||
生成章节草稿,支持自定义提示词
|
||||
"""
|
||||
if custom_prompt_text is None:
|
||||
prompt_text = build_chapter_prompt(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
filepath=filepath,
|
||||
novel_number=novel_number,
|
||||
word_number=word_number,
|
||||
temperature=temperature,
|
||||
user_guidance=user_guidance,
|
||||
characters_involved=characters_involved,
|
||||
key_items=key_items,
|
||||
scene_location=scene_location,
|
||||
time_constraint=time_constraint,
|
||||
embedding_api_key=embedding_api_key,
|
||||
embedding_url=embedding_url,
|
||||
embedding_interface_format=embedding_interface_format,
|
||||
embedding_model_name=embedding_model_name,
|
||||
embedding_retrieval_k=embedding_retrieval_k,
|
||||
interface_format=interface_format,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
else:
|
||||
prompt_text = custom_prompt_text
|
||||
|
||||
chapters_dir = os.path.join(filepath, "chapters")
|
||||
os.makedirs(chapters_dir, exist_ok=True)
|
||||
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
chapter_content = invoke_with_cleaning(llm_adapter, prompt_text)
|
||||
if not chapter_content.strip():
|
||||
logging.warning("Generated chapter draft is empty.")
|
||||
chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt")
|
||||
clear_file_content(chapter_file)
|
||||
save_string_to_txt(chapter_content, chapter_file)
|
||||
logging.info(f"[Draft] Chapter {novel_number} generated as a draft.")
|
||||
return chapter_content
|
||||
@@ -0,0 +1,77 @@
|
||||
#novel_generator/common.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用重试、清洗、日志工具
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
|
||||
def call_with_retry(func, max_retries=3, sleep_time=2, fallback_return=None, **kwargs):
|
||||
"""
|
||||
通用的重试机制封装。
|
||||
:param func: 要执行的函数
|
||||
:param max_retries: 最大重试次数
|
||||
:param sleep_time: 重试前的等待秒数
|
||||
:param fallback_return: 如果多次重试仍失败时的返回值
|
||||
:param kwargs: 传给func的命名参数
|
||||
:return: func的结果,若失败则返回 fallback_return
|
||||
"""
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
return func(**kwargs)
|
||||
except Exception as e:
|
||||
logging.warning(f"[call_with_retry] Attempt {attempt} failed with error: {e}")
|
||||
traceback.print_exc()
|
||||
if attempt < max_retries:
|
||||
time.sleep(sleep_time)
|
||||
else:
|
||||
logging.error("Max retries reached, returning fallback_return.")
|
||||
return fallback_return
|
||||
|
||||
def remove_think_tags(text: str) -> str:
|
||||
"""移除 <think>...</think> 包裹的内容"""
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||
|
||||
def debug_log(prompt: str, response_content: str):
|
||||
logging.info(
|
||||
f"\n[######################################### Prompt #########################################]\n{prompt}\n"
|
||||
)
|
||||
logging.info(
|
||||
f"\n[######################################### Response #########################################]\n{response_content}\n"
|
||||
)
|
||||
|
||||
def invoke_with_cleaning(llm_adapter, prompt: str, max_retries: int = 3) -> str:
|
||||
"""调用 LLM 并清理返回结果"""
|
||||
print("\n" + "="*50)
|
||||
print("发送到 LLM 的提示词:")
|
||||
print("-"*50)
|
||||
print(prompt)
|
||||
print("="*50 + "\n")
|
||||
|
||||
result = ""
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
result = llm_adapter.invoke(prompt)
|
||||
print("\n" + "="*50)
|
||||
print("LLM 返回的内容:")
|
||||
print("-"*50)
|
||||
print(result)
|
||||
print("="*50 + "\n")
|
||||
|
||||
# 清理结果中的特殊格式标记
|
||||
result = result.replace("```", "").strip()
|
||||
if result:
|
||||
return result
|
||||
retry_count += 1
|
||||
except Exception as e:
|
||||
print(f"调用失败 ({retry_count + 1}/{max_retries}): {str(e)}")
|
||||
retry_count += 1
|
||||
if retry_count >= max_retries:
|
||||
raise e
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#novel_generator/finalization.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
定稿章节和扩写章节(finalize_chapter、enrich_chapter_text)
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from llm_adapters import create_llm_adapter
|
||||
from embedding_adapters import create_embedding_adapter
|
||||
from prompt_definitions import summary_prompt, update_character_state_prompt
|
||||
from novel_generator.common import invoke_with_cleaning
|
||||
from utils import read_file, clear_file_content, save_string_to_txt
|
||||
from novel_generator.vectorstore_utils import update_vector_store
|
||||
|
||||
def finalize_chapter(
|
||||
novel_number: int,
|
||||
word_number: int,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
temperature: float,
|
||||
filepath: str,
|
||||
embedding_api_key: str,
|
||||
embedding_url: str,
|
||||
embedding_interface_format: str,
|
||||
embedding_model_name: str,
|
||||
interface_format: str,
|
||||
max_tokens: int,
|
||||
timeout: int = 600
|
||||
):
|
||||
"""
|
||||
对指定章节做最终处理:更新前文摘要、更新角色状态、插入向量库等。
|
||||
默认无需再做扩写操作,若有需要可在外部调用 enrich_chapter_text 处理后再定稿。
|
||||
"""
|
||||
chapters_dir = os.path.join(filepath, "chapters")
|
||||
chapter_file = os.path.join(chapters_dir, f"chapter_{novel_number}.txt")
|
||||
chapter_text = read_file(chapter_file).strip()
|
||||
if not chapter_text:
|
||||
logging.warning(f"Chapter {novel_number} is empty, cannot finalize.")
|
||||
return
|
||||
|
||||
global_summary_file = os.path.join(filepath, "global_summary.txt")
|
||||
old_global_summary = read_file(global_summary_file)
|
||||
character_state_file = os.path.join(filepath, "character_state.txt")
|
||||
old_character_state = read_file(character_state_file)
|
||||
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
prompt_summary = summary_prompt.format(
|
||||
chapter_text=chapter_text,
|
||||
global_summary=old_global_summary
|
||||
)
|
||||
new_global_summary = invoke_with_cleaning(llm_adapter, prompt_summary)
|
||||
if not new_global_summary.strip():
|
||||
new_global_summary = old_global_summary
|
||||
|
||||
prompt_char_state = update_character_state_prompt.format(
|
||||
chapter_text=chapter_text,
|
||||
old_state=old_character_state
|
||||
)
|
||||
new_char_state = invoke_with_cleaning(llm_adapter, prompt_char_state)
|
||||
if not new_char_state.strip():
|
||||
new_char_state = old_character_state
|
||||
|
||||
clear_file_content(global_summary_file)
|
||||
save_string_to_txt(new_global_summary, global_summary_file)
|
||||
clear_file_content(character_state_file)
|
||||
save_string_to_txt(new_char_state, character_state_file)
|
||||
|
||||
update_vector_store(
|
||||
embedding_adapter=create_embedding_adapter(
|
||||
embedding_interface_format,
|
||||
embedding_api_key,
|
||||
embedding_url,
|
||||
embedding_model_name
|
||||
),
|
||||
new_chapter=chapter_text,
|
||||
filepath=filepath
|
||||
)
|
||||
|
||||
logging.info(f"Chapter {novel_number} has been finalized.")
|
||||
|
||||
def enrich_chapter_text(
|
||||
chapter_text: str,
|
||||
word_number: int,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
temperature: float,
|
||||
interface_format: str,
|
||||
max_tokens: int,
|
||||
timeout: int=600
|
||||
) -> str:
|
||||
"""
|
||||
对章节文本进行扩写,使其更接近 word_number 字数,保持剧情连贯。
|
||||
"""
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=interface_format,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
api_key=api_key,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout
|
||||
)
|
||||
prompt = f"""以下章节文本较短,请在保持剧情连贯的前提下进行扩写,使其更充实,接近 {word_number} 字左右:
|
||||
原内容:
|
||||
{chapter_text}
|
||||
"""
|
||||
enriched_text = invoke_with_cleaning(llm_adapter, prompt)
|
||||
return enriched_text if enriched_text else chapter_text
|
||||
@@ -0,0 +1,87 @@
|
||||
#novel_generator/knowledge.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
知识文件导入至向量库(advanced_split_content、import_knowledge_file)
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
import nltk
|
||||
import warnings
|
||||
from utils import read_file
|
||||
from novel_generator.vectorstore_utils import load_vector_store, init_vector_store
|
||||
from langchain.docstore.document import Document
|
||||
|
||||
# 禁用特定的Torch警告
|
||||
warnings.filterwarnings('ignore', message='.*Torch was not compiled with flash attention.*')
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
|
||||
def advanced_split_content(content: str, similarity_threshold: float = 0.7, max_length: int = 500) -> list:
|
||||
"""使用基本分段策略"""
|
||||
nltk.download('punkt', quiet=True)
|
||||
nltk.download('punkt_tab', quiet=True)
|
||||
sentences = nltk.sent_tokenize(content)
|
||||
if not sentences:
|
||||
return []
|
||||
|
||||
final_segments = []
|
||||
current_segment = []
|
||||
current_length = 0
|
||||
|
||||
for sentence in sentences:
|
||||
sentence_length = len(sentence)
|
||||
if current_length + sentence_length > max_length:
|
||||
if current_segment:
|
||||
final_segments.append(" ".join(current_segment))
|
||||
current_segment = [sentence]
|
||||
current_length = sentence_length
|
||||
else:
|
||||
current_segment.append(sentence)
|
||||
current_length += sentence_length
|
||||
|
||||
if current_segment:
|
||||
final_segments.append(" ".join(current_segment))
|
||||
|
||||
return final_segments
|
||||
|
||||
def import_knowledge_file(
|
||||
embedding_api_key: str,
|
||||
embedding_url: str,
|
||||
embedding_interface_format: str,
|
||||
embedding_model_name: str,
|
||||
file_path: str,
|
||||
filepath: str
|
||||
):
|
||||
logging.info(f"开始导入知识库文件: {file_path}, 接口格式: {embedding_interface_format}, 模型: {embedding_model_name}")
|
||||
if not os.path.exists(file_path):
|
||||
logging.warning(f"知识库文件不存在: {file_path}")
|
||||
return
|
||||
content = read_file(file_path)
|
||||
if not content.strip():
|
||||
logging.warning("知识库文件内容为空。")
|
||||
return
|
||||
paragraphs = advanced_split_content(content)
|
||||
from embedding_adapters import create_embedding_adapter
|
||||
embedding_adapter = create_embedding_adapter(
|
||||
embedding_interface_format,
|
||||
embedding_api_key,
|
||||
embedding_url if embedding_url else "http://localhost:11434/api",
|
||||
embedding_model_name
|
||||
)
|
||||
store = load_vector_store(embedding_adapter, filepath)
|
||||
if not store:
|
||||
logging.info("Vector store does not exist or load failed. Initializing a new one for knowledge import...")
|
||||
store = init_vector_store(embedding_adapter, paragraphs, filepath)
|
||||
if store:
|
||||
logging.info("知识库文件已成功导入至向量库(新初始化)。")
|
||||
else:
|
||||
logging.warning("知识库导入失败,跳过。")
|
||||
else:
|
||||
try:
|
||||
docs = [Document(page_content=str(p)) for p in paragraphs]
|
||||
store.add_documents(docs)
|
||||
logging.info("知识库文件已成功导入至向量库(追加模式)。")
|
||||
except Exception as e:
|
||||
logging.warning(f"知识库导入失败: {e}")
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,244 @@
|
||||
#novel_generator/vectorstore_utils.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
向量库相关操作(初始化、更新、检索、清空、文本切分等)
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import traceback
|
||||
import nltk
|
||||
import numpy as np
|
||||
import re
|
||||
import ssl
|
||||
import requests
|
||||
import warnings
|
||||
from langchain_chroma import Chroma
|
||||
|
||||
# 禁用特定的Torch警告
|
||||
warnings.filterwarnings('ignore', message='.*Torch was not compiled with flash attention.*')
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false" # 禁用tokenizer并行警告
|
||||
|
||||
from chromadb.config import Settings
|
||||
from langchain.docstore.document import Document
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
from .common import call_with_retry
|
||||
|
||||
def get_vectorstore_dir(filepath: str) -> str:
|
||||
"""获取 vectorstore 路径"""
|
||||
return os.path.join(filepath, "vectorstore")
|
||||
|
||||
def clear_vector_store(filepath: str) -> bool:
|
||||
"""清空 清空向量库"""
|
||||
import shutil
|
||||
store_dir = get_vectorstore_dir(filepath)
|
||||
if not os.path.exists(store_dir):
|
||||
logging.info("No vector store found to clear.")
|
||||
return False
|
||||
try:
|
||||
shutil.rmtree(store_dir)
|
||||
logging.info(f"Vector store directory '{store_dir}' removed.")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def init_vector_store(embedding_adapter, texts, filepath: str):
|
||||
"""
|
||||
在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。
|
||||
如果Embedding失败,则返回 None,不中断任务。
|
||||
"""
|
||||
from langchain.embeddings.base import Embeddings as LCEmbeddings
|
||||
|
||||
store_dir = get_vectorstore_dir(filepath)
|
||||
os.makedirs(store_dir, exist_ok=True)
|
||||
documents = [Document(page_content=str(t)) for t in texts]
|
||||
|
||||
try:
|
||||
class LCEmbeddingWrapper(LCEmbeddings):
|
||||
def embed_documents(self, texts):
|
||||
return call_with_retry(
|
||||
func=embedding_adapter.embed_documents,
|
||||
max_retries=3,
|
||||
fallback_return=[],
|
||||
texts=texts
|
||||
)
|
||||
def embed_query(self, query: str):
|
||||
res = call_with_retry(
|
||||
func=embedding_adapter.embed_query,
|
||||
max_retries=3,
|
||||
fallback_return=[],
|
||||
query=query
|
||||
)
|
||||
return res
|
||||
|
||||
chroma_embedding = LCEmbeddingWrapper()
|
||||
vectorstore = Chroma.from_documents(
|
||||
documents,
|
||||
embedding=chroma_embedding,
|
||||
persist_directory=store_dir,
|
||||
client_settings=Settings(anonymized_telemetry=False),
|
||||
collection_name="novel_collection"
|
||||
)
|
||||
return vectorstore
|
||||
except Exception as e:
|
||||
logging.warning(f"Init vector store failed: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def load_vector_store(embedding_adapter, filepath: str):
|
||||
"""
|
||||
读取已存在的 Chroma 向量库。若不存在则返回 None。
|
||||
如果加载失败(embedding 或IO问题),则返回 None。
|
||||
"""
|
||||
from langchain.embeddings.base import Embeddings as LCEmbeddings
|
||||
store_dir = get_vectorstore_dir(filepath)
|
||||
if not os.path.exists(store_dir):
|
||||
logging.info("Vector store not found. Will return None.")
|
||||
return None
|
||||
|
||||
try:
|
||||
class LCEmbeddingWrapper(LCEmbeddings):
|
||||
def embed_documents(self, texts):
|
||||
return call_with_retry(
|
||||
func=embedding_adapter.embed_documents,
|
||||
max_retries=3,
|
||||
fallback_return=[],
|
||||
texts=texts
|
||||
)
|
||||
def embed_query(self, query: str):
|
||||
res = call_with_retry(
|
||||
func=embedding_adapter.embed_query,
|
||||
max_retries=3,
|
||||
fallback_return=[],
|
||||
query=query
|
||||
)
|
||||
return res
|
||||
|
||||
chroma_embedding = LCEmbeddingWrapper()
|
||||
return Chroma(
|
||||
persist_directory=store_dir,
|
||||
embedding_function=chroma_embedding,
|
||||
client_settings=Settings(anonymized_telemetry=False),
|
||||
collection_name="novel_collection"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to load vector store: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def split_by_length(text: str, max_length: int = 500):
|
||||
"""按照 max_length 切分文本"""
|
||||
segments = []
|
||||
start_idx = 0
|
||||
while start_idx < len(text):
|
||||
end_idx = min(start_idx + max_length, len(text))
|
||||
segment = text[start_idx:end_idx]
|
||||
segments.append(segment.strip())
|
||||
start_idx = end_idx
|
||||
return segments
|
||||
|
||||
def split_text_for_vectorstore(chapter_text: str, max_length: int = 500, similarity_threshold: float = 0.7):
|
||||
"""
|
||||
对新的章节文本进行分段后,再用于存入向量库。
|
||||
使用 embedding 进行文本相似度计算。
|
||||
"""
|
||||
if not chapter_text.strip():
|
||||
return []
|
||||
|
||||
nltk.download('punkt', quiet=True)
|
||||
nltk.download('punkt_tab', quiet=True)
|
||||
sentences = nltk.sent_tokenize(chapter_text)
|
||||
if not sentences:
|
||||
return []
|
||||
|
||||
# 直接按长度分段,不做相似度合并
|
||||
final_segments = []
|
||||
current_segment = []
|
||||
current_length = 0
|
||||
|
||||
for sentence in sentences:
|
||||
sentence_length = len(sentence)
|
||||
if current_length + sentence_length > max_length:
|
||||
if current_segment:
|
||||
final_segments.append(" ".join(current_segment))
|
||||
current_segment = [sentence]
|
||||
current_length = sentence_length
|
||||
else:
|
||||
current_segment.append(sentence)
|
||||
current_length += sentence_length
|
||||
|
||||
if current_segment:
|
||||
final_segments.append(" ".join(current_segment))
|
||||
|
||||
return final_segments
|
||||
|
||||
def update_vector_store(embedding_adapter, new_chapter: str, filepath: str):
|
||||
"""
|
||||
将最新章节文本插入到向量库中。
|
||||
若库不存在则初始化;若初始化/更新失败,则跳过。
|
||||
"""
|
||||
from utils import read_file, clear_file_content, save_string_to_txt
|
||||
splitted_texts = split_text_for_vectorstore(new_chapter)
|
||||
if not splitted_texts:
|
||||
logging.warning("No valid text to insert into vector store. Skipping.")
|
||||
return
|
||||
|
||||
store = load_vector_store(embedding_adapter, filepath)
|
||||
if not store:
|
||||
logging.info("Vector store does not exist or failed to load. Initializing a new one for new chapter...")
|
||||
store = init_vector_store(embedding_adapter, splitted_texts, filepath)
|
||||
if not store:
|
||||
logging.warning("Init vector store failed, skip embedding.")
|
||||
else:
|
||||
logging.info("New vector store created successfully.")
|
||||
return
|
||||
|
||||
try:
|
||||
docs = [Document(page_content=str(t)) for t in splitted_texts]
|
||||
store.add_documents(docs)
|
||||
logging.info("Vector store updated with the new chapter splitted segments.")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to update vector store: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
def get_relevant_context_from_vector_store(embedding_adapter, query: str, filepath: str, k: int = 2) -> str:
|
||||
"""
|
||||
从向量库中检索与 query 最相关的 k 条文本,拼接后返回。
|
||||
如果向量库加载/检索失败,则返回空字符串。
|
||||
最终只返回最多2000字符的检索片段。
|
||||
"""
|
||||
store = load_vector_store(embedding_adapter, filepath)
|
||||
if not store:
|
||||
logging.info("No vector store found or load failed. Returning empty context.")
|
||||
return ""
|
||||
|
||||
try:
|
||||
docs = store.similarity_search(query, k=k)
|
||||
if not docs:
|
||||
logging.info(f"No relevant documents found for query '{query}'. Returning empty context.")
|
||||
return ""
|
||||
combined = "\n".join([d.page_content for d in docs])
|
||||
if len(combined) > 2000:
|
||||
combined = combined[:2000]
|
||||
return combined
|
||||
except Exception as e:
|
||||
logging.warning(f"Similarity search failed: {e}")
|
||||
traceback.print_exc()
|
||||
return ""
|
||||
|
||||
def _get_sentence_transformer(model_name: str = 'paraphrase-MiniLM-L6-v2'):
|
||||
"""获取sentence transformer模型,处理SSL问题"""
|
||||
try:
|
||||
# 设置torch环境变量
|
||||
os.environ["TORCH_ALLOW_TF32_CUBLAS_OVERRIDE"] = "0"
|
||||
os.environ["TORCH_CUDNN_V8_API_ENABLED"] = "0"
|
||||
|
||||
# 禁用SSL验证
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
# ...existing code...
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load sentence transformer model: {e}")
|
||||
traceback.print_exc()
|
||||
return None
|
||||
@@ -0,0 +1,662 @@
|
||||
# prompt_definitions.py
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
集中存放所有提示词 (Prompt),整合雪花写作法、角色弧光理论、悬念三要素模型等
|
||||
并包含新增加的前三章摘要/下一章关键字提炼提示词,以及章节正文写作提示词。
|
||||
"""
|
||||
|
||||
# =============== 生成草稿提示词当前章节摘要、知识库提炼 ===============
|
||||
# 当前章节摘要生成提示词
|
||||
summarize_recent_chapters_prompt = """\
|
||||
作为一名专业的小说编辑和知识管理专家,正在基于已完成的前三章内容和本章信息生成当前章节的精准摘要。请严格遵循以下工作流程:
|
||||
前三章内容:
|
||||
{combined_text}
|
||||
|
||||
当前章节信息:
|
||||
第{novel_number}章《{chapter_title}》:
|
||||
├── 本章定位:{chapter_role}
|
||||
├── 核心作用:{chapter_purpose}
|
||||
├── 悬念密度:{suspense_level}
|
||||
├── 伏笔操作:{foreshadowing}
|
||||
├── 认知颠覆:{plot_twist_level}
|
||||
└── 本章简述:{chapter_summary}
|
||||
|
||||
下一章信息:
|
||||
第{next_chapter_number}章《{next_chapter_title}》:
|
||||
├── 本章定位:{next_chapter_role}
|
||||
├── 核心作用:{next_chapter_purpose}
|
||||
├── 悬念密度:{next_chapter_suspense_level}
|
||||
├── 伏笔操作:{next_chapter_foreshadowing}
|
||||
├── 认知颠覆:{next_chapter_plot_twist_level}
|
||||
└── 本章简述:{next_chapter_summary}
|
||||
|
||||
**上下文分析阶段**:
|
||||
1. 回顾前三章核心内容:
|
||||
- 第一章核心要素:[章节标题]→[核心冲突/理论]→[关键人物/概念]
|
||||
- 第二章发展路径:[已建立的人物关系]→[技术/情节进展]→[遗留伏笔]
|
||||
- 第三章转折点:[新出现的变量]→[世界观扩展]→[待解决问题]
|
||||
2. 提取延续性要素:
|
||||
- 必继承要素:列出前3章中必须延续的3个核心设定
|
||||
- 可调整要素:识别2个允许适度变化的辅助设定
|
||||
|
||||
**当前章节摘要生成规则**:
|
||||
1. 内容架构:
|
||||
- 继承权重:70%内容需与前3章形成逻辑递进
|
||||
- 创新空间:30%内容可引入新要素,但需标注创新类型(如:技术突破/人物黑化)
|
||||
2. 结构控制:
|
||||
- 采用"承继→发展→铺垫"三段式结构
|
||||
- 每段含1个前文呼应点+1个新进展
|
||||
3. 预警机制:
|
||||
- 若检测到与前3章设定冲突,用[!]标记并说明
|
||||
- 对开放式发展路径,提供2种合理演化方向
|
||||
|
||||
现在请你基于目前故事的进展,完成以下两件事:
|
||||
用最多800字,写一个简洁明了的「当前章节摘要」;
|
||||
|
||||
请按如下格式输出(不需要额外解释):
|
||||
当前章节摘要: <这里写当前章节摘要>
|
||||
"""
|
||||
|
||||
# 知识库相关性检索提示词
|
||||
knowledge_search_prompt = """\
|
||||
请基于以下当前写作需求,生成合适的知识库检索关键词:
|
||||
|
||||
章节元数据:
|
||||
- 准备创作:第{chapter_number}章
|
||||
- 章节主题:{chapter_title}
|
||||
- 核心人物:{characters_involved}
|
||||
- 关键道具:{key_items}
|
||||
- 场景位置:{scene_location}
|
||||
|
||||
写作目标:
|
||||
- 本章定位:{chapter_role}
|
||||
- 核心作用:{chapter_purpose}
|
||||
- 伏笔操作:{foreshadowing}
|
||||
|
||||
当前摘要:
|
||||
{short_summary}
|
||||
|
||||
- 用户指导:
|
||||
{user_guidance}
|
||||
|
||||
- 核心人物(可能未指定):{characters_involved}
|
||||
- 关键道具(可能未指定):{key_items}
|
||||
- 空间坐标(可能未指定):{scene_location}
|
||||
- 时间压力(可能未指定):{time_constraint}
|
||||
|
||||
生成规则:
|
||||
|
||||
1.关键词组合逻辑:
|
||||
-类型1:[实体]+[属性](如"量子计算机 故障日志")
|
||||
-类型2:[事件]+[后果](如"实验室爆炸 辐射泄漏")
|
||||
-类型3:[地点]+[特征](如"地下城 氧气循环系统")
|
||||
|
||||
2.优先级:
|
||||
-首选用户指导中明确提及的术语
|
||||
-次选当前章节涉及的核心道具/地点
|
||||
-最后补充可能关联的扩展概念
|
||||
|
||||
3.过滤机制:
|
||||
-排除抽象程度高于"中级"的概念
|
||||
-排除与前3章重复率超60%的词汇
|
||||
|
||||
请生成3-5组检索词,按优先级降序排列。
|
||||
格式:每组用"·"连接2-3个关键词,每组占一行
|
||||
|
||||
示例:
|
||||
科技公司·数据泄露
|
||||
地下实验室·基因编辑·禁忌实验
|
||||
"""
|
||||
|
||||
# 知识库内容过滤提示词
|
||||
knowledge_filter_prompt = """\
|
||||
对知识库内容进行三级过滤:
|
||||
|
||||
待过滤内容:
|
||||
{retrieved_texts}
|
||||
|
||||
当前叙事需求:
|
||||
{chapter_info}
|
||||
|
||||
过滤流程:
|
||||
|
||||
冲突检测:
|
||||
|
||||
删除与已有摘要重复度>40%的内容
|
||||
|
||||
标记存在世界观矛盾的内容(使用▲前缀)
|
||||
|
||||
价值评估:
|
||||
|
||||
关键价值点(❗标记):
|
||||
· 提供新的角色关系可能性
|
||||
· 包含可转化的隐喻素材
|
||||
· 存在至少2个可延伸的细节锚点
|
||||
|
||||
次级价值点(·标记):
|
||||
· 补充环境细节
|
||||
· 提供技术/流程描述
|
||||
|
||||
结构重组:
|
||||
|
||||
按"情节燃料/人物维度/世界碎片/叙事技法"分类
|
||||
|
||||
为每个分类添加适用场景提示(如"可用于XX类型伏笔")
|
||||
|
||||
输出格式:
|
||||
[分类名称]→[适用场景]
|
||||
❗/· [内容片段] (▲冲突提示)
|
||||
...
|
||||
|
||||
示例:
|
||||
[情节燃料]→可用于时间压力类悬念
|
||||
❗ 地下氧气系统剩余23%储量(可制造生存危机)
|
||||
▲ 与第三章提到的"永久生态循环系统"存在设定冲突
|
||||
"""
|
||||
|
||||
# =============== 1. 核心种子设定(雪花第1层)===================
|
||||
core_seed_prompt = """\
|
||||
作为专业作家,请用"雪花写作法"第一步构建故事核心:
|
||||
主题:{topic}
|
||||
类型:{genre}
|
||||
篇幅:约{number_of_chapters}章(每章{word_number}字)
|
||||
|
||||
请用单句公式概括故事本质,例如:
|
||||
"当[主角]遭遇[核心事件],必须[关键行动],否则[灾难后果];与此同时,[隐藏的更大危机]正在发酵。"
|
||||
|
||||
要求:
|
||||
1. 必须包含显性冲突与潜在危机
|
||||
2. 体现人物核心驱动力
|
||||
3. 暗示世界观关键矛盾
|
||||
4. 使用25-100字精准表达
|
||||
|
||||
仅返回故事核心文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
# =============== 2. 角色动力学设定(角色弧光模型)===================
|
||||
character_dynamics_prompt = """\
|
||||
基于以下元素:
|
||||
- 内容指导:{user_guidance}
|
||||
- 核心种子:{core_seed}
|
||||
|
||||
请设计3-6个具有动态变化潜力的核心角色,每个角色需包含:
|
||||
特征:
|
||||
- 背景、外貌、性别、年龄、职业等
|
||||
- 暗藏的秘密或潜在弱点(可与世界观或其他角色有关)
|
||||
|
||||
核心驱动力三角:
|
||||
- 表面追求(物质目标)
|
||||
- 深层渴望(情感需求)
|
||||
- 灵魂需求(哲学层面)
|
||||
|
||||
角色弧线设计:
|
||||
初始状态 → 触发事件 → 认知失调 → 蜕变节点 → 最终状态
|
||||
|
||||
关系冲突网:
|
||||
- 与其他角色的关系或对立点
|
||||
- 与至少两个其他角色的价值观冲突
|
||||
- 一个合作纽带
|
||||
- 一个隐藏的背叛可能性
|
||||
|
||||
要求:
|
||||
仅给出最终文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
# =============== 3. 世界构建矩阵(三维度交织法)===================
|
||||
world_building_prompt = """\
|
||||
基于以下元素:
|
||||
- 内容指导:{user_guidance}
|
||||
- 核心冲突:"{core_seed}"
|
||||
|
||||
为服务上述内容,请构建三维交织的世界观:
|
||||
|
||||
1. 物理维度:
|
||||
- 空间结构(地理×社会阶层分布图)
|
||||
- 时间轴(关键历史事件年表)
|
||||
- 法则体系(物理/魔法/社会规则的漏洞点)
|
||||
|
||||
2. 社会维度:
|
||||
- 权力结构断层线(可引发冲突的阶层/种族/组织矛盾)
|
||||
- 文化禁忌(可被打破的禁忌及其后果)
|
||||
- 经济命脉(资源争夺焦点)
|
||||
|
||||
3. 隐喻维度:
|
||||
- 贯穿全书的视觉符号系统(如反复出现的意象)
|
||||
- 氣候/环境变化映射的心理状态
|
||||
- 建筑风格暗示的文明困境
|
||||
|
||||
要求:
|
||||
每个维度至少包含3个可与角色决策产生互动的动态元素。
|
||||
仅给出最终文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
# =============== 4. 情节架构(三幕式悬念)===================
|
||||
plot_architecture_prompt = """\
|
||||
基于以下元素:
|
||||
- 内容指导:{user_guidance}
|
||||
- 核心种子:{core_seed}
|
||||
- 角色体系:{character_dynamics}
|
||||
- 世界观:{world_building}
|
||||
|
||||
要求按以下结构设计:
|
||||
第一幕(触发)
|
||||
- 日常状态中的异常征兆(3处铺垫)
|
||||
- 引出故事:展示主线、暗线、副线的开端
|
||||
- 关键事件:打破平衡的催化剂(需改变至少3个角色的关系)
|
||||
- 错误抉择:主角的认知局限导致的错误反应
|
||||
|
||||
第二幕(对抗)
|
||||
- 剧情升级:主线+副线的交叉点
|
||||
- 双重压力:外部障碍升级+内部挫折
|
||||
- 虚假胜利:看似解决实则深化危机的转折点
|
||||
- 灵魂黑夜:世界观认知颠覆时刻
|
||||
|
||||
第三幕(解决)
|
||||
- 代价显现:解决危机必须牺牲的核心价值
|
||||
- 嵌套转折:至少包含三层认知颠覆(表面解→新危机→终极抉择)
|
||||
- 余波:留下2个开放式悬念因子
|
||||
|
||||
每个阶段需包含3个关键转折点及其对应的伏笔回收方案。
|
||||
仅给出最终文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
# =============== 5. 章节目录生成(悬念节奏曲线)===================
|
||||
chapter_blueprint_prompt = """\
|
||||
基于以下元素:
|
||||
- 内容指导:{user_guidance}
|
||||
- 小说架构:
|
||||
{novel_architecture}
|
||||
|
||||
设计{number_of_chapters}章的节奏分布:
|
||||
1. 章节集群划分:
|
||||
- 每3-5章构成一个悬念单元,包含完整的小高潮
|
||||
- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲)
|
||||
- 关键转折章需预留多视角铺垫
|
||||
|
||||
2. 每章需明确:
|
||||
- 章节定位(角色/事件/主题等)
|
||||
- 核心悬念类型(信息差/道德困境/时间压力等)
|
||||
- 情感基调迁移(如从怀疑→恐惧→决绝)
|
||||
- 伏笔操作(埋设/强化/回收)
|
||||
- 认知颠覆强度(1-5级)
|
||||
|
||||
输出格式示例:
|
||||
第n章 - [标题]
|
||||
本章定位:[角色/事件/主题/...]
|
||||
核心作用:[推进/转折/揭示/...]
|
||||
悬念密度:[紧凑/渐进/爆发/...]
|
||||
伏笔操作:埋设(A线索)→强化(B矛盾)...
|
||||
认知颠覆:★☆☆☆☆
|
||||
本章简述:[一句话概括]
|
||||
|
||||
第n+1章 - [标题]
|
||||
本章定位:[角色/事件/主题/...]
|
||||
核心作用:[推进/转折/揭示/...]
|
||||
悬念密度:[紧凑/渐进/爆发/...]
|
||||
伏笔操作:埋设(A线索)→强化(B矛盾)...
|
||||
认知颠覆:★☆☆☆☆
|
||||
本章简述:[一句话概括]
|
||||
|
||||
要求:
|
||||
- 使用精炼语言描述,每章字数控制在100字以内。
|
||||
- 合理安排节奏,确保整体悬念曲线的连贯性。
|
||||
- 在生成{number_of_chapters}章前不要出现结局章节。
|
||||
|
||||
仅给出最终文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
chunked_chapter_blueprint_prompt = """\
|
||||
基于以下元素:
|
||||
- 内容指导:{user_guidance}
|
||||
- 小说架构:
|
||||
{novel_architecture}
|
||||
|
||||
需要生成总共{number_of_chapters}章的节奏分布,
|
||||
|
||||
当前已有章节目录(若为空则说明是初始生成):\n
|
||||
{chapter_list}
|
||||
|
||||
现在请设计第{n}章到第{m}的节奏分布:
|
||||
1. 章节集群划分:
|
||||
- 每3-5章构成一个悬念单元,包含完整的小高潮
|
||||
- 单元之间设置"认知过山车"(连续2章紧张→1章缓冲)
|
||||
- 关键转折章需预留多视角铺垫
|
||||
|
||||
2. 每章需明确:
|
||||
- 章节定位(角色/事件/主题等)
|
||||
- 核心悬念类型(信息差/道德困境/时间压力等)
|
||||
- 情感基调迁移(如从怀疑→恐惧→决绝)
|
||||
- 伏笔操作(埋设/强化/回收)
|
||||
- 认知颠覆强度(1-5级)
|
||||
|
||||
输出格式示例:
|
||||
第n章 - [标题]
|
||||
本章定位:[角色/事件/主题/...]
|
||||
核心作用:[推进/转折/揭示/...]
|
||||
悬念密度:[紧凑/渐进/爆发/...]
|
||||
伏笔操作:埋设(A线索)→强化(B矛盾)...
|
||||
认知颠覆:★☆☆☆☆
|
||||
本章简述:[一句话概括]
|
||||
|
||||
第n+1章 - [标题]
|
||||
本章定位:[角色/事件/主题/...]
|
||||
核心作用:[推进/转折/揭示/...]
|
||||
悬念密度:[紧凑/渐进/爆发/...]
|
||||
伏笔操作:埋设(A线索)→强化(B矛盾)...
|
||||
认知颠覆:★☆☆☆☆
|
||||
本章简述:[一句话概括]
|
||||
|
||||
要求:
|
||||
- 使用精炼语言描述,每章字数控制在100字以内。
|
||||
- 合理安排节奏,确保整体悬念曲线的连贯性。
|
||||
- 在生成{number_of_chapters}章前不要出现结局章节。
|
||||
|
||||
仅给出最终文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
# =============== 6. 前文摘要更新 ===================
|
||||
summary_prompt = """\
|
||||
以下是新完成的章节文本:
|
||||
{chapter_text}
|
||||
|
||||
这是当前的前文摘要(可为空):
|
||||
{global_summary}
|
||||
|
||||
请根据本章新增内容,更新前文摘要。
|
||||
要求:
|
||||
- 保留既有重要信息,同时融入新剧情要点
|
||||
- 以简洁、连贯的语言描述全书进展
|
||||
- 客观描绘,不展开联想或解释
|
||||
- 总字数控制在2000字以内
|
||||
|
||||
仅返回前文摘要文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
# =============== 7. 角色状态更新 ===================
|
||||
create_character_state_prompt = """\
|
||||
依据当前角色动力学设定:{character_dynamics}
|
||||
|
||||
请生成一个角色状态文档,内容格式:
|
||||
例:
|
||||
张三:
|
||||
├──物品:
|
||||
│ ├──青衫:一件破损的青色长袍,带有暗红色的污渍
|
||||
│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文
|
||||
├──能力
|
||||
│ ├──技能1:强大的精神感知能力:能够察觉到周围人的心中活动
|
||||
│ └──技能2:无形攻击:能够释放一种无法被视觉捕捉的精神攻击
|
||||
├──状态
|
||||
│ ├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻
|
||||
│ └──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安
|
||||
├──主要角色间关系网
|
||||
│ ├──李四:张三从小就与她有关联,对她的成长一直保持关注
|
||||
│ └──王二:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁
|
||||
├──触发或加深的事件
|
||||
│ ├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件
|
||||
│ └──李四被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍
|
||||
|
||||
角色名:
|
||||
├──物品:
|
||||
│ ├──某物(道具):描述
|
||||
│ └──XX长剑(武器):描述
|
||||
│ ...
|
||||
├──能力
|
||||
│ ├──技能1:描述
|
||||
│ └──技能2:描述
|
||||
│ ...
|
||||
├──状态
|
||||
│ ├──身体状态:
|
||||
│ └──心理状态:描述
|
||||
│
|
||||
├──主要角色间关系网
|
||||
│ ├──李四:描述
|
||||
│ └──王二:描述
|
||||
│ ...
|
||||
├──触发或加深的事件
|
||||
│ ├──事件1:描述
|
||||
│ └──事件2:描述
|
||||
...
|
||||
|
||||
新出场角色:
|
||||
- (此处填写未来任何新增角色或临时出场人物的基本信息)
|
||||
|
||||
要求:
|
||||
仅返回编写好的角色状态文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
update_character_state_prompt = """\
|
||||
以下是新完成的章节文本:
|
||||
{chapter_text}
|
||||
|
||||
这是当前的角色状态文档:
|
||||
{old_state}
|
||||
|
||||
请更新主要角色状态,内容格式:
|
||||
例:
|
||||
张三:
|
||||
├──物品:
|
||||
│ ├──青衫:一件破损的青色长袍,带有暗红色的污渍
|
||||
│ └──寒铁长剑:一柄断裂的铁剑,剑身上刻有古老的符文
|
||||
├──能力
|
||||
│ ├──技能1:强大的精神感知能力:能够察觉到周围人的心中活动
|
||||
│ └──技能2:无形攻击:能够释放一种无法被视觉捕捉的精神攻击
|
||||
├──状态
|
||||
│ ├──身体状态: 身材挺拔,穿着华丽的铠甲,面色冷峻
|
||||
│ └──心理状态: 目前的心态比较平静,但内心隐藏着对柳溪镇未来掌控的野心和不安
|
||||
├──主要角色间关系网
|
||||
│ ├──李四:张三从小就与她有关联,对她的成长一直保持关注
|
||||
│ └──王二:两人之间有着复杂的过去,最近因一场冲突而让对方感到威胁
|
||||
├──触发或加深的事件
|
||||
│ ├──村庄内突然出现不明符号:这个不明符号似乎在暗示柳溪镇即将发生重大事件
|
||||
│ └──李四被刺穿皮肤:这次事件让两人意识到对方的强大实力,促使他们迅速离开队伍
|
||||
|
||||
角色名:
|
||||
├──物品:
|
||||
│ ├──某物(道具):描述
|
||||
│ └──XX长剑(武器):描述
|
||||
│ ...
|
||||
├──能力
|
||||
│ ├──技能1:描述
|
||||
│ └──技能2:描述
|
||||
│ ...
|
||||
├──状态
|
||||
│ ├──身体状态:
|
||||
│ └──心理状态:描述
|
||||
│
|
||||
├──主要角色间关系网
|
||||
│ ├──李四:描述
|
||||
│ └──王二:描述
|
||||
│ ...
|
||||
├──触发或加深的事件
|
||||
│ ├──事件1:描述
|
||||
│ └──事件2:描述
|
||||
...
|
||||
|
||||
......
|
||||
|
||||
新出场角色:
|
||||
- 任何新增角色或临时出场人物的基本信息,简要描述即可,不要展开,淡出视线的角色可删除。
|
||||
|
||||
要求:
|
||||
- 请直接在已有文档基础上进行增删
|
||||
- 不改变原有结构,语言尽量简洁、有条理
|
||||
|
||||
仅返回更新后的角色状态文本,不要解释任何内容。
|
||||
"""
|
||||
|
||||
# =============== 8. 章节正文写作 ===================
|
||||
|
||||
# 8.1 第一章草稿提示
|
||||
first_chapter_draft_prompt = """\
|
||||
即将创作:第 {novel_number} 章《{chapter_title}》
|
||||
本章定位:{chapter_role}
|
||||
核心作用:{chapter_purpose}
|
||||
悬念密度:{suspense_level}
|
||||
伏笔操作:{foreshadowing}
|
||||
认知颠覆:{plot_twist_level}
|
||||
本章简述:{chapter_summary}
|
||||
|
||||
可用元素:
|
||||
- 核心人物(可能未指定):{characters_involved}
|
||||
- 关键道具(可能未指定):{key_items}
|
||||
- 空间坐标(可能未指定):{scene_location}
|
||||
- 时间压力(可能未指定):{time_constraint}
|
||||
|
||||
参考文档:
|
||||
- 小说设定:
|
||||
{novel_setting}
|
||||
|
||||
完成第 {novel_number} 章的正文,字数要求{word_number}字,至少设计下方2个或以上具有动态张力的场景:
|
||||
1. 对话场景:
|
||||
- 潜台词冲突(表面谈论A,实际博弈B)
|
||||
- 权力关系变化(通过非对称对话长度体现)
|
||||
|
||||
2. 动作场景:
|
||||
- 环境交互细节(至少3个感官描写)
|
||||
- 节奏控制(短句加速+比喻减速)
|
||||
- 动作揭示人物隐藏特质
|
||||
|
||||
3. 心理场景:
|
||||
- 认知失调的具体表现(行为矛盾)
|
||||
- 隐喻系统的运用(连接世界观符号)
|
||||
- 决策前的价值天平描写
|
||||
|
||||
4. 环境场景:
|
||||
- 空间透视变化(宏观→微观→异常焦点)
|
||||
- 非常规感官组合(如"听见阳光的重量")
|
||||
- 动态环境反映心理(环境与人物心理对应)
|
||||
|
||||
格式要求:
|
||||
- 仅返回章节正文文本;
|
||||
- 不使用分章节小标题;
|
||||
- 不要使用markdown格式。
|
||||
|
||||
额外指导(可能未指定):{user_guidance}
|
||||
"""
|
||||
|
||||
# 8.2 后续章节草稿提示
|
||||
next_chapter_draft_prompt = """\
|
||||
参考文档:
|
||||
└── 前文摘要:
|
||||
{global_summary}
|
||||
|
||||
└── 前章结尾段:
|
||||
{previous_chapter_excerpt}
|
||||
|
||||
└── 用户指导:
|
||||
{user_guidance}
|
||||
|
||||
└── 角色状态:
|
||||
{character_state}
|
||||
|
||||
└── 当前章节摘要:
|
||||
{short_summary}
|
||||
|
||||
当前章节信息:
|
||||
第{novel_number}章《{chapter_title}》:
|
||||
├── 章节定位:{chapter_role}
|
||||
├── 核心作用:{chapter_purpose}
|
||||
├── 悬念密度:{suspense_level}
|
||||
├── 伏笔设计:{foreshadowing}
|
||||
├── 转折程度:{plot_twist_level}
|
||||
├── 章节简述:{chapter_summary}
|
||||
├── 字数要求:{word_number}字
|
||||
├── 核心人物:{characters_involved}
|
||||
├── 关键道具:{key_items}
|
||||
├── 场景地点:{scene_location}
|
||||
└── 时间压力:{time_constraint}
|
||||
|
||||
下一章节目录
|
||||
第{next_chapter_number}章《{next_chapter_title}》:
|
||||
├── 章节定位:{next_chapter_role}
|
||||
├── 核心作用:{next_chapter_purpose}
|
||||
├── 悬念密度:{next_chapter_suspense_level}
|
||||
├── 伏笔设计:{next_chapter_foreshadowing}
|
||||
├── 转折程度:{next_chapter_plot_twist_level}
|
||||
└── 章节简述:{next_chapter_summary}
|
||||
|
||||
知识库参考:(按优先级应用)
|
||||
{filtered_context}
|
||||
|
||||
🎯 知识库应用规则:
|
||||
1. 内容分级:
|
||||
- 写作技法类(优先):
|
||||
▸ 场景构建模板
|
||||
▸ 对话写作技巧
|
||||
▸ 悬念营造手法
|
||||
- 设定资料类(选择性):
|
||||
▸ 独特世界观元素
|
||||
▸ 未使用过的技术细节
|
||||
- 禁忌项类(必须规避):
|
||||
▸ 已在前文出现过的特定情节
|
||||
▸ 重复的人物关系发展
|
||||
|
||||
2. 使用限制:
|
||||
● 禁止直接复制已有章节的情节模式
|
||||
● 历史章节内容仅允许:
|
||||
→ 参照叙事节奏(不超过20%相似度)
|
||||
→ 延续必要的人物反应模式(需改编30%以上)
|
||||
● 第三方写作知识优先用于:
|
||||
→ 增强场景表现力(占知识应用的60%以上)
|
||||
→ 创新悬念设计(至少1处新技巧)
|
||||
|
||||
3. 冲突检测:
|
||||
⚠️ 若检测到与历史章节重复:
|
||||
- 相似度>40%:必须重构叙事角度
|
||||
- 相似度20-40%:替换至少3个关键要素
|
||||
- 相似度<20%:允许保留核心概念但改变表现形式
|
||||
|
||||
依据前面所有设定,开始完成第 {novel_number} 章的正文,字数要求{word_number}字,
|
||||
内容生成严格遵循:
|
||||
-用户指导
|
||||
-当前章节摘要
|
||||
-当前章节信息
|
||||
-无逻辑漏洞,
|
||||
确保章节内容与前文摘要、前章结尾段衔接流畅、下一章目录保证上下文完整性,
|
||||
|
||||
格式要求:
|
||||
- 仅返回章节正文文本;
|
||||
- 不使用分章节小标题;
|
||||
- 不要使用markdown格式。
|
||||
"""
|
||||
|
||||
Character_Import_Prompt = """\
|
||||
根据以下文本内容,分析出所有角色及其属性信息,严格按照以下格式要求:
|
||||
|
||||
<<角色状态格式要求>>
|
||||
1. 必须包含以下五个分类(按顺序):
|
||||
● 物品 ● 能力 ● 状态 ● 主要角色间关系网 ● 触发或加深的事件
|
||||
2. 每个属性条目必须用【名称: 描述】格式
|
||||
例:├──青衫: 一件破损的青色长袍,带有暗红色的污渍
|
||||
3. 状态必须包含:
|
||||
● 身体状态: [当前身体状况]
|
||||
● 心理状态: [当前心理状况]
|
||||
4. 关系网格式:
|
||||
● [角色名称]: [关系类型,如"竞争对手"/"盟友"]
|
||||
5. 触发事件格式:
|
||||
● [事件名称]: [简要描述及影响]
|
||||
|
||||
<<示例>>
|
||||
李员外:
|
||||
├──物品:
|
||||
│ ├──青衫: 一件破损的青色长袍,带有暗红色污渍
|
||||
│ └──寒铁长剑: 剑身有裂痕,刻有「青云」符文
|
||||
├──能力:
|
||||
│ ├──精神感知: 能感知半径30米内的生命体
|
||||
│ └──剑气压制: 通过目光释放精神威压
|
||||
├──状态:
|
||||
│ ├──身体状态: 右臂有未愈合的刀伤
|
||||
│ └──心理状态: 对苏明远的实力感到忌惮
|
||||
├──主要角色间关系网:
|
||||
│ ├──苏明远: 竞争对手,十年前的同僚
|
||||
│ └──林婉儿: 暗中培养的继承人
|
||||
├──触发或加深的事件:
|
||||
│ ├──兵器库遇袭: 丢失三把传家宝剑,影响战力
|
||||
│ └──匿名威胁信: 信纸带有檀香味,暗示内部泄密
|
||||
│
|
||||
|
||||
请严格按上述格式分析以下内容:
|
||||
<<待分析小说文本开始>>
|
||||
{content}
|
||||
<<待分析小说文本结束>>
|
||||
"""
|
||||
Binary file not shown.
+37
@@ -0,0 +1,37 @@
|
||||
# tooltips.py
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
tooltips = {
|
||||
"api_key": "在这里填写你的API Key。如果使用OpenAI官方接口,请在 https://platform.openai.com/account/api-keys 获取。",
|
||||
"base_url": "模型的接口地址。若使用OpenAI官方:https://api.openai.com/v1。若使用Ollama本地部署,则类似 http://localhost:11434/v1。调用Gemini模型则无需填写。",
|
||||
"interface_format": "指定LLM接口兼容格式,可选DeepSeek、OpenAI、Ollama、ML Studio、Gemini等。\n\n注意:"+
|
||||
"OpenAI 兼容是指的可以通过该标准请求的任何接口,不是只允许使用api.openai.com接口\n"+
|
||||
"例如Ollama接口格式也兼容OpenAI,可以无需修改直接使用\n"+
|
||||
"ML Studio接口格式与OpenAI接口格式也一致。",
|
||||
"model_name": "要使用的模型名称,例如deepseek-reasoner、gpt-4o等。如果是Ollama等,请填写你下载好的本地模型名。",
|
||||
"temperature": "生成文本的随机度。数值越大越具有发散性,越小越严谨。",
|
||||
"max_tokens": "限制单次生成的最大Token数。范围1~100000,请根据模型上下文及需求填写合适值。\n"+
|
||||
"以下是一些常见模型的最大值:\n"+
|
||||
"o1:100,000\n"+
|
||||
"o1-mini:65,536\n"+
|
||||
"gpt-4o:16384\n"+
|
||||
"gpt-4o-mini:16384\n"+
|
||||
"deepseek-reasoner:8192\n"+
|
||||
"deepseek-chat:4096\n",
|
||||
"embedding_api_key": "调用Embedding模型时所需的API Key。",
|
||||
"embedding_interface_format": "Embedding模型接口风格,比如OpenAI或Ollama。",
|
||||
"embedding_url": "Embedding模型接口地址。",
|
||||
"embedding_model_name": "Embedding模型名称,如text-embedding-ada-002。",
|
||||
"embedding_retrieval_k": "向量检索时返回的Top-K结果数量。",
|
||||
"topic": "小说的大致主题或主要故事背景描述。",
|
||||
"genre": "小说的题材类型,如玄幻、都市、科幻等。",
|
||||
"num_chapters": "小说期望的章节总数。",
|
||||
"word_number": "每章的目标字数。",
|
||||
"filepath": "生成文件存储的根目录路径。所有txt文件、向量库等放在该目录下。",
|
||||
"chapter_num": "当前正在处理的章节号,用于生成草稿或定稿操作。",
|
||||
"user_guidance": "为本章提供的一些额外指令或写作引导。",
|
||||
"characters_involved": "本章需要重点描写或影响剧情的角色名单。",
|
||||
"key_items": "在本章中出现的重要道具、线索或物品。",
|
||||
"scene_location": "本章主要发生的地点或场景描述。",
|
||||
"time_constraint": "本章剧情中涉及的时间压力或时限设置。"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# ui/__init__.py
|
||||
from .main_window import NovelGeneratorGUI
|
||||
@@ -0,0 +1,140 @@
|
||||
# ui/chapters_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
from utils import read_file, save_string_to_txt, clear_file_content
|
||||
|
||||
def build_chapters_tab(self):
|
||||
self.chapters_view_tab = self.tabview.add("Chapters Manage")
|
||||
self.chapters_view_tab.rowconfigure(0, weight=0)
|
||||
self.chapters_view_tab.rowconfigure(1, weight=1)
|
||||
self.chapters_view_tab.columnconfigure(0, weight=1)
|
||||
|
||||
top_frame = ctk.CTkFrame(self.chapters_view_tab)
|
||||
top_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
||||
top_frame.columnconfigure(0, weight=0)
|
||||
top_frame.columnconfigure(1, weight=0)
|
||||
top_frame.columnconfigure(2, weight=0)
|
||||
top_frame.columnconfigure(3, weight=0)
|
||||
top_frame.columnconfigure(4, weight=1)
|
||||
|
||||
prev_btn = ctk.CTkButton(top_frame, text="<< 上一章", command=self.prev_chapter, font=("Microsoft YaHei", 12))
|
||||
prev_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
|
||||
next_btn = ctk.CTkButton(top_frame, text="下一章 >>", command=self.next_chapter, font=("Microsoft YaHei", 12))
|
||||
next_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
self.chapter_select_var = ctk.StringVar(value="")
|
||||
self.chapter_select_menu = ctk.CTkOptionMenu(top_frame, values=[], variable=self.chapter_select_var, command=self.on_chapter_selected, font=("Microsoft YaHei", 12))
|
||||
self.chapter_select_menu.grid(row=0, column=2, padx=5, pady=5, sticky="w")
|
||||
|
||||
save_btn = ctk.CTkButton(top_frame, text="保存修改", command=self.save_current_chapter, font=("Microsoft YaHei", 12))
|
||||
save_btn.grid(row=0, column=3, padx=5, pady=5, sticky="w")
|
||||
|
||||
refresh_btn = ctk.CTkButton(top_frame, text="刷新章节列表", command=self.refresh_chapters_list, font=("Microsoft YaHei", 12))
|
||||
refresh_btn.grid(row=0, column=5, padx=5, pady=5, sticky="e")
|
||||
|
||||
self.chapters_word_count_label = ctk.CTkLabel(top_frame, text="字数:0", font=("Microsoft YaHei", 12))
|
||||
self.chapters_word_count_label.grid(row=0, column=4, padx=(0,10), sticky="e")
|
||||
|
||||
self.chapter_view_text = ctk.CTkTextbox(self.chapters_view_tab, wrap="word", font=("Microsoft YaHei", 12))
|
||||
|
||||
def update_word_count(event=None):
|
||||
text = self.chapter_view_text.get("0.0", "end-1c")
|
||||
text_length = len(text)
|
||||
self.chapters_word_count_label.configure(text=f"字数:{text_length}")
|
||||
|
||||
self.chapter_view_text.bind("<KeyRelease>", update_word_count)
|
||||
self.chapter_view_text.bind("<ButtonRelease>", update_word_count)
|
||||
TextWidgetContextMenu(self.chapter_view_text)
|
||||
self.chapter_view_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=6)
|
||||
|
||||
self.chapters_list = []
|
||||
refresh_chapters_list(self)
|
||||
|
||||
def refresh_chapters_list(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
chapters_dir = os.path.join(filepath, "chapters")
|
||||
if not os.path.exists(chapters_dir):
|
||||
self.safe_log("尚未找到 chapters 文件夹,请先生成章节或检查保存路径。")
|
||||
self.chapter_select_menu.configure(values=[])
|
||||
return
|
||||
|
||||
all_files = os.listdir(chapters_dir)
|
||||
chapter_nums = []
|
||||
for f in all_files:
|
||||
if f.startswith("chapter_") and f.endswith(".txt"):
|
||||
number_part = f.replace("chapter_", "").replace(".txt", "")
|
||||
if number_part.isdigit():
|
||||
chapter_nums.append(number_part)
|
||||
chapter_nums.sort(key=lambda x: int(x))
|
||||
self.chapters_list = chapter_nums
|
||||
self.chapter_select_menu.configure(values=self.chapters_list)
|
||||
current_selected = self.chapter_select_var.get()
|
||||
if current_selected not in self.chapters_list:
|
||||
if self.chapters_list:
|
||||
self.chapter_select_var.set(self.chapters_list[0])
|
||||
load_chapter_content(self, self.chapters_list[0])
|
||||
else:
|
||||
self.chapter_select_var.set("")
|
||||
self.chapter_view_text.delete("0.0", "end")
|
||||
|
||||
def on_chapter_selected(self, value):
|
||||
load_chapter_content(self, value)
|
||||
|
||||
def load_chapter_content(self, chapter_number_str):
|
||||
if not chapter_number_str:
|
||||
return
|
||||
filepath = self.filepath_var.get().strip()
|
||||
chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt")
|
||||
if not os.path.exists(chapter_file):
|
||||
self.safe_log(f"章节文件 {chapter_file} 不存在!")
|
||||
return
|
||||
content = read_file(chapter_file)
|
||||
self.chapter_view_text.delete("0.0", "end")
|
||||
self.chapter_view_text.insert("0.0", content)
|
||||
|
||||
def save_current_chapter(self):
|
||||
chapter_number_str = self.chapter_select_var.get()
|
||||
if not chapter_number_str:
|
||||
messagebox.showwarning("警告", "尚未选择章节,无法保存。")
|
||||
return
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先配置保存文件路径")
|
||||
return
|
||||
chapter_file = os.path.join(filepath, "chapters", f"chapter_{chapter_number_str}.txt")
|
||||
content = self.chapter_view_text.get("0.0", "end").strip()
|
||||
clear_file_content(chapter_file)
|
||||
save_string_to_txt(content, chapter_file)
|
||||
self.safe_log(f"已保存对第 {chapter_number_str} 章的修改。")
|
||||
|
||||
def prev_chapter(self):
|
||||
if not self.chapters_list:
|
||||
return
|
||||
current = self.chapter_select_var.get()
|
||||
if current not in self.chapters_list:
|
||||
return
|
||||
idx = self.chapters_list.index(current)
|
||||
if idx > 0:
|
||||
new_idx = idx - 1
|
||||
self.chapter_select_var.set(self.chapters_list[new_idx])
|
||||
load_chapter_content(self, self.chapters_list[new_idx])
|
||||
else:
|
||||
messagebox.showinfo("提示", "已经是第一章了。")
|
||||
|
||||
def next_chapter(self):
|
||||
if not self.chapters_list:
|
||||
return
|
||||
current = self.chapter_select_var.get()
|
||||
if current not in self.chapters_list:
|
||||
return
|
||||
idx = self.chapters_list.index(current)
|
||||
if idx < len(self.chapters_list) - 1:
|
||||
new_idx = idx + 1
|
||||
self.chapter_select_var.set(self.chapters_list[new_idx])
|
||||
load_chapter_content(self, self.chapters_list[new_idx])
|
||||
else:
|
||||
messagebox.showinfo("提示", "已经是最后一章了。")
|
||||
@@ -0,0 +1,56 @@
|
||||
# ui/character_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from utils import read_file, save_string_to_txt, clear_file_content
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
|
||||
def build_character_tab(self):
|
||||
self.character_tab = self.tabview.add("Character State")
|
||||
self.character_tab.rowconfigure(0, weight=0)
|
||||
self.character_tab.rowconfigure(1, weight=1)
|
||||
self.character_tab.columnconfigure(0, weight=1)
|
||||
|
||||
load_btn = ctk.CTkButton(self.character_tab, text="加载 character_state.txt", command=self.load_character_state, font=("Microsoft YaHei", 12))
|
||||
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
|
||||
self.character_wordcount_label = ctk.CTkLabel(self.character_tab, text="字数:0", font=("Microsoft YaHei", 12))
|
||||
self.character_wordcount_label.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
save_btn = ctk.CTkButton(self.character_tab, text="保存修改", command=self.save_character_state, font=("Microsoft YaHei", 12))
|
||||
save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e")
|
||||
|
||||
self.character_text = ctk.CTkTextbox(self.character_tab, wrap="word", font=("Microsoft YaHei", 12))
|
||||
|
||||
def update_word_count(event=None):
|
||||
text = self.character_text.get("0.0", "end-1c")
|
||||
text_length = len(text)
|
||||
self.character_wordcount_label.configure(text=f"字数:{text_length}")
|
||||
|
||||
self.character_text.bind("<KeyRelease>", update_word_count)
|
||||
self.character_text.bind("<ButtonRelease>", update_word_count)
|
||||
TextWidgetContextMenu(self.character_text)
|
||||
self.character_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
|
||||
|
||||
def load_character_state(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径")
|
||||
return
|
||||
filename = os.path.join(filepath, "character_state.txt")
|
||||
content = read_file(filename)
|
||||
self.character_text.delete("0.0", "end")
|
||||
self.character_text.insert("0.0", content)
|
||||
self.log("已加载 character_state.txt 到编辑区。")
|
||||
|
||||
def save_character_state(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径")
|
||||
return
|
||||
content = self.character_text.get("0.0", "end").strip()
|
||||
filename = os.path.join(filepath, "character_state.txt")
|
||||
clear_file_content(filename)
|
||||
save_string_to_txt(content, filename)
|
||||
self.log("已保存对 character_state.txt 的修改。")
|
||||
@@ -0,0 +1,319 @@
|
||||
# ui/config_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
from tkinter import messagebox
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from config_manager import load_config, save_config
|
||||
from tooltips import tooltips
|
||||
|
||||
|
||||
def create_label_with_help(self, parent, label_text, tooltip_key, row, column,
|
||||
font=None, sticky="e", padx=5, pady=5):
|
||||
"""
|
||||
封装一个带"?"按钮的Label,用于展示提示信息。
|
||||
"""
|
||||
frame = ctk.CTkFrame(parent)
|
||||
frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky)
|
||||
frame.columnconfigure(0, weight=0)
|
||||
|
||||
label = ctk.CTkLabel(frame, text=label_text, font=font)
|
||||
label.pack(side="left")
|
||||
|
||||
btn = ctk.CTkButton(
|
||||
frame,
|
||||
text="?",
|
||||
width=22,
|
||||
height=22,
|
||||
font=("Microsoft YaHei", 10),
|
||||
command=lambda: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明"))
|
||||
)
|
||||
btn.pack(side="left", padx=3)
|
||||
|
||||
return frame
|
||||
|
||||
def build_config_tabview(self):
|
||||
"""
|
||||
创建包含 LLM Model settings 和 Embedding settings 的选项卡。
|
||||
"""
|
||||
self.config_tabview = ctk.CTkTabview(self.config_frame)
|
||||
self.config_tabview.grid(row=0, column=0, sticky="we", padx=5, pady=5)
|
||||
|
||||
self.ai_config_tab = self.config_tabview.add("LLM Model settings")
|
||||
self.embeddings_config_tab = self.config_tabview.add("Embedding settings")
|
||||
|
||||
build_ai_config_tab(self)
|
||||
build_embeddings_config_tab(self)
|
||||
|
||||
# 底部的"保存配置"和"加载配置"按钮
|
||||
self.btn_frame_config = ctk.CTkFrame(self.config_frame)
|
||||
self.btn_frame_config.grid(row=1, column=0, padx=5, pady=5, sticky="ew")
|
||||
self.btn_frame_config.columnconfigure(0, weight=1)
|
||||
self.btn_frame_config.columnconfigure(1, weight=1)
|
||||
|
||||
save_config_btn = ctk.CTkButton(self.btn_frame_config, text="保存当前选择接口配置到文件", command=self.save_config_btn, font=("Microsoft YaHei", 12))
|
||||
save_config_btn.grid(row=0, column=0, padx=5, pady=5, sticky="ew")
|
||||
|
||||
load_config_btn = ctk.CTkButton(self.btn_frame_config, text="加载当前选择接口配置到程序", command=self.load_config_btn, font=("Microsoft YaHei", 12))
|
||||
load_config_btn.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
||||
|
||||
def build_ai_config_tab(self):
|
||||
def on_interface_format_changed(new_value):
|
||||
self.interface_format_var.set(new_value)
|
||||
config_data = load_config(self.config_file)
|
||||
if config_data:
|
||||
config_data["last_interface_format"] = new_value
|
||||
save_config(config_data, self.config_file)
|
||||
if self.loaded_config and "llm_configs" in self.loaded_config and new_value in self.loaded_config["llm_configs"]:
|
||||
llm_conf = self.loaded_config["llm_configs"][new_value]
|
||||
self.api_key_var.set(llm_conf.get("api_key", ""))
|
||||
self.base_url_var.set(llm_conf.get("base_url", self.base_url_var.get()))
|
||||
self.model_name_var.set(llm_conf.get("model_name", ""))
|
||||
self.temperature_var.set(llm_conf.get("temperature", 0.7))
|
||||
self.max_tokens_var.set(llm_conf.get("max_tokens", 8192))
|
||||
self.timeout_var.set(llm_conf.get("timeout", 600))
|
||||
else:
|
||||
if new_value == "Ollama":
|
||||
self.base_url_var.set("http://localhost:11434/v1")
|
||||
elif new_value == "ML Studio":
|
||||
self.base_url_var.set("http://localhost:1234/v1")
|
||||
elif new_value == "OpenAI":
|
||||
self.base_url_var.set("https://api.openai.com/v1")
|
||||
self.model_name_var.set("gpt-4o-mini")
|
||||
elif new_value == "Azure OpenAI":
|
||||
self.base_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/chat/completions?api-version=2024-08-01-preview")
|
||||
elif new_value == "DeepSeek":
|
||||
self.base_url_var.set("https://api.deepseek.com/v1")
|
||||
self.model_name_var.set("deepseek-chat")
|
||||
elif new_value == "Gemini":
|
||||
self.base_url_var.set("")
|
||||
elif new_value == "Azure AI":
|
||||
self.base_url_var.set("https://<your-endpoint>.services.ai.azure.com/models/chat/completions?api-version=2024-05-01-preview")
|
||||
elif new_value == "阿里云百炼":
|
||||
self.base_url_var.set("https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
self.model_name_var.set("qwen-plus")
|
||||
elif new_value == "硅基流动":
|
||||
self.base_url_var.set("https://api.siliconflow.cn/v1")
|
||||
self.model_name_var.set("deepseek-ai/DeepSeek-V3")
|
||||
|
||||
for i in range(7):
|
||||
self.ai_config_tab.grid_rowconfigure(i, weight=0)
|
||||
self.ai_config_tab.grid_columnconfigure(0, weight=0)
|
||||
self.ai_config_tab.grid_columnconfigure(1, weight=1)
|
||||
self.ai_config_tab.grid_columnconfigure(2, weight=0)
|
||||
|
||||
# 1) API Key
|
||||
create_label_with_help(self, parent=self.ai_config_tab, label_text="LLM API Key:", tooltip_key="api_key", row=0, column=0, font=("Microsoft YaHei", 12))
|
||||
api_key_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.api_key_var, font=("Microsoft YaHei", 12),show="*")
|
||||
api_key_entry.grid(row=0, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
|
||||
|
||||
# 2) Base URL
|
||||
create_label_with_help(self, parent=self.ai_config_tab, label_text="LLM Base URL:", tooltip_key="base_url", row=1, column=0, font=("Microsoft YaHei", 12))
|
||||
base_url_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.base_url_var, font=("Microsoft YaHei", 12))
|
||||
base_url_entry.grid(row=1, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
|
||||
|
||||
# 3) 接口格式
|
||||
create_label_with_help(self, parent=self.ai_config_tab, label_text="LLM 接口格式:", tooltip_key="interface_format", row=2, column=0, font=("Microsoft YaHei", 12))
|
||||
# 在这里的接口选项列表中添加 "硅基流动"
|
||||
interface_options = ["DeepSeek", "阿里云百炼", "OpenAI", "Azure OpenAI", "Azure AI", "Ollama", "ML Studio", "Gemini", "火山引擎", "硅基流动"]
|
||||
interface_dropdown = ctk.CTkOptionMenu(self.ai_config_tab, values=interface_options, variable=self.interface_format_var, command=on_interface_format_changed, font=("Microsoft YaHei", 12))
|
||||
interface_dropdown.grid(row=2, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
|
||||
|
||||
# 4) Model Name
|
||||
create_label_with_help(self, parent=self.ai_config_tab, label_text="Model Name:", tooltip_key="model_name", row=3, column=0, font=("Microsoft YaHei", 12))
|
||||
model_name_entry = ctk.CTkEntry(self.ai_config_tab, textvariable=self.model_name_var, font=("Microsoft YaHei", 12))
|
||||
model_name_entry.grid(row=3, column=1, padx=5, pady=5, columnspan=2, sticky="nsew")
|
||||
|
||||
# 5) Temperature
|
||||
create_label_with_help(self, parent=self.ai_config_tab, label_text="Temperature:", tooltip_key="temperature", row=4, column=0, font=("Microsoft YaHei", 12))
|
||||
def update_temp_label(value):
|
||||
self.temp_value_label.configure(text=f"{float(value):.2f}")
|
||||
temp_scale = ctk.CTkSlider(self.ai_config_tab, from_=0.0, to=2.0, number_of_steps=200, command=update_temp_label, variable=self.temperature_var)
|
||||
temp_scale.grid(row=4, column=1, padx=5, pady=5, sticky="we")
|
||||
self.temp_value_label = ctk.CTkLabel(self.ai_config_tab, text=f"{self.temperature_var.get():.2f}", font=("Microsoft YaHei", 12))
|
||||
self.temp_value_label.grid(row=4, column=2, padx=5, pady=5, sticky="w")
|
||||
|
||||
# 6) Max Tokens
|
||||
create_label_with_help(self, parent=self.ai_config_tab, label_text="Max Tokens:", tooltip_key="max_tokens", row=5, column=0, font=("Microsoft YaHei", 12))
|
||||
def update_max_tokens_label(value):
|
||||
self.max_tokens_value_label.configure(text=str(int(float(value))))
|
||||
max_tokens_slider = ctk.CTkSlider(self.ai_config_tab, from_=0, to=102400, number_of_steps=100, command=update_max_tokens_label, variable=self.max_tokens_var)
|
||||
max_tokens_slider.grid(row=5, column=1, padx=5, pady=5, sticky="we")
|
||||
self.max_tokens_value_label = ctk.CTkLabel(self.ai_config_tab, text=str(self.max_tokens_var.get()), font=("Microsoft YaHei", 12))
|
||||
self.max_tokens_value_label.grid(row=5, column=2, padx=5, pady=5, sticky="w")
|
||||
|
||||
# 7) Timeout (sec)
|
||||
create_label_with_help(self, parent=self.ai_config_tab, label_text="Timeout (sec):", tooltip_key="timeout", row=6, column=0, font=("Microsoft YaHei", 12))
|
||||
def update_timeout_label(value):
|
||||
integer_val = int(float(value))
|
||||
self.timeout_value_label.configure(text=str(integer_val))
|
||||
timeout_slider = ctk.CTkSlider(self.ai_config_tab, from_=0, to=3600, number_of_steps=3600, command=update_timeout_label, variable=self.timeout_var)
|
||||
timeout_slider.grid(row=6, column=1, padx=5, pady=5, sticky="we")
|
||||
self.timeout_value_label = ctk.CTkLabel(self.ai_config_tab, text=str(self.timeout_var.get()), font=("Microsoft YaHei", 12))
|
||||
self.timeout_value_label.grid(row=6, column=2, padx=5, pady=5, sticky="w")
|
||||
|
||||
# 添加测试按钮
|
||||
test_btn = ctk.CTkButton(self.ai_config_tab, text="测试配置", command=self.test_llm_config, font=("Microsoft YaHei", 12))
|
||||
test_btn.grid(row=7, column=0, columnspan=3, padx=5, pady=5, sticky="ew")
|
||||
|
||||
def build_embeddings_config_tab(self):
|
||||
def on_embedding_interface_changed(new_value):
|
||||
self.embedding_interface_format_var.set(new_value)
|
||||
config_data = load_config(self.config_file)
|
||||
if config_data:
|
||||
config_data["last_embedding_interface_format"] = new_value
|
||||
save_config(config_data, self.config_file)
|
||||
if self.loaded_config and "embedding_configs" in self.loaded_config and new_value in self.loaded_config["embedding_configs"]:
|
||||
emb_conf = self.loaded_config["embedding_configs"][new_value]
|
||||
self.embedding_api_key_var.set(emb_conf.get("api_key", ""))
|
||||
self.embedding_url_var.set(emb_conf.get("base_url", self.embedding_url_var.get()))
|
||||
self.embedding_model_name_var.set(emb_conf.get("model_name", ""))
|
||||
self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4)))
|
||||
else:
|
||||
if new_value == "Ollama":
|
||||
self.embedding_url_var.set("http://localhost:11434/api")
|
||||
elif new_value == "ML Studio":
|
||||
self.embedding_url_var.set("http://localhost:1234/v1")
|
||||
elif new_value == "OpenAI":
|
||||
self.embedding_url_var.set("https://api.openai.com/v1")
|
||||
self.embedding_model_name_var.set("text-embedding-ada-002")
|
||||
elif new_value == "Azure OpenAI":
|
||||
self.embedding_url_var.set("https://[az].openai.azure.com/openai/deployments/[model]/embeddings?api-version=2023-05-15")
|
||||
elif new_value == "DeepSeek":
|
||||
self.embedding_url_var.set("https://api.deepseek.com/v1")
|
||||
elif new_value == "Gemini":
|
||||
self.embedding_url_var.set("https://generativelanguage.googleapis.com/v1beta/")
|
||||
self.embedding_model_name_var.set("models/text-embedding-004")
|
||||
elif new_value == "SiliconFlow":
|
||||
self.embedding_url_var.set("https://api.siliconflow.cn/v1/embeddings")
|
||||
self.embedding_model_name_var.set("BAAI/bge-m3")
|
||||
|
||||
for i in range(5):
|
||||
self.embeddings_config_tab.grid_rowconfigure(i, weight=0)
|
||||
self.embeddings_config_tab.grid_columnconfigure(0, weight=0)
|
||||
self.embeddings_config_tab.grid_columnconfigure(1, weight=1)
|
||||
self.embeddings_config_tab.grid_columnconfigure(2, weight=0)
|
||||
|
||||
# 1) Embedding API Key
|
||||
create_label_with_help(self, parent=self.embeddings_config_tab, label_text="Embedding API Key:", tooltip_key="embedding_api_key", row=0, column=0, font=("Microsoft YaHei", 12))
|
||||
emb_api_key_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_api_key_var, font=("Microsoft YaHei", 12))
|
||||
emb_api_key_entry.grid(row=0, column=1, padx=5, pady=5, sticky="nsew")
|
||||
|
||||
# 2) Embedding 接口格式
|
||||
create_label_with_help(self, parent=self.embeddings_config_tab, label_text="Embedding 接口格式:", tooltip_key="embedding_interface_format", row=1, column=0, font=("Microsoft YaHei", 12))
|
||||
|
||||
emb_interface_options = ["DeepSeek", "OpenAI", "Azure OpenAI", "Gemini", "Ollama", "ML Studio","SiliconFlow"]
|
||||
|
||||
emb_interface_dropdown = ctk.CTkOptionMenu(self.embeddings_config_tab, values=emb_interface_options, variable=self.embedding_interface_format_var, command=on_embedding_interface_changed, font=("Microsoft YaHei", 12))
|
||||
emb_interface_dropdown.grid(row=1, column=1, padx=5, pady=5, sticky="nsew")
|
||||
|
||||
# 3) Embedding Base URL
|
||||
create_label_with_help(self, parent=self.embeddings_config_tab, label_text="Embedding Base URL:", tooltip_key="embedding_url", row=2, column=0, font=("Microsoft YaHei", 12))
|
||||
emb_url_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_url_var, font=("Microsoft YaHei", 12))
|
||||
emb_url_entry.grid(row=2, column=1, padx=5, pady=5, sticky="nsew")
|
||||
|
||||
# 4) Embedding Model Name
|
||||
create_label_with_help(self, parent=self.embeddings_config_tab, label_text="Embedding Model Name:", tooltip_key="embedding_model_name", row=3, column=0, font=("Microsoft YaHei", 12))
|
||||
emb_model_name_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_model_name_var, font=("Microsoft YaHei", 12))
|
||||
emb_model_name_entry.grid(row=3, column=1, padx=5, pady=5, sticky="nsew")
|
||||
|
||||
# 5) Retrieval Top-K
|
||||
create_label_with_help(self, parent=self.embeddings_config_tab, label_text="Retrieval Top-K:", tooltip_key="embedding_retrieval_k", row=4, column=0, font=("Microsoft YaHei", 12))
|
||||
emb_retrieval_k_entry = ctk.CTkEntry(self.embeddings_config_tab, textvariable=self.embedding_retrieval_k_var, font=("Microsoft YaHei", 12))
|
||||
emb_retrieval_k_entry.grid(row=4, column=1, padx=5, pady=5, sticky="nsew")
|
||||
|
||||
# 添加测试按钮
|
||||
test_btn = ctk.CTkButton(self.embeddings_config_tab, text="测试配置", command=self.test_embedding_config, font=("Microsoft YaHei", 12))
|
||||
test_btn.grid(row=5, column=0, columnspan=2, padx=5, pady=5, sticky="ew")
|
||||
|
||||
def load_config_btn(self):
|
||||
cfg = load_config(self.config_file)
|
||||
if cfg:
|
||||
last_llm = cfg.get("last_interface_format", "OpenAI")
|
||||
last_embedding = cfg.get("last_embedding_interface_format", "OpenAI")
|
||||
self.interface_format_var.set(last_llm)
|
||||
self.embedding_interface_format_var.set(last_embedding)
|
||||
llm_configs = cfg.get("llm_configs", {})
|
||||
if last_llm in llm_configs:
|
||||
llm_conf = llm_configs[last_llm]
|
||||
self.api_key_var.set(llm_conf.get("api_key", ""))
|
||||
self.base_url_var.set(llm_conf.get("base_url", "https://api.openai.com/v1"))
|
||||
self.model_name_var.set(llm_conf.get("model_name", "gpt-4o-mini"))
|
||||
self.temperature_var.set(llm_conf.get("temperature", 0.7))
|
||||
self.max_tokens_var.set(llm_conf.get("max_tokens", 8192))
|
||||
self.timeout_var.set(llm_conf.get("timeout", 600))
|
||||
embedding_configs = cfg.get("embedding_configs", {})
|
||||
if last_embedding in embedding_configs:
|
||||
emb_conf = embedding_configs[last_embedding]
|
||||
self.embedding_api_key_var.set(emb_conf.get("api_key", ""))
|
||||
self.embedding_url_var.set(emb_conf.get("base_url", "https://api.openai.com/v1"))
|
||||
self.embedding_model_name_var.set(emb_conf.get("model_name", "text-embedding-ada-002"))
|
||||
self.embedding_retrieval_k_var.set(str(emb_conf.get("retrieval_k", 4)))
|
||||
other_params = cfg.get("other_params", {})
|
||||
self.topic_text.delete("0.0", "end")
|
||||
self.topic_text.insert("0.0", other_params.get("topic", ""))
|
||||
self.genre_var.set(other_params.get("genre", "玄幻"))
|
||||
self.num_chapters_var.set(str(other_params.get("num_chapters", 10)))
|
||||
self.word_number_var.set(str(other_params.get("word_number", 3000)))
|
||||
self.filepath_var.set(other_params.get("filepath", ""))
|
||||
self.chapter_num_var.set(str(other_params.get("chapter_num", "1")))
|
||||
self.user_guide_text.delete("0.0", "end")
|
||||
self.user_guide_text.insert("0.0", other_params.get("user_guidance", ""))
|
||||
self.characters_involved_var.set(other_params.get("characters_involved", ""))
|
||||
self.key_items_var.set(other_params.get("key_items", ""))
|
||||
self.scene_location_var.set(other_params.get("scene_location", ""))
|
||||
self.time_constraint_var.set(other_params.get("time_constraint", ""))
|
||||
self.log("已加载配置。")
|
||||
else:
|
||||
messagebox.showwarning("提示", "未找到或无法读取配置文件。")
|
||||
|
||||
def save_config_btn(self):
|
||||
current_llm_interface = self.interface_format_var.get().strip()
|
||||
current_embedding_interface = self.embedding_interface_format_var.get().strip()
|
||||
llm_config = {
|
||||
"api_key": self.api_key_var.get(),
|
||||
"base_url": self.base_url_var.get(),
|
||||
"model_name": self.model_name_var.get(),
|
||||
"temperature": self.temperature_var.get(),
|
||||
"max_tokens": self.max_tokens_var.get(),
|
||||
"timeout": self.safe_get_int(self.timeout_var, 600)
|
||||
}
|
||||
embedding_config = {
|
||||
"api_key": self.embedding_api_key_var.get(),
|
||||
"base_url": self.embedding_url_var.get(),
|
||||
"model_name": self.embedding_model_name_var.get(),
|
||||
"retrieval_k": self.safe_get_int(self.embedding_retrieval_k_var, 4)
|
||||
}
|
||||
other_params = {
|
||||
"topic": self.topic_text.get("0.0", "end").strip(),
|
||||
"genre": self.genre_var.get(),
|
||||
"num_chapters": self.safe_get_int(self.num_chapters_var, 10),
|
||||
"word_number": self.safe_get_int(self.word_number_var, 3000),
|
||||
"filepath": self.filepath_var.get(),
|
||||
"chapter_num": self.chapter_num_var.get(),
|
||||
"user_guidance": self.user_guide_text.get("0.0", "end").strip(),
|
||||
"characters_involved": self.characters_involved_var.get(),
|
||||
"key_items": self.key_items_var.get(),
|
||||
"scene_location": self.scene_location_var.get(),
|
||||
"time_constraint": self.time_constraint_var.get()
|
||||
}
|
||||
existing_config = load_config(self.config_file)
|
||||
if not existing_config:
|
||||
existing_config = {}
|
||||
existing_config["last_interface_format"] = current_llm_interface
|
||||
existing_config["last_embedding_interface_format"] = current_embedding_interface
|
||||
if "llm_configs" not in existing_config:
|
||||
existing_config["llm_configs"] = {}
|
||||
existing_config["llm_configs"][current_llm_interface] = llm_config
|
||||
|
||||
if "embedding_configs" not in existing_config:
|
||||
existing_config["embedding_configs"] = {}
|
||||
existing_config["embedding_configs"][current_embedding_interface] = embedding_config
|
||||
|
||||
existing_config["other_params"] = other_params
|
||||
|
||||
if save_config(existing_config, self.config_file):
|
||||
messagebox.showinfo("提示", "配置已保存至 config.json")
|
||||
self.log("配置已保存。")
|
||||
else:
|
||||
messagebox.showerror("错误", "保存配置失败。")
|
||||
@@ -0,0 +1,54 @@
|
||||
# ui/context_menu.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import tkinter as tk
|
||||
import customtkinter as ctk
|
||||
|
||||
class TextWidgetContextMenu:
|
||||
"""
|
||||
为 customtkinter.TextBox 或 tkinter.Text 提供右键复制/剪切/粘贴/全选的功能。
|
||||
"""
|
||||
def __init__(self, widget):
|
||||
self.widget = widget
|
||||
self.menu = tk.Menu(widget, tearoff=0)
|
||||
self.menu.add_command(label="复制", command=self.copy)
|
||||
self.menu.add_command(label="粘贴", command=self.paste)
|
||||
self.menu.add_command(label="剪切", command=self.cut)
|
||||
self.menu.add_separator()
|
||||
self.menu.add_command(label="全选", command=self.select_all)
|
||||
|
||||
# 绑定右键事件
|
||||
self.widget.bind("<Button-3>", self.show_menu)
|
||||
|
||||
def show_menu(self, event):
|
||||
if isinstance(self.widget, ctk.CTkTextbox):
|
||||
try:
|
||||
self.menu.tk_popup(event.x_root, event.y_root)
|
||||
finally:
|
||||
self.menu.grab_release()
|
||||
|
||||
def copy(self):
|
||||
try:
|
||||
text = self.widget.get("sel.first", "sel.last")
|
||||
self.widget.clipboard_clear()
|
||||
self.widget.clipboard_append(text)
|
||||
except tk.TclError:
|
||||
pass # 没有选中文本时忽略错误
|
||||
|
||||
def paste(self):
|
||||
try:
|
||||
text = self.widget.clipboard_get()
|
||||
self.widget.insert("insert", text)
|
||||
except tk.TclError:
|
||||
pass # 剪贴板为空时忽略错误
|
||||
|
||||
def cut(self):
|
||||
try:
|
||||
text = self.widget.get("sel.first", "sel.last")
|
||||
self.widget.delete("sel.first", "sel.last")
|
||||
self.widget.clipboard_clear()
|
||||
self.widget.clipboard_append(text)
|
||||
except tk.TclError:
|
||||
pass # 没有选中文本时忽略错误
|
||||
|
||||
def select_all(self):
|
||||
self.widget.tag_add("sel", "1.0", "end")
|
||||
@@ -0,0 +1,56 @@
|
||||
# ui/directory_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from utils import read_file, save_string_to_txt, clear_file_content
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
|
||||
def build_directory_tab(self):
|
||||
self.directory_tab = self.tabview.add("Chapter Blueprint")
|
||||
self.directory_tab.rowconfigure(0, weight=0)
|
||||
self.directory_tab.rowconfigure(1, weight=1)
|
||||
self.directory_tab.columnconfigure(0, weight=1)
|
||||
|
||||
load_btn = ctk.CTkButton(self.directory_tab, text="加载 Novel_directory.txt", command=self.load_chapter_blueprint, font=("Microsoft YaHei", 12))
|
||||
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
|
||||
self.directory_word_count_label = ctk.CTkLabel(self.directory_tab, text="字数:0", font=("Microsoft YaHei", 12))
|
||||
self.directory_word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
save_btn = ctk.CTkButton(self.directory_tab, text="保存修改", command=self.save_chapter_blueprint, font=("Microsoft YaHei", 12))
|
||||
save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e")
|
||||
|
||||
self.directory_text = ctk.CTkTextbox(self.directory_tab, wrap="word", font=("Microsoft YaHei", 12))
|
||||
|
||||
def update_word_count(event=None):
|
||||
text = self.directory_text.get("0.0", "end")
|
||||
count = len(text) - 1
|
||||
self.directory_word_count_label.configure(text=f"字数:{count}")
|
||||
|
||||
self.directory_text.bind("<KeyRelease>", update_word_count)
|
||||
self.directory_text.bind("<ButtonRelease>", update_word_count)
|
||||
TextWidgetContextMenu(self.directory_text)
|
||||
self.directory_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
|
||||
|
||||
def load_chapter_blueprint(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径")
|
||||
return
|
||||
filename = os.path.join(filepath, "Novel_directory.txt")
|
||||
content = read_file(filename)
|
||||
self.directory_text.delete("0.0", "end")
|
||||
self.directory_text.insert("0.0", content)
|
||||
self.log("已加载 Novel_directory.txt 内容到编辑区。")
|
||||
|
||||
def save_chapter_blueprint(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径")
|
||||
return
|
||||
content = self.directory_text.get("0.0", "end").strip()
|
||||
filename = os.path.join(filepath, "Novel_directory.txt")
|
||||
clear_file_content(filename)
|
||||
save_string_to_txt(content, filename)
|
||||
self.log("已保存对 Novel_directory.txt 的修改。")
|
||||
@@ -0,0 +1,538 @@
|
||||
# ui/generation_handlers.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
import customtkinter as ctk
|
||||
import traceback
|
||||
from utils import read_file, save_string_to_txt, clear_file_content
|
||||
from novel_generator import (
|
||||
Novel_architecture_generate,
|
||||
Chapter_blueprint_generate,
|
||||
generate_chapter_draft,
|
||||
finalize_chapter,
|
||||
import_knowledge_file,
|
||||
clear_vector_store,
|
||||
enrich_chapter_text
|
||||
)
|
||||
from consistency_checker import check_consistency
|
||||
|
||||
def generate_novel_architecture_ui(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先选择保存文件路径")
|
||||
return
|
||||
|
||||
def task():
|
||||
confirm = messagebox.askyesno("确认", "确定要生成小说架构吗?")
|
||||
if not confirm:
|
||||
self.enable_button_safe(self.btn_generate_architecture)
|
||||
return
|
||||
|
||||
self.disable_button_safe(self.btn_generate_architecture)
|
||||
try:
|
||||
interface_format = self.interface_format_var.get().strip()
|
||||
api_key = self.api_key_var.get().strip()
|
||||
base_url = self.base_url_var.get().strip()
|
||||
model_name = self.model_name_var.get().strip()
|
||||
temperature = self.temperature_var.get()
|
||||
max_tokens = self.max_tokens_var.get()
|
||||
timeout_val = self.safe_get_int(self.timeout_var, 600)
|
||||
|
||||
topic = self.topic_text.get("0.0", "end").strip()
|
||||
genre = self.genre_var.get().strip()
|
||||
num_chapters = self.safe_get_int(self.num_chapters_var, 10)
|
||||
word_number = self.safe_get_int(self.word_number_var, 3000)
|
||||
# 获取内容指导
|
||||
user_guidance = self.user_guide_text.get("0.0", "end").strip()
|
||||
|
||||
self.safe_log("开始生成小说架构...")
|
||||
Novel_architecture_generate(
|
||||
interface_format=interface_format,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
llm_model=model_name,
|
||||
topic=topic,
|
||||
genre=genre,
|
||||
number_of_chapters=num_chapters,
|
||||
word_number=word_number,
|
||||
filepath=filepath,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout_val,
|
||||
user_guidance=user_guidance # 添加内容指导参数
|
||||
)
|
||||
self.safe_log("✅ 小说架构生成完成。请在 'Novel Architecture' 标签页查看或编辑。")
|
||||
except Exception:
|
||||
self.handle_exception("生成小说架构时出错")
|
||||
finally:
|
||||
self.enable_button_safe(self.btn_generate_architecture)
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def generate_chapter_blueprint_ui(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先选择保存文件路径")
|
||||
return
|
||||
|
||||
def task():
|
||||
if not messagebox.askyesno("确认", "确定要生成章节目录吗?"):
|
||||
self.enable_button_safe(self.btn_generate_chapter)
|
||||
return
|
||||
self.disable_button_safe(self.btn_generate_directory)
|
||||
try:
|
||||
interface_format = self.interface_format_var.get().strip()
|
||||
api_key = self.api_key_var.get().strip()
|
||||
base_url = self.base_url_var.get().strip()
|
||||
model_name = self.model_name_var.get().strip()
|
||||
number_of_chapters = self.safe_get_int(self.num_chapters_var, 10)
|
||||
temperature = self.temperature_var.get()
|
||||
max_tokens = self.max_tokens_var.get()
|
||||
timeout_val = self.safe_get_int(self.timeout_var, 600)
|
||||
user_guidance = self.user_guide_text.get("0.0", "end").strip() # 新增获取用户指导
|
||||
|
||||
self.safe_log("开始生成章节蓝图...")
|
||||
Chapter_blueprint_generate(
|
||||
interface_format=interface_format,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
llm_model=model_name,
|
||||
number_of_chapters=number_of_chapters,
|
||||
filepath=filepath,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout_val,
|
||||
user_guidance=user_guidance # 新增参数
|
||||
)
|
||||
self.safe_log("✅ 章节蓝图生成完成。请在 'Chapter Blueprint' 标签页查看或编辑。")
|
||||
except Exception:
|
||||
self.handle_exception("生成章节蓝图时出错")
|
||||
finally:
|
||||
self.enable_button_safe(self.btn_generate_directory)
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def generate_chapter_draft_ui(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先配置保存文件路径。")
|
||||
return
|
||||
|
||||
def task():
|
||||
self.disable_button_safe(self.btn_generate_chapter)
|
||||
try:
|
||||
interface_format = self.interface_format_var.get().strip()
|
||||
api_key = self.api_key_var.get().strip()
|
||||
base_url = self.base_url_var.get().strip()
|
||||
model_name = self.model_name_var.get().strip()
|
||||
temperature = self.temperature_var.get()
|
||||
max_tokens = self.max_tokens_var.get()
|
||||
timeout_val = self.safe_get_int(self.timeout_var, 600)
|
||||
|
||||
chap_num = self.safe_get_int(self.chapter_num_var, 1)
|
||||
word_number = self.safe_get_int(self.word_number_var, 3000)
|
||||
user_guidance = self.user_guide_text.get("0.0", "end").strip()
|
||||
|
||||
char_inv = self.characters_involved_var.get().strip()
|
||||
key_items = self.key_items_var.get().strip()
|
||||
scene_loc = self.scene_location_var.get().strip()
|
||||
time_constr = self.time_constraint_var.get().strip()
|
||||
|
||||
embedding_api_key = self.embedding_api_key_var.get().strip()
|
||||
embedding_url = self.embedding_url_var.get().strip()
|
||||
embedding_interface_format = self.embedding_interface_format_var.get().strip()
|
||||
embedding_model_name = self.embedding_model_name_var.get().strip()
|
||||
embedding_k = self.safe_get_int(self.embedding_retrieval_k_var, 4)
|
||||
|
||||
self.safe_log(f"生成第{chap_num}章草稿:准备生成请求提示词...")
|
||||
|
||||
# 调用新添加的 build_chapter_prompt 函数构造初始提示词
|
||||
from novel_generator.chapter import build_chapter_prompt
|
||||
prompt_text = build_chapter_prompt(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
filepath=filepath,
|
||||
novel_number=chap_num,
|
||||
word_number=word_number,
|
||||
temperature=temperature,
|
||||
user_guidance=user_guidance,
|
||||
characters_involved=char_inv,
|
||||
key_items=key_items,
|
||||
scene_location=scene_loc,
|
||||
time_constraint=time_constr,
|
||||
embedding_api_key=embedding_api_key,
|
||||
embedding_url=embedding_url,
|
||||
embedding_interface_format=embedding_interface_format,
|
||||
embedding_model_name=embedding_model_name,
|
||||
embedding_retrieval_k=embedding_k,
|
||||
interface_format=interface_format,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout_val
|
||||
)
|
||||
|
||||
# 弹出可编辑提示词对话框,等待用户确认或取消
|
||||
result = {"prompt": None}
|
||||
event = threading.Event()
|
||||
|
||||
def create_dialog():
|
||||
dialog = ctk.CTkToplevel(self.master)
|
||||
dialog.title("当前章节请求提示词(可编辑)")
|
||||
dialog.geometry("600x400")
|
||||
text_box = ctk.CTkTextbox(dialog, wrap="word", font=("Microsoft YaHei", 12))
|
||||
text_box.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# 字数统计标签
|
||||
wordcount_label = ctk.CTkLabel(dialog, text="字数:0", font=("Microsoft YaHei", 12))
|
||||
wordcount_label.pack(side="left", padx=(10,0), pady=5)
|
||||
|
||||
# 插入角色内容
|
||||
final_prompt = prompt_text
|
||||
role_names = [name.strip() for name in self.char_inv_text.get("0.0", "end").strip().split(',') if name.strip()]
|
||||
role_lib_path = os.path.join(filepath, "角色库")
|
||||
role_contents = []
|
||||
|
||||
if os.path.exists(role_lib_path):
|
||||
for root, dirs, files in os.walk(role_lib_path):
|
||||
for file in files:
|
||||
if file.endswith(".txt") and os.path.splitext(file)[0] in role_names:
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
role_contents.append(f.read().strip()) # 直接使用文件内容,不添加重复名字
|
||||
except Exception as e:
|
||||
self.safe_log(f"读取角色文件 {file} 失败: {str(e)}")
|
||||
|
||||
if role_contents:
|
||||
role_content_str = "\n".join(role_contents)
|
||||
# 更精确的替换逻辑,处理不同情况下的占位符
|
||||
placeholder_variations = [
|
||||
"核心人物(可能未指定):{characters_involved}",
|
||||
"核心人物:{characters_involved}",
|
||||
"核心人物(可能未指定):{characters_involved}",
|
||||
"核心人物:{characters_involved}"
|
||||
]
|
||||
|
||||
for placeholder in placeholder_variations:
|
||||
if placeholder in final_prompt:
|
||||
final_prompt = final_prompt.replace(
|
||||
placeholder,
|
||||
f"核心人物:\n{role_content_str}"
|
||||
)
|
||||
break
|
||||
else: # 如果没有找到任何已知占位符变体
|
||||
lines = final_prompt.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if "核心人物" in line and ":" in line:
|
||||
lines[i] = f"核心人物:\n{role_content_str}"
|
||||
break
|
||||
final_prompt = '\n'.join(lines)
|
||||
|
||||
text_box.insert("0.0", final_prompt)
|
||||
# 更新字数函数
|
||||
def update_word_count(event=None):
|
||||
text = text_box.get("0.0", "end-1c")
|
||||
text_length = len(text)
|
||||
wordcount_label.configure(text=f"字数:{text_length}")
|
||||
|
||||
text_box.bind("<KeyRelease>", update_word_count)
|
||||
text_box.bind("<ButtonRelease>", update_word_count)
|
||||
update_word_count() # 初始化统计
|
||||
|
||||
button_frame = ctk.CTkFrame(dialog)
|
||||
button_frame.pack(pady=10)
|
||||
def on_confirm():
|
||||
result["prompt"] = text_box.get("1.0", "end").strip()
|
||||
dialog.destroy()
|
||||
event.set()
|
||||
def on_cancel():
|
||||
result["prompt"] = None
|
||||
dialog.destroy()
|
||||
event.set()
|
||||
btn_confirm = ctk.CTkButton(button_frame, text="确认使用", font=("Microsoft YaHei", 12), command=on_confirm)
|
||||
btn_confirm.pack(side="left", padx=10)
|
||||
btn_cancel = ctk.CTkButton(button_frame, text="取消请求", font=("Microsoft YaHei", 12), command=on_cancel)
|
||||
btn_cancel.pack(side="left", padx=10)
|
||||
# 若用户直接关闭弹窗,则调用 on_cancel 处理
|
||||
dialog.protocol("WM_DELETE_WINDOW", on_cancel)
|
||||
dialog.grab_set()
|
||||
self.master.after(0, create_dialog)
|
||||
event.wait() # 等待用户操作完成
|
||||
edited_prompt = result["prompt"]
|
||||
if edited_prompt is None:
|
||||
self.safe_log("❌ 用户取消了草稿生成请求。")
|
||||
return
|
||||
|
||||
self.safe_log("开始生成章节草稿...")
|
||||
from novel_generator.chapter import generate_chapter_draft
|
||||
draft_text = generate_chapter_draft(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
filepath=filepath,
|
||||
novel_number=chap_num,
|
||||
word_number=word_number,
|
||||
temperature=temperature,
|
||||
user_guidance=user_guidance,
|
||||
characters_involved=char_inv,
|
||||
key_items=key_items,
|
||||
scene_location=scene_loc,
|
||||
time_constraint=time_constr,
|
||||
embedding_api_key=embedding_api_key,
|
||||
embedding_url=embedding_url,
|
||||
embedding_interface_format=embedding_interface_format,
|
||||
embedding_model_name=embedding_model_name,
|
||||
embedding_retrieval_k=embedding_k,
|
||||
interface_format=interface_format,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout_val,
|
||||
custom_prompt_text=edited_prompt # 使用用户编辑后的提示词
|
||||
)
|
||||
if draft_text:
|
||||
self.safe_log(f"✅ 第{chap_num}章草稿生成完成。请在左侧查看或编辑。")
|
||||
self.master.after(0, lambda: self.show_chapter_in_textbox(draft_text))
|
||||
else:
|
||||
self.safe_log("⚠️ 本章草稿生成失败或无内容。")
|
||||
except Exception:
|
||||
self.handle_exception("生成章节草稿时出错")
|
||||
finally:
|
||||
self.enable_button_safe(self.btn_generate_chapter)
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def finalize_chapter_ui(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先配置保存文件路径。")
|
||||
return
|
||||
|
||||
def task():
|
||||
if not messagebox.askyesno("确认", "确定要定稿当前章节吗?"):
|
||||
self.enable_button_safe(self.btn_finalize_chapter)
|
||||
return
|
||||
|
||||
self.disable_button_safe(self.btn_finalize_chapter)
|
||||
try:
|
||||
interface_format = self.interface_format_var.get().strip()
|
||||
api_key = self.api_key_var.get().strip()
|
||||
base_url = self.base_url_var.get().strip()
|
||||
model_name = self.model_name_var.get().strip()
|
||||
temperature = self.temperature_var.get()
|
||||
max_tokens = self.max_tokens_var.get()
|
||||
timeout_val = self.safe_get_int(self.timeout_var, 600)
|
||||
|
||||
embedding_api_key = self.embedding_api_key_var.get().strip()
|
||||
embedding_url = self.embedding_url_var.get().strip()
|
||||
embedding_interface_format = self.embedding_interface_format_var.get().strip()
|
||||
embedding_model_name = self.embedding_model_name_var.get().strip()
|
||||
|
||||
chap_num = self.safe_get_int(self.chapter_num_var, 1)
|
||||
word_number = self.safe_get_int(self.word_number_var, 3000)
|
||||
|
||||
self.safe_log(f"开始定稿第{chap_num}章...")
|
||||
|
||||
chapters_dir = os.path.join(filepath, "chapters")
|
||||
os.makedirs(chapters_dir, exist_ok=True)
|
||||
chapter_file = os.path.join(chapters_dir, f"chapter_{chap_num}.txt")
|
||||
|
||||
edited_text = self.chapter_result.get("0.0", "end").strip()
|
||||
|
||||
if len(edited_text) < 0.7 * word_number:
|
||||
ask = messagebox.askyesno("字数不足", f"当前章节字数 ({len(edited_text)}) 低于目标字数({word_number})的70%,是否要尝试扩写?")
|
||||
if ask:
|
||||
self.safe_log("正在扩写章节内容...")
|
||||
enriched = enrich_chapter_text(
|
||||
chapter_text=edited_text,
|
||||
word_number=word_number,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
interface_format=interface_format,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout_val
|
||||
)
|
||||
edited_text = enriched
|
||||
self.master.after(0, lambda: self.chapter_result.delete("0.0", "end"))
|
||||
self.master.after(0, lambda: self.chapter_result.insert("0.0", edited_text))
|
||||
clear_file_content(chapter_file)
|
||||
save_string_to_txt(edited_text, chapter_file)
|
||||
|
||||
finalize_chapter(
|
||||
novel_number=chap_num,
|
||||
word_number=word_number,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
filepath=filepath,
|
||||
embedding_api_key=embedding_api_key,
|
||||
embedding_url=embedding_url,
|
||||
embedding_interface_format=embedding_interface_format,
|
||||
embedding_model_name=embedding_model_name,
|
||||
interface_format=interface_format,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout_val
|
||||
)
|
||||
self.safe_log(f"✅ 第{chap_num}章定稿完成(已更新前文摘要、角色状态、向量库)。")
|
||||
|
||||
final_text = read_file(chapter_file)
|
||||
self.master.after(0, lambda: self.show_chapter_in_textbox(final_text))
|
||||
except Exception:
|
||||
self.handle_exception("定稿章节时出错")
|
||||
finally:
|
||||
self.enable_button_safe(self.btn_finalize_chapter)
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def do_consistency_check(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先配置保存文件路径。")
|
||||
return
|
||||
|
||||
def task():
|
||||
self.disable_button_safe(self.btn_check_consistency)
|
||||
try:
|
||||
api_key = self.api_key_var.get().strip()
|
||||
base_url = self.base_url_var.get().strip()
|
||||
model_name = self.model_name_var.get().strip()
|
||||
temperature = self.temperature_var.get()
|
||||
interface_format = self.interface_format_var.get()
|
||||
max_tokens = self.max_tokens_var.get()
|
||||
timeout = self.timeout_var.get()
|
||||
|
||||
chap_num = self.safe_get_int(self.chapter_num_var, 1)
|
||||
chap_file = os.path.join(filepath, "chapters", f"chapter_{chap_num}.txt")
|
||||
chapter_text = read_file(chap_file)
|
||||
|
||||
if not chapter_text.strip():
|
||||
self.safe_log("⚠️ 当前章节文件为空或不存在,无法审校。")
|
||||
return
|
||||
|
||||
self.safe_log("开始一致性审校...")
|
||||
result = check_consistency(
|
||||
novel_setting="",
|
||||
character_state=read_file(os.path.join(filepath, "character_state.txt")),
|
||||
global_summary=read_file(os.path.join(filepath, "global_summary.txt")),
|
||||
chapter_text=chapter_text,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
interface_format=interface_format,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
plot_arcs=""
|
||||
)
|
||||
self.safe_log("审校结果:")
|
||||
self.safe_log(result)
|
||||
except Exception:
|
||||
self.handle_exception("审校时出错")
|
||||
finally:
|
||||
self.enable_button_safe(self.btn_check_consistency)
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def import_knowledge_handler(self):
|
||||
selected_file = tk.filedialog.askopenfilename(
|
||||
title="选择要导入的知识库文件",
|
||||
filetypes=[("Text Files", "*.txt"), ("All Files", "*.*")]
|
||||
)
|
||||
if selected_file:
|
||||
def task():
|
||||
self.disable_button_safe(self.btn_import_knowledge)
|
||||
try:
|
||||
emb_api_key = self.embedding_api_key_var.get().strip()
|
||||
emb_url = self.embedding_url_var.get().strip()
|
||||
emb_format = self.embedding_interface_format_var.get().strip()
|
||||
emb_model = self.embedding_model_name_var.get().strip()
|
||||
|
||||
# 尝试不同编码读取文件
|
||||
content = None
|
||||
encodings = ['utf-8', 'gbk', 'gb2312', 'ansi']
|
||||
for encoding in encodings:
|
||||
try:
|
||||
with open(selected_file, 'r', encoding=encoding) as f:
|
||||
content = f.read()
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
self.safe_log(f"读取文件时发生错误: {str(e)}")
|
||||
raise
|
||||
|
||||
if content is None:
|
||||
raise Exception("无法以任何已知编码格式读取文件")
|
||||
|
||||
# 创建临时UTF-8文件
|
||||
import tempfile
|
||||
import os
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, suffix='.txt') as temp:
|
||||
temp.write(content)
|
||||
temp_path = temp.name
|
||||
|
||||
try:
|
||||
self.safe_log(f"开始导入知识库文件: {selected_file}")
|
||||
import_knowledge_file(
|
||||
embedding_api_key=emb_api_key,
|
||||
embedding_url=emb_url,
|
||||
embedding_interface_format=emb_format,
|
||||
embedding_model_name=emb_model,
|
||||
file_path=temp_path,
|
||||
filepath=self.filepath_var.get().strip()
|
||||
)
|
||||
self.safe_log("✅ 知识库文件导入完成。")
|
||||
finally:
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
self.handle_exception("导入知识库时出错")
|
||||
finally:
|
||||
self.enable_button_safe(self.btn_import_knowledge)
|
||||
|
||||
try:
|
||||
thread = threading.Thread(target=task, daemon=True)
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
self.enable_button_safe(self.btn_import_knowledge)
|
||||
messagebox.showerror("错误", f"线程启动失败: {str(e)}")
|
||||
|
||||
def clear_vectorstore_handler(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先配置保存文件路径。")
|
||||
return
|
||||
|
||||
first_confirm = messagebox.askyesno("警告", "确定要清空本地向量库吗?此操作不可恢复!")
|
||||
if first_confirm:
|
||||
second_confirm = messagebox.askyesno("二次确认", "你确定真的要删除所有向量数据吗?此操作不可恢复!")
|
||||
if second_confirm:
|
||||
if clear_vector_store(filepath):
|
||||
self.log("已清空向量库。")
|
||||
else:
|
||||
self.log(f"未能清空向量库,请关闭程序后手动删除 {filepath} 下的 vectorstore 文件夹。")
|
||||
|
||||
def show_plot_arcs_ui(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先在主Tab中设置保存文件路径")
|
||||
return
|
||||
|
||||
plot_arcs_file = os.path.join(filepath, "plot_arcs.txt")
|
||||
if not os.path.exists(plot_arcs_file):
|
||||
messagebox.showinfo("剧情要点", "当前还未生成任何剧情要点或冲突记录。")
|
||||
return
|
||||
|
||||
arcs_text = read_file(plot_arcs_file).strip()
|
||||
if not arcs_text:
|
||||
arcs_text = "当前没有记录的剧情要点或冲突。"
|
||||
|
||||
top = ctk.CTkToplevel(self.master)
|
||||
top.title("剧情要点/未解决冲突")
|
||||
top.geometry("600x400")
|
||||
text_area = ctk.CTkTextbox(top, wrap="word", font=("Microsoft YaHei", 12))
|
||||
text_area.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
text_area.insert("0.0", arcs_text)
|
||||
text_area.configure(state="disabled")
|
||||
@@ -0,0 +1,7 @@
|
||||
# ui/helpers.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
def log_error(message: str):
|
||||
logging.error(f"{message}\n{traceback.format_exc()}")
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# ui/main_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
|
||||
def build_main_tab(self):
|
||||
"""
|
||||
主Tab包含左侧的"本章内容"编辑框和输出日志,以及右侧的主要操作和参数设置区
|
||||
"""
|
||||
self.main_tab = self.tabview.add("Main Functions")
|
||||
self.main_tab.rowconfigure(0, weight=1)
|
||||
self.main_tab.columnconfigure(0, weight=1)
|
||||
self.main_tab.columnconfigure(1, weight=0)
|
||||
|
||||
self.left_frame = ctk.CTkFrame(self.main_tab)
|
||||
self.left_frame.grid(row=0, column=0, sticky="nsew", padx=2, pady=2)
|
||||
|
||||
self.right_frame = ctk.CTkFrame(self.main_tab)
|
||||
self.right_frame.grid(row=0, column=1, sticky="nsew", padx=2, pady=2)
|
||||
|
||||
build_left_layout(self)
|
||||
build_right_layout(self)
|
||||
|
||||
def build_left_layout(self):
|
||||
"""
|
||||
左侧区域:本章内容(可编辑) + Step流程按钮 + 输出日志(只读)
|
||||
"""
|
||||
self.left_frame.grid_rowconfigure(0, weight=0)
|
||||
self.left_frame.grid_rowconfigure(1, weight=2)
|
||||
self.left_frame.grid_rowconfigure(2, weight=0)
|
||||
self.left_frame.grid_rowconfigure(3, weight=0)
|
||||
self.left_frame.grid_rowconfigure(4, weight=1)
|
||||
self.left_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.chapter_label = ctk.CTkLabel(self.left_frame, text="本章内容(可编辑) 字数:0", font=("Microsoft YaHei", 12))
|
||||
self.chapter_label.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||
|
||||
# 章节文本编辑框
|
||||
self.chapter_result = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 14))
|
||||
TextWidgetContextMenu(self.chapter_result)
|
||||
self.chapter_result.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5))
|
||||
|
||||
|
||||
|
||||
def update_word_count(event=None):
|
||||
text = self.chapter_result.get("0.0", "end")
|
||||
count = len(text) - 1 # 减去最后一个换行符
|
||||
self.chapter_label.configure(text=f"本章内容(可编辑) 字数:{count}")
|
||||
|
||||
self.chapter_result.bind("<KeyRelease>", update_word_count)
|
||||
self.chapter_result.bind("<ButtonRelease>", update_word_count)
|
||||
|
||||
# Step 按钮区域
|
||||
self.step_buttons_frame = ctk.CTkFrame(self.left_frame)
|
||||
self.step_buttons_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=5)
|
||||
self.step_buttons_frame.columnconfigure((0, 1, 2, 3), weight=1)
|
||||
|
||||
self.btn_generate_architecture = ctk.CTkButton(
|
||||
self.step_buttons_frame,
|
||||
text="Step1. 生成架构",
|
||||
command=self.generate_novel_architecture_ui,
|
||||
font=("Microsoft YaHei", 12)
|
||||
)
|
||||
self.btn_generate_architecture.grid(row=0, column=0, padx=5, pady=2, sticky="ew")
|
||||
|
||||
self.btn_generate_directory = ctk.CTkButton(
|
||||
self.step_buttons_frame,
|
||||
text="Step2. 生成目录",
|
||||
command=self.generate_chapter_blueprint_ui,
|
||||
font=("Microsoft YaHei", 12)
|
||||
)
|
||||
self.btn_generate_directory.grid(row=0, column=1, padx=5, pady=2, sticky="ew")
|
||||
|
||||
self.btn_generate_chapter = ctk.CTkButton(
|
||||
self.step_buttons_frame,
|
||||
text="Step3. 生成草稿",
|
||||
command=self.generate_chapter_draft_ui,
|
||||
font=("Microsoft YaHei", 12)
|
||||
)
|
||||
self.btn_generate_chapter.grid(row=0, column=2, padx=5, pady=2, sticky="ew")
|
||||
|
||||
self.btn_finalize_chapter = ctk.CTkButton(
|
||||
self.step_buttons_frame,
|
||||
text="Step4. 定稿章节",
|
||||
command=self.finalize_chapter_ui,
|
||||
font=("Microsoft YaHei", 12)
|
||||
)
|
||||
self.btn_finalize_chapter.grid(row=0, column=3, padx=5, pady=2, sticky="ew")
|
||||
|
||||
# 日志文本框
|
||||
log_label = ctk.CTkLabel(self.left_frame, text="输出日志 (只读)", font=("Microsoft YaHei", 12))
|
||||
log_label.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||
|
||||
self.log_text = ctk.CTkTextbox(self.left_frame, wrap="word", font=("Microsoft YaHei", 12))
|
||||
TextWidgetContextMenu(self.log_text)
|
||||
self.log_text.grid(row=4, column=0, sticky="nsew", padx=5, pady=(0, 5))
|
||||
self.log_text.configure(state="disabled")
|
||||
|
||||
def build_right_layout(self):
|
||||
"""
|
||||
右侧区域:配置区(tabview) + 小说主参数 + 可选功能按钮
|
||||
"""
|
||||
self.right_frame.grid_rowconfigure(0, weight=0)
|
||||
self.right_frame.grid_rowconfigure(1, weight=1)
|
||||
self.right_frame.grid_rowconfigure(2, weight=0)
|
||||
self.right_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# 配置区(AI/Embedding)
|
||||
self.config_frame = ctk.CTkFrame(self.right_frame, corner_radius=10, border_width=2, border_color="gray")
|
||||
self.config_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
||||
self.config_frame.columnconfigure(0, weight=1)
|
||||
# 其余部分将在 config_tab.py 与 novel_params_tab.py 中构建
|
||||
@@ -0,0 +1,367 @@
|
||||
# ui/main_window.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
import traceback
|
||||
import customtkinter as ctk
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
from .role_library import RoleLibrary
|
||||
from llm_adapters import create_llm_adapter
|
||||
|
||||
from config_manager import load_config, save_config, test_llm_config, test_embedding_config
|
||||
from utils import read_file, save_string_to_txt, clear_file_content
|
||||
from tooltips import tooltips
|
||||
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
from ui.main_tab import build_main_tab, build_left_layout, build_right_layout
|
||||
from ui.config_tab import build_config_tabview, load_config_btn, save_config_btn
|
||||
from ui.novel_params_tab import build_novel_params_area, build_optional_buttons_area
|
||||
from ui.generation_handlers import (
|
||||
generate_novel_architecture_ui,
|
||||
generate_chapter_blueprint_ui,
|
||||
generate_chapter_draft_ui,
|
||||
finalize_chapter_ui,
|
||||
do_consistency_check,
|
||||
import_knowledge_handler,
|
||||
clear_vectorstore_handler,
|
||||
show_plot_arcs_ui
|
||||
)
|
||||
from ui.setting_tab import build_setting_tab, load_novel_architecture, save_novel_architecture
|
||||
from ui.directory_tab import build_directory_tab, load_chapter_blueprint, save_chapter_blueprint
|
||||
from ui.character_tab import build_character_tab, load_character_state, save_character_state
|
||||
from ui.summary_tab import build_summary_tab, load_global_summary, save_global_summary
|
||||
from ui.chapters_tab import build_chapters_tab, refresh_chapters_list, on_chapter_selected, load_chapter_content, save_current_chapter, prev_chapter, next_chapter
|
||||
|
||||
class NovelGeneratorGUI:
|
||||
"""
|
||||
小说生成器的主GUI类,包含所有的界面布局、事件处理、与后端逻辑的交互等。
|
||||
"""
|
||||
def __init__(self, master):
|
||||
self.master = master
|
||||
self.master.title("Novel Generator GUI")
|
||||
try:
|
||||
if os.path.exists("icon.ico"):
|
||||
self.master.iconbitmap("icon.ico")
|
||||
except Exception:
|
||||
pass
|
||||
self.master.geometry("1350x840")
|
||||
|
||||
# --------------- 配置文件路径 ---------------
|
||||
self.config_file = "config.json"
|
||||
self.loaded_config = load_config(self.config_file)
|
||||
|
||||
if self.loaded_config:
|
||||
last_llm = self.loaded_config.get("last_interface_format", "OpenAI")
|
||||
last_embedding = self.loaded_config.get("last_embedding_interface_format", "OpenAI")
|
||||
else:
|
||||
last_llm = "OpenAI"
|
||||
last_embedding = "OpenAI"
|
||||
|
||||
if self.loaded_config and "llm_configs" in self.loaded_config and last_llm in self.loaded_config["llm_configs"]:
|
||||
llm_conf = self.loaded_config["llm_configs"][last_llm]
|
||||
else:
|
||||
llm_conf = {
|
||||
"api_key": "",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model_name": "gpt-4o-mini",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 8192,
|
||||
"timeout": 600
|
||||
}
|
||||
|
||||
if self.loaded_config and "embedding_configs" in self.loaded_config and last_embedding in self.loaded_config["embedding_configs"]:
|
||||
emb_conf = self.loaded_config["embedding_configs"][last_embedding]
|
||||
else:
|
||||
emb_conf = {
|
||||
"api_key": "",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model_name": "text-embedding-ada-002",
|
||||
"retrieval_k": 4
|
||||
}
|
||||
|
||||
# -- LLM通用参数 --
|
||||
self.api_key_var = ctk.StringVar(value=llm_conf.get("api_key", ""))
|
||||
self.base_url_var = ctk.StringVar(value=llm_conf.get("base_url", "https://api.openai.com/v1"))
|
||||
self.interface_format_var = ctk.StringVar(value=last_llm)
|
||||
self.model_name_var = ctk.StringVar(value=llm_conf.get("model_name", "gpt-4o-mini"))
|
||||
self.temperature_var = ctk.DoubleVar(value=llm_conf.get("temperature", 0.7))
|
||||
self.max_tokens_var = ctk.IntVar(value=llm_conf.get("max_tokens", 8192))
|
||||
self.timeout_var = ctk.IntVar(value=llm_conf.get("timeout", 600))
|
||||
|
||||
# -- Embedding相关 --
|
||||
self.embedding_interface_format_var = ctk.StringVar(value=last_embedding)
|
||||
self.embedding_api_key_var = ctk.StringVar(value=emb_conf.get("api_key", ""))
|
||||
self.embedding_url_var = ctk.StringVar(value=emb_conf.get("base_url", "https://api.openai.com/v1"))
|
||||
self.embedding_model_name_var = ctk.StringVar(value=emb_conf.get("model_name", "text-embedding-ada-002"))
|
||||
self.embedding_retrieval_k_var = ctk.StringVar(value=str(emb_conf.get("retrieval_k", 4)))
|
||||
|
||||
# -- 小说参数相关 --
|
||||
if self.loaded_config and "other_params" in self.loaded_config:
|
||||
op = self.loaded_config["other_params"]
|
||||
self.topic_default = op.get("topic", "")
|
||||
self.genre_var = ctk.StringVar(value=op.get("genre", "玄幻"))
|
||||
self.num_chapters_var = ctk.StringVar(value=str(op.get("num_chapters", 10)))
|
||||
self.word_number_var = ctk.StringVar(value=str(op.get("word_number", 3000)))
|
||||
self.filepath_var = ctk.StringVar(value=op.get("filepath", ""))
|
||||
self.chapter_num_var = ctk.StringVar(value=str(op.get("chapter_num", "1")))
|
||||
self.characters_involved_var = ctk.StringVar(value=op.get("characters_involved", ""))
|
||||
self.key_items_var = ctk.StringVar(value=op.get("key_items", ""))
|
||||
self.scene_location_var = ctk.StringVar(value=op.get("scene_location", ""))
|
||||
self.time_constraint_var = ctk.StringVar(value=op.get("time_constraint", ""))
|
||||
self.user_guidance_default = op.get("user_guidance", "")
|
||||
else:
|
||||
self.topic_default = ""
|
||||
self.genre_var = ctk.StringVar(value="玄幻")
|
||||
self.num_chapters_var = ctk.StringVar(value="10")
|
||||
self.word_number_var = ctk.StringVar(value="3000")
|
||||
self.filepath_var = ctk.StringVar(value="")
|
||||
self.chapter_num_var = ctk.StringVar(value="1")
|
||||
self.characters_involved_var = ctk.StringVar(value="")
|
||||
self.key_items_var = ctk.StringVar(value="")
|
||||
self.scene_location_var = ctk.StringVar(value="")
|
||||
self.time_constraint_var = ctk.StringVar(value="")
|
||||
self.user_guidance_default = ""
|
||||
|
||||
# --------------- 整体Tab布局 ---------------
|
||||
self.tabview = ctk.CTkTabview(self.master)
|
||||
self.tabview.pack(fill="both", expand=True)
|
||||
|
||||
# 创建各个标签页
|
||||
build_main_tab(self)
|
||||
build_config_tabview(self)
|
||||
build_novel_params_area(self, start_row=1)
|
||||
build_optional_buttons_area(self, start_row=2)
|
||||
build_setting_tab(self)
|
||||
build_directory_tab(self)
|
||||
build_character_tab(self)
|
||||
build_summary_tab(self)
|
||||
build_chapters_tab(self)
|
||||
|
||||
# ----------------- 通用辅助函数 -----------------
|
||||
def show_tooltip(self, key: str):
|
||||
info_text = tooltips.get(key, "暂无说明")
|
||||
messagebox.showinfo("参数说明", info_text)
|
||||
|
||||
def safe_get_int(self, var, default=1):
|
||||
try:
|
||||
val_str = str(var.get()).strip()
|
||||
return int(val_str)
|
||||
except:
|
||||
var.set(str(default))
|
||||
return default
|
||||
|
||||
def log(self, message: str):
|
||||
self.log_text.configure(state="normal")
|
||||
self.log_text.insert("end", message + "\n")
|
||||
self.log_text.see("end")
|
||||
self.log_text.configure(state="disabled")
|
||||
|
||||
def safe_log(self, message: str):
|
||||
self.master.after(0, lambda: self.log(message))
|
||||
|
||||
def disable_button_safe(self, btn):
|
||||
self.master.after(0, lambda: btn.configure(state="disabled"))
|
||||
|
||||
def enable_button_safe(self, btn):
|
||||
self.master.after(0, lambda: btn.configure(state="normal"))
|
||||
|
||||
def handle_exception(self, context: str):
|
||||
full_message = f"{context}\n{traceback.format_exc()}"
|
||||
logging.error(full_message)
|
||||
self.safe_log(full_message)
|
||||
|
||||
def show_chapter_in_textbox(self, text: str):
|
||||
self.chapter_result.delete("0.0", "end")
|
||||
self.chapter_result.insert("0.0", text)
|
||||
self.chapter_result.see("end")
|
||||
|
||||
def test_llm_config(self):
|
||||
"""
|
||||
测试当前的LLM配置是否可用
|
||||
"""
|
||||
interface_format = self.interface_format_var.get().strip()
|
||||
api_key = self.api_key_var.get().strip()
|
||||
base_url = self.base_url_var.get().strip()
|
||||
model_name = self.model_name_var.get().strip()
|
||||
temperature = self.temperature_var.get()
|
||||
max_tokens = self.max_tokens_var.get()
|
||||
timeout = self.timeout_var.get()
|
||||
|
||||
test_llm_config(
|
||||
interface_format=interface_format,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
log_func=self.safe_log,
|
||||
handle_exception_func=self.handle_exception
|
||||
)
|
||||
|
||||
def test_embedding_config(self):
|
||||
"""
|
||||
测试当前的Embedding配置是否可用
|
||||
"""
|
||||
api_key = self.embedding_api_key_var.get().strip()
|
||||
base_url = self.embedding_url_var.get().strip()
|
||||
interface_format = self.embedding_interface_format_var.get().strip()
|
||||
model_name = self.embedding_model_name_var.get().strip()
|
||||
|
||||
test_embedding_config(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
interface_format=interface_format,
|
||||
model_name=model_name,
|
||||
log_func=self.safe_log,
|
||||
handle_exception_func=self.handle_exception
|
||||
)
|
||||
|
||||
def browse_folder(self):
|
||||
selected_dir = filedialog.askdirectory()
|
||||
if selected_dir:
|
||||
self.filepath_var.set(selected_dir)
|
||||
|
||||
def show_character_import_window(self):
|
||||
"""显示角色导入窗口"""
|
||||
import_window = ctk.CTkToplevel(self.master)
|
||||
import_window.title("导入角色信息")
|
||||
import_window.geometry("600x500")
|
||||
import_window.transient(self.master) # 设置为父窗口的临时窗口
|
||||
import_window.grab_set() # 保持窗口在顶层
|
||||
|
||||
# 主容器
|
||||
main_frame = ctk.CTkFrame(import_window)
|
||||
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# 滚动容器
|
||||
scroll_frame = ctk.CTkScrollableFrame(main_frame)
|
||||
scroll_frame.pack(fill="both", expand=True, padx=5, pady=5)
|
||||
|
||||
# 获取角色库路径
|
||||
role_lib_path = os.path.join(self.filepath_var.get().strip(), "角色库")
|
||||
self.selected_roles = [] # 存储选中的角色名称
|
||||
|
||||
# 动态加载角色分类
|
||||
if os.path.exists(role_lib_path):
|
||||
# 配置网格布局参数
|
||||
scroll_frame.columnconfigure(0, weight=1)
|
||||
max_roles_per_row = 4
|
||||
current_row = 0
|
||||
|
||||
for category in os.listdir(role_lib_path):
|
||||
category_path = os.path.join(role_lib_path, category)
|
||||
if os.path.isdir(category_path):
|
||||
# 创建分类容器
|
||||
category_frame = ctk.CTkFrame(scroll_frame)
|
||||
category_frame.grid(row=current_row, column=0, sticky="w", pady=(10,5), padx=5)
|
||||
|
||||
# 添加分类标签
|
||||
category_label = ctk.CTkLabel(category_frame, text=f"【{category}】",
|
||||
font=("Microsoft YaHei", 12, "bold"))
|
||||
category_label.grid(row=0, column=0, padx=(0,10), sticky="w")
|
||||
|
||||
# 初始化角色排列参数
|
||||
role_count = 0
|
||||
row_num = 0
|
||||
col_num = 1 # 从第1列开始(第0列是分类标签)
|
||||
|
||||
# 添加角色复选框
|
||||
for role_file in os.listdir(category_path):
|
||||
if role_file.endswith(".txt"):
|
||||
role_name = os.path.splitext(role_file)[0]
|
||||
if not any(name == role_name for _, name in self.selected_roles):
|
||||
chk = ctk.CTkCheckBox(category_frame, text=role_name)
|
||||
chk.grid(row=row_num, column=col_num, padx=5, pady=2, sticky="w")
|
||||
self.selected_roles.append((chk, role_name))
|
||||
|
||||
# 更新行列位置
|
||||
role_count += 1
|
||||
col_num += 1
|
||||
if col_num > max_roles_per_row:
|
||||
col_num = 1
|
||||
row_num += 1
|
||||
|
||||
# 如果没有角色,调整分类标签占满整行
|
||||
if role_count == 0:
|
||||
category_label.grid(columnspan=max_roles_per_row+1, sticky="w")
|
||||
|
||||
# 更新主布局的行号
|
||||
current_row += 1
|
||||
|
||||
# 添加分隔线
|
||||
separator = ctk.CTkFrame(scroll_frame, height=1, fg_color="gray")
|
||||
separator.grid(row=current_row, column=0, sticky="ew", pady=5)
|
||||
current_row += 1
|
||||
|
||||
# 底部按钮框架
|
||||
btn_frame = ctk.CTkFrame(main_frame)
|
||||
btn_frame.pack(fill="x", pady=10)
|
||||
|
||||
# 选择按钮
|
||||
def confirm_selection():
|
||||
selected = [name for chk, name in self.selected_roles if chk.get() == 1]
|
||||
self.char_inv_text.delete("0.0", "end")
|
||||
self.char_inv_text.insert("0.0", ", ".join(selected))
|
||||
import_window.destroy()
|
||||
|
||||
btn_confirm = ctk.CTkButton(btn_frame, text="选择", command=confirm_selection)
|
||||
btn_confirm.pack(side="left", padx=20)
|
||||
|
||||
# 取消按钮
|
||||
btn_cancel = ctk.CTkButton(btn_frame, text="取消", command=import_window.destroy)
|
||||
btn_cancel.pack(side="right", padx=20)
|
||||
|
||||
def show_role_library(self):
|
||||
save_path = self.filepath_var.get().strip()
|
||||
if not save_path:
|
||||
messagebox.showwarning("警告", "请先设置保存路径")
|
||||
return
|
||||
|
||||
# 初始化LLM适配器
|
||||
llm_adapter = create_llm_adapter(
|
||||
interface_format=self.interface_format_var.get(),
|
||||
base_url=self.base_url_var.get(),
|
||||
model_name=self.model_name_var.get(),
|
||||
api_key=self.api_key_var.get(),
|
||||
temperature=self.temperature_var.get(),
|
||||
max_tokens=self.max_tokens_var.get(),
|
||||
timeout=self.timeout_var.get()
|
||||
)
|
||||
|
||||
# 传递LLM适配器实例到角色库
|
||||
if hasattr(self, '_role_lib'):
|
||||
if self._role_lib.window and self._role_lib.window.winfo_exists():
|
||||
self._role_lib.window.destroy()
|
||||
|
||||
self._role_lib = RoleLibrary(self.master, save_path, llm_adapter) # 新增参数
|
||||
|
||||
# ----------------- 将导入的各模块函数直接赋给类方法 -----------------
|
||||
generate_novel_architecture_ui = generate_novel_architecture_ui
|
||||
generate_chapter_blueprint_ui = generate_chapter_blueprint_ui
|
||||
generate_chapter_draft_ui = generate_chapter_draft_ui
|
||||
finalize_chapter_ui = finalize_chapter_ui
|
||||
do_consistency_check = do_consistency_check
|
||||
import_knowledge_handler = import_knowledge_handler
|
||||
clear_vectorstore_handler = clear_vectorstore_handler
|
||||
show_plot_arcs_ui = show_plot_arcs_ui
|
||||
load_config_btn = load_config_btn
|
||||
save_config_btn = save_config_btn
|
||||
load_novel_architecture = load_novel_architecture
|
||||
save_novel_architecture = save_novel_architecture
|
||||
load_chapter_blueprint = load_chapter_blueprint
|
||||
save_chapter_blueprint = save_chapter_blueprint
|
||||
load_character_state = load_character_state
|
||||
save_character_state = save_character_state
|
||||
load_global_summary = load_global_summary
|
||||
save_global_summary = save_global_summary
|
||||
refresh_chapters_list = refresh_chapters_list
|
||||
on_chapter_selected = on_chapter_selected
|
||||
save_current_chapter = save_current_chapter
|
||||
prev_chapter = prev_chapter
|
||||
next_chapter = next_chapter
|
||||
test_llm_config = test_llm_config
|
||||
test_embedding_config = test_embedding_config
|
||||
browse_folder = browse_folder
|
||||
@@ -0,0 +1,146 @@
|
||||
# ui/novel_params_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import customtkinter as ctk
|
||||
from tkinter import filedialog, messagebox
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
from tooltips import tooltips
|
||||
|
||||
def build_novel_params_area(self, start_row=1):
|
||||
self.params_frame = ctk.CTkScrollableFrame(self.right_frame, orientation="vertical")
|
||||
self.params_frame.grid(row=start_row, column=0, sticky="nsew", padx=5, pady=5)
|
||||
self.params_frame.columnconfigure(1, weight=1)
|
||||
|
||||
# 1) 主题(Topic)
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="主题(Topic):", tooltip_key="topic", row=0, column=0, font=("Microsoft YaHei", 12), sticky="ne")
|
||||
self.topic_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12))
|
||||
TextWidgetContextMenu(self.topic_text)
|
||||
self.topic_text.grid(row=0, column=1, padx=5, pady=5, sticky="nsew")
|
||||
if hasattr(self, 'topic_default') and self.topic_default:
|
||||
self.topic_text.insert("0.0", self.topic_default)
|
||||
|
||||
# 2) 类型(Genre)
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="类型(Genre):", tooltip_key="genre", row=1, column=0, font=("Microsoft YaHei", 12))
|
||||
genre_entry = ctk.CTkEntry(self.params_frame, textvariable=self.genre_var, font=("Microsoft YaHei", 12))
|
||||
genre_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
|
||||
|
||||
# 3) 章节数 & 每章字数
|
||||
row_for_chapter_and_word = 2
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="章节数 & 每章字数:", tooltip_key="num_chapters", row=row_for_chapter_and_word, column=0, font=("Microsoft YaHei", 12))
|
||||
chapter_word_frame = ctk.CTkFrame(self.params_frame)
|
||||
chapter_word_frame.grid(row=row_for_chapter_and_word, column=1, padx=5, pady=5, sticky="ew")
|
||||
chapter_word_frame.columnconfigure((0, 1, 2, 3), weight=0)
|
||||
num_chapters_label = ctk.CTkLabel(chapter_word_frame, text="章节数:", font=("Microsoft YaHei", 12))
|
||||
num_chapters_label.grid(row=0, column=0, padx=5, pady=5, sticky="e")
|
||||
num_chapters_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.num_chapters_var, width=60, font=("Microsoft YaHei", 12))
|
||||
num_chapters_entry.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
word_number_label = ctk.CTkLabel(chapter_word_frame, text="每章字数:", font=("Microsoft YaHei", 12))
|
||||
word_number_label.grid(row=0, column=2, padx=(15, 5), pady=5, sticky="e")
|
||||
word_number_entry = ctk.CTkEntry(chapter_word_frame, textvariable=self.word_number_var, width=60, font=("Microsoft YaHei", 12))
|
||||
word_number_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w")
|
||||
|
||||
# 4) 保存路径
|
||||
row_fp = 3
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="保存路径:", tooltip_key="filepath", row=row_fp, column=0, font=("Microsoft YaHei", 12))
|
||||
self.filepath_frame = ctk.CTkFrame(self.params_frame)
|
||||
self.filepath_frame.grid(row=row_fp, column=1, padx=5, pady=5, sticky="nsew")
|
||||
self.filepath_frame.columnconfigure(0, weight=1)
|
||||
filepath_entry = ctk.CTkEntry(self.filepath_frame, textvariable=self.filepath_var, font=("Microsoft YaHei", 12))
|
||||
filepath_entry.grid(row=0, column=0, padx=5, pady=5, sticky="ew")
|
||||
browse_btn = ctk.CTkButton(self.filepath_frame, text="浏览...", command=self.browse_folder, width=60, font=("Microsoft YaHei", 12))
|
||||
browse_btn.grid(row=0, column=1, padx=5, pady=5, sticky="e")
|
||||
|
||||
# 5) 章节号
|
||||
row_chap_num = 4
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="章节号:", tooltip_key="chapter_num", row=row_chap_num, column=0, font=("Microsoft YaHei", 12))
|
||||
chapter_num_entry = ctk.CTkEntry(self.params_frame, textvariable=self.chapter_num_var, width=80, font=("Microsoft YaHei", 12))
|
||||
chapter_num_entry.grid(row=row_chap_num, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
# 6) 内容指导
|
||||
row_user_guide = 5
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="内容指导:", tooltip_key="user_guidance", row=row_user_guide, column=0, font=("Microsoft YaHei", 12), sticky="ne")
|
||||
self.user_guide_text = ctk.CTkTextbox(self.params_frame, height=80, wrap="word", font=("Microsoft YaHei", 12))
|
||||
TextWidgetContextMenu(self.user_guide_text)
|
||||
self.user_guide_text.grid(row=row_user_guide, column=1, padx=5, pady=5, sticky="nsew")
|
||||
if hasattr(self, 'user_guidance_default') and self.user_guidance_default:
|
||||
self.user_guide_text.insert("0.0", self.user_guidance_default)
|
||||
|
||||
# 7) 可选元素:核心人物/关键道具/空间坐标/时间压力
|
||||
row_idx = 6
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="核心人物:", tooltip_key="characters_involved", row=row_idx, column=0, font=("Microsoft YaHei", 12))
|
||||
|
||||
# 核心人物输入框+按钮容器
|
||||
char_inv_frame = ctk.CTkFrame(self.params_frame)
|
||||
char_inv_frame.grid(row=row_idx, column=1, padx=5, pady=5, sticky="nsew")
|
||||
char_inv_frame.columnconfigure(0, weight=1)
|
||||
char_inv_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# 三行文本输入框
|
||||
self.char_inv_text = ctk.CTkTextbox(char_inv_frame, height=60, wrap="word", font=("Microsoft YaHei", 12))
|
||||
self.char_inv_text.grid(row=0, column=0, padx=(0,5), pady=5, sticky="nsew")
|
||||
if hasattr(self, 'characters_involved_var'):
|
||||
self.char_inv_text.insert("0.0", self.characters_involved_var.get())
|
||||
|
||||
# 导入按钮
|
||||
import_btn = ctk.CTkButton(char_inv_frame, text="导入", width=60,
|
||||
command=self.show_character_import_window,
|
||||
font=("Microsoft YaHei", 12))
|
||||
import_btn.grid(row=0, column=1, padx=(0,5), pady=5, sticky="e")
|
||||
row_idx += 1
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="关键道具:", tooltip_key="key_items", row=row_idx, column=0, font=("Microsoft YaHei", 12))
|
||||
key_items_entry = ctk.CTkEntry(self.params_frame, textvariable=self.key_items_var, font=("Microsoft YaHei", 12))
|
||||
key_items_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew")
|
||||
row_idx += 1
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="空间坐标:", tooltip_key="scene_location", row=row_idx, column=0, font=("Microsoft YaHei", 12))
|
||||
scene_loc_entry = ctk.CTkEntry(self.params_frame, textvariable=self.scene_location_var, font=("Microsoft YaHei", 12))
|
||||
scene_loc_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew")
|
||||
row_idx += 1
|
||||
create_label_with_help_for_novel_params(self, parent=self.params_frame, label_text="时间压力:", tooltip_key="time_constraint", row=row_idx, column=0, font=("Microsoft YaHei", 12))
|
||||
time_const_entry = ctk.CTkEntry(self.params_frame, textvariable=self.time_constraint_var, font=("Microsoft YaHei", 12))
|
||||
time_const_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew")
|
||||
|
||||
def build_optional_buttons_area(self, start_row=2):
|
||||
self.optional_btn_frame = ctk.CTkFrame(self.right_frame)
|
||||
self.optional_btn_frame.grid(row=start_row, column=0, sticky="ew", padx=5, pady=5)
|
||||
self.optional_btn_frame.columnconfigure((0, 1, 2, 3, 4), weight=1)
|
||||
|
||||
self.btn_check_consistency = ctk.CTkButton(
|
||||
self.optional_btn_frame, text="一致性审校", command=self.do_consistency_check,
|
||||
font=("Microsoft YaHei", 12), width=100 # 固定宽度
|
||||
)
|
||||
self.btn_check_consistency.grid(row=0, column=0, padx=5, pady=5, sticky="ew")
|
||||
|
||||
self.btn_import_knowledge = ctk.CTkButton(
|
||||
self.optional_btn_frame, text="导入知识库", command=self.import_knowledge_handler,
|
||||
font=("Microsoft YaHei", 12), width=100
|
||||
)
|
||||
self.btn_import_knowledge.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
||||
|
||||
self.btn_clear_vectorstore = ctk.CTkButton(
|
||||
self.optional_btn_frame, text="清空向量库", fg_color="red",
|
||||
command=self.clear_vectorstore_handler, font=("Microsoft YaHei", 12), width=100
|
||||
)
|
||||
self.btn_clear_vectorstore.grid(row=0, column=2, padx=5, pady=5, sticky="ew")
|
||||
|
||||
self.plot_arcs_btn = ctk.CTkButton(
|
||||
self.optional_btn_frame, text="查看剧情要点", command=self.show_plot_arcs_ui,
|
||||
font=("Microsoft YaHei", 12), width=100
|
||||
)
|
||||
self.plot_arcs_btn.grid(row=0, column=3, padx=5, pady=5, sticky="ew")
|
||||
|
||||
# 新增角色库按钮
|
||||
self.role_library_btn = ctk.CTkButton(
|
||||
self.optional_btn_frame, text="角色库", command=self.show_role_library,
|
||||
font=("Microsoft YaHei", 12), width=100
|
||||
)
|
||||
self.role_library_btn.grid(row=0, column=4, padx=5, pady=5, sticky="ew")
|
||||
|
||||
def create_label_with_help_for_novel_params(self, parent, label_text, tooltip_key, row, column, font=None, sticky="e", padx=5, pady=5):
|
||||
frame = ctk.CTkFrame(parent)
|
||||
frame.grid(row=row, column=column, padx=padx, pady=pady, sticky=sticky)
|
||||
frame.columnconfigure(0, weight=0)
|
||||
label = ctk.CTkLabel(frame, text=label_text, font=font)
|
||||
label.pack(side="left")
|
||||
btn = ctk.CTkButton(frame, text="?", width=22, height=22, font=("Microsoft YaHei", 10),
|
||||
command=lambda: messagebox.showinfo("参数说明", tooltips.get(tooltip_key, "暂无说明")))
|
||||
btn.pack(side="left", padx=3)
|
||||
return frame
|
||||
+1528
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
# ui/setting_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from utils import read_file, save_string_to_txt, clear_file_content
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
|
||||
def build_setting_tab(self):
|
||||
self.setting_tab = self.tabview.add("Novel Architecture")
|
||||
self.setting_tab.rowconfigure(0, weight=0)
|
||||
self.setting_tab.rowconfigure(1, weight=1)
|
||||
self.setting_tab.columnconfigure(0, weight=1)
|
||||
|
||||
load_btn = ctk.CTkButton(self.setting_tab, text="加载 Novel_architecture.txt", command=self.load_novel_architecture, font=("Microsoft YaHei", 12))
|
||||
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
|
||||
self.setting_word_count_label = ctk.CTkLabel(self.setting_tab, text="字数:0", font=("Microsoft YaHei", 12))
|
||||
self.setting_word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
save_btn = ctk.CTkButton(self.setting_tab, text="保存修改", command=self.save_novel_architecture, font=("Microsoft YaHei", 12))
|
||||
save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e")
|
||||
|
||||
self.setting_text = ctk.CTkTextbox(self.setting_tab, wrap="word", font=("Microsoft YaHei", 12))
|
||||
TextWidgetContextMenu(self.setting_text)
|
||||
self.setting_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
|
||||
|
||||
def update_word_count(event=None):
|
||||
text = self.setting_text.get("0.0", "end")
|
||||
count = len(text) - 1
|
||||
self.setting_word_count_label.configure(text=f"字数:{count}")
|
||||
|
||||
self.setting_text.bind("<KeyRelease>", update_word_count)
|
||||
self.setting_text.bind("<ButtonRelease>", update_word_count)
|
||||
|
||||
def load_novel_architecture(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径")
|
||||
return
|
||||
filename = os.path.join(filepath, "Novel_architecture.txt")
|
||||
content = read_file(filename)
|
||||
self.setting_text.delete("0.0", "end")
|
||||
self.setting_text.insert("0.0", content)
|
||||
self.log("已加载 Novel_architecture.txt 内容到编辑区。")
|
||||
|
||||
def save_novel_architecture(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径。")
|
||||
return
|
||||
content = self.setting_text.get("0.0", "end").strip()
|
||||
filename = os.path.join(filepath, "Novel_architecture.txt")
|
||||
clear_file_content(filename)
|
||||
save_string_to_txt(content, filename)
|
||||
self.log("已保存对 Novel_architecture.txt 的修改。")
|
||||
@@ -0,0 +1,57 @@
|
||||
# ui/summary_tab.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
from utils import read_file, save_string_to_txt, clear_file_content
|
||||
from ui.context_menu import TextWidgetContextMenu
|
||||
|
||||
def build_summary_tab(self):
|
||||
self.summary_tab = self.tabview.add("Global Summary")
|
||||
self.summary_tab.rowconfigure(0, weight=0)
|
||||
self.summary_tab.rowconfigure(1, weight=1)
|
||||
self.summary_tab.columnconfigure(0, weight=1)
|
||||
self.summary_tab.columnconfigure(1, weight=0)
|
||||
self.summary_tab.columnconfigure(2, weight=0)
|
||||
|
||||
load_btn = ctk.CTkButton(self.summary_tab, text="加载 global_summary.txt", command=self.load_global_summary, font=("Microsoft YaHei", 12))
|
||||
load_btn.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
|
||||
self.word_count_label = ctk.CTkLabel(self.summary_tab, text="字数:0", font=("Microsoft YaHei", 12))
|
||||
self.word_count_label.grid(row=0, column=1, padx=5, pady=5, sticky="w")
|
||||
|
||||
save_btn = ctk.CTkButton(self.summary_tab, text="保存修改", command=self.save_global_summary, font=("Microsoft YaHei", 12))
|
||||
save_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e")
|
||||
|
||||
self.summary_text = ctk.CTkTextbox(self.summary_tab, wrap="word", font=("Microsoft YaHei", 12))
|
||||
TextWidgetContextMenu(self.summary_text)
|
||||
self.summary_text.grid(row=1, column=0, sticky="nsew", padx=5, pady=5, columnspan=3)
|
||||
|
||||
def update_word_count(event=None):
|
||||
text = self.summary_text.get("0.0", "end")
|
||||
count = len(text) - 1
|
||||
self.word_count_label.configure(text=f"字数:{count}")
|
||||
|
||||
self.summary_text.bind("<KeyRelease>", update_word_count)
|
||||
self.summary_text.bind("<ButtonRelease>", update_word_count)
|
||||
def load_global_summary(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径")
|
||||
return
|
||||
filename = os.path.join(filepath, "global_summary.txt")
|
||||
content = read_file(filename)
|
||||
self.summary_text.delete("0.0", "end")
|
||||
self.summary_text.insert("0.0", content)
|
||||
self.log("已加载 global_summary.txt 到编辑区。")
|
||||
|
||||
def save_global_summary(self):
|
||||
filepath = self.filepath_var.get().strip()
|
||||
if not filepath:
|
||||
messagebox.showwarning("警告", "请先设置保存文件路径")
|
||||
return
|
||||
content = self.summary_text.get("0.0", "end").strip()
|
||||
filename = os.path.join(filepath, "global_summary.txt")
|
||||
clear_file_content(filename)
|
||||
save_string_to_txt(content, filename)
|
||||
self.log("已保存对 global_summary.txt 的修改。")
|
||||
@@ -0,0 +1,53 @@
|
||||
# utils.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import json
|
||||
|
||||
def read_file(filename: str) -> str:
|
||||
"""读取文件的全部内容,若文件不存在或异常则返回空字符串。"""
|
||||
try:
|
||||
with open(filename, 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
return content
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
except Exception as e:
|
||||
print(f"[read_file] 读取文件时发生错误: {e}")
|
||||
return ""
|
||||
|
||||
def append_text_to_file(text_to_append: str, file_path: str):
|
||||
"""在文件末尾追加文本(带换行)。若文本非空且无换行,则自动加换行。"""
|
||||
if text_to_append and not text_to_append.startswith('\n'):
|
||||
text_to_append = '\n' + text_to_append
|
||||
|
||||
try:
|
||||
with open(file_path, 'a', encoding='utf-8') as file:
|
||||
file.write(text_to_append)
|
||||
except IOError as e:
|
||||
print(f"[append_text_to_file] 发生错误:{e}")
|
||||
|
||||
def clear_file_content(filename: str):
|
||||
"""清空指定文件内容。"""
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as file:
|
||||
pass
|
||||
except IOError as e:
|
||||
print(f"[clear_file_content] 无法清空文件 '{filename}' 的内容:{e}")
|
||||
|
||||
def save_string_to_txt(content: str, filename: str):
|
||||
"""将字符串保存为 txt 文件(覆盖写)。"""
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as file:
|
||||
file.write(content)
|
||||
except Exception as e:
|
||||
print(f"[save_string_to_txt] 保存文件时发生错误: {e}")
|
||||
|
||||
def save_data_to_json(data: dict, file_path: str) -> bool:
|
||||
"""将数据保存到 JSON 文件。"""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as json_file:
|
||||
json.dump(data, json_file, ensure_ascii=False, indent=4)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[save_data_to_json] 保存数据到JSON文件时出错: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user