feat: v0.5.0 - 总结世界书拆分优化、Part调试面板、Amily表格并发等

主要更新:
- 总结世界书并发拆分功能(自动检测约5万字拆分为Part)
- Part调试面板
- Amily表格并发填充模块(src/table-filler/)
- 合并去重开关
- 内置默认独立模板
- 多主题支持优化
- 添加.gitignore排除不必要文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-27 01:46:18 +08:00
parent e78cd230d9
commit 6078f85d06
46 changed files with 10778 additions and 842 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
不发布内容/
*.bak

View File

@@ -9,9 +9,182 @@
## [Unreleased]
### 计划中
- 更多 AI 服务支持
- 性能优化
---
## [0.5.0] - 2025-02-13
### 总结世界书拆分功能优化
- **优化**Part 1 复用原总结世界书的 API 配置
- 用户只需配置 Part 2、3、4... 的 API
- 减少重复配置工作量
- **优化**Part 配置界面统一
- 所有 Part 使用相同的完整配置弹窗
- 支持独立配置模型、API 地址、温度等参数
- **优化**Part UI 样式统一
- Part 显示改为 `mm-chip` 样式,与记忆世界书分类一致
- 修复条目数和删除按钮挤在一起的布局问题
- **优化**:配置弹窗添加楼层+字符信息横幅
- 显示当前 Part 的楼层范围和字符数
- **修复**:发送前检查预览中 Part 名称显示
-`Part floor_1_45` 改为 `Part 1 (1-45楼)` 格式
- 移除多余的 "(复用)" 标签
- **修复**:进度追踪器任务注册
- 启用拆分时正确注册每个 Part 为独立任务
- 修复进度条一直显示的问题
### Part 调试与合并功能
- **新增**Part 结果调试弹窗
- 在总结世界书标题栏添加调试开关(虫子图标)
- 处理完成后显示各 Part 的原始返回内容
- 显示合并后的最终结果,方便排查问题
- **新增**:合并去重开关
- 在总结世界书标题栏添加去重开关(过滤器图标)
- 开启时:同一楼层保留内容最长的事件
- 关闭时:相同楼层的内容放在一起,保持原始顺序
- 仅在拆分模式下可用,非拆分模式无去重逻辑
- **优化**:合并逻辑改进
- 移除按楼层排序,保持 AI 返回的原始顺序(按关联性排序)
- 去重功能仅在拆分模式下生效,确保非拆分模式的多事件不被误删
### 配置管理优化
- **优化**:清除旧数据功能保留拆分配置
- 清除旧数据时保留 `summaryPartConfigs`Part API 配置)
- 清除旧数据时保留 `summaryAutoSplit`(拆分功能设置)
- 避免用户误操作丢失已配置的 Part API
### 记忆搜索助手优化
- **修复**:记忆搜索助手支持拆分模式
- 启用拆分时,记忆搜索助手也会并发处理多个 Part
- 支持 Part 调试弹窗和去重开关
- 修复之前拆分模式下记忆搜索助手忽略拆分配置的问题
### Amily 表格并发优化
- **新增**:内置默认独立模板
- 添加 `prompts/table-filler/default-independent-template.json` 内置模板
- 独立模式自动加载内置默认模板,无需手动配置即可使用
- 模板编辑界面显示"内置默认"状态标识
- 用户编辑后自动转为自定义模板保存
- **新增**:恢复默认模板按钮
- 自定义模板旁显示恢复按钮,可一键恢复内置默认
- 设置面板显示"使用默认 X 个"状态提示
- **修复**:独立模式提示词重复问题
- 修复 flowTemplate 中原有指令与用户模板重复的问题
- 替换前先清理原有的 `<Instructions for filling out the form>``<需要更新的旧表格>` 标签
- **修复**:独立模式模板注入位置错误
- 修复用户模板被插入到错误位置的问题
- 改用 indexOf 替代正则匹配,提高标签定位可靠性
- 移除导致提前注入的 fallback 逻辑
- **优化**:替换标签名提示文案
- 更新悬停提示,说明默认标签名和替换逻辑
### UI 样式修复
- **修复**:图标切换按钮样式优化
- 拆分、去重、调试等开关按钮添加背景和边框
- 激活状态使用主题色填充,图标变白色
- 悬停效果更明显,与整体界面风格统一
- **修复**:输入框文字不可见问题
- 添加 `.mm-input` 通用样式类
- 修复"替换标签名"输入框白色文字在浅色背景不可见的问题
- 输入框颜色自动适配当前主题
- **优化**:标签过滤开关样式改进
- "提取模式"和"排除模式"改为按钮式开关
- 未启用时显示虚线边框和开关图标,更明显可点击
- 启用时显示实线边框和高亮背景
### 代码修复
- **修复**`request-collector.js` 文件编码损坏问题
- 重写文件修复 UTF-8 编码
---
## [0.4.9] - 2025-02-12
### 总结世界书并发拆分功能(新功能)
- **新增**:自动检测与拆分
- 导入总结世界书时自动识别内容字数
- 约每 5 万字符自动拆分为一个 Part
- 允许浮动范围 4~6 万字符,确保段落完整性
- 在段落边界(`---` 分隔符)处拆分,不切断任何段落
- **新增**Part 列表 UI
- 在主界面「总结世界书」区域下方显示拆分后的各个 Part
- 每个 Part 显示楼层范围和字符数
- 配置状态指示:已配置(绿色)/ 未配置(黄色)
- **新增**Part 独立 API 配置
- 每个 Part 可点击配置独立的 API复用现有配置弹窗
- 配置按 Part ID 保存,内容变化时自动匹配已有配置
- **新增**:功能开关
- 在总结世界书分组标题右侧添加开关
- 用户可选择是否启用拆分功能
- **新增**:并发触发与结果合并
- 触发时同时发送多个 Part 的 API 请求
- 合并所有 Part 的 AI 回复,按楼层排序去重
### Amily表格并发增强
- **新增**:失败重试横幅通知(右下角 Win10 风格通知)
- 支持用户选择"重试"或"放弃"失败的表格
- 重试成功后自动从失败列表移除
- **新增**:调试弹窗功能
- 发送前检查:显示每个表格的提示词内容
- 合并后检查:显示各表格原始响应和合并结果
- **新增**:多主题适配支持
- 暖灰棕、淡紫薰衣草、森林绿、玫瑰灰、静谧蓝灰
- 星空紫、星空蓝、星空黑(含星星点缀动画效果)
### UI 改进
- **优化**提示词模式改为分段控制器Segmented Control样式
- **优化**:调试模式改为独立行的 iOS 风格开关
- **修复**:已配置表格支持点击行打开编辑弹窗
- **优化**:刷新表格列表按钮改为图标模式,悬停显示提示
### 提示词处理优化
- **优化**:共享模式聚焦指令通用化(移除对预设阶段结构的依赖)
- **新增**:独立模式 V2 - 按名称存储模板
- 表格重排序不再影响配置
- 支持 `{{tableData}}``{{tableName}}``{{tableIndex}}` 占位符
- **新增**:标签精准替换功能(`<Instructions for filling out the form>`
### 代码优化
- **优化**:表格拆分算法改进,支持多种格式解析
- 完整格式:`* 0:角色表\n【说明】...<角色表内容>...`
- 内容标签格式:`<角色表内容>...</角色表内容>`
- 简化标签格式:`<角色表>...</角色表>`
- **优化**:移除填表模式检查,只检测请求特征
- **新增**XHR 拦截支持(覆盖 `$.ajax` 调用)
---
## [0.4.8] - 2025-02-10
### 新增功能
- **Amily表格并发**:为 Amily2 表格模块提供并发填表支持
- 拦截 Amily2 表格填充请求,将 7 个表格拆分后并发调用 API
- 支持双模式架构:拦截模式(立即可用)+ Bus 联动模式(预留接口)
- 支持两种提示词模式:共享模式(复用 Amily2 提示词)/ 独立模式(导入专用预设)
- 每个表格可配置独立 API未配置的使用默认 API
- 自动检测 Amily2 填表模式兼容性(仅支持分步填表模式)
- 通过 Amily2Bus 暴露 `TableFillerProxy` 接口供未来 Amily2 调用
### 新增模块
- `src/table-filler/` - Amily表格并发核心模块
- `index.js` - 模块入口,双模式初始化
- `mode-manager.js` - 调用模式管理auto/intercept_only/bus_only
- `bus-integration.js` - Amily2Bus 联动集成
- `interceptor.js` - API 拦截器
- `table-splitter.js` - 表格数据拆分与合并
- `prompt-handler.js` - 提示词处理器
- `parallel-executor.js` - 并发执行器
- `src/ui/components/table-filler.js` - Amily表格并发 UI 组件
### 配置扩展
- `default-config.js` 新增 `tableFillerConfig` 配置结构
- `config-manager.js` 新增表格填表配置管理函数
### UI 更新
- 设置面板新增「Amily表格并发」折叠卡片
- 支持启用开关、调用模式选择、提示词模式切换
- 支持默认 API 配置和表格独立 API 配置(弹窗式配置界面)
- 支持并发预设 JSON 导入/导出
- 备注信息改为鼠标悬停提示,界面更简洁
---

671
LICENSE
View File

@@ -1,658 +1,65 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International
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.
记忆管理并发系统 (Memory Manager Concurrent)
Copyright (C) 2024-2025 可乐、繁华 (Cola-Echo, Fanhua)
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.
本作品采用知识共享 署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。
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.
This work is licensed under the Creative Commons
Attribution-NonCommercial-NoDerivatives 4.0 International License.
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
enabled 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,
distributing (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.
完整许可协议文本:
https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode.zh-hans
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.
Full license text:
https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode
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
generate 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
the 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
authorization or waiver of a right under any patent, whether or not
it is enforceable, that would otherwise be infringed by the making,
using, or selling of a covered work. For purposes of this definition,
"control" includes the right to grant patent sublicenses in a manner
consistent with the requirements of this License.
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
sinteracting 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
affirmative consent must be given in advance of any such use as a
binding election for all users and conveyors of that Program.
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/>.
联系方式 / Contact:
GitHub: https://github.com/Cola-Echo/memory-manager-concurrent

2
dist/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,9 @@
{
"display_name": "记忆管理并发系统",
"description": "智能记忆检索与注入系统,支持并发处理、世界书管理和剧情优化",
"version": "0.4.7",
"version": "0.5.0",
"author": "可乐、繁华",
"license": "CC BY-NC-ND 4.0",
"homePage": "https://github.com/Cola-Echo/memory-manager-concurrent",
"js": "dist/index.js",
"css": "style.css",

View File

@@ -1,6 +1,6 @@
{
"name": "memory-manager-concurrent",
"version": "0.4.7",
"version": "0.5.0",
"description": "SillyTavern 记忆管理并发系统 - 智能记忆检索与注入系统",
"main": "dist/index.js",
"scripts": {
@@ -14,7 +14,7 @@
"webpack-cli": "^5.1.4"
},
"author": "可乐、繁华",
"license": "AGPL-3.0",
"license": "CC-BY-NC-ND-4.0",
"repository": {
"type": "git",
"url": "https://github.com/Cola-Echo/memory-manager-concurrent"

File diff suppressed because one or more lines are too long

View File

@@ -372,10 +372,10 @@ export async function callOpenAIWithMessages(
headers["Authorization"] = `Bearer ${apiKey}`;
}
const fullMessages = [
{ role: "system", content: systemPrompt },
...messages,
];
// 构建消息列表,如果 systemPrompt 为空则不添加
const fullMessages = systemPrompt
? [{ role: "system", content: systemPrompt }, ...messages]
: [...messages];
const response = await fetch(apiUrl, {
method: "POST",

View File

@@ -190,9 +190,12 @@ export function clearOldData(maxAgeMs = OLD_DATA_MAX_AGE_MS) {
const preserved = {
memoryConfigs: structuredClone(config?.memoryConfigs || {}),
summaryConfigs: structuredClone(config?.summaryConfigs || {}),
summaryPartConfigs: structuredClone(config?.summaryPartConfigs || {}),
summaryAutoSplit: structuredClone(config?.global?.summaryAutoSplit || {}),
indexMergeConfig: structuredClone(config?.global?.indexMergeConfig || {}),
plotOptimizeConfig: structuredClone(config?.global?.plotOptimizeConfig || {}),
providers: structuredClone(config?.global?.multiAIGeneration?.providers || []),
tableFillerConfig: structuredClone(config?.global?.tableFillerConfig || {}),
};
// 保留完整的 API 配置字段(包括 enabled 等)
@@ -244,9 +247,51 @@ export function clearOldData(maxAgeMs = OLD_DATA_MAX_AGE_MS) {
const newConfig = structuredClone(defaultConfig);
newConfig.memoryConfigs = preserved.memoryConfigs;
newConfig.summaryConfigs = preserved.summaryConfigs;
newConfig.summaryPartConfigs = preserved.summaryPartConfigs;
newConfig.global.summaryAutoSplit = preserved.summaryAutoSplit;
newConfig.global.indexMergeConfig = pickApiFields(preserved.indexMergeConfig, newConfig.global.indexMergeConfig);
newConfig.global.plotOptimizeConfig = pickApiFields(preserved.plotOptimizeConfig, newConfig.global.plotOptimizeConfig);
newConfig.global.multiAIGeneration.providers = sanitizedProviders;
// 恢复表格填表并发配置(保留 API 配置)
if (preserved.tableFillerConfig) {
const tableFillerApiFields = ["apiFormat", "apiUrl", "apiKey", "model", "maxTokens", "temperature", "customTemplate", "responsePath"];
const sanitizedTableFillerConfig = {
enabled: preserved.tableFillerConfig.enabled ?? false,
callMode: preserved.tableFillerConfig.callMode ?? "auto",
promptMode: "shared", // 提示词模式重置为共享(清除预设关联)
retryCount: preserved.tableFillerConfig.retryCount ?? 2,
retryDelay: preserved.tableFillerConfig.retryDelay ?? 2000,
importedPreset: null, // 清除导入的预设
defaultApi: {},
tableApiConfigs: {},
};
// 保留默认 API 配置
if (preserved.tableFillerConfig.defaultApi) {
for (const f of tableFillerApiFields) {
if (Object.hasOwn(preserved.tableFillerConfig.defaultApi, f)) {
sanitizedTableFillerConfig.defaultApi[f] = preserved.tableFillerConfig.defaultApi[f];
}
}
}
// 保留各表格独立 API 配置
if (preserved.tableFillerConfig.tableApiConfigs) {
for (const [tableName, tableConfig] of Object.entries(preserved.tableFillerConfig.tableApiConfigs)) {
sanitizedTableFillerConfig.tableApiConfigs[tableName] = {};
for (const f of tableFillerApiFields) {
if (Object.hasOwn(tableConfig, f)) {
sanitizedTableFillerConfig.tableApiConfigs[tableName][f] = tableConfig[f];
}
}
// 保留 useDefault 标记
if (Object.hasOwn(tableConfig, "useDefault")) {
sanitizedTableFillerConfig.tableApiConfigs[tableName].useDefault = tableConfig.useDefault;
}
}
}
newConfig.global.tableFillerConfig = sanitizedTableFillerConfig;
}
saveConfig(newConfig);
// localStorage 旧数据清理(无时间戳的也视为旧)
@@ -574,3 +619,569 @@ export function setMultiAIEnabled(enabled) {
multiAI.enabled = enabled;
saveMultiAIConfig(multiAI);
}
// ============================================================================
// 表格填表并发配置管理
// ============================================================================
/**
* 获取表格填表配置
* @returns {object} 表格填表配置对象
*/
export function getTableFillerConfig() {
const config = loadConfig();
const tableFillerConfig = config?.global?.tableFillerConfig;
if (!tableFillerConfig) {
return {
enabled: false,
callMode: "auto",
promptMode: "shared",
retryCount: 2,
retryDelay: 2000,
importedPreset: null,
defaultApi: {},
tableApiConfigs: {},
independentTemplates: {},
independentTagName: "Instructions for filling out the form",
};
}
// 确保 retryCount 有默认值
if (tableFillerConfig.retryCount === undefined) {
tableFillerConfig.retryCount = 2;
}
// 确保 retryDelay 有默认值
if (tableFillerConfig.retryDelay === undefined) {
tableFillerConfig.retryDelay = 2000;
}
// 确保 independentTemplates 有默认值
if (!tableFillerConfig.independentTemplates) {
tableFillerConfig.independentTemplates = {};
}
// 确保 independentTagName 有默认值
if (!tableFillerConfig.independentTagName) {
tableFillerConfig.independentTagName = "Instructions for filling out the form";
}
return tableFillerConfig;
}
/**
* 检查表格填表功能是否启用
* @returns {boolean}
*/
export function isTableFillerEnabled() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig?.enabled === true;
}
/**
* 检查调试模式是否启用
* @returns {boolean}
*/
export function isDebugModeEnabled() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig?.debugMode === true;
}
/**
* 保存表格填表配置
* @param {object} tableFillerConfig 表格填表配置
*/
export function saveTableFillerConfig(tableFillerConfig) {
const config = loadConfig();
if (!config.global) config.global = {};
config.global.tableFillerConfig = tableFillerConfig;
saveConfig(config);
}
/**
* 更新表格填表配置的部分字段
* @param {object} updates 要更新的字段
*/
export function updateTableFillerConfig(updates) {
const tableFillerConfig = getTableFillerConfig();
const newConfig = { ...tableFillerConfig, ...updates };
saveTableFillerConfig(newConfig);
}
/**
* 设置表格填表功能启用状态
* @param {boolean} enabled 是否启用
*/
export function setTableFillerEnabled(enabled) {
updateTableFillerConfig({ enabled });
}
/**
* 获取表格的 API 配置
* @param {string} tableName 表格名称
* @returns {object} API 配置
*/
export function getTableApiConfig(tableName) {
const tableFillerConfig = getTableFillerConfig();
const tableConfig = tableFillerConfig.tableApiConfigs?.[tableName];
// 如果表格有独立配置且不是使用默认
if (tableConfig && !tableConfig.useDefault) {
return tableConfig;
}
// 使用默认 API 配置
return tableFillerConfig.defaultApi || {};
}
/**
* 设置表格的 API 配置
* @param {string} tableName 表格名称
* @param {object} apiConfig API 配置
*/
export function setTableApiConfig(tableName, apiConfig) {
const tableFillerConfig = getTableFillerConfig();
if (!tableFillerConfig.tableApiConfigs) {
tableFillerConfig.tableApiConfigs = {};
}
tableFillerConfig.tableApiConfigs[tableName] = apiConfig;
saveTableFillerConfig(tableFillerConfig);
}
/**
* 删除表格的独立 API 配置(恢复使用默认)
* @param {string} tableName 表格名称
*/
export function deleteTableApiConfig(tableName) {
const tableFillerConfig = getTableFillerConfig();
if (tableFillerConfig.tableApiConfigs?.[tableName]) {
delete tableFillerConfig.tableApiConfigs[tableName];
saveTableFillerConfig(tableFillerConfig);
}
}
/**
* 检查表格填表配置是否有效
* @returns {boolean}
*/
export function hasValidTableFillerConfig() {
const config = getTableFillerConfig();
// 必须有默认 API 配置
if (!config.defaultApi?.apiUrl || !config.defaultApi?.model) {
return false;
}
return true;
}
/**
* 获取表格的独立模板
* @param {string} tableName 表格名称
* @returns {object|null} 模板配置
*/
export function getIndependentTemplate(tableName) {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig.independentTemplates?.[tableName] || null;
}
/**
* 默认独立模板缓存
*/
let defaultIndependentTemplatesCache = null;
/**
* 加载内置默认独立模板
* @returns {Promise<object|null>} 默认模板对象
*/
export async function loadDefaultIndependentTemplates() {
// 如果已缓存,直接返回
if (defaultIndependentTemplatesCache) {
return defaultIndependentTemplatesCache;
}
try {
const response = await fetch('/scripts/extensions/third-party/memory-manager-concurrent/prompts/table-filler/default-independent-template.json');
if (!response.ok) {
Logger.warn('[独立模板] 加载内置默认模板失败:', response.status);
return null;
}
const data = await response.json();
defaultIndependentTemplatesCache = data;
Logger.log('[独立模板] 已加载内置默认模板');
return data;
} catch (e) {
Logger.error('[独立模板] 加载内置默认模板出错:', e);
return null;
}
}
/**
* 获取表格的独立模板(带默认值回退)
* 优先从持久化配置加载,若没有则从内置默认模板加载
* @param {string} tableName 表格名称
* @returns {Promise<object|null>} 模板配置
*/
export async function getIndependentTemplateWithDefault(tableName) {
// 1. 先从持久化配置加载
const savedTemplate = getIndependentTemplate(tableName);
if (savedTemplate) {
return savedTemplate;
}
// 2. 从内置默认模板加载
const defaultTemplates = await loadDefaultIndependentTemplates();
if (defaultTemplates?.templates?.[tableName]) {
return { template: defaultTemplates.templates[tableName] };
}
return null;
}
/**
* 获取所有独立模板(合并持久化和默认模板)
* @returns {Promise<object>} 合并后的所有模板
*/
export async function getAllIndependentTemplatesWithDefault() {
const savedTemplates = getAllIndependentTemplates();
const defaultTemplates = await loadDefaultIndependentTemplates();
// 合并:持久化优先
const merged = { ...savedTemplates };
if (defaultTemplates?.templates) {
for (const [tableName, templateObj] of Object.entries(defaultTemplates.templates)) {
if (!merged[tableName]) {
// 处理嵌套结构templateObj 可能是 { template: "..." } 或直接是字符串
const templateContent = typeof templateObj === 'string' ? templateObj : templateObj?.template;
if (templateContent) {
merged[tableName] = { template: templateContent, isDefault: true };
}
}
}
}
return merged;
}
/**
* 检查是否有可用的独立模板(持久化或默认)
* @returns {Promise<boolean>}
*/
export async function hasAnyIndependentTemplates() {
const savedTemplates = getAllIndependentTemplates();
if (Object.keys(savedTemplates).length > 0) {
return true;
}
const defaultTemplates = await loadDefaultIndependentTemplates();
return defaultTemplates?.templates && Object.keys(defaultTemplates.templates).length > 0;
}
/**
* 设置表格的独立模板
* @param {string} tableName 表格名称
* @param {string} template 模板内容
*/
export function setIndependentTemplate(tableName, template) {
const tableFillerConfig = getTableFillerConfig();
if (!tableFillerConfig.independentTemplates) {
tableFillerConfig.independentTemplates = {};
}
tableFillerConfig.independentTemplates[tableName] = { template };
saveTableFillerConfig(tableFillerConfig);
}
/**
* 删除表格的独立模板
* @param {string} tableName 表格名称
*/
export function deleteIndependentTemplate(tableName) {
const tableFillerConfig = getTableFillerConfig();
if (tableFillerConfig.independentTemplates?.[tableName]) {
delete tableFillerConfig.independentTemplates[tableName];
saveTableFillerConfig(tableFillerConfig);
}
}
/**
* 获取所有独立模板
* @returns {object} 所有模板
*/
export function getAllIndependentTemplates() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig.independentTemplates || {};
}
/**
* 设置独立模式的标签名称
* @param {string} tagName 标签名称
*/
export function setIndependentTagName(tagName) {
updateTableFillerConfig({ independentTagName: tagName });
}
/**
* 获取独立模式的标签名称
* @returns {string} 标签名称
*/
export function getIndependentTagName() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig.independentTagName || "Instructions for filling out the form";
}
// ============================================================================
// 总结世界书拆分配置管理
// ============================================================================
/**
* 获取总结世界书拆分配置
* @returns {object} 拆分配置
*/
export function getSummaryAutoSplitConfig() {
const config = loadConfig();
const splitConfig = config?.global?.summaryAutoSplit;
if (!splitConfig) {
return {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
};
}
return splitConfig;
}
/**
* 检查总结世界书拆分功能是否启用
* @returns {boolean}
*/
export function isSummaryAutoSplitEnabled() {
const splitConfig = getSummaryAutoSplitConfig();
return splitConfig?.enabled === true;
}
/**
* 检查总结世界书合并去重是否启用
* @returns {boolean}
*/
export function isSummaryMergeDeduplicateEnabled() {
const splitConfig = getSummaryAutoSplitConfig();
return splitConfig?.deduplicateOnMerge === true;
}
/**
* 设置总结世界书合并去重启用状态
* @param {boolean} enabled 是否启用
*/
export function setSummaryMergeDeduplicateEnabled(enabled) {
const config = loadConfig();
if (!config.global) config.global = {};
if (!config.global.summaryAutoSplit) {
config.global.summaryAutoSplit = {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
deduplicateOnMerge: false,
};
}
config.global.summaryAutoSplit.deduplicateOnMerge = enabled;
saveConfig(config);
}
/**
* 设置总结世界书拆分功能启用状态
* @param {boolean} enabled 是否启用
*/
export function setSummaryAutoSplitEnabled(enabled) {
const config = loadConfig();
if (!config.global) config.global = {};
if (!config.global.summaryAutoSplit) {
config.global.summaryAutoSplit = {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
};
}
config.global.summaryAutoSplit.enabled = enabled;
saveConfig(config);
}
/**
* 更新总结世界书拆分配置
* @param {object} updates 要更新的字段
*/
export function updateSummaryAutoSplitConfig(updates) {
const config = loadConfig();
if (!config.global) config.global = {};
if (!config.global.summaryAutoSplit) {
config.global.summaryAutoSplit = {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
};
}
config.global.summaryAutoSplit = { ...config.global.summaryAutoSplit, ...updates };
saveConfig(config);
}
/**
* 获取指定世界书的Part配置
* @param {string} bookName 世界书名称
* @returns {object|null} Part配置
*/
export function getSummaryPartConfigs(bookName) {
const config = loadConfig();
return config?.summaryPartConfigs?.[bookName] || null;
}
/**
* 设置指定世界书的Part配置
* @param {string} bookName 世界书名称
* @param {object} partConfigs Part配置
*/
export function setSummaryPartConfigs(bookName, partConfigs) {
const config = loadConfig();
if (!config.summaryPartConfigs) {
config.summaryPartConfigs = {};
}
config.summaryPartConfigs[bookName] = partConfigs;
saveConfig(config);
}
/**
* 删除指定世界书的Part配置
* @param {string} bookName 世界书名称
*/
export function deleteSummaryPartConfigs(bookName) {
const config = loadConfig();
if (config.summaryPartConfigs?.[bookName]) {
delete config.summaryPartConfigs[bookName];
saveConfig(config);
}
}
/**
* 获取所有世界书的Part配置
* @returns {object} 所有Part配置
*/
export function getAllSummaryPartConfigs() {
const config = loadConfig();
return config?.summaryPartConfigs || {};
}
/**
* 获取指定Part的API配置
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
* @returns {object|null} API配置
*/
export function getSummaryPartApiConfig(bookName, partId) {
const partConfigs = getSummaryPartConfigs(bookName);
if (!partConfigs?.parts) return null;
const part = partConfigs.parts.find(p => p.id === partId);
return part?.apiConfig || null;
}
/**
* 设置指定Part的API配置
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
* @param {object} apiConfig API配置
*/
export function setSummaryPartApiConfig(bookName, partId, apiConfig) {
const config = loadConfig();
if (!config.summaryPartConfigs) {
config.summaryPartConfigs = {};
}
if (!config.summaryPartConfigs[bookName]) {
config.summaryPartConfigs[bookName] = { parts: [] };
}
const parts = config.summaryPartConfigs[bookName].parts;
const existingIndex = parts.findIndex(p => p.id === partId);
if (existingIndex >= 0) {
parts[existingIndex].apiConfig = apiConfig;
} else {
parts.push({ id: partId, apiConfig });
}
saveConfig(config);
}
/**
* 删除指定Part的API配置
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
*/
export function deleteSummaryPartApiConfig(bookName, partId) {
const config = loadConfig();
if (!config.summaryPartConfigs?.[bookName]?.parts) return;
const parts = config.summaryPartConfigs[bookName].parts;
const index = parts.findIndex(p => p.id === partId);
if (index >= 0) {
parts[index].apiConfig = null;
saveConfig(config);
}
}
/**
* 检查指定世界书的所有Part是否都已配置API
* @param {string} bookName 世界书名称
* @param {Array} parts Part列表
* @returns {object} 检查结果 { allConfigured: boolean, unconfiguredParts: Array }
*/
export function checkSummaryPartsConfigured(bookName, parts) {
const partConfigs = getSummaryPartConfigs(bookName);
const unconfiguredParts = [];
for (const part of parts) {
const savedPart = partConfigs?.parts?.find(p => p.id === part.id);
if (!savedPart?.apiConfig?.apiUrl || !savedPart?.apiConfig?.model) {
unconfiguredParts.push(part);
}
}
return {
allConfigured: unconfiguredParts.length === 0,
unconfiguredParts,
};
}
/**
* 迁移原有的单API配置到Part 1
* @param {string} bookName 世界书名称
* @param {object} firstPart 第一个Part对象
* @returns {boolean} 是否进行了迁移
*/
export function migrateSummaryConfigToPart(bookName, firstPart) {
const config = loadConfig();
const existingConfig = config?.summaryConfigs?.[bookName];
if (!existingConfig?.apiUrl || !existingConfig?.model) {
return false;
}
// 检查是否已有Part配置
if (config.summaryPartConfigs?.[bookName]?.parts?.length > 0) {
return false;
}
// 迁移配置
if (!config.summaryPartConfigs) {
config.summaryPartConfigs = {};
}
config.summaryPartConfigs[bookName] = {
parts: [{
id: firstPart.id,
startFloor: firstPart.startFloor,
endFloor: firstPart.endFloor,
charCount: firstPart.charCount,
apiConfig: { ...existingConfig },
}],
};
saveConfig(config);
Logger.log(`[ConfigManager] 已将 ${bookName} 的原有API配置迁移至 Part 1`);
return true;
}

View File

@@ -78,9 +78,59 @@ export const defaultConfig = Object.freeze({
},
// 剧情优化助手开关(移到 global 内部保持一致性)
enablePlotOptimize: false,
// 表格填表并发配置
tableFillerConfig: {
enabled: false,
// 调用模式:'auto'(自动选择)、'bus_only'仅Bus、'intercept_only'(仅拦截)
callMode: "auto",
// 提示词模式:'independent'(独立)或 'shared'(共享)
promptMode: "shared",
// 重试次数(单个表格失败后重试的次数)
retryCount: 2,
// 重试延迟基数毫秒使用指数退避第N次重试等待 retryDelay * 2^(N-1)
retryDelay: 2000,
// 导入的预设 JSON包含所有表格的提示词配置
importedPreset: null,
// 默认 API未单独配置的表格使用
defaultApi: {
apiUrl: "",
apiKey: "",
model: "",
apiFormat: "openai",
maxTokens: 4096,
temperature: 0.7,
responsePath: "choices.0.message.content",
},
// 每个表格的 API 配置(可选,留空则使用 defaultApi
tableApiConfigs: {
// "角色表": { useDefault: true } 或 { apiUrl, apiKey, model, ... }
},
},
// 总结世界书自动拆分配置
summaryAutoSplit: {
enabled: false, // 全局开关
targetChars: 50000, // 目标拆分字符数
minChars: 40000, // 最小字符数(确保段落完整)
maxChars: 60000, // 最大字符数(确保段落完整)
},
},
memoryConfigs: {},
summaryConfigs: {},
// 拆分后的Part配置动态生成每个Part可独立配置API
summaryPartConfigs: {
// "Amily2-Lore-char-哥布林杀手9.6": {
// parts: [
// {
// id: "floor_1_60",
// startFloor: 1,
// endFloor: 60,
// charCount: 48000,
// apiConfig: { enabled: true, apiUrl: "...", model: "...", ... }
// },
// ...
// ]
// }
},
importedBooks: [],
importedPromptFiles: {}, // 提示词文件存储(跨浏览器同步)
});

View File

@@ -1,8 +1,8 @@
/**
* 记忆管理并发系统 - 主入口
* @version 0.4.0
* @version 0.4.9
* @author 可乐、繁华
* @license AGPLv3
* @license CC BY-NC-ND 4.0
* @see https://github.com/Cola-Echo/memory-manager-concurrent
*
* 这是模块化重构后的入口文件
@@ -96,6 +96,9 @@ import {
// 模型显示更新
updateIndexMergeModelDisplay,
updatePlotOptimizeModelDisplay,
// 总结世界书拆分配置弹窗
setSummaryPartConfigModalFunction,
showSummaryPartConfigModal,
} from "@ui";
// 世界书模块
@@ -123,8 +126,11 @@ import {
getHistoricalPromptTemplate,
} from "@memory";
// 表格填表模块
import { initTableFiller } from "@table-filler/index";
// 版本信息
const VERSION = "0.4.7";
const VERSION = "0.4.9";
// 面板状态
let isPanelVisible = false;
@@ -260,6 +266,9 @@ async function initPlugin() {
refreshAIConfigList,
);
// 设置总结世界书拆分配置弹窗函数
setSummaryPartConfigModalFunction(showSummaryPartConfigModal);
// 注入记忆处理回调
setProcessMemoryCallback(processMemoryForMessage);
@@ -343,6 +352,13 @@ async function initUI() {
// 启动世界书轮询检测
startWorldBookPolling();
// 初始化表格填表模块(延迟以确保 Amily2 加载完成)
setTimeout(() => {
initTableFiller().catch((e) => {
Logger.debug("表格填表模块初始化失败:", e);
});
}, 3000);
Logger.log("UI 初始化完成");
} catch (error) {
Logger.error("UI 初始化失败:", error);

View File

@@ -0,0 +1,203 @@
/**
* Part 结果调试弹窗模块
* @module memory/part-debug-modal
*/
import { getGlobalSettings } from "@config/config-manager";
import { enableModalDrag } from "@ui/modals/index";
// 是否启用调试模式
let debugEnabled = false;
/**
* 设置调试模式
* @param {boolean} enabled 是否启用
*/
export function setPartDebugEnabled(enabled) {
debugEnabled = enabled;
}
/**
* 获取调试模式状态
* @returns {boolean}
*/
export function isPartDebugEnabled() {
return debugEnabled;
}
/**
* 显示 Part 结果调试弹窗
* @param {Array} partResults 各 Part 的结果数组
* @param {string} bookName 世界书名称
* @param {object} mergedResult 合并后的结果
*/
export function showPartDebugModal(partResults, bookName, mergedResult) {
if (!debugEnabled) return;
// 创建弹窗容器
const modal = document.createElement("div");
modal.className = "mm-modal mm-modal-visible";
modal.style.zIndex = "999999";
// 应用当前主题
const settings = getGlobalSettings();
const theme = settings.theme || "default";
if (theme !== "default") {
modal.setAttribute("data-mm-theme", theme);
}
// 创建弹窗内容
const content = document.createElement("div");
content.className = "mm-modal-content";
content.style.maxWidth = "900px";
content.style.maxHeight = "85vh";
content.style.display = "flex";
content.style.flexDirection = "column";
// 创建弹窗头部
const header = document.createElement("div");
header.className = "mm-modal-header";
header.innerHTML = `
<h4 style="margin: 0; display: flex; align-items: center; gap: 8px;">
<i class="fa-solid fa-bug" style="color: #9b59b6;"></i>
总结世界书拆分调试 - ${bookName}
</h4>
<button class="mm-modal-close mm-btn mm-btn-icon">
<i class="fa-solid fa-times"></i>
</button>
`;
// 创建弹窗主体
const body = document.createElement("div");
body.className = "mm-modal-body";
body.style.padding = "16px";
body.style.overflow = "auto";
body.style.flex = "1";
// 统计信息
const validResults = partResults.filter(r => r !== null && r.rawMemory);
const statsHtml = `
<div style="background: var(--mm-bg-secondary); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="display: flex; gap: 24px; flex-wrap: wrap;">
<div><strong>总 Part 数:</strong>${partResults.length}</div>
<div><strong>有效返回:</strong><span style="color: #27ae60;">${validResults.length}</span></div>
<div><strong>无返回/失败:</strong><span style="color: ${partResults.length - validResults.length > 0 ? '#e74c3c' : '#27ae60'};">${partResults.length - validResults.length}</span></div>
<div><strong>合并后事件数:</strong>${mergedResult?.eventCount || 0}</div>
</div>
</div>
`;
// 各 Part 结果
let partsHtml = '<div style="display: flex; flex-direction: column; gap: 12px;">';
partResults.forEach((result, index) => {
const partNum = index + 1;
const hasResult = result !== null && result.rawMemory;
const statusColor = hasResult ? "#27ae60" : "#e74c3c";
const statusIcon = hasResult ? "fa-check-circle" : "fa-times-circle";
const statusText = hasResult ? "成功" : "无返回";
// 提取楼层范围
let floorRange = "";
if (result?.partId) {
const match = result.partId.match(/floor_(\d+)_(\d+)/);
if (match) {
floorRange = `${match[1]}-${match[2]}`;
}
}
partsHtml += `
<div style="border: 1px solid var(--mm-border); border-radius: 8px; overflow: hidden;">
<div style="background: var(--mm-bg-secondary); padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; cursor: pointer;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fa-solid ${statusIcon}" style="color: ${statusColor};"></i>
<strong>Part ${partNum}</strong>
${floorRange ? `<span style="color: var(--mm-text-muted); font-size: 12px;">(${floorRange})</span>` : ""}
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="color: ${statusColor}; font-size: 13px;">${statusText}</span>
${hasResult ? `<span style="color: var(--mm-text-muted); font-size: 12px;">${result.rawMemory.length} 字符</span>` : ""}
<i class="fa-solid fa-chevron-down" style="color: var(--mm-text-muted);"></i>
</div>
</div>
<div style="display: none; padding: 12px; background: var(--mm-bg); max-height: 300px; overflow: auto;">
${hasResult
? `<pre style="margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; line-height: 1.5; color: var(--mm-text);">${escapeHtml(result.rawMemory)}</pre>`
: `<div style="color: var(--mm-text-muted); font-style: italic;">该 Part 未返回内容(可能未配置 API 或请求失败)</div>`
}
</div>
</div>
`;
});
partsHtml += '</div>';
// 合并结果
let mergedHtml = '';
if (mergedResult && mergedResult.rawMemory) {
mergedHtml = `
<div style="margin-top: 16px; border: 2px solid #3498db; border-radius: 8px; overflow: hidden;">
<div style="background: rgba(52, 152, 219, 0.1); padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; cursor: pointer;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fa-solid fa-layer-group" style="color: #3498db;"></i>
<strong>合并后结果</strong>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="color: var(--mm-text-muted); font-size: 12px;">${mergedResult.rawMemory.length} 字符</span>
<i class="fa-solid fa-chevron-down" style="color: var(--mm-text-muted);"></i>
</div>
</div>
<div style="display: block; padding: 12px; background: var(--mm-bg); max-height: 300px; overflow: auto;">
<pre style="margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; line-height: 1.5; color: var(--mm-text);">${escapeHtml(mergedResult.rawMemory)}</pre>
</div>
</div>
`;
}
body.innerHTML = statsHtml + partsHtml + mergedHtml;
// 创建弹窗底部
const footer = document.createElement("div");
footer.className = "mm-modal-footer";
footer.style.display = "flex";
footer.style.justifyContent = "flex-end";
footer.style.gap = "10px";
footer.style.padding = "12px 16px";
footer.style.borderTop = "1px solid var(--mm-border)";
const closeBtn = document.createElement("button");
closeBtn.className = "mm-btn mm-btn-primary";
closeBtn.innerHTML = `<i class="fa-solid fa-check" style="margin-right: 6px;"></i>确定`;
footer.appendChild(closeBtn);
content.appendChild(header);
content.appendChild(body);
content.appendChild(footer);
modal.appendChild(content);
document.body.appendChild(modal);
// 启用弹窗拖拽移动
enableModalDrag(modal, content, header);
const cleanup = () => {
document.body.removeChild(modal);
};
closeBtn.addEventListener("click", cleanup);
header.querySelector(".mm-modal-close").addEventListener("click", cleanup);
modal.addEventListener("click", (e) => {
if (e.target === modal) cleanup();
});
}
/**
* HTML 转义
* @param {string} str 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}

View File

@@ -11,6 +11,11 @@ import {
getSummaryConfig,
isPluginEnabled,
getEnabledProviders,
getSummaryAutoSplitConfig,
getSummaryPartConfigs,
getSummaryPartApiConfig,
isSummaryAutoSplitEnabled,
isSummaryMergeDeduplicateEnabled,
} from "@config/config-manager";
import Logger from "@core/logger";
import { getContext } from "@core/sillytavern-api";
@@ -31,6 +36,7 @@ import {
import { classifyWorldBooks, getImportedWorldBooks } from "@worldbook/api";
import { formatAsWorldBook, getSummaryContent } from "@worldbook/parser";
import { refreshWorldBookList } from "@worldbook/refresh";
import { analyzeSummaryContent } from "@worldbook/summary-splitter";
import { getJailbreakPrefix } from "./jailbreak";
import {
buildDataInjection,
@@ -40,6 +46,7 @@ import {
} from "./prompt-builder";
import { mergeResults } from "./result-merger";
import { collectAllRequestInfos } from "./request-collector";
import { showPartDebugModal, isPartDebugEnabled } from "./part-debug-modal";
// 创建模块专用日志记录器
const log = Logger.createModuleLogger("记忆处理");
@@ -205,19 +212,23 @@ export async function processCategory(
// 获取提示词模板
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "记忆世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const baseSystemPrompt = replacePromptVariables(
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const finalSystemPrompt =
getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
@@ -281,19 +292,23 @@ export async function processSummaryBook(book, userMessage, context, signal) {
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const baseSystemPrompt = replacePromptVariables(
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const finalSystemPrompt =
getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
@@ -325,6 +340,325 @@ export async function processSummaryBook(book, userMessage, context, signal) {
}
}
/**
* 处理单个总结世界书的 Part
* @param {object} book 世界书对象
* @param {object} part Part 信息 { id, startFloor, endFloor, content, charCount }
* @param {string} userMessage 用户消息
* @param {string} context 上下文
* @param {AbortSignal} signal 中止信号
* @returns {Promise<object|null>} 处理结果
*/
export async function processSummaryPart(book, part, userMessage, context, signal) {
const progressTracker = getProgressTracker();
const taskId = `summary_${book.name}_${part.id}`;
try {
progressTracker?.startTask(taskId);
// Part 1index=0复用原总结世界书的 API 配置,其他 Part 使用各自的配置
let partConfig;
if (part.index === 0) {
partConfig = getSummaryConfig(book.name);
} else {
partConfig = getSummaryPartApiConfig(book.name, part.id);
}
if (!partConfig || !partConfig.enabled) {
log.warn(`总结世界书 "${book.name}" Part "${part.id}" 未启用,跳过`);
progressTracker?.completeTask(taskId, false, "未配置");
return null;
}
const globalConfig = getGlobalConfig();
// Part 的内容带有标识
const partContent = `=== Part ${part.id} (${part.startFloor}-${part.endFloor}楼) ===\n${part.content}`;
// 构建数据注入
const dataInjection = buildDataInjection({
worldBookContent: partContent,
context: context,
userMessage: userMessage,
});
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序,与总结世界书使用相同流程)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
partConfig,
globalConfig,
);
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 调用 API添加 taskId 以支持流式进度更新)
const response = await APIAdapter.call(
{ ...partConfig, taskId },
finalSystemPrompt,
finalUserMessage,
signal,
);
progressTracker?.completeTask(taskId, true);
return {
source: `${book.name} (${part.startFloor}-${part.endFloor}楼)`,
category: book.name,
type: "summary_part",
rawMemory: response,
bookName: book.name,
partId: part.id,
startFloor: part.startFloor,
endFloor: part.endFloor,
};
} catch (error) {
if (error.name === "AbortError") {
progressTracker?.completeTask(taskId, false, "已取消");
throw error;
}
log.error(`处理总结世界书 "${book.name}" Part "${part.id}" 失败:`, error);
progressTracker?.completeTask(taskId, false, error.message);
return null;
}
}
/**
* 合并多个 Part 的处理结果
* @param {Array} partResults Part 处理结果数组
* @param {string} bookName 世界书名称
* @returns {object|null} 合并后的结果
*/
export function mergePartResults(partResults, bookName) {
// 计算合并结果
const mergedResult = computeMergedResult(partResults, bookName);
// 显示调试弹窗(在返回结果前)
if (isPartDebugEnabled()) {
showPartDebugModal(partResults, bookName, mergedResult);
}
return mergedResult;
}
/**
* 计算合并结果(内部函数)
* @param {Array} partResults Part 处理结果数组
* @param {string} bookName 世界书名称
* @returns {object|null} 合并后的结果
*/
function computeMergedResult(partResults, bookName) {
const validResults = partResults.filter(r => r !== null && r.rawMemory);
if (validResults.length === 0) {
return null;
}
// 获取去重配置
const deduplicateEnabled = isSummaryMergeDeduplicateEnabled();
// 提取所有历史事件(保持原始顺序,不排序)
const allEvents = [];
const eventPattern = /<Historical_Occurrences>([\s\S]*?)<\/Historical_Occurrences>/gi;
// 兼容多种楼层格式【124楼】、【124至#125】、【124至125楼】
const floorPattern = /【(\d+)(?:楼】|至#?(\d+)楼?】)/;
for (const result of validResults) {
const content = result.rawMemory;
let match;
let foundEvents = false;
// 提取所有 Historical_Occurrences 块
while ((match = eventPattern.exec(content)) !== null) {
foundEvents = true;
const eventsContent = match[1];
// 按行分割并提取每个事件
const lines = eventsContent.split('\n').filter(line => line.trim());
for (const line of lines) {
const floorMatch = line.match(floorPattern);
const floor = floorMatch ? parseInt(floorMatch[1], 10) : 0;
allEvents.push({
floor: floor,
content: line.trim(),
sourcePartId: result.partId,
});
}
}
// 重置正则的 lastIndex
eventPattern.lastIndex = 0;
// 如果没有找到标签格式,尝试直接提取楼层事件
if (!foundEvents) {
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
const floorMatch = line.match(floorPattern);
if (floorMatch) {
const floor = parseInt(floorMatch[1], 10);
allEvents.push({
floor: floor,
content: line.trim(),
sourcePartId: result.partId,
});
}
}
}
}
// 处理事件列表
let finalEvents;
if (deduplicateEnabled) {
// 去重模式:同一楼层只保留内容最长的
const floorBestEvent = new Map();
for (const event of allEvents) {
const existing = floorBestEvent.get(event.floor);
if (!existing || event.content.length > existing.content.length) {
floorBestEvent.set(event.floor, event);
}
}
// 按原始出现顺序输出(使用第一次出现的顺序)
const seenFloors = new Set();
finalEvents = [];
for (const event of allEvents) {
if (!seenFloors.has(event.floor)) {
seenFloors.add(event.floor);
finalEvents.push(floorBestEvent.get(event.floor));
}
}
} else {
// 不去重模式:相同楼层的内容放在一起(保持原始顺序)
// 使用 Map 按楼层分组,保持首次出现的顺序
const floorGroups = new Map();
const floorOrder = [];
for (const event of allEvents) {
if (!floorGroups.has(event.floor)) {
floorGroups.set(event.floor, []);
floorOrder.push(event.floor);
}
floorGroups.get(event.floor).push(event);
}
// 按首次出现顺序输出
finalEvents = [];
for (const floor of floorOrder) {
finalEvents.push(...floorGroups.get(floor));
}
}
// 重新构建响应
const mergedContent = finalEvents.map(e => e.content).join('\n');
const rawMemory = finalEvents.length > 0
? `<Historical_Occurrences>\n${mergedContent}\n</Historical_Occurrences>`
: validResults.map(r => r.rawMemory).join('\n\n');
const mergedResult = {
source: bookName,
category: bookName,
type: "summary",
rawMemory: rawMemory,
bookName: bookName,
partCount: validResults.length,
eventCount: finalEvents.length,
};
return mergedResult;
}
/**
* 处理总结世界书(支持自动拆分)
* @param {object} book 世界书对象
* @param {string} userMessage 用户消息
* @param {string} context 上下文
* @param {AbortSignal} signal 中止信号
* @returns {Promise<object|null>} 处理结果
*/
export async function processSummaryBookWithSplit(book, userMessage, context, signal) {
// 检查是否启用拆分
if (!isSummaryAutoSplitEnabled()) {
// 未启用拆分,使用原有逻辑
return processSummaryBook(book, userMessage, context, signal);
}
// 获取总结内容
const summaryContent = getSummaryContent(book);
// 获取拆分配置
const splitConfig = getSummaryAutoSplitConfig();
// 分析拆分方案
const parts = analyzeSummaryContent(summaryContent, splitConfig);
if (parts.length <= 1) {
// 内容不足以拆分,使用原有逻辑
log.debug(`总结世界书 "${book.name}" 内容字符数不足以拆分,使用单任务处理`);
return processSummaryBook(book, userMessage, context, signal);
}
log.log(`总结世界书 "${book.name}" 拆分为 ${parts.length} 个 Part 进行并发处理`);
// 检查每个 Part 是否都有 API 配置Part 1 复用原配置)
const partConfigs = getSummaryPartConfigs(book.name);
const originalConfig = getSummaryConfig(book.name);
const unconfiguredParts = [];
for (const part of parts) {
if (part.index === 0) {
// Part 1index=0复用原总结世界书配置
if (!originalConfig || !originalConfig.enabled) {
unconfiguredParts.push(part);
}
} else {
// 其他 Part 使用各自的配置
const partConfig = partConfigs?.parts?.find(p => p.id === part.id);
if (!partConfig || !partConfig.apiConfig || !partConfig.apiConfig.enabled) {
unconfiguredParts.push(part);
}
}
}
if (unconfiguredParts.length > 0) {
const partNames = unconfiguredParts.map(p => `Part ${p.id} (${p.startFloor}-${p.endFloor}楼)`).join(', ');
log.warn(`总结世界书 "${book.name}" 有 ${unconfiguredParts.length} 个 Part 未配置 API: ${partNames}`);
// 即使有未配置的 Part仍然处理已配置的 Part
}
// 并发处理所有已配置的 Part
const partPromises = parts.map(part => {
if (part.index === 0) {
// Part 1index=0复用原配置
if (!originalConfig || !originalConfig.enabled) {
return Promise.resolve(null);
}
} else {
// 其他 Part 使用各自的配置
const partConfig = partConfigs?.parts?.find(p => p.id === part.id);
if (!partConfig || !partConfig.apiConfig || !partConfig.apiConfig.enabled) {
return Promise.resolve(null);
}
}
return processSummaryPart(book, part, userMessage, context, signal);
});
const partResults = await Promise.all(partPromises);
// 合并结果
return mergePartResults(partResults, book.name);
}
/**
* 收集所有分类的索引内容(用于索引合并模式)
* @param {Array} memoryBooks 记忆世界书数组
@@ -394,19 +728,23 @@ export async function processIndexMerge(
// 获取提示词模板
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "索引合并",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const baseSystemPrompt = replacePromptVariables(
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
config,
globalConfig,
);
// 添加破限词前缀
const finalSystemPrompt =
getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
@@ -751,26 +1089,88 @@ export async function processMemoryForMessage(userMessage) {
continue;
}
const taskId = `summary_${book.name}`;
const taskController = new AbortController();
taskAbortControllers.set(taskId, taskController);
// 检查是否启用拆分,并分析是否需要拆分
const splitEnabled = isSummaryAutoSplitEnabled();
let parts = [];
if (splitEnabled) {
const summaryContent = getSummaryContent(book);
const splitConfig = getSummaryAutoSplitConfig();
parts = analyzeSummaryContent(summaryContent, splitConfig);
}
taskInfoList.push({
id: taskId,
name: book.name,
type: "summary",
});
if (splitEnabled && parts.length > 1) {
// 启用拆分且有多个Part注册Part任务用于进度追踪
const partConfigs = getSummaryPartConfigs(book.name);
const originalConfig = getSummaryConfig(book.name);
tasks.push({
taskId,
fn: () =>
processSummaryBook(
book,
userMessage,
context,
taskController.signal,
),
});
// 收集已配置的Part用于进度追踪显示
const configuredParts = [];
for (const part of parts) {
let isConfigured = false;
if (part.index === 0) {
isConfigured = originalConfig && originalConfig.enabled;
} else {
const partConfig = partConfigs?.parts?.find(p => p.id === part.id);
isConfigured = partConfig && partConfig.apiConfig && partConfig.apiConfig.enabled;
}
if (isConfigured) {
configuredParts.push(part);
}
}
if (configuredParts.length === 0) {
log.warn(`总结世界书 "${book.name}" 所有 Part 均未配置,跳过`);
continue;
}
// 为每个已配置的Part注册任务信息用于进度追踪显示
for (const part of configuredParts) {
const taskId = `summary_${book.name}_${part.id}`;
taskInfoList.push({
id: taskId,
name: `${book.name} Part ${part.index + 1}`,
type: "summary_part",
});
}
// 使用单个任务执行 processSummaryBookWithSplit内部会并发处理Part并合并结果
const mainTaskId = `summary_${book.name}`;
const taskController = new AbortController();
taskAbortControllers.set(mainTaskId, taskController);
tasks.push({
taskId: mainTaskId,
fn: () =>
processSummaryBookWithSplit(
book,
userMessage,
context,
taskController.signal,
),
});
} else {
// 未启用拆分或内容不足以拆分:注册单个任务
const taskId = `summary_${book.name}`;
const taskController = new AbortController();
taskAbortControllers.set(taskId, taskController);
taskInfoList.push({
id: taskId,
name: book.name,
type: "summary",
});
tasks.push({
taskId,
fn: () =>
processSummaryBook(
book,
userMessage,
context,
taskController.signal,
),
});
}
} catch (e) {
log.warn(`总结世界书 "${book.name}" 未配置,跳过`);
}
@@ -930,7 +1330,9 @@ export async function processMemoryForMessage(userMessage) {
for (const m of selectedMemories) {
const floor = m.uid || "0";
const content = m.content || "";
historicalLines.push(`${floor}楼】${content}`);
// 如果 floor 已经是完整标签格式,直接使用
const floorTag = String(floor).startsWith('【') ? floor : `${floor}楼】`;
historicalLines.push(`${floorTag}${content}`);
}
const rawMemory = `<Historical_Occurrences>\n${historicalLines.join(

View File

@@ -4,7 +4,28 @@
*/
import Logger from '@core/logger';
import { getGlobalConfig } from '@config/config-manager';
import { getGlobalConfig, getGlobalSettings } from '@config/config-manager';
// 默认流程顺序(与 flow-configs/default.json 保持一致)
const DEFAULT_FLOW_ORDER = ["jailbreak", "main", "worldbook", "context", "auxiliary", "user"];
/**
* 获取流程配置顺序
* @param {string} flowType 流程类型(记忆世界书、总结世界书、索引合并、剧情优化)
* @returns {Array<string>} 流程顺序数组
*/
function getFlowOrder(flowType) {
const settings = getGlobalSettings();
const savedOrder = settings.promptPartsOrder || {};
const sourceOrder = savedOrder[flowType];
// 如果有用户保存的顺序,使用它;否则使用默认顺序
if (sourceOrder && Array.isArray(sourceOrder) && sourceOrder.length > 0) {
return sourceOrder;
}
return DEFAULT_FLOW_ORDER;
}
/**
* 构建数据注入对象
@@ -20,22 +41,49 @@ export function buildDataInjection(data) {
}
/**
* 将数据注入到提示词模板
* 将数据注入到提示词模板(支持流程配置顺序)
* @param {object} template 提示词模板
* @param {object} dataInjection 数据注入对象
* @param {object} options 选项
* @param {string} options.flowType 流程类型,默认 "记忆世界书"
* @param {string} options.jailbreakPrefix 破限词前缀
* @returns {object} 注入后的提示词
*/
export function injectDataToPrompt(template, dataInjection) {
let mainPrompt = template.mainPrompt || template.main_prompt || "";
let systemPrompt = template.systemPrompt || template.system_prompt || "";
export function injectDataToPrompt(template, dataInjection, options = {}) {
const {
flowType = "记忆世界书",
jailbreakPrefix = "",
} = options;
// 构建数据注入内容
let injectionContent = "";
let injectionParts = [];
const mainPromptRaw = template.mainPrompt || template.main_prompt || "";
const systemPromptRaw = template.systemPrompt || template.system_prompt || "";
// 注入世界书内容
// 分离 mainPrompt 中 <数据注入区> 前后的内容
let mainPromptBefore = mainPromptRaw;
let mainPromptAfter = "";
if (mainPromptRaw.includes("<数据注入区>")) {
const parts = mainPromptRaw.split("<数据注入区>");
mainPromptBefore = parts[0] || "";
mainPromptAfter = parts.slice(1).join("<数据注入区>") || "";
}
// 构建各个来源的内容块
const sourceContents = {};
const injectionParts = [];
// jailbreak - 破限词
if (jailbreakPrefix && jailbreakPrefix.trim()) {
sourceContents.jailbreak = jailbreakPrefix.trim();
}
// main - 主提示词(<数据注入区>前的部分)
if (mainPromptBefore && mainPromptBefore.trim()) {
sourceContents.main = mainPromptBefore.trim();
}
// worldbook - 世界书内容
if (dataInjection.worldBookContent) {
injectionContent += `<世界书内容>\n${dataInjection.worldBookContent}\n</世界书内容>\n\n`;
sourceContents.worldbook = `<世界书内容>\n${dataInjection.worldBookContent}\n</世界书内容>`;
injectionParts.push({
label: "世界书内容",
content: dataInjection.worldBookContent,
@@ -43,7 +91,7 @@ export function injectDataToPrompt(template, dataInjection) {
});
} else {
const emptyWorldbook = `[当前无世界书数据,禁止编造任何历史事件回忆或关键词]`;
injectionContent += `<世界书内容>\n${emptyWorldbook}\n</世界书内容>\n\n`;
sourceContents.worldbook = `<世界书内容>\n${emptyWorldbook}\n</世界书内容>`;
injectionParts.push({
label: "世界书内容",
content: emptyWorldbook,
@@ -51,9 +99,9 @@ export function injectDataToPrompt(template, dataInjection) {
});
}
// 注入前文内容(最近对话上下文)
// context - 前文内容
if (dataInjection.context) {
injectionContent += `<前文内容>\n${dataInjection.context}\n</前文内容>\n\n`;
sourceContents.context = `<前文内容>\n${dataInjection.context}\n</前文内容>`;
injectionParts.push({
label: "前文内容",
content: dataInjection.context,
@@ -61,27 +109,55 @@ export function injectDataToPrompt(template, dataInjection) {
});
}
// 注入用户消息
if (dataInjection.userMessage) {
injectionContent += `<核心用户消息>\n${dataInjection.userMessage}\n</核心用户消息>\n`;
// auxiliary - 辅助提示词systemPrompt + mainPrompt 中 <数据注入区> 后的部分)
let auxiliaryContent = "";
if (mainPromptAfter && mainPromptAfter.trim()) {
auxiliaryContent += mainPromptAfter.trim();
}
if (systemPromptRaw && systemPromptRaw.trim()) {
if (auxiliaryContent) {
auxiliaryContent += "\n";
}
auxiliaryContent += systemPromptRaw.trim();
}
if (auxiliaryContent) {
sourceContents.auxiliary = auxiliaryContent;
}
// 将数据注入到 <数据注入区> 占位符
if (mainPrompt.includes("<数据注入区>")) {
mainPrompt = mainPrompt.replace(
"<数据注入区>",
`<数据注入区>\n${injectionContent}`
);
// user - 用户消息(作为最后的用户提示词,不在系统提示词中)
// 注意user 部分不放入 systemPrompt而是单独返回给调用方处理
// 获取流程顺序
const flowOrder = getFlowOrder(flowType);
// 按流程顺序构建系统提示词
const orderedParts = [];
for (const source of flowOrder) {
// user 来源不放入系统提示词
if (source === "user") continue;
if (sourceContents[source]) {
orderedParts.push(sourceContents[source]);
}
}
// 合并 mainPrompt 和 systemPrompt
const finalSystemPrompt = mainPrompt + "\n" + systemPrompt;
// 添加未在流程配置中的部分(保持原顺序)
for (const [source, content] of Object.entries(sourceContents)) {
if (source === "user") continue;
if (!flowOrder.includes(source) && content) {
orderedParts.push(content);
}
}
// 合并为最终的系统提示词
const finalSystemPrompt = orderedParts.join("\n\n");
return {
systemPrompt: finalSystemPrompt,
injectionParts: injectionParts,
mainPrompt: mainPrompt,
auxiliaryPrompt: systemPrompt,
mainPrompt: mainPromptBefore,
auxiliaryPrompt: auxiliaryContent,
flowOrder: flowOrder,
};
}

View File

@@ -8,9 +8,13 @@ import {
getGlobalSettings,
getMemoryConfig,
getSummaryConfig,
isSummaryAutoSplitEnabled,
getSummaryAutoSplitConfig,
getSummaryPartApiConfig,
} from "@config/config-manager";
import Logger from "@core/logger";
import { formatAsWorldBook, getSummaryContent } from "@worldbook/parser";
import { analyzeSummaryContent } from "@worldbook/summary-splitter";
import { getJailbreakPrefix } from "./jailbreak";
import {
buildDataInjection,
@@ -26,16 +30,16 @@ import {
// 来源标签映射(与 flow-config.js 保持一致)
const SOURCE_LABELS = {
jailbreak: "[条件块] 破限词",
main: "[条件块] 主提示词 (mainPrompt <数据注入区>前)",
main: "[条件块] 主提示词 (mainPrompt <数据注入点>)",
user: "[条件块] 核心用户消息 <核心用户消息>",
worldbook: "[条件块] 世界书内容 <世界书内容>",
context: "[条件块] 前文内容 <前文内容>",
auxiliary: "[条件块] 辅助提示词 (systemPrompt <数据注入区>后)",
auxiliary: "[条件块] 辅助提示词 (systemPrompt <数据注入点>)",
};
/**
* 根据流程配置对 promptParts 重新排序
* @param {Array} promptParts 原 prompt 部分列表
* @param {Array} promptParts 原<EFBFBD><EFBFBD> prompt 部分列表
* @param {string} flowType 流程类型
* @returns {Array} 排序后的 promptParts
*/
@@ -86,23 +90,27 @@ export async function collectMemoryRequestInfo(category, data, userMessage, cont
});
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词(包含流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "记忆世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
const finalSystemPrompt = jailbreakPrefix
? jailbreakPrefix + "\n\n" + baseSystemPrompt
: baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
// 构建详细的 prompt 部分列表(用于预览显示)
const promptParts = [];
// 添加破限词
@@ -118,7 +126,7 @@ export async function collectMemoryRequestInfo(category, data, userMessage, cont
const mainPromptWithoutInjection =
template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
aiConfig,
globalConfig,
);
@@ -156,8 +164,7 @@ export async function collectMemoryRequestInfo(category, data, userMessage, cont
source: "user",
});
// 根据流程配置对 promptParts 重新排序
// 使用 "记忆世界书" 作为流程类型(与流程配置弹窗中的分类名称一致)
// 根据流程配置对 promptParts 重新排序(使用与实际发送相同的顺序)
const sortedPromptParts = sortPromptPartsByFlowConfig(promptParts, "记忆世界书");
return {
@@ -210,23 +217,27 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词(包含流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
const finalSystemPrompt = jailbreakPrefix
? jailbreakPrefix + "\n\n" + baseSystemPrompt
: baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
// 构建详细的 prompt 部分列表(用于预览显示)
const promptParts = [];
// 添加破限词
@@ -242,7 +253,7 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
const mainPromptWithoutInjection =
template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
aiConfig,
globalConfig,
);
@@ -280,8 +291,7 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
source: "user",
});
// 根据流程配置对 promptParts 重新排序
// 使用 "总结世界书" 作为流程类型(与流程配置弹窗中的分类名称一致)
// 根据流程配置对 promptParts 重新排序(使用与实际发送相同的顺序)
const sortedPromptParts = sortPromptPartsByFlowConfig(promptParts, "总结世界书");
return {
@@ -311,6 +321,142 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
}
}
/**
* 收集单个总结世界书Part的请求信息
* @param {object} book 世界书对象
* @param {object} part Part信息 { id, index, startFloor, endFloor, content, charCount }
* @param {string} userMessage 用户消息
* @param {string} context 上下文
* @returns {Promise<object|null>} 请求信息
*/
export async function collectSummaryPartRequestInfo(book, part, userMessage, context) {
// Part 1index=0复用原总结世界书的 API 配置,其他 Part 使用各自的配置
let aiConfig;
if (part.index === 0) {
aiConfig = getSummaryConfig(book.name);
} else {
aiConfig = getSummaryPartApiConfig(book.name, part.id);
}
if (!aiConfig || !aiConfig.enabled) {
return null;
}
const globalConfig = getGlobalConfig();
try {
// Part 的内容带有标记
const partNumber = part.index + 1;
const partContent = `=== Part ${partNumber} (${part.startFloor}-${part.endFloor}楼) ===\n${part.content}`;
const dataInjection = buildDataInjection({
worldBookContent: partContent,
context: context,
userMessage: userMessage,
});
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
const promptParts = [];
if (jailbreakPrefix && jailbreakPrefix.trim()) {
promptParts.push({
label: "破限词",
content: jailbreakPrefix,
source: "jailbreak",
});
}
const mainPromptWithoutInjection = template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入点>")[0].trim(),
aiConfig,
globalConfig,
);
if (cleanMainPrompt) {
promptParts.push({
label: "主提示词",
content: cleanMainPrompt,
source: "main",
});
}
if (prompt.injectionParts && prompt.injectionParts.length > 0) {
promptParts.push(...prompt.injectionParts);
}
if (prompt.auxiliaryPrompt && prompt.auxiliaryPrompt.trim()) {
const processedAuxiliary = replacePromptVariables(
prompt.auxiliaryPrompt,
aiConfig,
globalConfig,
);
promptParts.push({
label: "辅助提示词",
content: processedAuxiliary,
source: "auxiliary",
});
}
promptParts.push({
label: SOURCE_LABELS.user || "用户消息",
content: finalUserMessage,
source: "user",
});
const sortedPromptParts = sortPromptPartsByFlowConfig(promptParts, "总结世界书");
return {
category: `${book.name} - Part ${partNumber} (${part.startFloor}-${part.endFloor}楼)`,
source: `${book.name}_part_${part.id}`,
model: aiConfig.model || "未指定模型",
promptParts: sortedPromptParts,
prompt: `${finalSystemPrompt}\n\n${finalUserMessage}`,
aiConfig: {
apiFormat: aiConfig.apiFormat,
apiUrl: aiConfig.apiUrl,
apiKey: aiConfig.apiKey,
model: aiConfig.model,
maxTokens: aiConfig.maxTokens,
temperature: aiConfig.temperature,
responsePath: aiConfig.responsePath,
},
taskType: "summary_part",
bookName: book.name,
partId: part.id,
startFloor: part.startFloor,
endFloor: part.endFloor,
};
} catch (err) {
Logger.error(
`收集总结任务 "${book.name}" Part ${part.index + 1} 请求信息失败:`,
err.message,
);
return null;
}
}
/**
* 收集索引合并任务的请求信息
* @param {string} mergedContent 合并后的索引内容
@@ -337,23 +483,27 @@ export async function collectIndexMergeRequestInfo(
});
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词(包含流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "索引合并",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
indexMergeConfig,
globalConfig,
);
// 添加破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
const finalSystemPrompt = jailbreakPrefix
? jailbreakPrefix + "\n\n" + baseSystemPrompt
: baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
// 构建详细的 prompt 部分列表(用于预览显示)
const promptParts = [];
// 添加破限词
@@ -369,7 +519,7 @@ export async function collectIndexMergeRequestInfo(
const mainPromptWithoutInjection =
template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
indexMergeConfig,
globalConfig,
);
@@ -496,6 +646,30 @@ export async function collectAllRequestInfos(
continue;
}
// 检查是否启用拆分
if (isSummaryAutoSplitEnabled()) {
const summaryContent = getSummaryContent(book);
const splitConfig = getSummaryAutoSplitConfig();
const parts = analyzeSummaryContent(summaryContent, splitConfig);
if (parts.length > 1) {
// 拆分模式为每个Part收集请求信息
for (const part of parts) {
const partInfo = await collectSummaryPartRequestInfo(
book,
part,
userMessage,
context,
);
if (partInfo) {
requestInfos.push(partInfo);
}
}
continue; // 跳过整本书的收集
}
}
// 未启用拆分或内容不足以拆分:收集整本书的请求信息
const summaryInfo = await collectSummaryRequestInfo(
book,
userMessage,

View File

@@ -109,7 +109,8 @@ export function mergeResults(results, latestContext = "") {
) {
events.split("\n").forEach((line) => {
const trimmed = line.trim();
if (trimmed && /^【\d+楼】/.test(trimmed)) {
// 兼容多种楼层格式【124楼】、【124至#125】、【124至125楼】
if (trimmed && /^【\d+(?:楼】|至#?\d+楼?】)/.test(trimmed)) {
historicalEvents.add(trimmed);
}
});

View File

@@ -0,0 +1,157 @@
/**
* Amily2Bus 联动集成模块
* 通过 Amily2Bus 暴露并发填表能力
* @module table-filler/bus-integration
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { setBusRegistered } from "./mode-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
let busContext = null;
/**
* 检查是否有有效配置
* @returns {boolean}
*/
function hasValidConfig() {
const config = getTableFillerConfig();
if (!config) return false;
// 检查默认 API 配置
const defaultApi = config.defaultApi;
if (defaultApi?.apiUrl && defaultApi?.model) {
return true;
}
// 检查是否有表格独立 API 配置
const tableApis = config.tableApiConfigs;
if (tableApis && Object.keys(tableApis).length > 0) {
return true;
}
return false;
}
/**
* 处理来自 Amily2 的 Bus 调用
* @param {Object} params 填表参数
* @returns {Promise<string>}
*/
async function handleBusCall(params) {
const { messages, options = {}, tableData } = params;
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
try {
// 如果已提供拆分好的表格数据,直接使用
// 否则从 messages 中提取
const tables = tableData || splitTablesFromMessages(messages);
if (tables.length === 0) {
throw new Error("未能解析出表格数据");
}
Logger.log(`[TableFiller-Bus] 开始并发填表,共 ${tables.length} 个表格`);
const results = await executor.fillAllTables(tables, messages, options);
return mergeResults(results);
} catch (error) {
Logger.error("[TableFiller-Bus] 并发填表失败:", error);
throw error; // 抛出错误让 Amily2 处理回退
}
}
/**
* 通过 Amily2Bus 暴露并发填表能力
* Amily2 可通过 window.Amily2Bus.query('TableFillerProxy') 获取
* @returns {Object|null} Bus 上下文对象
*/
export function registerToBus() {
if (!window.Amily2Bus) {
Logger.warn("[TableFiller] Amily2Bus 未找到,跳过 Bus 注册");
setBusRegistered(false);
return null;
}
try {
// 检查是否已注册
const existing = window.Amily2Bus.query("TableFillerProxy");
if (existing) {
Logger.log("[TableFiller] TableFillerProxy 已存在,跳过重复注册");
setBusRegistered(true);
return existing;
}
// 注册插件身份
busContext = window.Amily2Bus.register("TableFillerProxy");
// 暴露并发填表能力
busContext.expose({
// 版本信息
version: "1.0.0",
description: "Amily2 表格模块并发填表代理",
/**
* 核心方法:并发填表
* @param {Object} params 填表参数
* @param {Array} params.messages 原始 messages 数组
* @param {Object} params.options 调用选项
* @param {Object} params.tableData 可选,已拆分的表格数据
* @returns {Promise<string>} 合并后的 <Amily2Edit> 指令
*/
fillParallel: async (params) => {
return await handleBusCall(params);
},
/**
* 检查并发模式是否可用
* @returns {boolean}
*/
isAvailable: () => {
return isTableFillerEnabled() && hasValidConfig();
},
/**
* 获取当前配置状态
* @returns {Object}
*/
getStatus: () => {
const config = getTableFillerConfig();
return {
enabled: config?.enabled || false,
promptMode: config?.promptMode || "shared",
tableCount: Object.keys(
config?.importedPreset?.tablePresets || {},
).length,
hasDefaultApi: !!(config?.defaultApi?.apiUrl),
};
},
});
busContext.log(
"Init",
"info",
"TableFillerProxy 已通过 Amily2Bus 暴露联动接口",
);
setBusRegistered(true);
Logger.log("[TableFiller] Bus 注册成功");
return busContext;
} catch (e) {
Logger.error("[TableFiller] Bus 注册失败:", e);
setBusRegistered(false);
return null;
}
}
/**
* 获取 Bus 上下文
* @returns {Object|null}
*/
export function getBusContext() {
return busContext;
}

View File

@@ -0,0 +1,761 @@
/**
* 调试弹窗模块
* 用于检查并发填表的提示词和结果
* @module table-filler/debug-modal
*/
import { getGlobalSettings } from "@config/config-manager";
/**
* 获取当前主题
* @returns {string|null}
*/
function getCurrentTheme() {
try {
const settings = getGlobalSettings();
return settings?.theme || null;
} catch {
return null;
}
}
/**
* 获取主题对应的颜色方案
* @param {string} theme
* @returns {Object}
*/
function getThemeColors(theme) {
const themes = {
'default': {
bg: '#1a1a2e',
headerBg: '#2a2a4e',
bodyBg: '#0a0a1e',
border: '#333',
text: '#fff',
textMuted: '#a0a0a0',
primary: '#4a90d9',
success: '#4CAF50',
},
'warm-brown': {
bg: '#2a2520',
headerBg: '#3a3530',
bodyBg: '#1a1510',
border: '#4a4540',
text: '#e4dcd0',
textMuted: '#a09080',
primary: '#a08070',
success: '#6a9a6a',
},
'lavender': {
bg: '#1e1a24',
headerBg: '#2e2a34',
bodyBg: '#0e0a14',
border: '#3a3644',
text: '#e4e0ea',
textMuted: '#9b8aa8',
primary: '#9b8aa8',
success: '#7aa87a',
},
'forest': {
bg: '#1a2420',
headerBg: '#2a3430',
bodyBg: '#0a1410',
border: '#3a4a40',
text: '#e0e8e4',
textMuted: '#6a9a7a',
primary: '#6a9a7a',
success: '#5a8a6a',
},
'rose': {
bg: '#241a1c',
headerBg: '#342a2c',
bodyBg: '#140a0c',
border: '#443a3c',
text: '#e8e0e2',
textMuted: '#b08a90',
primary: '#b08a90',
success: '#8aaa8a',
},
'slate': {
bg: '#1a1e22',
headerBg: '#2a2e32',
bodyBg: '#0a0e12',
border: '#3a3e42',
text: '#e4e8ec',
textMuted: '#7a8a98',
primary: '#7a8a98',
success: '#6a9a7a',
},
'starry-purple': {
bg: 'rgba(26, 21, 37, 0.95)',
headerBg: 'rgba(42, 26, 64, 0.95)',
bodyBg: 'rgba(13, 10, 20, 0.95)',
border: 'rgba(138, 100, 200, 0.3)',
text: '#e4dcea',
textMuted: '#9d7cd8',
primary: '#9d7cd8',
success: '#7ac87a',
},
'starry-blue': {
bg: 'rgba(16, 24, 40, 0.95)',
headerBg: 'rgba(16, 32, 64, 0.95)',
bodyBg: 'rgba(8, 12, 20, 0.95)',
border: 'rgba(100, 150, 220, 0.3)',
text: '#e4ecf4',
textMuted: '#5d8fca',
primary: '#5d8fca',
success: '#6aaa7a',
},
'starry-black': {
bg: 'rgba(12, 12, 16, 0.95)',
headerBg: 'rgba(26, 26, 30, 0.95)',
bodyBg: 'rgba(10, 10, 12, 0.95)',
border: 'rgba(255, 255, 255, 0.15)',
text: '#e8e8ec',
textMuted: '#707078',
primary: '#606068',
success: '#5a8a6a',
},
};
return themes[theme] || themes['default'];
}
/**
* 显示发送前的调试弹窗
* @param {Array} tablePrompts 每个表格的提示词 [{tableName, messages}]
* @returns {Promise<boolean>} 用户是否确认继续
*/
export function showPreSendDebugModal(tablePrompts) {
return new Promise((resolve) => {
const theme = getCurrentTheme() || 'default';
const colors = getThemeColors(theme);
// 创建弹窗容器
const overlay = document.createElement('div');
overlay.id = 'mm-debug-modal-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 99999;
display: flex;
justify-content: center;
align-items: center;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: ${colors.bg};
border: 1px solid ${colors.border};
border-radius: 8px;
width: 90%;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
color: ${colors.text};
`;
// 应用主题属性
if (theme !== 'default') {
modal.setAttribute('data-mm-theme', theme);
}
// 标题栏
const header = document.createElement('div');
header.style.cssText = `
padding: 16px 20px;
border-bottom: 1px solid ${colors.border};
font-size: 18px;
font-weight: bold;
`;
header.textContent = `📋 发送前检查 - ${tablePrompts.length} 个表格的提示词`;
// 内容区域
const content = document.createElement('div');
content.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 16px;
`;
// 为每个表格创建折叠面板
tablePrompts.forEach((item, index) => {
const panel = createCollapsiblePanel(
`${index + 1}. ${item.tableName}`,
formatMessages(item.messages),
false, // 默认折叠
colors
);
content.appendChild(panel);
});
// 按钮区域
const footer = document.createElement('div');
footer.style.cssText = `
padding: 16px 20px;
border-top: 1px solid ${colors.border};
display: flex;
justify-content: flex-end;
gap: 12px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '取消发送';
cancelBtn.style.cssText = `
padding: 10px 24px;
border: 1px solid ${colors.textMuted};
background: transparent;
color: ${colors.text};
border-radius: 4px;
cursor: pointer;
`;
cancelBtn.onclick = () => {
overlay.remove();
resolve(false);
};
const confirmBtn = document.createElement('button');
confirmBtn.textContent = '确认发送';
confirmBtn.style.cssText = `
padding: 10px 24px;
border: none;
background: ${colors.success};
color: #fff;
border-radius: 4px;
cursor: pointer;
`;
confirmBtn.onclick = () => {
overlay.remove();
resolve(true);
};
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
modal.appendChild(header);
modal.appendChild(content);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
/**
* 显示合并结果的调试弹窗
* @param {Array} results 每个表格的结果 [{tableName, response, success}]
* @param {string} mergedContent 合并后的内容
* @returns {Promise<boolean>} 用户是否确认继续
*/
export function showPostMergeDebugModal(results, mergedContent) {
return new Promise((resolve) => {
const theme = getCurrentTheme() || 'default';
const colors = getThemeColors(theme);
const overlay = document.createElement('div');
overlay.id = 'mm-debug-modal-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 99999;
display: flex;
justify-content: center;
align-items: center;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: ${colors.bg};
border: 1px solid ${colors.border};
border-radius: 8px;
width: 90%;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
color: ${colors.text};
`;
// 应用主题属性
if (theme !== 'default') {
modal.setAttribute('data-mm-theme', theme);
}
// 标题栏
const header = document.createElement('div');
header.style.cssText = `
padding: 16px 20px;
border-bottom: 1px solid ${colors.border};
font-size: 18px;
font-weight: bold;
`;
const successCount = results.filter(r => r.success).length;
header.textContent = `📥 合并结果检查 - ${successCount}/${results.length} 成功`;
// 内容区域
const content = document.createElement('div');
content.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 16px;
`;
// 每个表格的原始响应
results.forEach((item, index) => {
const statusIcon = item.success ? '✅' : '❌';
const panel = createCollapsiblePanel(
`${statusIcon} ${index + 1}. ${item.tableName}`,
item.response || '(无响应)',
false,
colors
);
content.appendChild(panel);
});
// 合并后的最终内容
const mergedPanel = createCollapsiblePanel(
'📦 合并后的最终内容(将返回给 Amily',
mergedContent,
true, // 默认展开
colors
);
mergedPanel.style.marginTop = '20px';
mergedPanel.style.borderColor = colors.success;
content.appendChild(mergedPanel);
// 按钮区域
const footer = document.createElement('div');
footer.style.cssText = `
padding: 16px 20px;
border-top: 1px solid ${colors.border};
display: flex;
justify-content: flex-end;
gap: 12px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '取消(回退原始请求)';
cancelBtn.style.cssText = `
padding: 10px 24px;
border: 1px solid ${colors.textMuted};
background: transparent;
color: ${colors.text};
border-radius: 4px;
cursor: pointer;
`;
cancelBtn.onclick = () => {
overlay.remove();
resolve(false);
};
const confirmBtn = document.createElement('button');
confirmBtn.textContent = '确认返回给 Amily';
confirmBtn.style.cssText = `
padding: 10px 24px;
border: none;
background: ${colors.success};
color: #fff;
border-radius: 4px;
cursor: pointer;
`;
confirmBtn.onclick = () => {
overlay.remove();
resolve(true);
};
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
modal.appendChild(header);
modal.appendChild(content);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
/**
* 创建可折叠面板
* @param {string} title 标题
* @param {string} content 内容
* @param {boolean} expanded 是否默认展开
* @param {Object} colors 颜色方案
*/
function createCollapsiblePanel(title, content, expanded = false, colors = null) {
// 如果没有传入颜色,使用默认主题
if (!colors) {
const theme = getCurrentTheme() || 'default';
colors = getThemeColors(theme);
}
const panel = document.createElement('div');
panel.style.cssText = `
border: 1px solid ${colors.border};
border-radius: 4px;
margin-bottom: 8px;
overflow: hidden;
`;
const header = document.createElement('div');
header.style.cssText = `
padding: 12px 16px;
background: ${colors.headerBg};
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
`;
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
const arrow = document.createElement('span');
arrow.textContent = expanded ? '▼' : '▶';
arrow.style.transition = 'transform 0.2s';
header.appendChild(titleSpan);
header.appendChild(arrow);
const body = document.createElement('div');
body.style.cssText = `
padding: 12px 16px;
background: ${colors.bodyBg};
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
display: ${expanded ? 'block' : 'none'};
color: ${colors.text};
`;
body.textContent = content;
header.onclick = () => {
const isExpanded = body.style.display !== 'none';
body.style.display = isExpanded ? 'none' : 'block';
arrow.textContent = isExpanded ? '▶' : '▼';
};
panel.appendChild(header);
panel.appendChild(body);
return panel;
}
/**
* 格式化 messages 数组为可读文本
*/
function formatMessages(messages) {
if (!messages || !Array.isArray(messages)) {
return '(无消息)';
}
return messages.map((msg, i) => {
const role = msg.role || 'unknown';
const content = msg.content || '(空)';
// 不再截断,显示完整内容
return `=== [${i}] ${role.toUpperCase()} ===\n${content}`;
}).join('\n\n');
}
/**
* 显示失败重试横幅右下角通知样式类似Win10通知
* @param {Array} failedTables 失败的表格列表 [{tableName, error, retryAttempts}]
* @param {Function} onRetry 重试回调
* @param {Function} onGiveUp 放弃回调
* @returns {HTMLElement} 横幅元素(用于外部控制移除)
*/
export function showRetryBanner(failedTables, onRetry, onGiveUp) {
// 移除已存在的横幅
const existingBanner = document.getElementById('mm-retry-banner');
if (existingBanner) {
existingBanner.remove();
}
// 添加动画和响应式样式
if (!document.getElementById('mm-banner-styles')) {
const style = document.createElement('style');
style.id = 'mm-banner-styles';
style.textContent = `
@keyframes mm-banner-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes mm-banner-slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
@keyframes mm-twinkle {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
#mm-retry-banner {
position: fixed;
bottom: 20px;
right: 20px;
width: 300px;
max-width: calc(100vw - 40px);
background: rgba(15, 52, 96, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 3px solid #dc3545;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 99998;
animation: mm-banner-slide-in 0.3s ease-out;
overflow: hidden;
}
/* 暖灰棕主题 */
#mm-retry-banner[data-mm-theme="warm-brown"] {
background: rgba(61, 53, 46, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 淡紫薰衣草主题 */
#mm-retry-banner[data-mm-theme="lavender"] {
background: rgba(45, 40, 56, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 森林绿主题 */
#mm-retry-banner[data-mm-theme="forest"] {
background: rgba(37, 53, 48, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 玫瑰灰主题 */
#mm-retry-banner[data-mm-theme="rose"] {
background: rgba(56, 40, 48, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 静谧蓝灰主题 */
#mm-retry-banner[data-mm-theme="slate"] {
background: rgba(40, 46, 53, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 星空紫主题 */
#mm-retry-banner[data-mm-theme="starry-purple"] {
background:
radial-gradient(1px 1px at 20px 30px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 40px 70px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 50px 160px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 100px 40px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.5), transparent),
radial-gradient(1.5px 1.5px at 160px 120px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 200px 50px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 250px 90px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 280px 140px, rgba(255,255,255,0.5), transparent),
rgba(26, 21, 37, 0.7);
border-color: rgba(138, 100, 200, 0.3);
}
/* 星空蓝主题 */
#mm-retry-banner[data-mm-theme="starry-blue"] {
background:
radial-gradient(1px 1px at 15px 25px, rgba(255,255,255,0.8), transparent),
radial-gradient(1.5px 1.5px at 45px 65px, rgba(200,220,255,0.9), transparent),
radial-gradient(1px 1px at 75px 150px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 110px 35px, rgba(200,220,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 140px 95px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 180px 55px, rgba(200,220,255,0.5), transparent),
radial-gradient(1px 1px at 220px 110px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 260px 70px, rgba(200,220,255,0.6), transparent),
radial-gradient(1px 1px at 290px 130px, rgba(255,255,255,0.5), transparent),
rgba(16, 24, 40, 0.7);
border-color: rgba(100, 150, 220, 0.3);
}
/* 星空黑主题 */
#mm-retry-banner[data-mm-theme="starry-black"] {
background:
radial-gradient(1px 1px at 10px 20px, rgba(255,255,255,0.9), transparent),
radial-gradient(1.5px 1.5px at 35px 75px, rgba(255,255,255,0.7), transparent),
radial-gradient(1px 1px at 60px 140px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 95px 30px, rgba(255,255,255,0.6), transparent),
radial-gradient(1.5px 1.5px at 125px 100px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 165px 60px, rgba(255,255,255,0.5), transparent),
radial-gradient(1px 1px at 195px 120px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 235px 45px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 275px 85px, rgba(255,255,255,0.8), transparent),
rgba(12, 12, 16, 0.75);
border-color: rgba(255, 255, 255, 0.15);
}
#mm-retry-banner .mm-banner-content {
padding: 12px 14px;
}
#mm-retry-banner .mm-banner-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
#mm-retry-banner .mm-banner-icon {
color: #dc3545;
font-size: 16px;
flex-shrink: 0;
}
#mm-retry-banner .mm-banner-title {
color: #e4e4e4;
font-weight: 600;
font-size: 13px;
flex: 1;
}
#mm-retry-banner .mm-banner-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
#mm-retry-banner .mm-banner-btn {
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
#mm-retry-banner .mm-banner-btn-secondary {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: #a0a0a0;
}
#mm-retry-banner .mm-banner-btn-secondary:hover {
border-color: rgba(255, 255, 255, 0.3);
color: #e4e4e4;
background: rgba(255, 255, 255, 0.12);
}
#mm-retry-banner .mm-banner-btn-primary {
border: none;
background: #4a90d9;
color: #fff;
font-weight: 500;
}
#mm-retry-banner .mm-banner-btn-primary:hover {
background: #3a7bc8;
}
#mm-retry-banner .mm-banner-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 移动端适配 */
@media (max-width: 400px) {
#mm-retry-banner {
bottom: 10px;
right: 10px;
width: calc(100vw - 20px);
}
#mm-retry-banner .mm-banner-content {
padding: 10px 12px;
}
#mm-retry-banner .mm-banner-btn {
padding: 5px 12px;
font-size: 11px;
}
}
`;
document.head.appendChild(style);
}
const banner = document.createElement('div');
banner.id = 'mm-retry-banner';
// 应用当前主题
const theme = getCurrentTheme();
if (theme && theme !== 'default') {
banner.setAttribute('data-mm-theme', theme);
}
const content = document.createElement('div');
content.className = 'mm-banner-content';
// 标题行
const header = document.createElement('div');
header.className = 'mm-banner-header';
const icon = document.createElement('span');
icon.className = 'mm-banner-icon';
icon.innerHTML = '<i class="fa-solid fa-exclamation-triangle"></i>';
const title = document.createElement('span');
title.className = 'mm-banner-title';
title.textContent = `${failedTables.length} 个表格填充失败`;
header.appendChild(icon);
header.appendChild(title);
// 按钮行
const actions = document.createElement('div');
actions.className = 'mm-banner-actions';
const giveUpBtn = document.createElement('button');
giveUpBtn.className = 'mm-banner-btn mm-banner-btn-secondary';
giveUpBtn.textContent = '放弃';
giveUpBtn.onclick = () => {
banner.remove();
onGiveUp();
};
const retryBtn = document.createElement('button');
retryBtn.className = 'mm-banner-btn mm-banner-btn-primary';
retryBtn.textContent = '重试';
retryBtn.onclick = () => {
retryBtn.disabled = true;
retryBtn.textContent = '重试中...';
giveUpBtn.disabled = true;
onRetry();
};
actions.appendChild(giveUpBtn);
actions.appendChild(retryBtn);
content.appendChild(header);
content.appendChild(actions);
banner.appendChild(content);
document.body.appendChild(banner);
return banner;
}
/**
* 更新重试横幅状态
* @param {Array} failedTables 失败的表格列表
*/
export function updateRetryBanner(failedTables) {
const banner = document.getElementById('mm-retry-banner');
if (!banner) return;
const title = banner.querySelector('.mm-banner-title');
if (title) {
title.textContent = `${failedTables.length} 个表格填充失败`;
}
}
/**
* 移除重试横幅
*/
export function removeRetryBanner() {
const banner = document.getElementById('mm-retry-banner');
if (banner) {
banner.style.animation = 'mm-banner-slide-out 0.3s ease-out forwards';
setTimeout(() => banner.remove(), 280);
}
}
/**
* 显示重试进度提示
* @param {string} tableName 表格名称
* @param {number} attempt 当前尝试次数
* @param {number} maxRetry 最大重试次数
*/
export function showRetryProgress(tableName, attempt, maxRetry) {
// 使用 toastr 显示(如果可用)
if (window.toastr) {
window.toastr.info(
`正在重试 ${tableName}${attempt}/${maxRetry}...`,
"并发填表",
{ timeOut: 2000 }
);
}
}

View File

@@ -0,0 +1,668 @@
/**
* Fetch 拦截器
* 通过替换 window.fetch 和 XMLHttpRequest 拦截 Amily2 的 API 请求
*
* 工作原理:
* 1. 替换 window.fetch覆盖 openai、openai_test、google 模式)
* 2. 替换 XMLHttpRequest覆盖 sillytavern_backend 的 $.ajax 调用)
* 3. 拦截发往 AI 请求端点的请求
* 4. 检查请求体中是否包含表格填充特征
* 5. 如果是表格请求:拆分 → 并发调用 → 合并 → 返回伪造响应
* 6. 如果不是:透传给原始函数
*
* @module table-filler/fetch-interceptor
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled, isDebugModeEnabled } from "@config/config-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
import { showPostMergeDebugModal, showRetryBanner, removeRetryBanner } from "./debug-modal";
// 原始函数引用
let originalFetch = null;
let originalXHROpen = null;
let originalXHRSend = null;
// 安装状态
let isFetchInstalled = false;
let isXHRInstalled = false;
// 进度回调
let progressCallback = null;
// 并发填表正在进行中的标记(防止循环拦截)
let isParallelFillInProgress = false;
// 需要拦截的 API 端点
const INTERCEPT_ENDPOINTS = [
'/api/backends/chat-completions/generate',
'/v1/chat/completions',
'/chat/completions'
];
/**
* 设置进度回调
* @param {Function} callback
*/
export function setFetchInterceptorProgressCallback(callback) {
progressCallback = callback;
}
/**
* 安装 Fetch 拦截器
* @returns {boolean} 是否成功安装
*/
export function installFetchInterceptor() {
if (isFetchInstalled) {
Logger.log("[FetchInterceptor] Fetch 拦截器已安装,跳过");
return true;
}
if (!window.fetch) {
Logger.error("[FetchInterceptor] window.fetch 不存在");
return false;
}
// 保存原始 fetch
originalFetch = window.fetch;
// 替换为拦截版本
window.fetch = interceptedFetch;
isFetchInstalled = true;
Logger.log("[FetchInterceptor] ✓ Fetch 拦截器已安装");
return true;
}
/**
* 安装 XMLHttpRequest 拦截器(覆盖 $.ajax
* @returns {boolean} 是否成功安装
*/
export function installXHRInterceptor() {
if (isXHRInstalled) {
Logger.log("[FetchInterceptor] XHR 拦截器已安装,跳过");
return true;
}
if (!window.XMLHttpRequest) {
Logger.error("[FetchInterceptor] XMLHttpRequest 不存在");
return false;
}
// 保存原始函数
originalXHROpen = XMLHttpRequest.prototype.open;
originalXHRSend = XMLHttpRequest.prototype.send;
// 拦截 open 记录 URL 和 method
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._tableFillerUrl = url;
this._tableFillerMethod = method;
return originalXHROpen.apply(this, [method, url, ...args]);
};
// 拦截 send 检查请求体
XMLHttpRequest.prototype.send = function(body) {
const xhr = this;
const url = this._tableFillerUrl;
const method = this._tableFillerMethod;
// 只检测 POST 请求到 AI 端点(仅记录日志,不拦截)
if (method === 'POST' && shouldInterceptUrl(url)) {
try {
const bodyObj = typeof body === 'string' ? JSON.parse(body) : body;
if (bodyObj && isTableFillerEnabled() && isTableFillerRequest(bodyObj.messages)) {
Logger.log("[FetchInterceptor] ✓ XHR 检测到表格填充请求,但暂时不启用并发(调试中)");
}
} catch (e) {
// 静默忽略解析错误
}
}
return originalXHRSend.apply(this, [body]);
};
isXHRInstalled = true;
Logger.log("[FetchInterceptor] ✓ XHR 拦截器已安装");
return true;
}
/**
* 卸载 Fetch 拦截器
*/
export function uninstallFetchInterceptor() {
if (isFetchInstalled && originalFetch) {
window.fetch = originalFetch;
originalFetch = null;
isFetchInstalled = false;
Logger.log("[FetchInterceptor] Fetch 拦截器已卸载");
}
if (isXHRInstalled && originalXHROpen && originalXHRSend) {
XMLHttpRequest.prototype.open = originalXHROpen;
XMLHttpRequest.prototype.send = originalXHRSend;
originalXHROpen = null;
originalXHRSend = null;
isXHRInstalled = false;
Logger.log("[FetchInterceptor] XHR 拦截器已卸载");
}
}
/**
* 获取安装状态
* @returns {boolean}
*/
export function isFetchInterceptorInstalled() {
return isFetchInstalled || isXHRInstalled;
}
/**
* 拦截后的 fetch 函数
* @param {string|Request} input URL 或 Request 对象
* @param {RequestInit} init 请求配置
* @returns {Promise<Response>}
*/
async function interceptedFetch(input, init) {
// 获取 URL
const url = typeof input === 'string' ? input : input.url;
// 只拦截 SillyTavern 的 AI 请求端点
if (shouldInterceptUrl(url)) {
// 如果并发填表正在进行中,跳过拦截(防止循环)
if (isParallelFillInProgress) {
return originalFetch.apply(window, [input, init]);
}
try {
// 先检查请求体大小,避免解析过大的请求
const bodySize = init?.body?.length || 0;
// 如果请求体超过 5MB跳过拦截可能导致内存问题
if (bodySize > 5 * 1024 * 1024) {
console.warn("[MM] 请求体过大,跳过拦截:", bodySize);
return originalFetch.apply(window, [input, init]);
}
const body = parseRequestBody(init);
if (body && isTableFillerEnabled() && isTableFillerRequest(body.messages)) {
// 检查是否有配置的表格 API
const config = getTableFillerConfig();
const hasTableConfigs = config.tableApiConfigs && Object.keys(config.tableApiConfigs).length > 0;
if (hasTableConfigs) {
// 尝试解析表格
const tables = splitTablesFromMessages(body.messages);
if (tables.length > 1) {
// 有多个表格,启用并发处理
Logger.log(`[FetchInterceptor] 检测到 ${tables.length} 个表格,启用并发模式`);
try {
// 执行并发填表
const response = await handleParallelFill(url, body, init);
return response;
} catch (parallelError) {
Logger.error("[FetchInterceptor] 并发填表失败,回退到原始请求:", parallelError);
// 回退到原始请求
return originalFetch.apply(window, [input, init]);
}
} else {
Logger.log("[FetchInterceptor] 只有单个表格,使用原始请求");
}
}
}
} catch (e) {
Logger.error("[FetchInterceptor] 拦截处理错误:", e);
}
}
// 透传给原始 fetch
return originalFetch.apply(window, [input, init]);
}
/**
* 检查是否应该拦截此 URL
* @param {string} url
* @returns {boolean}
*/
function shouldInterceptUrl(url) {
if (!url || typeof url !== 'string') return false;
// 检查是否匹配任一端点
return INTERCEPT_ENDPOINTS.some(endpoint => url.includes(endpoint));
}
/**
* 解析请求体
* @param {RequestInit} init
* @returns {Object|null}
*/
function parseRequestBody(init) {
if (!init || !init.body) return null;
try {
if (typeof init.body === 'string') {
return JSON.parse(init.body);
}
// 如果是其他类型(如 FormData无法解析
return null;
} catch {
return null;
}
}
/**
* 检测是否为 Amily2 表格填充请求
* 不检查填表模式,只检测请求特征
* @param {Array} messages 消息数组
* @returns {boolean}
*/
function isTableFillerRequest(messages) {
if (!messages || !Array.isArray(messages)) return false;
// 检测 Amily2 表格模块的特征标记
// 遍历消息而不是序列化整个数组(避免性能问题)
try {
for (const msg of messages) {
const content = msg.content;
if (!content || typeof content !== 'string') continue;
// 主要特征flowTemplate 中的标记
if (content.includes('# dataTable 说明') || content.includes('dataTable 说明')) {
Logger.log("[FetchInterceptor] ✓ 检测到 dataTable 说明特征");
return true;
}
// 检查辅助特征需要至少2个
let auxCount = 0;
// 辅助特征ruleTemplate 中的身份标识
if (content.includes('职业是小说填表AI') || content.includes('酒馆国家的臣民')) {
auxCount++;
}
// 辅助特征:输出格式标签
if (content.includes('<Amily2Edit>') || content.includes('Amily2Edit')) {
auxCount++;
}
// 辅助特征:表格操作函数
if (content.includes('insertRow(') || content.includes('updateRow(') || content.includes('deleteRow(')) {
auxCount++;
}
// 辅助特征Amily2TableData 占位符或表格结构
if (content.includes('Amily2TableData') || content.includes('rowIndex')) {
auxCount++;
}
if (auxCount >= 2) {
Logger.log(`[FetchInterceptor] ✓ 检测到 ${auxCount} 个辅助特征`);
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* 处理并发填表
* @param {string} url 原始请求 URL
* @param {Object} requestBody 请求体
* @param {RequestInit} originalInit 原始请求配置
* @returns {Promise<Response>}
*/
async function handleParallelFill(url, requestBody, originalInit) {
// 设置标记,防止循环拦截
isParallelFillInProgress = true;
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
// 设置进度回调
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
// 从 messages 中提取表格数据
const tables = splitTablesFromMessages(requestBody.messages);
if (tables.length === 0) {
Logger.warn("[FetchInterceptor] 未检测到多表格,使用原始请求");
// 回退到原始请求
return originalFetch.apply(window, [url, originalInit]);
}
Logger.log(`[FetchInterceptor] 检测到 ${tables.length} 个表格,启用并发模式`);
// 显示开始通知
if (window.toastr) {
window.toastr.info(
`正在并发处理 ${tables.length} 个表格...`,
"并发填表",
{ timeOut: 3000 }
);
}
// 并发填充(发送前调试弹窗在 executor 内部处理)
let results = await executor.fillAllTables(tables, requestBody, {
originalUrl: url,
originalInit: originalInit,
originalFetch: originalFetch
});
// 检查是否用户取消
const allCancelled = results.every(r => r.error?.message === "用户取消");
if (allCancelled) {
Logger.log("[FetchInterceptor] 用户取消,回退到原始请求");
return originalFetch.apply(window, [url, originalInit]);
}
// 处理失败表格的重试交互
let failedResults = results.filter(r => !r.success);
let successResults = results.filter(r => r.success);
// 如果有失败的表格,显示重试横幅让用户选择(必须等待用户操作)
while (failedResults.length > 0) {
const userChoice = await showRetryInteraction(failedResults);
if (userChoice === 'giveup') {
// 用户选择放弃,继续处理已成功的结果
Logger.log("[FetchInterceptor] 用户放弃重试失败的表格");
break;
}
// 用户选择重试
Logger.log(`[FetchInterceptor] 用户选择重试 ${failedResults.length} 个失败的表格`);
// 找到对应的表格对象
const failedTableNames = failedResults.map(r => r.tableName);
const failedTables = tables.filter(t => failedTableNames.includes(t.name));
// 重新创建 executor 并重试
const retryExecutor = new ParallelExecutor(config);
if (progressCallback) {
retryExecutor.setProgressCallback(progressCallback);
}
const retryResults = await retryExecutor.fillAllTables(failedTables, requestBody, {
originalUrl: url,
originalInit: originalInit,
originalFetch: originalFetch
});
// 更新结果
for (const retryResult of retryResults) {
if (retryResult.success) {
// 成功了,从失败列表移到成功列表
successResults.push(retryResult);
failedResults = failedResults.filter(r => r.tableName !== retryResult.tableName);
} else {
// 仍然失败,更新错误信息
const idx = failedResults.findIndex(r => r.tableName === retryResult.tableName);
if (idx >= 0) {
failedResults[idx] = retryResult;
}
}
}
// 如果全部成功了,跳出循环
if (failedResults.length === 0) {
Logger.log("[FetchInterceptor] 重试后全部成功");
break;
}
Logger.log(`[FetchInterceptor] 重试后仍有 ${failedResults.length} 个表格失败,等待用户选择`);
}
// 移除重试横幅
removeRetryBanner();
// 合并最终结果
results = [...successResults, ...failedResults];
// 统计结果
const successCount = results.filter(r => r.success).length;
if (successCount === 0) {
// 全部失败时回退到原始请求
Logger.warn("[FetchInterceptor] 所有表格均失败,回退到原始请求");
if (window.toastr) {
window.toastr.error("所有表格填充均失败,回退到原始请求", "并发填表失败");
}
return originalFetch.apply(window, [url, originalInit]);
}
// 合并结果
const mergedContent = mergeResults(results);
// 调试模式:显示合并后的弹窗
if (isDebugModeEnabled()) {
const shouldReturn = await showPostMergeDebugModal(results, mergedContent);
if (!shouldReturn) {
Logger.log("[FetchInterceptor] 用户取消返回,回退到原始请求");
return originalFetch.apply(window, [url, originalInit]);
}
}
// 构造伪造的 Response 对象
return createFakeResponse(mergedContent);
} catch (error) {
Logger.error("[FetchInterceptor] 并发填表失败:", error);
// 显示错误通知,帮助用户了解问题
if (window.toastr) {
window.toastr.error(
`并发填表失败: ${error.message || '未知错误'},已回退到原始请求`,
"并发填表错误",
{ timeOut: 8000 }
);
}
// 失败时回退到原始请求
return originalFetch.apply(window, [url, originalInit]);
} finally {
// 清除标记,允许下一次拦截
isParallelFillInProgress = false;
// 确保横幅被移除
removeRetryBanner();
}
}
/**
* 显示重试交互横幅并等待用户选择
* @param {Array} failedResults 失败的结果
* @returns {Promise<'retry'|'giveup'>}
*/
function showRetryInteraction(failedResults) {
return new Promise((resolve) => {
showRetryBanner(
failedResults,
() => resolve('retry'), // onRetry
() => resolve('giveup') // onGiveUp
);
});
}
/**
* 创建伪造的 Response 对象
* 模拟 OpenAI 兼容格式的响应
* @param {string} content 响应内容
* @returns {Response}
*/
function createFakeResponse(content) {
const responseData = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "concurrent-table-filler",
choices: [{
index: 0,
message: {
role: "assistant",
content: content
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
};
const responseBody = JSON.stringify(responseData);
return new Response(responseBody, {
status: 200,
statusText: "OK",
headers: {
'Content-Type': 'application/json',
'X-Concurrent-Table-Filler': 'true'
}
});
}
/**
* 导出原始 fetch 引用(供其他模块使用)
* @returns {Function|null}
*/
export function getOriginalFetch() {
return originalFetch;
}
/**
* 处理 XHR 并发填表
* @param {XMLHttpRequest} xhr 原始 XHR 对象
* @param {string} url 请求 URL
* @param {Object} requestBody 请求体对象
* @param {string} originalBody 原始请求体字符串
*/
async function handleXHRParallelFill(xhr, url, requestBody, originalBody) {
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
const tables = splitTablesFromMessages(requestBody.messages);
if (tables.length === 0) {
Logger.warn("[FetchInterceptor] XHR 未检测到多表格,使用原始请求");
// 回退到原始请求
return originalXHRSend.call(xhr, originalBody);
}
Logger.log(`[FetchInterceptor] XHR 检测到 ${tables.length} 个表格,启用并发模式`);
if (window.toastr) {
window.toastr.info(
`🚀 正在并发处理 ${tables.length} 个表格...`,
"并发填表已启动",
{ timeOut: 3000 }
);
}
const results = await executor.fillAllTables(tables, requestBody, {
originalUrl: url,
originalFetch: originalFetch
});
const successCount = results.filter(r => r.success).length;
const failedCount = results.length - successCount;
if (window.toastr) {
if (failedCount === 0) {
window.toastr.success(`${successCount} 个表格全部处理成功`, "并发填表完成");
} else if (successCount > 0) {
window.toastr.warning(`⚠️ ${successCount}/${results.length} 个表格成功`, "并发填表部分完成");
} else {
window.toastr.error("❌ 所有表格处理失败", "并发填表失败");
return originalXHRSend.call(xhr, originalBody);
}
}
const mergedContent = mergeResults(results);
// 模拟 XHR 响应
simulateXHRResponse(xhr, mergedContent);
} catch (error) {
Logger.error("[FetchInterceptor] XHR 并发填表失败:", error);
if (window.toastr) {
window.toastr.error(`❌ 并发填表出错: ${error.message}`, "并发填表错误");
}
// 回退到原始请求
return originalXHRSend.call(xhr, originalBody);
}
}
/**
* 模拟 XHR 响应
* @param {XMLHttpRequest} xhr
* @param {string} content 响应内容
*/
function simulateXHRResponse(xhr, content) {
const responseData = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "concurrent-table-filler",
choices: [{
index: 0,
message: {
role: "assistant",
content: content
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
};
const responseText = JSON.stringify(responseData);
// 设置只读属性需要使用 Object.defineProperty
Object.defineProperty(xhr, 'readyState', { value: 4, writable: false });
Object.defineProperty(xhr, 'status', { value: 200, writable: false });
Object.defineProperty(xhr, 'statusText', { value: 'OK', writable: false });
Object.defineProperty(xhr, 'responseText', { value: responseText, writable: false });
Object.defineProperty(xhr, 'response', { value: responseText, writable: false });
// 触发事件
if (typeof xhr.onreadystatechange === 'function') {
xhr.onreadystatechange();
}
if (typeof xhr.onload === 'function') {
xhr.onload();
}
// 触发 load 事件
try {
xhr.dispatchEvent(new Event('load'));
xhr.dispatchEvent(new Event('loadend'));
} catch (e) {
Logger.debug("[FetchInterceptor] 触发 XHR 事件失败:", e.message);
}
}
/**
* 导出 isTableFillerRequest 供其他模块使用
*/
export { isTableFillerRequest };

155
src/table-filler/index.js Normal file
View File

@@ -0,0 +1,155 @@
/**
* 表格填表模块入口
* 支持多种拦截模式Fetch、XHR、Service、Bus
* @module table-filler/index
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { registerToBus, getBusContext } from "./bus-integration";
import { installInterceptor, uninstallInterceptor, setInterceptorProgressCallback, getInterceptorStatus } from "./interceptor";
import { isFetchInterceptorInstalled } from "./fetch-interceptor";
import { isServiceInterceptorInstalled } from "./service-interceptor";
import {
CallMode,
detectAvailableMode,
getModeStatus,
isBusRegistered,
isInterceptorInstalled,
isSecondaryApiMode,
getAmily2FillingModeName,
} from "./mode-manager";
// 导出子模块
export { CallMode, getModeStatus, isSecondaryApiMode, getAmily2FillingModeName } from "./mode-manager";
export { ParallelExecutor } from "./parallel-executor";
export { PromptMode, TABLE_PHASE_MAP } from "./prompt-handler";
export { splitTablesFromMessages, mergeResults } from "./table-splitter";
export { isFetchInterceptorInstalled, getOriginalFetch, isTableFillerRequest } from "./fetch-interceptor";
export { isServiceInterceptorInstalled } from "./service-interceptor";
// 初始化状态
let isInitialized = false;
/**
* 初始化表格填表模块
* @param {boolean} immediate - 是否立即安装拦截器(默认延迟)
* @returns {Promise<void>}
*/
export async function initTableFiller(immediate = false) {
if (isInitialized) {
Logger.log("[TableFiller] 模块已初始化,跳过");
return;
}
const config = getTableFillerConfig();
if (!config?.enabled) {
Logger.log("[TableFiller] 功能未启用");
return;
}
Logger.log("[TableFiller] 开始初始化...");
// 1. 始终注册 Bus 接口(供未来 Amily2 调用)
const busCtx = registerToBus();
// 2. 根据配置决定是否安装拦截器
const mode = config.callMode || CallMode.AUTO;
if (mode === CallMode.AUTO || mode === CallMode.INTERCEPT_ONLY) {
// 检测当前模式
const available = detectAvailableMode();
if (mode === CallMode.AUTO && available.bus) {
// Bus 模式可用,不安装拦截器
Logger.log("[TableFiller] Bus 模式可用,跳过拦截器安装");
} else {
// 安装拦截器
if (immediate) {
// 立即安装(用于重新初始化)
const installed = await installInterceptor();
if (installed) {
Logger.log("[TableFiller] 拦截器安装成功");
// 触发状态更新事件
dispatchStatusUpdateEvent();
}
} else {
// 延迟安装,确保 Amily2 模块已加载(首次初始化)
setTimeout(async () => {
const installed = await installInterceptor();
if (installed) {
Logger.log("[TableFiller] 拦截器安装成功");
// 触发状态更新事件
dispatchStatusUpdateEvent();
}
}, 2000);
}
}
}
isInitialized = true;
Logger.log("[TableFiller] 初始化完成", {
mode: config.callMode,
busRegistered: isBusRegistered(),
});
}
/**
* 触发状态更新事件,通知 UI 刷新
*/
function dispatchStatusUpdateEvent() {
try {
window.dispatchEvent(new CustomEvent('tableFillerStatusUpdate', {
detail: getTableFillerStatus()
}));
} catch (e) {
Logger.debug("[TableFiller] 触发状态更新事件失败:", e.message);
}
}
/**
* 禁用表格填表模块
*/
export function disableTableFiller() {
uninstallInterceptor();
isInitialized = false;
Logger.log("[TableFiller] 模块已禁用");
// 通知已由 UI 层显示,此处不再重复
}
/**
* 获取表格填表模块状态
* @returns {Object}
*/
export function getTableFillerStatus() {
const interceptorStatus = getInterceptorStatus();
return {
initialized: isInitialized,
enabled: isTableFillerEnabled(),
mode: getModeStatus(),
busContext: getBusContext() ? true : false,
interceptor: interceptorStatus,
fetchInterceptor: isFetchInterceptorInstalled(),
};
}
/**
* 设置进度回调
* @param {Function} callback
*/
export function setProgressCallback(callback) {
setInterceptorProgressCallback(callback);
}
/**
* 重新初始化(配置变更后调用)
*/
export async function reinitTableFiller() {
if (isInitialized) {
disableTableFiller();
}
// 使用立即模式安装拦截器,避免延迟导致状态不更新
await initTableFiller(true);
}

View File

@@ -0,0 +1,328 @@
/**
* API 拦截器
* 拦截 Amily2 的 API 调用,实现并发填表
* @module table-filler/interceptor
*
* 实现方式:
* 1. Fetch 拦截:替换 window.fetch覆盖 openai、openai_test、google 模式)
* 2. XHR 拦截:替换 XMLHttpRequest覆盖 sillytavern_backend 的 $.ajax
* 3. Service 拦截Hook ConnectionManagerRequestService覆盖 sillytavern_preset
* 4. 全局钩子:暴露 _tableFillerInterceptor 供 Amily2 可选调用
* 5. Bus 联动:注册 TableFillerProxy 供未来 Amily2 版本直接调用
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { setInterceptorInstalled } from "./mode-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
import {
installFetchInterceptor,
installXHRInterceptor,
uninstallFetchInterceptor,
isFetchInterceptorInstalled,
setFetchInterceptorProgressCallback,
isTableFillerRequest
} from "./fetch-interceptor";
import {
installServiceInterceptor,
uninstallServiceInterceptor,
isServiceInterceptorInstalled,
setServiceInterceptorProgressCallback
} from "./service-interceptor";
// 原始函数引用(用于 Bus 钩子)
let originalNccsCall = null;
let isHooked = false;
// 进度回调
let progressCallback = null;
/**
* 设置进度回调
* @param {Function} callback
*/
export function setInterceptorProgressCallback(callback) {
progressCallback = callback;
// 同时设置给所有拦截器
setFetchInterceptorProgressCallback(callback);
setServiceInterceptorProgressCallback(callback);
}
// 重新导出 isTableFillerRequest
export { isTableFillerRequest };
/**
* 处理并发填表(核心逻辑)
* @param {Array} messages 消息数组
* @param {Object} options 选项
* @param {Function} fallbackFn 回退函数(可选)
* @returns {Promise<string>}
*/
export async function handleParallelFill(messages, options = {}, fallbackFn = null) {
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
// 设置进度回调
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
// 从 messages 中提取表格数据
const tables = splitTablesFromMessages(messages);
if (tables.length === 0) {
Logger.warn("[Interceptor] 未能解析出表格数据");
if (window.toastr) {
window.toastr.info("未检测到多表格数据,使用原始模式", "并发填表");
}
if (fallbackFn) return fallbackFn(messages, options);
throw new Error("未检测到表格数据");
}
Logger.log(
`[Interceptor] 检测到 ${tables.length} 个表格,启用并发模式`,
);
// 显示通知
if (window.toastr) {
window.toastr.info(
`🚀 正在并发处理 ${tables.length} 个表格...`,
"并发填表已启动",
{ timeOut: 3000 },
);
}
// 并发填充
const results = await executor.fillAllTables(tables, messages, options);
// 统计结果
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
// 显示结果通知
if (window.toastr) {
if (failedCount === 0) {
window.toastr.success(
`${successCount} 个表格全部处理成功`,
"并发填表完成",
);
} else if (successCount > 0) {
window.toastr.warning(
`⚠️ ${successCount}/${results.length} 个表格成功,${failedCount} 个失败`,
"并发填表部分完成",
);
} else {
window.toastr.error(
"❌ 所有表格处理失败",
"并发填表失败",
);
if (fallbackFn) return fallbackFn(messages, options);
throw new Error("所有表格处理失败");
}
}
// 合并结果
return mergeResults(results);
} catch (error) {
Logger.error("[Interceptor] 并发填表失败:", error);
if (window.toastr) {
window.toastr.error(
`❌ 并发填表出错: ${error.message}`,
"并发填表错误",
);
}
// 失败时回退到原始调用
if (fallbackFn) return fallbackFn(messages, options);
throw error;
}
}
/**
* 创建拦截版本的调用函数
* @param {Function} originalFn 原始函数
* @returns {Function}
*/
function createInterceptedCall(originalFn) {
return async function interceptedCall(messages, options = {}) {
if (isTableFillerEnabled() && isTableFillerRequest(messages)) {
Logger.log("[Interceptor] ✓ 检测到表格填充请求,启用并发模式");
return await handleParallelFill(messages, options, originalFn);
}
return originalFn(messages, options);
};
}
/**
* 安装全局钩子
* 在 window 上暴露拦截器接口,供 Amily2 可选调用
*/
function installGlobalHook() {
// 暴露全局拦截器接口
window._tableFillerInterceptor = {
// 版本
version: "1.0.0",
// 检查是否应该拦截此请求
shouldIntercept: (messages) => {
return isTableFillerEnabled() && isTableFillerRequest(messages);
},
// 并发填表接口(供 Amily2 调用)
fillParallel: handleParallelFill,
// 检查是否启用
isEnabled: isTableFillerEnabled,
// 获取配置
getConfig: getTableFillerConfig,
};
Logger.log("[Interceptor] 全局钩子已安装 (window._tableFillerInterceptor)");
}
/**
* 安装 API 拦截器
* 安装所有拦截器以覆盖各种 API 提供商
* @returns {Promise<boolean>}
*/
export async function installInterceptor() {
if (isHooked) {
Logger.log("[Interceptor] 拦截器已安装,跳过");
return true;
}
try {
// 1. 安装 Fetch 拦截器(覆盖 openai、openai_test、google
const fetchInstalled = installFetchInterceptor();
if (fetchInstalled) {
Logger.log("[Interceptor] ✓ Fetch 拦截器安装成功");
}
// 2. 安装 XHR 拦截器(覆盖 sillytavern_backend 的 $.ajax
const xhrInstalled = installXHRInterceptor();
if (xhrInstalled) {
Logger.log("[Interceptor] ✓ XHR 拦截器安装成功");
}
// 3. 安装 Service 拦截器(覆盖 sillytavern_preset
const serviceInstalled = installServiceInterceptor();
if (serviceInstalled) {
Logger.log("[Interceptor] ✓ Service 拦截器安装成功");
}
// 4. 安装全局钩子(预留方式)
installGlobalHook();
// 5. 尝试通过 Amily2Bus 进行更深度的集成(预留方式)
let busHookSuccess = false;
if (window.Amily2Bus) {
try {
const nccsApi = window.Amily2Bus.query("NccsApi");
if (nccsApi && typeof nccsApi.call === 'function') {
originalNccsCall = nccsApi.call;
nccsApi.call = createInterceptedCall(originalNccsCall);
busHookSuccess = true;
Logger.log("[Interceptor] ✓ Bus.NccsApi.call 已替换");
}
} catch (e) {
Logger.debug("[Interceptor] Bus 钩子失败:", e.message);
}
}
isHooked = true;
setInterceptorInstalled(true);
// 统计安装结果
const installedCount = [fetchInstalled, xhrInstalled, serviceInstalled].filter(Boolean).length;
// 显示状态通知
if (window.toastr) {
if (installedCount > 0) {
window.toastr.success(
`已安装 ${installedCount} 个拦截器`,
"Amily表格并发"
);
} else {
window.toastr.warning(
"拦截器安装失败",
"Amily表格并发"
);
}
}
Logger.log("============================================");
Logger.log("[Interceptor] 拦截器安装完成");
Logger.log("[Interceptor] Fetch 拦截: " + (fetchInstalled ? "✓" : "✗"));
Logger.log("[Interceptor] XHR 拦截: " + (xhrInstalled ? "✓" : "✗"));
Logger.log("[Interceptor] Service 拦截: " + (serviceInstalled ? "✓" : "✗"));
Logger.log("[Interceptor] Bus 钩子: " + (busHookSuccess ? "✓" : "✗"));
Logger.log("[Interceptor] 全局钩子: ✓");
Logger.log("============================================");
return true;
} catch (e) {
Logger.error("[Interceptor] 安装拦截器失败:", e);
setInterceptorInstalled(false);
return false;
}
}
/**
* 卸载 API 拦截器
*/
export function uninstallInterceptor() {
if (!isHooked) {
return;
}
// 1. 卸载 Fetch 和 XHR 拦截器
uninstallFetchInterceptor();
// 2. 卸载 Service 拦截器
uninstallServiceInterceptor();
// 3. 恢复 Bus 上的原始函数
if (window.Amily2Bus && originalNccsCall) {
try {
const nccsApi = window.Amily2Bus.query("NccsApi");
if (nccsApi) {
nccsApi.call = originalNccsCall;
Logger.log("[Interceptor] 已恢复 Bus.NccsApi.call");
}
} catch (e) {
Logger.debug("[Interceptor] 恢复 Bus 钩子失败:", e.message);
}
}
// 4. 清理全局钩子
delete window._tableFillerInterceptor;
originalNccsCall = null;
isHooked = false;
setInterceptorInstalled(false);
Logger.log("[Interceptor] API 拦截器已卸载");
if (window.toastr) {
window.toastr.info("并发填表拦截器已卸载", "Amily表格并发");
}
}
/**
* 获取拦截器状态
* @returns {Object}
*/
export function getInterceptorStatus() {
return {
hooked: isHooked,
fetchInterceptor: isFetchInterceptorInstalled(),
serviceInterceptor: isServiceInterceptorInstalled(),
globalHook: !!window._tableFillerInterceptor,
busHook: !!originalNccsCall
};
}

View File

@@ -0,0 +1,264 @@
/**
* 表格填表模式管理器
* 支持双模式:拦截模式和 Bus 联动模式
* @module table-filler/mode-manager
*/
import Logger from "@core/logger";
/**
* 调用模式枚举
*/
export const CallMode = {
AUTO: "auto", // 自动选择(优先 Busfallback 拦截)
BUS_ONLY: "bus_only", // 仅 Bus 模式(需 Amily2 支持)
INTERCEPT_ONLY: "intercept_only", // 仅拦截模式
};
/**
* 模式状态
*/
const modeState = {
busRegistered: false,
interceptorInstalled: false,
fetchInterceptorInstalled: false,
currentMode: null,
};
/**
* 设置 Bus 注册状态
* @param {boolean} registered
*/
export function setBusRegistered(registered) {
modeState.busRegistered = registered;
}
/**
* 设置拦截器安装状态
* @param {boolean} installed
*/
export function setInterceptorInstalled(installed) {
modeState.interceptorInstalled = installed;
}
/**
* 设置 Fetch 拦截器安装状态
* @param {boolean} installed
*/
export function setFetchInterceptorInstalled(installed) {
modeState.fetchInterceptorInstalled = installed;
}
/**
* 获取 Bus 注册状态
* @returns {boolean}
*/
export function isBusRegistered() {
return modeState.busRegistered;
}
/**
* 获取拦截器安装状态
* @returns {boolean}
*/
export function isInterceptorInstalled() {
return modeState.interceptorInstalled;
}
/**
* 获取 Fetch 拦截器安装状态
* @returns {boolean}
*/
export function isFetchInterceptorInstalled() {
return modeState.fetchInterceptorInstalled;
}
/**
* 检测当前可用的调用模式
* @returns {{bus: boolean, intercept: boolean, recommended: string}}
*/
export function detectAvailableMode() {
const busAvailable = checkBusMode();
const interceptAvailable = checkInterceptMode();
return {
bus: busAvailable,
intercept: interceptAvailable,
recommended: busAvailable
? "bus"
: interceptAvailable
? "intercept"
: "none",
};
}
/**
* 检查 Bus 模式是否可用
* @returns {boolean}
*/
function checkBusMode() {
// 1. Amily2Bus 存在
if (!window.Amily2Bus) return false;
// 2. 已成功注册
const proxy = window.Amily2Bus.query("TableFillerProxy");
if (!proxy) return false;
// 3. Amily2 支持 Bus 调用(检查 Amily2 版本或标记)
// 这个标记需要等 Amily2 更新后才会存在
const amilyApi = window.Amily2Bus.query("Amily2");
const amilySupport = amilyApi?.supportsBusTableFiller;
return !!amilySupport;
}
/**
* 检查拦截模式是否可用
* @returns {boolean}
*/
function checkInterceptMode() {
// 检查 Fetch 拦截器是否已安装(主要方式)
if (modeState.fetchInterceptorInstalled) {
return true;
}
// 检查拦截器是否已安装
if (modeState.interceptorInstalled) {
return true;
}
// 检查全局钩子是否存在
if (window._tableFillerInterceptor) {
return true;
}
// 检查 Amily2 模块是否加载(支持多种键名)
try {
const possibleKeys = [
"ST-Amily2-Chat-Optimisation",
"Amily2",
"amily2",
"Amily2-Chat-Optimisation"
];
for (const key of possibleKeys) {
if (window.extension_settings?.[key]) {
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* 检查 Amily2 当前填表模式是否为分步模式
* @returns {boolean}
*/
export function isSecondaryApiMode() {
try {
// 尝试多种可能的设置键名
const possibleKeys = [
"Amily2",
"ST-Amily2-Chat-Optimisation",
"amily2",
"Amily2-Chat-Optimisation"
];
let amilySettings = null;
for (const key of possibleKeys) {
if (window.extension_settings?.[key]) {
amilySettings = window.extension_settings[key];
break;
}
}
if (!amilySettings) {
// 如果找不到设置默认返回true允许使用
return true;
}
// 检查多种可能的填表模式字段名
const fillingMode = amilySettings.filling_mode
|| amilySettings.fillingMode
|| amilySettings.tableFillingMode
|| amilySettings.batchFillerMode
|| "secondary-api"; // 默认假设是分步模式
return fillingMode === "secondary-api" || fillingMode === "secondary";
} catch {
return true; // 出错时默认允许
}
}
/**
* 获取 Amily2 当前填表模式名称
* @returns {string}
*/
export function getAmily2FillingModeName() {
try {
// 尝试多种可能的设置键名
const possibleKeys = [
"Amily2",
"ST-Amily2-Chat-Optimisation",
"amily2",
"Amily2-Chat-Optimisation"
];
let amilySettings = null;
for (const key of possibleKeys) {
if (window.extension_settings?.[key]) {
amilySettings = window.extension_settings[key];
break;
}
}
if (!amilySettings) {
return "未检测到Amily2";
}
const fillingMode = amilySettings.filling_mode
|| amilySettings.fillingMode
|| amilySettings.tableFillingMode
|| amilySettings.batchFillerMode
|| "unknown";
const modeNames = {
"main-api": "原始填表",
"secondary-api": "分步填表",
"secondary": "分步填表",
optimized: "优化中填表",
unknown: "未知模式",
};
return modeNames[fillingMode] || fillingMode;
} catch {
return "检测失败";
}
}
/**
* 获取当前模式状态详情
* @returns {Object}
*/
export function getModeStatus() {
const available = detectAvailableMode();
return {
busRegistered: modeState.busRegistered,
interceptorInstalled: modeState.interceptorInstalled,
fetchInterceptorInstalled: modeState.fetchInterceptorInstalled,
busAvailable: available.bus,
interceptAvailable: available.intercept,
recommended: available.recommended,
amily2Mode: getAmily2FillingModeName(),
isSecondaryApi: isSecondaryApiMode(),
};
}
/**
* 记录模式状态日志
*/
export function logModeStatus() {
const status = getModeStatus();
Logger.log("[TableFiller] 模式状态:", status);
}

View File

@@ -0,0 +1,371 @@
/**
* 并发执行器
* 并发调用多个 API 处理表格填充
* @module table-filler/parallel-executor
*/
import Logger from "@core/logger";
import { APIAdapter } from "@api/adapter";
import { isDebugModeEnabled, loadDefaultIndependentTemplates } from "@config/config-manager";
import { buildSingleTableMessages } from "./table-splitter";
import { buildPromptForTable } from "./prompt-handler";
import { showPreSendDebugModal, showPostMergeDebugModal, showRetryBanner, removeRetryBanner } from "./debug-modal";
/**
* 并发执行器类
*/
export class ParallelExecutor {
/**
* @param {Object} config 配置对象
*/
constructor(config) {
this.config = config;
this.abortControllers = new Map();
this.onProgress = null;
// 从配置读取重试次数,默认为 2
this.retryCount = config.retryCount ?? 2;
// 从配置读取重试延迟基数(毫秒),默认为 2000
this.retryDelay = config.retryDelay ?? 2000;
// 从配置读取调试模式
this.debugMode = config.debugMode ?? false;
}
/**
* 设置进度回调
* @param {Function} callback 进度回调函数
*/
setProgressCallback(callback) {
this.onProgress = callback;
}
/**
* 报告进度
* @param {string} tableName 表格名称
* @param {string} status 状态
* @param {string} message 消息
*/
reportProgress(tableName, status, message) {
if (this.onProgress) {
this.onProgress({
tableName,
status,
message,
timestamp: Date.now(),
});
}
}
/**
* 获取表格的 API 配置
* @param {string} tableName 表格名称
* @returns {Object}
*/
getApiConfigForTable(tableName) {
const tableApis = this.config.tableApiConfigs || {};
const tableConfig = tableApis[tableName];
// 如果表格有独立配置且不是使用默认
if (tableConfig && !tableConfig.useDefault) {
return tableConfig;
}
// 使用默认 API 配置
return this.config.defaultApi || {};
}
/**
* 并发填充所有表格
* @param {Array} tables 表格数组
* @param {Array|Object} originalMessagesOrBody 原始消息数组或请求体对象
* @param {Object} originalOptions 原始选项
* @returns {Promise<Array>}
*/
async fillAllTables(tables, originalMessagesOrBody, originalOptions = {}) {
Logger.log(
`[ParallelExecutor] 开始并发填表,共 ${tables.length} 个表格,重试次数: ${this.retryCount},重试延迟基数: ${this.retryDelay}ms`,
);
// 兼容处理:如果传入的是请求体对象,提取 messages
const originalMessages = Array.isArray(originalMessagesOrBody)
? originalMessagesOrBody
: originalMessagesOrBody?.messages || [];
// 预加载默认独立模板并合并到 config 中
if (this.config.promptMode === "independent") {
const defaultTemplates = await loadDefaultIndependentTemplates();
if (defaultTemplates?.templates) {
// 合并默认模板(持久化优先)
const mergedTemplates = { ...this.config.independentTemplates };
for (const [tableName, templateObj] of Object.entries(defaultTemplates.templates)) {
if (!mergedTemplates[tableName]) {
// 处理嵌套结构templateObj 可能是 { template: "..." } 或直接是字符串
const templateContent = typeof templateObj === 'string' ? templateObj : templateObj?.template;
if (templateContent) {
mergedTemplates[tableName] = { template: templateContent };
}
}
}
this.config.independentTemplates = mergedTemplates;
Logger.log(`[ParallelExecutor] 已合并默认独立模板,共 ${Object.keys(mergedTemplates).length}`);
}
}
// 【调试】先构建所有表格的提示词,显示调试弹窗
const tablePrompts = [];
for (const table of tables) {
try {
const messages = buildPromptForTable(table, originalMessages, this.config);
tablePrompts.push({
tableName: table.name,
messages: messages
});
} catch (buildError) {
Logger.error(`[ParallelExecutor] 构建表格「${table.name}」提示词失败:`, buildError);
if (window.toastr) {
window.toastr.error(
`构建「${table.name}」提示词失败: ${buildError.message}`,
"并发填表错误",
{ timeOut: 5000 }
);
}
throw buildError;
}
}
// 调试模式:显示发送前调试弹窗
if (isDebugModeEnabled()) {
const shouldContinue = await showPreSendDebugModal(tablePrompts);
if (!shouldContinue) {
Logger.log("[ParallelExecutor] 用户取消了发送");
// 返回空结果,让上层回退到原始请求
return tables.map(t => ({
tableName: t.name,
success: false,
response: null,
error: new Error("用户取消"),
retryAttempts: 0
}));
}
}
// 为每个表格创建任务
const tasks = tables.map((table, index) => ({
table,
apiConfig: this.getApiConfigForTable(table.name),
abortController: new AbortController(),
// 使用已构建的提示词
prebuiltMessages: tablePrompts[index].messages
}));
// 并发执行
const promises = tasks.map((task) =>
this.fillSingleTableWithRetry(task, originalMessages, originalOptions),
);
const settledResults = await Promise.allSettled(promises);
// 处理结果
const results = settledResults.map((result, index) => ({
tableName: tables[index].name,
success:
result.status === "fulfilled" && result.value?.success,
response:
result.status === "fulfilled" ? result.value?.response : null,
error:
result.status === "rejected"
? result.reason
: result.value?.error || null,
retryAttempts: result.status === "fulfilled" ? result.value?.retryAttempts : 0,
}));
// 统计结果
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
Logger.log(
`[ParallelExecutor] 填表完成: ${successCount}/${results.length} 成功`,
);
// 详细记录失败的表格(仅日志,不弹通知)
if (failedCount > 0) {
const failedDetails = results
.filter((r) => !r.success)
.map((r) => {
const errorMsg = r.error?.message || '未知错误';
return `${r.tableName}: ${errorMsg}`;
});
Logger.warn(`[ParallelExecutor] 失败的表格详情:\n${failedDetails.join('\n')}`);
}
return results;
}
/**
* 带重试的单表格填充
* @param {Object} task 任务对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} originalOptions 原始选项
* @returns {Promise<{success: boolean, response: string|null, error: Error|null, retryAttempts: number}>}
*/
async fillSingleTableWithRetry(task, originalMessages, originalOptions) {
const { table } = task;
let lastError = null;
let retryAttempts = 0;
Logger.log(`[ParallelExecutor] 开始处理表格 ${table.name},最大重试次数: ${this.retryCount}`);
for (let attempt = 0; attempt <= this.retryCount; attempt++) {
try {
if (attempt > 0) {
retryAttempts = attempt;
// 使用固定延迟时间
const delayMs = this.retryDelay;
this.reportProgress(table.name, "retrying", `重试第 ${attempt} 次(等待 ${delayMs / 1000} 秒)...`);
Logger.log(`[ParallelExecutor] 表格 ${table.name} 重试第 ${attempt} 次,延迟 ${delayMs}ms`);
// 重试前等待
await this.delay(delayMs);
// 创建新的 AbortController
task.abortController = new AbortController();
}
Logger.log(`[ParallelExecutor] 表格 ${table.name}${attempt} 次尝试调用 API`);
const result = await this.fillSingleTable(task, originalMessages, originalOptions);
if (result.success) {
if (retryAttempts > 0) {
Logger.log(`[ParallelExecutor] 表格 ${table.name} 在第 ${retryAttempts} 次重试后成功`);
}
return { ...result, retryAttempts };
}
// 调用返回了失败结果,记录错误
lastError = result.error;
const errorMsg = lastError?.message || '未知错误';
Logger.warn(`[ParallelExecutor] 表格 ${table.name}${attempt} 次尝试失败:`, errorMsg);
} catch (error) {
// 捕获异常,记录错误
lastError = error;
const errorMsg = error.message || '未知异常';
Logger.warn(`[ParallelExecutor] 表格 ${table.name}${attempt} 次尝试异常:`, errorMsg);
}
}
// 所有重试都失败
Logger.error(`[ParallelExecutor] 表格 ${table.name}${this.retryCount} 次重试后最终失败`);
this.reportProgress(table.name, "failed", `失败 (重试 ${retryAttempts} 次后): ${lastError?.message || '未知错误'}`);
return { success: false, response: null, error: lastError, retryAttempts };
}
/**
* 延迟函数
* @param {number} ms 毫秒
* @returns {Promise<void>}
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 填充单个表格
* @param {Object} task 任务对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} originalOptions 原始选项
* @returns {Promise<{success: boolean, response: string|null, error: Error|null}>}
*/
async fillSingleTable(task, originalMessages, originalOptions) {
const { table, apiConfig, abortController, prebuiltMessages } = task;
this.abortControllers.set(table.name, abortController);
this.reportProgress(table.name, "started", "开始处理");
try {
// 检查 API 配置
if (!apiConfig.apiUrl || !apiConfig.model) {
throw new Error(`表格 ${table.name} 未配置有效的 API`);
}
// 使用预构建的 messages如果有否则重新构建
const messages = prebuiltMessages || buildPromptForTable(
table,
originalMessages,
this.config,
);
this.reportProgress(table.name, "calling", "正在调用 API");
// 准备 API 配置
const finalConfig = {
...apiConfig,
apiFormat: apiConfig.apiFormat || "openai",
source: "table_filler",
taskId: `table_${table.name}`,
};
// 调用 API内部不再重试由外层 fillSingleTableWithRetry 控制)
const response = await APIAdapter.callWithMessages(
finalConfig,
null, // systemPrompt 已在 messages 中
messages,
`table_${table.name}`,
0, // 不在这里重试,由外层控制
abortController.signal,
);
this.reportProgress(table.name, "completed", "处理完成");
Logger.log(`[ParallelExecutor] 表格 ${table.name} 填充成功`);
return { success: true, response, error: null };
} catch (error) {
this.reportProgress(
table.name,
"failed",
`失败: ${error.message}`,
);
Logger.error(
`[ParallelExecutor] 表格 ${table.name} 填充失败:`,
error,
);
return { success: false, response: null, error };
} finally {
this.abortControllers.delete(table.name);
}
}
/**
* 取消所有任务
*/
abortAll() {
Logger.log("[ParallelExecutor] 取消所有任务");
this.abortControllers.forEach((controller, tableName) => {
controller.abort();
this.reportProgress(tableName, "aborted", "已取消");
});
this.abortControllers.clear();
}
/**
* 取消特定表格的任务
* @param {string} tableName 表格名称
*/
abortTable(tableName) {
const controller = this.abortControllers.get(tableName);
if (controller) {
controller.abort();
this.abortControllers.delete(tableName);
this.reportProgress(tableName, "aborted", "已取消");
Logger.log(`[ParallelExecutor] 取消表格 ${tableName} 的任务`);
}
}
/**
* 获取正在处理的表格列表
* @returns {Array<string>}
*/
getProcessingTables() {
return Array.from(this.abortControllers.keys());
}
}
export default ParallelExecutor;

View File

@@ -0,0 +1,335 @@
/**
* 提示词处理器
* 支持独立提示词和共享提示词两种模式
* @module table-filler/prompt-handler
*/
import Logger from "@core/logger";
/**
* 提示词处理模式
*/
export const PromptMode = {
INDEPENDENT: "independent", // 独立提示词:精准替换 ruleTemplate 和 flowTemplate
SHARED: "shared", // 共享提示词:保留原提示词,只替换表格数据 + 添加聚焦指令
};
/**
* 表格与思考阶段对应关系
*/
export const TABLE_PHASE_MAP = {
角色表: { phase: 2, name: "角色表检查" },
关系表: { phase: 3, name: "关系表检查" },
物品表: { phase: 4, name: "物品表检查" },
组织表: { phase: 5, name: "组织表检查" },
地点表: { phase: 6, name: "地点表检查" },
能力表: { phase: 7, name: "能力表检查" },
任务表: { phase: 8, name: "任务表检查" },
};
/**
* 为单个表格构建提示词
* @param {Object} table 表格对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
export function buildPromptForTable(table, originalMessages, config) {
if (config.promptMode === PromptMode.INDEPENDENT) {
// 独立模式:优先使用 V2按名称存储的模板回退到 V1导入的预设
if (config.independentTemplates?.[table.name]) {
return buildIndependentPromptV2(table, originalMessages, config);
}
// 回退到 V1兼容旧版导入的预设
return buildIndependentPrompt(table, originalMessages, config);
} else {
return buildSharedPrompt(table, originalMessages, config);
}
}
/**
* 独立提示词模式
* 精准替换 ruleTemplate 和 flowTemplate保留其他所有内容
* @param {Object} table 表格对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
function buildIndependentPrompt(table, originalMessages, config) {
const tableConfig = config.importedPreset?.tablePresets?.[table.name];
if (
!tableConfig?.batchFillerRuleTemplate ||
!tableConfig?.batchFillerFlowTemplate
) {
Logger.warn(
`[PromptHandler] 表格 ${table.name} 未配置独立提示词,回退到共享模式`,
);
return buildSharedPrompt(table, originalMessages, config);
}
// 复制原始 messages精准替换特定内容
return originalMessages.map((msg) => {
const content = msg.content;
// 识别并替换 ruleTemplate通过特征标记识别
if (isRuleTemplateMessage(content)) {
return {
...msg,
content: tableConfig.batchFillerRuleTemplate,
};
}
// 识别并替换 flowTemplate通过特征标记识别
if (isFlowTemplateMessage(content)) {
// 使用表格专属的 flowTemplate替换表格数据占位符
const newFlowContent =
tableConfig.batchFillerFlowTemplate.replace(
"{{{Amily2TableData}}}",
table.fullContent,
);
return {
...msg,
content: newFlowContent,
};
}
// 其他消息preset prompts、worldbook、coreContent保持不变
return msg;
});
}
/**
* 共享提示词模式
* 复用通用提示词,添加聚焦指令让 AI 只处理当前表格
* @param {Object} table 表格对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
function buildSharedPrompt(table, originalMessages, config) {
const phaseInfo = TABLE_PHASE_MAP[table.name];
// 表格名列表(用于移除其他表格)
const allTableNames = Object.keys(TABLE_PHASE_MAP);
// 标记是否已添加聚焦指令(只添加一次)
let focusInstructionAdded = false;
// 深拷贝原始 messages确保并发处理时不会相互影响
const messages = originalMessages.map((msg) => {
// 创建消息的深拷贝
const newMsg = { ...msg };
let content = newMsg.content;
if (!content) return newMsg;
// 移除其他表格的数据,只保留当前表格
for (const tableName of allTableNames) {
if (tableName === table.name) continue;
// 移除 "* N:表格名" 开头的完整块(包含【说明】【增加】【删除】【修改】和<表格名内容>
// 匹配从 "* 数字:表格名" 开始,到下一个 "* 数字:" 或 "</需要更新的旧表格>" 之前的所有内容
const fullBlockRegex = new RegExp(
`\\* \\d+:${tableName}[\\s\\S]*?(?=\\n\\* \\d+:|</需要更新的旧表格>)`,
'g'
);
content = content.replace(fullBlockRegex, '');
// 备用:移除独立的 <表格名内容>...</表格名内容> 格式(如果不在 * N: 块内)
const contentTagRegex = new RegExp(`<${tableName}内容>[\\s\\S]*?<\\/${tableName}内容>`, 'g');
content = content.replace(contentTagRegex, '');
// 备用:移除独立的 <表格名>...</表格名> 格式
const simpleTagRegex = new RegExp(`<${tableName}>[\\s\\S]*?<\\/${tableName}>`, 'g');
content = content.replace(simpleTagRegex, '');
}
// 清理多余的空行
content = content.replace(/\n{3,}/g, '\n\n');
// 在 flowTemplate 消息中添加聚焦指令(只添加一次)
if (!focusInstructionAdded && isFlowTemplateMessage(content)) {
content = addFocusInstruction(content, table.name, phaseInfo, table.index);
focusInstructionAdded = true;
}
newMsg.content = content;
return newMsg;
});
return messages;
}
/**
* 识别 ruleTemplate 消息
* 通过 Amily2 ruleTemplate 的特征标记识别
* @param {string} content 消息内容
* @returns {boolean}
*/
function isRuleTemplateMessage(content) {
if (!content) return false;
return (
content.includes("酒馆国家协议") ||
content.includes("酒馆国家的臣民") ||
content.includes("Amily需要严格遵守以下规则")
);
}
/**
* 识别 flowTemplate 消息
* 通过 Amily2 flowTemplate 的特征标记识别
* @param {string} content 消息内容
* @returns {boolean}
*/
function isFlowTemplateMessage(content) {
if (!content) return false;
return (
content.includes("# dataTable 说明") ||
content.includes("dataTable 说明") ||
content.includes("Amily2TableData") ||
content.includes("表格操作指南") ||
content.includes("insertRow(") ||
content.includes("updateRow(")
);
}
/**
* 添加聚焦指令
* 使用通用化格式,不依赖特定预设结构(如阶段号)
* @param {string} content 原始内容
* @param {string} tableName 表格名称
* @param {Object} phaseInfo 阶段信息(可选,不再强制使用)
* @param {number} tableIndex 表格索引
* @returns {string}
*/
function addFocusInstruction(content, tableName, phaseInfo, tableIndex) {
// 构建其他表格列表
const otherTables = Object.keys(TABLE_PHASE_MAP).filter(name => name !== tableName);
const otherPhases = otherTables.map(name => `阶段${TABLE_PHASE_MAP[name].phase}(${name})`).join('、');
const focusInstruction = `
##【并发模式-单表格聚焦指令】##
本次请求采用并发填表模式,你只需要处理「${tableName}」(索引: ${tableIndex})。
【重要】思考流程限制:
- 仅执行与「${tableName}」相关的思考步骤
- 完全跳过其他表格的思考步骤:${otherPhases}
- 严格按照预设中的操作函数格式和输出示例进行输出
【操作范围】
- 仅输出对「${tableName}」的操作指令
- 其他表格由并行任务处理,请勿跨表操作
##【聚焦指令结束】##
`;
// 在内容开头添加聚焦指令(确保 AI 优先看到)
return focusInstruction + "\n" + content;
}
/**
* 获取表格的阶段信息
* @param {string} tableName 表格名称
* @returns {Object|null}
*/
export function getTablePhaseInfo(tableName) {
return TABLE_PHASE_MAP[tableName] || null;
}
/**
* 独立模式 V2按名称查找模板 + 标签精准替换
*
* 处理逻辑:
* 1. 拦截 Amily 发送的内容
* 2. 从 <Instructions for filling out the form> 内的 <需要更新的旧表格> 提取表格数据并拆分(由 table-splitter.js 完成)
* 3. 把拦截内容的 <Instructions for filling out the form> 内全部清空
* 4. 用插件的独立提示词模板 + 占位符注入单个表格数据
* 5. 把第4步的结果放回第3步清空的 <Instructions for filling out the form> 标签内
*
* @param {Object} table 表格对象(含 fullContent 单表格数据)
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
export function buildIndependentPromptV2(table, originalMessages, config) {
// 按名称查找配置(而非索引)
const tableConfig = config.independentTemplates?.[table.name];
// 获取模板内容(处理可能的嵌套结构)
let templateContent = tableConfig?.template;
if (typeof templateContent === 'object' && templateContent !== null) {
// 处理嵌套结构:{ template: { template: "..." } }
templateContent = templateContent.template;
}
if (!templateContent || typeof templateContent !== 'string') {
// 无有效模板时回退到共享模式
Logger.warn(`[PromptHandler] 表格「${table.name}」模板无效或为空,回退到共享模式`);
return buildSharedPrompt(table, originalMessages, config);
}
// 标签名(支持用户自定义)
const tagName = config.independentTagName || "Instructions for filling out the form";
// 【步骤4】用插件的独立提示词模板 + 占位符注入单个表格数据
let userTemplate = templateContent;
userTemplate = userTemplate.split('{{tableData}}').join(table.fullContent || '');
userTemplate = userTemplate.split('{{tableName}}').join(table.name || '');
userTemplate = userTemplate.split('{{tableIndex}}').join(String(table.index));
// 用于跟踪是否已经注入过用户模板(只注入一次)
let templateInjected = false;
const openTag = `<${tagName}>`;
const closeTag = `</${tagName}>`;
const processedMessages = originalMessages.map((msg) => {
const content = msg.content;
if (!content) return msg;
// 使用 indexOf 查找标签位置(比正则更可靠)
const openIndex = content.indexOf(openTag);
const closeIndex = content.indexOf(closeTag);
if (openIndex !== -1 && closeIndex !== -1 && closeIndex > openIndex) {
// 找到标签,清空原内容并注入用户模板
const before = content.substring(0, openIndex + openTag.length);
const after = content.substring(closeIndex);
if (!templateInjected) {
templateInjected = true;
// 注入用户模板到标签内
const newContent = before + '\n' + userTemplate + '\n' + after;
return { ...msg, content: newContent };
} else {
// 已注入过,清空此标签内容
const newContent = before + '\n' + after;
return { ...msg, content: newContent };
}
}
// 没有找到标签,直接返回原消息
return msg;
});
// 如果模板未能生效,显示警告通知
if (!templateInjected) {
if (window.toastr) {
window.toastr.warning(
`未找到标签 <${tagName}>,模板可能未生效`,
`${table.name} 独立模式`,
{ timeOut: 5000 }
);
}
}
return processedMessages;
}
/**
* 转义正则表达式特殊字符
* @param {string} str 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,206 @@
/**
* Service 拦截器
* Hook SillyTavern 的 ConnectionManagerRequestService
* 用于拦截 sillytavern_preset 模式的 API 调用
*
* @module table-filler/service-interceptor
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
import { isTableFillerRequest } from "./fetch-interceptor";
// 原始函数引用
let originalSendRequest = null;
// 安装状态
let isInstalled = false;
// 进度回调
let progressCallback = null;
/**
* 设置进度回调
* @param {Function} callback
*/
export function setServiceInterceptorProgressCallback(callback) {
progressCallback = callback;
}
/**
* 获取 SillyTavern 上下文
* @returns {Object|null}
*/
function getSTContext() {
// 尝试多种获取方式
if (window.SillyTavern?.getContext) {
return window.SillyTavern.getContext();
}
// 尝试从 extension 模块获取
try {
const extensions = window.extension_settings;
if (extensions) {
// 尝试从任一扩展获取 context
for (const key in extensions) {
const ext = extensions[key];
if (ext?.context?.ConnectionManagerRequestService) {
return ext.context;
}
}
}
} catch (e) {
Logger.debug("[ServiceInterceptor] 从 extensions 获取 context 失败:", e.message);
}
return null;
}
/**
* 安装 Service 拦截器
* @returns {boolean} 是否成功安装
*/
export function installServiceInterceptor() {
if (isInstalled) {
Logger.log("[ServiceInterceptor] 拦截器已安装,跳过");
return true;
}
const context = getSTContext();
if (!context?.ConnectionManagerRequestService) {
Logger.debug("[ServiceInterceptor] ConnectionManagerRequestService 不可用");
return false;
}
const service = context.ConnectionManagerRequestService;
if (typeof service.sendRequest !== 'function') {
Logger.debug("[ServiceInterceptor] sendRequest 方法不存在");
return false;
}
// 保存原始函数
originalSendRequest = service.sendRequest.bind(service);
// 替换为拦截版本
service.sendRequest = async function(profileId, messages, maxTokens) {
// 仅检测并记录日志,不做并发处理
if (isTableFillerEnabled() && isTableFillerRequest(messages)) {
Logger.log("[ServiceInterceptor] ✓ 检测到表格填充请求,但暂时不启用并发(调试中)");
}
// 透传给原始函数
return originalSendRequest(profileId, messages, maxTokens);
};
isInstalled = true;
Logger.log("[ServiceInterceptor] ✓ Service 拦截器已安装");
return true;
}
/**
* 卸载 Service 拦截器
*/
export function uninstallServiceInterceptor() {
if (!isInstalled || !originalSendRequest) {
return;
}
const context = getSTContext();
if (context?.ConnectionManagerRequestService) {
context.ConnectionManagerRequestService.sendRequest = originalSendRequest;
}
originalSendRequest = null;
isInstalled = false;
Logger.log("[ServiceInterceptor] Service 拦截器已卸载");
}
/**
* 获取安装状态
* @returns {boolean}
*/
export function isServiceInterceptorInstalled() {
return isInstalled;
}
/**
* 处理 Service 并发填表
* @param {string} profileId 配置文件 ID
* @param {Array} messages 消息数组
* @param {number} maxTokens 最大 token 数
* @returns {Promise<Object>}
*/
async function handleServiceParallelFill(profileId, messages, maxTokens) {
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
const tables = splitTablesFromMessages(messages);
if (tables.length === 0) {
Logger.warn("[ServiceInterceptor] 未检测到多表格,使用原始请求");
return originalSendRequest(profileId, messages, maxTokens);
}
Logger.log(`[ServiceInterceptor] 检测到 ${tables.length} 个表格,启用并发模式`);
if (window.toastr) {
window.toastr.info(
`🚀 正在并发处理 ${tables.length} 个表格...`,
"并发填表已启动",
{ timeOut: 3000 }
);
}
const results = await executor.fillAllTables(tables, { messages }, {
profileId,
maxTokens,
originalSendRequest
});
const successCount = results.filter(r => r.success).length;
const failedCount = results.length - successCount;
if (window.toastr) {
if (failedCount === 0) {
window.toastr.success(`${successCount} 个表格全部处理成功`, "并发填表完成");
} else if (successCount > 0) {
window.toastr.warning(`⚠️ ${successCount}/${results.length} 个表格成功`, "并发填表部分完成");
} else {
window.toastr.error("❌ 所有表格处理失败", "并发填表失败");
return originalSendRequest(profileId, messages, maxTokens);
}
}
const mergedContent = mergeResults(results);
// 返回模拟的响应对象
return {
choices: [{
message: {
role: "assistant",
content: mergedContent
},
finish_reason: "stop"
}]
};
} catch (error) {
Logger.error("[ServiceInterceptor] 并发填表失败:", error);
if (window.toastr) {
window.toastr.error(`❌ 并发填表出错: ${error.message}`, "并发填表错误");
}
// 回退到原始请求
return originalSendRequest(profileId, messages, maxTokens);
}
}

View File

@@ -0,0 +1,276 @@
/**
* 表格拆分器
* 从 messages 中提取并拆分表格数据
* @module table-filler/table-splitter
*/
import Logger from "@core/logger";
/**
* 表格名称列表(用于匹配)
*/
const TABLE_NAMES = [
"角色表", "关系表", "物品表", "组织表", "地点表", "能力表", "任务表",
"时空栏", "人物表", "道具表", "势力表", "场所表", "技能表", "事件表"
];
/**
* 从 messages 中提取并拆分表格数据
* @param {Array} messages 原始消息数组
* @returns {Array<{index: number, name: string, fullContent: string}>}
*/
export function splitTablesFromMessages(messages) {
if (!messages || !Array.isArray(messages)) {
Logger.debug("[TableSplitter] messages 不是有效数组");
return [];
}
// 合并所有消息内容进行搜索
const allContent = messages.map(m => m.content || '').join('\n');
// 方法1: 尝试匹配完整格式 "* 0:角色表\n【说明】..."
let tables = extractTablesFullFormat(allContent);
if (tables.length > 0) {
Logger.log(`[TableSplitter] 使用完整格式解析,找到 ${tables.length} 个表格:`, tables.map(t => t.name));
return tables;
}
// 方法2: 尝试匹配 <表格名内容>...</表格名内容> 格式
tables = extractTablesContentTagFormat(allContent);
if (tables.length > 0) {
Logger.log(`[TableSplitter] 使用内容标签格式解析,找到 ${tables.length} 个表格:`, tables.map(t => t.name));
return tables;
}
// 方法3: 尝试匹配 <表格名>...</表格名> 简化格式
tables = extractTablesSimpleTagFormat(allContent);
if (tables.length > 0) {
Logger.log(`[TableSplitter] 使用简化标签格式解析,找到 ${tables.length} 个表格:`, tables.map(t => t.name));
return tables;
}
Logger.debug("[TableSplitter] 未找到可解析的表格数据");
return [];
}
/**
* 提取完整格式表格
* 格式: * 0:角色表\n【说明】: ...\n<角色表内容>...\n【增加】: ...
* @param {string} content 内容
* @returns {Array}
*/
function extractTablesFullFormat(content) {
const tables = [];
// 匹配 "* 数字:表格名" 开头的块
// 使用更精确的边界:下一个表格块、结束标签
const tableRegex = /\* (\d+):([^\n]+)\n([\s\S]*?)(?=\* \d+:|<\/需要更新的旧表格>|$)/g;
let match;
while ((match = tableRegex.exec(content)) !== null) {
const tableName = match[2].trim();
// 验证是否是有效的表格名
if (TABLE_NAMES.some(name => tableName.includes(name) || name.includes(tableName))) {
tables.push({
index: parseInt(match[1]),
name: tableName,
fullContent: match[0].trim(),
});
}
}
return tables;
}
/**
* 提取内容标签格式表格
* 格式: <角色表内容>...</角色表内容>
* @param {string} content 内容
* @returns {Array}
*/
function extractTablesContentTagFormat(content) {
const tables = [];
// 匹配 <表格名内容>...</表格名内容>
for (let i = 0; i < TABLE_NAMES.length; i++) {
const tableName = TABLE_NAMES[i];
const regex = new RegExp(`<${tableName}内容>([\\s\\S]*?)<\\/${tableName}内容>`, 'g');
let match;
while ((match = regex.exec(content)) !== null) {
tables.push({
index: i,
name: tableName,
fullContent: match[0],
tableData: match[1].trim(),
});
}
}
return tables;
}
/**
* 提取简化标签格式表格
* 格式: <角色表>...</角色表>
* @param {string} content 内容
* @returns {Array}
*/
function extractTablesSimpleTagFormat(content) {
const tables = [];
// 匹配 <表格名>...</表格名>(排除 <表格名内容> 格式)
for (let i = 0; i < TABLE_NAMES.length; i++) {
const tableName = TABLE_NAMES[i];
// 使用负向先行断言排除 "内容>" 结尾
const regex = new RegExp(`<${tableName}>([\\s\\S]*?)<\\/${tableName}>`, 'g');
let match;
while ((match = regex.exec(content)) !== null) {
// 确保不是 <表格名内容> 格式
if (!match[0].includes(`<${tableName}内容>`)) {
tables.push({
index: i,
name: tableName,
fullContent: match[0],
tableData: match[1].trim(),
});
}
}
}
return tables;
}
/**
* 为单个表格构建独立的 messages
* 保留该表格的数据,移除其他表格的数据
* @param {Array} originalMessages 原始消息数组
* @param {Object} singleTable 单个表格对象
* @returns {Array}
*/
export function buildSingleTableMessages(originalMessages, singleTable) {
return originalMessages.map((msg) => {
let content = msg.content;
if (!content) return msg;
// 移除其他表格的数据,只保留当前表格
for (const tableName of TABLE_NAMES) {
if (tableName === singleTable.name) continue;
// 移除 <表格名内容>...</表格名内容> 格式
const contentTagRegex = new RegExp(`<${tableName}内容>[\\s\\S]*?<\\/${tableName}内容>`, 'g');
content = content.replace(contentTagRegex, '');
// 移除 <表格名>...</表格名> 格式
const simpleTagRegex = new RegExp(`<${tableName}>[\\s\\S]*?<\\/${tableName}>`, 'g');
content = content.replace(simpleTagRegex, '');
// 移除 "* N:表格名" 开头的完整块(包含【说明】【增加】【删除】【修改】和<表格名内容>
// 匹配从 "* 数字:表格名" 开始,到下一个 "* 数字:" 或 "</需要更新的旧表格>" 之前的所有内容
const fullBlockRegex = new RegExp(
`\\* \\d+:${tableName}[\\s\\S]*?(?=\\n\\* \\d+:|</需要更新的旧表格>)`,
'g'
);
content = content.replace(fullBlockRegex, '');
}
// 清理多余的空行
content = content.replace(/\n{3,}/g, '\n\n');
return { ...msg, content };
});
}
/**
* 从 AI 响应中提取 Amily2Edit 指令
* @param {string} response AI 响应文本
* @returns {string|null}
*/
export function extractCommands(response) {
if (!response) return null;
// 尝试匹配带注释的格式: <Amily2Edit><!--...--></Amily2Edit>
let match = response.match(
/<Amily2Edit>\s*<!--([\s\S]*?)-->\s*<\/Amily2Edit>/,
);
if (match) {
return match[1].trim();
}
// 尝试匹配不带注释的格式: <Amily2Edit>...</Amily2Edit>
match = response.match(
/<Amily2Edit>([\s\S]*?)<\/Amily2Edit>/,
);
if (match) {
// 如果内容被注释包裹,提取注释内的内容
const content = match[1].trim();
const commentMatch = content.match(/<!--([\s\S]*?)-->/);
if (commentMatch) {
return commentMatch[1].trim();
}
return content;
}
return null;
}
/**
* 合并所有表格的 AI 响应
* @param {Array} results 表格填充结果数组
* @returns {string}
*/
export function mergeResults(results) {
const successResults = results.filter((r) => r.success);
if (successResults.length === 0) {
throw new Error("所有表格填充均失败");
}
// 统计成功和失败
const successCount = successResults.length;
const failedTables = results
.filter((r) => !r.success)
.map((r) => r.tableName);
if (failedTables.length > 0) {
Logger.warn(
`[TableSplitter] 部分表格填充失败: ${failedTables.join(", ")}`,
);
}
Logger.log(
`[TableSplitter] 合并结果: ${successCount}/${results.length} 个表格成功`,
);
// 提取所有 <Amily2Edit> 块中的指令
const allCommands = [];
for (const r of successResults) {
const commands = extractCommands(r.response);
if (commands) {
allCommands.push(commands);
}
}
// 合并所有指令
const mergedCommands = allCommands.join("\n");
// 重新包装为 Amily2 期望的格式
return `<Amily2Edit>\n<!--\n${mergedCommands}\n-->\n</Amily2Edit>`;
}
/**
* 获取原始 messages 中的对话记录
* @param {Array} messages 消息数组
* @returns {string}
*/
export function extractDialogContent(messages) {
const userMsg = messages.find(
(m) => m.role === "user" && m.content?.includes("<对话记录>"),
);
if (!userMsg) return "";
const match = userMsg.content.match(/<对话记录>([\s\S]*?)<\/对话记录>/);
return match ? match[1].trim() : userMsg.content;
}

View File

@@ -4,14 +4,16 @@
*/
import Logger from '@core/logger';
import { getGlobalSettings, getGlobalConfig, getSummaryConfig } from '@config/config-manager';
import { getGlobalSettings, getGlobalConfig, getSummaryConfig, isSummaryAutoSplitEnabled, getSummaryAutoSplitConfig, getSummaryPartConfigs, getSummaryPartApiConfig, isSummaryMergeDeduplicateEnabled } from '@config/config-manager';
import { getImportedBookNames } from '@config/imported-books';
import { getImportedWorldBooks, classifyWorldBooks, isSummaryBook } from '@worldbook/api';
import { getSummaryContent } from '@worldbook/parser';
import { analyzeSummaryContent, needsSplit } from '@worldbook/summary-splitter';
import APIAdapter from '@api/adapter';
import { getHistoricalPromptTemplate } from '@utils/prompt-template';
import { buildDataInjection, injectDataToPrompt, replacePromptVariables, buildUserPrompt } from '@memory/prompt-builder';
import { getJailbreakPrefix } from '@memory/jailbreak';
import { isPartDebugEnabled, showPartDebugModal } from '@memory/part-debug-modal';
// 进度追踪器引用(将在初始化时设置)
let progressTracker = null;
@@ -382,7 +384,7 @@ export class MemorySearchPanel {
msg.innerHTML = `
<div class="mm-search-result-item" data-result-id="${resultId}" data-book-name="${this.escapeHtml(bookName)}">
<div class="mm-search-result-header">
<span class="mm-search-result-floor">${this.escapeHtml(floor)}楼】</span>
<span class="mm-search-result-floor">${this.escapeHtml(floor.startsWith('【') ? floor : `${floor}楼】`)}</span>
<div class="mm-search-result-actions">
<button class="mm-btn mm-btn-adopt mm-search-adopt-btn">
<i class="fa-solid fa-check"></i> 采纳
@@ -539,6 +541,17 @@ export class MemorySearchPanel {
booksContainer.style.height = `${newHeight}px`;
booksContainer.style.minHeight = `${newHeight}px`;
booksContainer.style.maxHeight = `${newHeight}px`;
// 同步更新内部内容区域的最大高度
const bookContents = booksContainer.querySelectorAll('.mm-search-book-content');
const headerHeight = 45; // 每个世界书头部的大约高度
const bookCount = bookContents.length || 1;
// 计算每个内容区域可用的高度(减去头部高度后平分)
const contentMaxHeight = Math.max(100, (newHeight - headerHeight * bookCount) / bookCount);
bookContents.forEach(content => {
content.style.maxHeight = `${contentMaxHeight}px`;
});
e.preventDefault();
};
@@ -716,7 +729,7 @@ export class MemorySearchPanel {
msg.innerHTML = `
<div class="mm-search-result-item" data-result-id="${resultId}">
<div class="mm-search-result-header">
<span class="mm-search-result-floor">【${floor}楼】</span>
<span class="mm-search-result-floor">${String(floor).startsWith('【') ? floor : `${floor}楼】`}</span>
<div class="mm-search-result-actions">
<button class="mm-btn mm-btn-adopt mm-search-adopt-btn">
<i class="fa-solid fa-check"></i> 采纳
@@ -901,7 +914,9 @@ export class MemorySearchPanel {
const floor = m.uid || m.key || "未知";
const content = m.content || "";
if (content.trim()) {
historicalLines.push(`${floor}楼】${content}`);
// 如果 floor 已经是完整标签格式,直接使用
const floorTag = String(floor).startsWith('【') ? floor : `${floor}楼】`;
historicalLines.push(`${floorTag}${content}`);
}
}
}
@@ -1229,45 +1244,169 @@ async function callHistoricalMemoryAI(panel, userMessage, context) {
}
/**
* 调用单个总结世界书的 AI
* 调用单个总结世界书的 AI(支持拆分模式)
*/
async function callSingleSummaryBookAI(panel, book, userMessage, context) {
const bookName = book.name;
try {
// 检查是否启用拆分
const splitEnabled = isSummaryAutoSplitEnabled();
const summaryContent = getSummaryContent(book);
if (splitEnabled) {
const splitConfig = getSummaryAutoSplitConfig();
const shouldSplit = needsSplit(summaryContent, splitConfig.targetChars);
if (shouldSplit) {
// 拆分模式:并发处理多个 Part
await callSummaryBookWithSplit(panel, book, userMessage, context, summaryContent, splitConfig);
return;
}
}
// 非拆分模式:单个 API 调用
await callSummaryBookSingle(panel, book, userMessage, context, summaryContent);
} catch (error) {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" 初始化失败:`, error.message);
panel.setBookStatus(bookName, "error", "失败");
panel.addBookSystemMessage(bookName, `初始化失败: ${error.message}`);
}
}
/**
* 单个 API 调用处理总结世界书(非拆分模式)
*/
async function callSummaryBookSingle(panel, book, userMessage, context, summaryContent) {
const bookName = book.name;
const taskId = `search_${bookName}`;
const abortController = new AbortController();
panel.setBookStatus(bookName, "loading", "调用AI中...");
panel.addBookAIMessage(bookName, "正在调用历史事件回忆AI...");
const aiConfig = getSummaryConfig(bookName);
const globalConfig = getGlobalConfig();
const dataInjection = buildDataInjection({
worldBookContent: summaryContent,
context: context || "",
userMessage: userMessage,
});
const template = await getHistoricalPromptTemplate();
const jailbreakPrefix = getJailbreakPrefix();
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
const finalSystemPrompt = replacePromptVariables(prompt.systemPrompt, aiConfig, globalConfig);
const finalUserMessage = buildUserPrompt(userMessage);
if (progressTracker) {
progressTracker.addTask(taskId, `搜索:${bookName}`, "search");
progressTracker.setTaskAbortController(taskId, abortController);
}
try {
panel.setBookStatus(bookName, "loading", "调用AI中...");
panel.addBookAIMessage(bookName, "正在调用历史事件回忆AI...");
const aiConfig = getSummaryConfig(bookName);
const globalConfig = getGlobalConfig();
const summaryContent = getSummaryContent(book);
const dataInjection = buildDataInjection({
worldBookContent: summaryContent,
context: context || "",
userMessage: userMessage,
});
const template = await getHistoricalPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(prompt.systemPrompt, aiConfig, globalConfig);
const finalSystemPrompt = getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
const finalUserMessage = buildUserPrompt(userMessage);
const response = await APIAdapter.callWithRetry(
{
...aiConfig,
category: bookName,
source: bookName,
taskId: taskId,
},
finalSystemPrompt,
finalUserMessage,
taskId,
3,
abortController.signal
);
if (progressTracker) {
progressTracker.addTask(taskId, `搜索:${bookName}`, "search");
progressTracker.completeTask(taskId, true);
}
const events = parseHistoricalEvents(response);
displaySearchResults(panel, bookName, events);
} catch (error) {
handleSearchError(panel, bookName, taskId, error);
}
}
/**
* 拆分模式:并发处理多个 Part
*/
async function callSummaryBookWithSplit(panel, book, userMessage, context, summaryContent, splitConfig) {
const bookName = book.name;
// 分析拆分方案
const parts = analyzeSummaryContent(summaryContent, splitConfig);
if (parts.length <= 1) {
// 内容不足以拆分,使用单个 API
await callSummaryBookSingle(panel, book, userMessage, context, summaryContent);
return;
}
panel.setBookStatus(bookName, "loading", `并发处理 ${parts.length} 个Part...`);
panel.addBookAIMessage(bookName, `内容已拆分为 ${parts.length} 个Part正在并发调用AI...`);
// 获取配置
const partConfigs = getSummaryPartConfigs(bookName);
const originalConfig = getSummaryConfig(bookName);
const globalConfig = getGlobalConfig();
// 并发处理所有 Part
const partPromises = parts.map(async (part) => {
// Part 1index=0复用原配置其他 Part 使用各自的配置
let partConfig;
if (part.index === 0) {
partConfig = originalConfig;
} else {
partConfig = getSummaryPartApiConfig(bookName, part.id);
}
if (!partConfig || !partConfig.enabled) {
Logger.warn(`[记忆搜索助手] Part "${part.id}" 未配置,跳过`);
return { partId: part.id, success: false, error: "未配置", events: [] };
}
const taskId = `search_${bookName}_${part.id}`;
const abortController = new AbortController();
if (progressTracker) {
progressTracker.addTask(taskId, `搜索:${bookName} Part${part.index + 1}`, "search");
progressTracker.setTaskAbortController(taskId, abortController);
}
try {
const partContent = `=== Part ${part.id} (${part.startFloor}-${part.endFloor}楼) ===\n${part.content}`;
const dataInjection = buildDataInjection({
worldBookContent: partContent,
context: context || "",
userMessage: userMessage,
});
const template = await getHistoricalPromptTemplate();
const jailbreakPrefix = getJailbreakPrefix();
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
const finalSystemPrompt = replacePromptVariables(prompt.systemPrompt, partConfig, globalConfig);
const finalUserMessage = buildUserPrompt(userMessage);
const response = await APIAdapter.callWithRetry(
{
...aiConfig,
...partConfig,
category: bookName,
source: bookName,
source: `${bookName} Part${part.index + 1}`,
taskId: taskId,
},
finalSystemPrompt,
@@ -1282,39 +1421,163 @@ async function callSingleSummaryBookAI(panel, book, userMessage, context) {
}
const events = parseHistoricalEvents(response);
if (events.length === 0) {
panel.setBookStatus(bookName, "success", "无结果");
panel.addBookSystemMessage(bookName, "AI未返回历史事件请尝试自定义搜索");
} else {
panel.setBookStatus(bookName, "success", `${events.length}`);
panel.addBookAIMessage(bookName, `AI返回 ${events.length} 条历史事件:`);
for (const event of events) {
panel.addBookSearchResult(bookName, {
uid: event.floor,
content: event.content,
});
}
}
return {
partId: part.id,
partIndex: part.index,
success: true,
rawMemory: response,
events: events,
};
} catch (error) {
const isAborted = error.name === "AbortError";
if (progressTracker) {
progressTracker.completeTask(taskId, false, isAborted ? "已终止" : error.message);
}
if (isAborted) {
Logger.warn(`[记忆搜索助手] 总结世界书 "${bookName}" 已被终止`);
panel.setBookStatus(bookName, "error", "已终止");
panel.addBookSystemMessage(bookName, "搜索已被用户终止");
} else {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" AI调用失败:`, error.message);
panel.setBookStatus(bookName, "error", "失败");
panel.addBookSystemMessage(bookName, `AI调用失败: ${error.message}`);
return {
partId: part.id,
partIndex: part.index,
success: false,
error: isAborted ? "已终止" : error.message,
events: [],
};
}
});
const partResults = await Promise.all(partPromises);
// 合并结果
const mergedEvents = mergePartEventsForSearch(partResults);
// 显示调试弹窗(如果启用)
if (isPartDebugEnabled()) {
const debugResults = partResults.map(r => ({
partId: r.partId,
rawMemory: r.rawMemory || `(${r.error || '无返回'})`,
}));
const mergedResult = {
rawMemory: mergedEvents.map(e => {
const floorTag = String(e.floor).startsWith('【') ? e.floor : `${e.floor}楼】`;
return `${floorTag}${e.content}`;
}).join('\n'),
eventCount: mergedEvents.length,
};
showPartDebugModal(debugResults, bookName, mergedResult);
}
// 统计结果
const successCount = partResults.filter(r => r.success).length;
const failCount = partResults.length - successCount;
if (mergedEvents.length === 0) {
panel.setBookStatus(bookName, failCount > 0 ? "error" : "success", "无结果");
panel.addBookSystemMessage(bookName, `${successCount}/${parts.length} 个Part成功AI未返回历史事件`);
} else {
panel.setBookStatus(bookName, "success", `${mergedEvents.length}`);
panel.addBookAIMessage(bookName, `${successCount}/${parts.length} 个Part成功共返回 ${mergedEvents.length} 条历史事件:`);
for (const event of mergedEvents) {
panel.addBookSearchResult(bookName, {
uid: event.floor,
content: event.content,
});
}
}
}
/**
* 合并多个 Part 的搜索结果
*/
function mergePartEventsForSearch(partResults) {
const deduplicateEnabled = isSummaryMergeDeduplicateEnabled();
const allEvents = [];
// 收集所有事件(保持原始顺序)
for (const result of partResults) {
if (result.success && result.events) {
for (const event of result.events) {
allEvents.push({
floor: event.floor,
content: event.content,
sourcePartId: result.partId,
});
}
}
} catch (error) {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" 初始化失败:`, error.message);
}
if (deduplicateEnabled) {
// 去重模式:同一楼层只保留内容最长的
const floorBestEvent = new Map();
for (const event of allEvents) {
const existing = floorBestEvent.get(event.floor);
if (!existing || event.content.length > existing.content.length) {
floorBestEvent.set(event.floor, event);
}
}
// 按原始出现顺序输出(使用第一次出现的顺序)
const seenFloors = new Set();
const uniqueEvents = [];
for (const event of allEvents) {
if (!seenFloors.has(event.floor)) {
seenFloors.add(event.floor);
uniqueEvents.push(floorBestEvent.get(event.floor));
}
}
return uniqueEvents;
} else {
// 不去重模式:相同楼层的内容放在一起
const floorGroups = new Map();
const floorOrder = [];
for (const event of allEvents) {
if (!floorGroups.has(event.floor)) {
floorGroups.set(event.floor, []);
floorOrder.push(event.floor);
}
floorGroups.get(event.floor).push(event);
}
const finalEvents = [];
for (const floor of floorOrder) {
finalEvents.push(...floorGroups.get(floor));
}
return finalEvents;
}
}
/**
* 显示搜索结果
*/
function displaySearchResults(panel, bookName, events) {
if (events.length === 0) {
panel.setBookStatus(bookName, "success", "无结果");
panel.addBookSystemMessage(bookName, "AI未返回历史事件请尝试自定义搜索");
} else {
panel.setBookStatus(bookName, "success", `${events.length}`);
panel.addBookAIMessage(bookName, `AI返回 ${events.length} 条历史事件:`);
for (const event of events) {
panel.addBookSearchResult(bookName, {
uid: event.floor,
content: event.content,
});
}
}
}
/**
* 处理搜索错误
*/
function handleSearchError(panel, bookName, taskId, error) {
const isAborted = error.name === "AbortError";
if (progressTracker) {
progressTracker.completeTask(taskId, false, isAborted ? "已终止" : error.message);
}
if (isAborted) {
Logger.warn(`[记忆搜索助手] 总结世界书 "${bookName}" 已被终止`);
panel.setBookStatus(bookName, "error", "已终止");
panel.addBookSystemMessage(bookName, "搜索已被用户终止");
} else {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" AI调用失败:`, error.message);
panel.setBookStatus(bookName, "error", "失败");
panel.addBookSystemMessage(bookName, `初始化失败: ${error.message}`);
panel.addBookSystemMessage(bookName, `AI调用失败: ${error.message}`);
}
}
@@ -1334,11 +1597,16 @@ function parseHistoricalEvents(response) {
for (const line of lines) {
const trimmed = line.trim();
const floorMatch = trimmed.match(/^【(\d+)楼】(.*)$/);
// 兼容多种楼层格式【124楼】、【124至#125】、【124至125楼】
// 捕获完整的楼层标签和内容
const floorMatch = trimmed.match(/^(【\d+(?:楼|至#?\d+楼?)】)(.*)$/);
if (floorMatch) {
// 保留完整的楼层标签(如 【124至#125】
const floorTag = floorMatch[1];
const content = floorMatch[2] || '';
events.push({
floor: floorMatch[1],
content: floorMatch[2].trim(),
floor: floorTag,
content: content.trim(),
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,13 @@ import {
bindWorldbookControlEvents,
} from './components/worldbook-control';
// 导入表格填表模块
import {
initTableFillerUI,
bindTableFillerEvents,
updateTableFillerBadge,
} from './components/table-filler';
// 导入配置弹窗模块中的函数(用于直接调用而非函数注入)
import {
saveConfig as saveConfigModal,
@@ -65,6 +72,12 @@ export {
toggleRecursionSetting,
};
export {
initTableFillerUI,
bindTableFillerEvents,
updateTableFillerBadge,
};
// 函数注入存储(用于在 index.js 中设置)
let togglePanelFn = null;
let showWorldBookSelectorFn = null;
@@ -106,6 +119,9 @@ let importPromptFileFn = null;
let exportPromptFileFn = null;
let switchPromptTypeFn = null;
// 总结世界书Part配置相关函数
let showSummaryPartConfigModalFn = null;
// 设置函数导出
export function setTogglePanelFunction(fn) { togglePanelFn = fn; }
export function setWorldBookSelectorFunction(fn) { showWorldBookSelectorFn = fn; }
@@ -155,6 +171,11 @@ export function setPromptEditorFunctions(show, hide, save, saveAs, del, restore,
switchPromptTypeFn = switchType;
}
// 总结世界书Part配置设置函数
export function setSummaryPartConfigModalFunction(fn) {
showSummaryPartConfigModalFn = fn;
}
// 兼容旧版导出名称
export function setSettingsFunctions(showFn, hideFn) {
// 设置面板直接通过 CSS 类切换,不需要回调
@@ -927,7 +948,21 @@ function bindWorldBookListEvents() {
if (editBtn) {
const category = editBtn.dataset.category;
const type = editBtn.dataset.type || "memory";
if (showConfigModalFn) showConfigModalFn(category, type);
// 检查是否有 Part 信息(总结世界书拆分模式)
let partInfo = null;
if (editBtn.dataset.partId) {
partInfo = {
partId: editBtn.dataset.partId,
partIndex: parseInt(editBtn.dataset.partIndex || "0", 10),
startFloor: parseInt(editBtn.dataset.startFloor || "0", 10),
endFloor: parseInt(editBtn.dataset.endFloor || "0", 10),
charCount: parseInt(editBtn.dataset.charCount || "0", 10),
bookName: editBtn.dataset.bookName || category,
};
}
if (showConfigModalFn) showConfigModalFn(category, type, partInfo);
return;
}
@@ -951,6 +986,20 @@ function bindWorldBookListEvents() {
}
return;
}
// 编辑Part配置
const editPartBtn = e.target.closest('[data-action="edit-part-config"]');
if (editPartBtn) {
const bookName = editPartBtn.dataset.book;
const partId = editPartBtn.dataset.partId;
Logger.log(`[Events] 点击Part配置: book=${bookName}, partId=${partId}, fn=${!!showSummaryPartConfigModalFn}`);
if (showSummaryPartConfigModalFn) {
showSummaryPartConfigModalFn(bookName, partId);
} else {
Logger.warn('[Events] showSummaryPartConfigModalFn 未设置');
}
return;
}
});
}
@@ -1570,6 +1619,9 @@ export function loadGlobalSettingsUI() {
// 初始化标签过滤 UI
initTagFilterUI(settings.contextTagFilter);
// 初始化表格填表 UI
initTableFillerUI();
}
/**
@@ -1796,6 +1848,57 @@ export function bindEvents() {
bindWorldbookControlEvents();
bindGameEvents();
bindMultiAIEvents();
bindTableFillerEvents();
bindSummaryAutoSplitEvents();
Logger.log("UI 事件绑定完成");
}
/**
* 绑定总结世界书自动拆分事件
*/
function bindSummaryAutoSplitEvents() {
// 使用事件委托处理动态创建的开关
document.addEventListener("change", (e) => {
if (e.target.id === "mm-summary-auto-split-toggle") {
const enabled = e.target.checked;
import('@config/config-manager').then(({ setSummaryAutoSplitEnabled }) => {
setSummaryAutoSplitEnabled(enabled);
// 刷新世界书列表以更新Part显示
refreshWorldBookList();
Logger.log(`[SummaryAutoSplit] 自动拆分已${enabled ? '启用' : '禁用'}`);
});
}
// Part 调试模式开关
if (e.target.id === "mm-summary-part-debug-toggle") {
const enabled = e.target.checked;
import('@memory/part-debug-modal').then(({ setPartDebugEnabled }) => {
setPartDebugEnabled(enabled);
if (typeof toastr !== "undefined") {
if (enabled) {
toastr.info("已启用调试模式处理完成后将显示各Part返回内容", "Part调试");
} else {
toastr.info("已关闭调试模式", "Part调试");
}
}
});
}
// 合并去重开关
if (e.target.id === "mm-summary-merge-deduplicate-toggle") {
const enabled = e.target.checked;
import('@config/config-manager').then(({ setSummaryMergeDeduplicateEnabled }) => {
setSummaryMergeDeduplicateEnabled(enabled);
if (typeof toastr !== "undefined") {
if (enabled) {
toastr.info("已启用去重,同一楼层只保留第一个", "合并去重");
} else {
toastr.info("已关闭去重,相同楼层内容会放在一起", "合并去重");
}
}
});
}
});
}

View File

@@ -88,6 +88,7 @@ export {
setRefreshAIConfigListFunction,
setFlowConfigFunctions,
setPromptEditorFunctions,
setSummaryPartConfigModalFunction,
// 标签过滤
initTagFilterUI,
updateTagFilterBadge,
@@ -179,6 +180,8 @@ export {
restoreDefaultPrompt,
switchPromptType,
bindPromptEditorEvents,
// 总结世界书Part配置弹窗
showSummaryPartConfigModal,
} from './modals';
// 剧情优化助手面板

View File

@@ -63,9 +63,11 @@ export function showClearDataConfirmModal() {
<ul style="margin: 0 0 16px 20px; padding: 0; line-height: 1.8; color: var(--mm-text-muted);">
<li><i class="fa-solid fa-robot" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>记忆分类 API 配置</li>
<li><i class="fa-solid fa-scroll" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>总结世界书 API 配置</li>
<li><i class="fa-solid fa-puzzle-piece" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>总结世界书拆分 API 配置Part 配置)</li>
<li><i class="fa-solid fa-layer-group" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>索引合并 API 配置</li>
<li><i class="fa-solid fa-wand-magic-sparkles" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>剧情优化 API 配置</li>
<li><i class="fa-solid fa-users" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>多AI生成的 API 配置(但会解除其提示词预设关联)</li>
<li><i class="fa-solid fa-table" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>Amily表格并发 API 配置(但会清除导入的预设)</li>
</ul>
</div>

View File

@@ -12,10 +12,15 @@ import {
setMemoryConfig,
setSummaryConfig,
updateGlobalSettings,
setSummaryPartApiConfig,
isSummaryAutoSplitEnabled,
getSummaryAutoSplitConfig,
} from "@config/config-manager";
import Logger from "@core/logger";
import { refreshWorldBookList } from "@worldbook/refresh";
import { getWorldBookList, getWorldBookEntries } from "@worldbook/api";
import { analyzeSummaryContent, formatCharCount } from "@worldbook/summary-splitter";
import { getSummaryContent } from "@worldbook/parser";
// 更新显示回调函数(将在初始化时注入)
let updateIndexMergeModelDisplayFn = null;
@@ -34,6 +39,9 @@ export function setUpdateDisplayFunctions(indexMergeFn, plotOptimizeFn, refreshC
// 当前编辑状态
let currentEditingCategory = null;
let currentEditingType = null;
// Part 编辑状态(用于总结世界书拆分)
let currentEditingPartId = null;
let currentEditingPartInfo = null;
// 剧情优化配置中选中的世界书和条目(临时状态)
let plotConfigSelectedBooks = new Set();
@@ -42,6 +50,15 @@ let plotConfigSelectedEntries = {};
let configWorldBooksCache = [];
let configEntriesCache = {};
/**
* 根据名称获取世界书对象
* @param {string} bookName 世界书名称
* @returns {object|null} 世界书对象
*/
function getWorldBookByName(bookName) {
return configWorldBooksCache.find(book => book.name === bookName) || null;
}
/**
* 切换配置标签页
* @param {string} tabName 标签页名称 ('api' | 'context')
@@ -78,10 +95,13 @@ export function toggleCustomFormatOptions(show) {
* 显示配置弹窗
* @param {string} category 分类名称
* @param {string} type 类型 ('memory' | 'summary' | 'merge' | 'plot')
* @param {object} partInfo Part信息可选用于总结世界书拆分{ partId, partIndex, startFloor, endFloor, charCount, bookName }
*/
export function showConfigModal(category, type = "memory") {
export function showConfigModal(category, type = "memory", partInfo = null) {
currentEditingCategory = category;
currentEditingType = type;
currentEditingPartId = partInfo?.partId || null;
currentEditingPartInfo = partInfo || null;
const modal = document.getElementById("mm-ai-config-modal");
if (!modal) return;
@@ -99,15 +119,54 @@ export function showConfigModal(category, type = "memory") {
if (type === "memory") {
itemConfig = config?.memoryConfigs?.[category] || {};
} else if (type === "summary") {
itemConfig = config?.summaryConfigs?.[category] || {};
// 如果是 Part 配置且不是 Part 1index=0从 Part 配置中获取
if (partInfo && partInfo.partIndex > 0) {
const partConfigs = config?.summaryPartConfigs?.[partInfo.bookName];
const savedPart = partConfigs?.parts?.find(p => p.id === partInfo.partId);
itemConfig = savedPart?.apiConfig || {};
} else {
itemConfig = config?.summaryConfigs?.[category] || {};
}
} else if (type === "merge" || type === "indexMerge") {
itemConfig = globalSettings.indexMergeConfig || {};
} else if (type === "plot") {
itemConfig = globalSettings.plotOptimizeConfig || {};
}
// 设置标题
const categoryNameEl = document.getElementById("mm-config-category-name");
if (categoryNameEl) categoryNameEl.textContent = category;
if (categoryNameEl) {
if (partInfo) {
categoryNameEl.textContent = `Part ${partInfo.partIndex + 1}`;
} else {
categoryNameEl.textContent = category;
}
}
// 显示/隐藏楼层+字符信息横幅
const partInfoBanner = document.getElementById("mm-config-part-info");
const partInfoText = document.getElementById("mm-config-part-info-text");
if (partInfoBanner && partInfoText) {
if (type === "summary") {
partInfoBanner.style.display = "flex";
if (partInfo) {
// 拆分模式:显示楼层范围和字符数
partInfoText.textContent = `${partInfo.startFloor}-${partInfo.endFloor}${formatCharCount(partInfo.charCount)} 字符 | ${partInfo.bookName}`;
} else {
// 非拆分模式:显示总字符数
const book = getWorldBookByName(category);
if (book) {
const content = getSummaryContent(book);
const totalChars = content.length;
partInfoText.textContent = `${formatCharCount(totalChars)} 字符 | ${category}`;
} else {
partInfoText.textContent = category;
}
}
} else {
partInfoBanner.style.display = "none";
}
}
const enabledEl = document.getElementById("mm-config-enabled");
if (enabledEl) enabledEl.checked = itemConfig.enabled !== false;
@@ -298,7 +357,19 @@ export async function saveConfig() {
} else if (currentEditingType === "summary") {
const eventsInput = document.getElementById("mm-config-max-events");
aiConfig.maxHistoryEvents = parseInt(eventsInput?.value || "15", 10);
setSummaryConfig(currentEditingCategory, aiConfig);
// 如果是 Part 配置且不是 Part 1index > 0保存到 summaryPartConfigs
if (currentEditingPartInfo && currentEditingPartInfo.partIndex > 0) {
setSummaryPartApiConfig(
currentEditingPartInfo.bookName,
currentEditingPartId,
aiConfig
);
Logger.log(`已保存 Part ${currentEditingPartInfo.partIndex + 1} 配置`);
} else {
// Part 1 或非拆分模式,保存到 summaryConfigs
setSummaryConfig(currentEditingCategory, aiConfig);
}
} else if (
currentEditingType === "indexMerge" ||
currentEditingType === "merge"

View File

@@ -34,6 +34,14 @@ export const SOURCE_LABELS = {
plot_input: "[剧情优化] 面板用户输入 <最新用户消息>",
};
// 流程类型与调用功能的映射说明用于UI悬停提示
const FLOW_TYPE_DESCRIPTIONS = {
"记忆世界书": "调用功能:记忆世界书处理",
"总结世界书": "调用功能:总结世界书处理、记忆搜索助手",
"索引合并": "调用功能:索引合并处理",
"剧情优化": "调用功能:剧情优化助手",
};
/**
* 从配置文件加载流程配置
* @param {boolean} forceReload - 是否强制重新加载(从服务器重新加载)
@@ -272,11 +280,15 @@ export async function renderFlowConfigList(savedOrder = null) {
(source) => source !== "jailbreak",
);
// 获取流程类型的悬停提示说明
const flowTypeDescription = FLOW_TYPE_DESCRIPTIONS[category] || "";
card.innerHTML = `
<div class="mm-collapse-header mm-flow-group-header">
<div class="mm-collapse-title">
<i class="fa-solid fa-folder"></i>
<span>${category}</span>
<span title="${flowTypeDescription.replace(/"/g, '&quot;')}">${category}</span>
<i class="fa-solid fa-circle-question mm-flow-hint-icon" title="${flowTypeDescription.replace(/"/g, '&quot;')}" style="margin-left: 6px; font-size: 12px; opacity: 0.6; cursor: help;"></i>
<span class="mm-collapse-badge">${visibleSources.length} 项</span>
</div>
<i class="fa-solid fa-chevron-down mm-collapse-arrow"></i>

View File

@@ -0,0 +1,533 @@
/**
* 独立模式模板编辑弹窗
* @module ui/modals/independent-template-modal
*/
import Logger from "@core/logger";
import {
setIndependentTemplate,
deleteIndependentTemplate,
getAllIndependentTemplates,
getGlobalSettings,
getIndependentTagName,
setIndependentTagName,
loadDefaultIndependentTemplates,
getAllIndependentTemplatesWithDefault,
} from "@config/config-manager";
const log = Logger.createModuleLogger("独立模式模板");
// 缓存的表格名称
let cachedTableNames = [];
// 当前编辑中的模板数据(临时存储,保存时才写入配置)
let pendingTemplates = {};
// 是否有未保存的更改
let hasUnsavedChanges = false;
/**
* 从 Amily2 获取表格名称列表
* @returns {Promise<string[]>}
*/
async function getAmily2TableNames() {
try {
// 复用 table-filler.js 中的获取逻辑
const amilyExtName = "ST-Amily2-Chat-Optimisation";
const settings = window.extension_settings?.[amilyExtName];
if (settings?.global_table_preset?.tables) {
const tables = settings.global_table_preset.tables;
if (Array.isArray(tables) && tables.length > 0) {
const names = tables.map((t) => t.name).filter(Boolean);
if (names.length > 0) return names;
}
}
if (settings?.tables && Array.isArray(settings.tables)) {
const names = settings.tables.map((t) => t.name).filter(Boolean);
if (names.length > 0) return names;
}
// 尝试从 DOM 获取
const tableTabsContainer = document.querySelector(".amily2-table-tabs");
if (tableTabsContainer) {
const tabButtons =
tableTabsContainer.querySelectorAll("button.menu_button");
const names = [];
tabButtons.forEach((btn) => {
if (!btn.querySelector(".fa-plus")) {
const name = btn.textContent?.trim().replace(/•$/, "").trim();
if (name) names.push(name);
}
});
if (names.length > 0) return names;
}
log.warn("未能获取 Amily2 表格名称");
return [];
} catch (e) {
log.error("获取 Amily2 表格名称失败:", e);
return [];
}
}
/**
* 显示独立模式模板编辑弹窗
*/
export async function showIndependentTemplateModal() {
const modal = document.getElementById("mm-independent-template-modal");
if (!modal) {
log.error("找不到独立模式模板弹窗元素");
return;
}
// 应用主题
const settings = getGlobalSettings();
const theme = settings.theme || "default";
if (theme !== "default") {
modal.setAttribute("data-mm-theme", theme);
} else {
modal.removeAttribute("data-mm-theme");
}
// 重置状态
hasUnsavedChanges = false;
// 加载表格名称
cachedTableNames = await getAmily2TableNames();
// 加载已保存的模板和默认模板到临时存储
const allTemplates = await getAllIndependentTemplatesWithDefault();
pendingTemplates = {};
// 分离持久化模板和默认模板
for (const [tableName, data] of Object.entries(allTemplates)) {
// 确保模板内容是字符串(处理可能的嵌套结构)
let templateContent = data.template;
if (typeof templateContent === 'object' && templateContent !== null) {
templateContent = templateContent.template;
}
if (typeof templateContent !== 'string') {
templateContent = '';
}
if (data.isDefault) {
// 默认模板:标记为默认,但不算已配置
pendingTemplates[tableName] = { template: templateContent, isDefault: true };
} else {
// 持久化模板
pendingTemplates[tableName] = { template: templateContent };
}
}
// 渲染模板列表
renderTemplateList();
// 显示弹窗
modal.classList.add("mm-modal-visible");
}
/**
* 隐藏独立模式模板编辑弹窗
*/
export function hideIndependentTemplateModal() {
const modal = document.getElementById("mm-independent-template-modal");
if (modal) {
modal.classList.remove("mm-modal-visible");
}
}
/**
* 渲染模板列表
*/
function renderTemplateList() {
const listEl = document.getElementById("mm-independent-template-list");
if (!listEl) return;
if (cachedTableNames.length === 0) {
listEl.innerHTML = `
<div class="mm-template-loading">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>未检测到 Amily2 表格<br><small>请确保已加载表格预设并开启聊天</small></span>
</div>
`;
return;
}
listEl.innerHTML = "";
cachedTableNames.forEach((tableName) => {
const templateData = pendingTemplates[tableName];
const isConfigured = !!templateData?.template && !templateData?.isDefault;
const isDefault = !!templateData?.isDefault;
const hasTemplate = !!templateData?.template;
const item = document.createElement("div");
item.className = `mm-template-item${isConfigured ? " configured" : ""}${isDefault ? " default" : ""}`;
item.dataset.tableName = tableName;
// 状态文字
let statusText = "未配置";
let statusClass = "";
if (isConfigured) {
statusText = "已配置";
statusClass = " configured";
} else if (isDefault) {
statusText = "内置默认";
statusClass = " default";
}
item.innerHTML = `
<div class="mm-template-item-header">
<div class="mm-template-item-left">
<i class="fa-solid fa-chevron-down mm-template-item-arrow"></i>
<span class="mm-template-item-name">${escapeHtml(tableName)}</span>
<span class="mm-template-item-status${statusClass}">${statusText}</span>
</div>
<div class="mm-template-item-right">
${isConfigured ? `<button type="button" class="mm-btn mm-btn-icon mm-btn-xs mm-btn-secondary mm-template-item-restore" title="恢复内置默认模板">
<i class="fa-solid fa-rotate-left"></i>
</button>` : ""}
${hasTemplate ? `<button type="button" class="mm-btn mm-btn-icon mm-btn-xs mm-btn-danger mm-template-item-clear" title="${isDefault ? "清空此模板" : "清空此模板"}">
<i class="fa-solid fa-trash"></i>
</button>` : ""}
</div>
</div>
<div class="mm-template-item-body">
<textarea class="mm-template-textarea" placeholder="输入提示词模板,可使用占位符:{{tableData}}、{{tableName}}、{{tableIndex}}">${escapeHtml(templateData?.template || "")}</textarea>
<div class="mm-template-preview-hint">
<i class="fa-solid fa-lightbulb"></i>
${isDefault ? "提示:这是内置默认模板,编辑后将保存为自定义模板" : "提示:未配置的表格将自动使用共享模式处理"}
</div>
</div>
`;
// 绑定折叠切换
const header = item.querySelector(".mm-template-item-header");
header.addEventListener("click", (e) => {
// 如果点击的是按钮,不切换折叠
if (e.target.closest(".mm-btn")) return;
item.classList.toggle("expanded");
});
// 绑定清空按钮
const clearBtn = item.querySelector(".mm-template-item-clear");
if (clearBtn) {
clearBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (confirm(`确定清空「${tableName}」的模板吗?`)) {
delete pendingTemplates[tableName];
hasUnsavedChanges = true;
renderTemplateList();
}
});
}
// 绑定恢复默认按钮
const restoreBtn = item.querySelector(".mm-template-item-restore");
if (restoreBtn) {
restoreBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const defaultTemplates = await loadDefaultIndependentTemplates();
const defaultTemplate = defaultTemplates?.templates?.[tableName];
if (defaultTemplate) {
const templateContent = typeof defaultTemplate === 'string' ? defaultTemplate : defaultTemplate?.template;
if (templateContent) {
pendingTemplates[tableName] = { template: templateContent, isDefault: true };
hasUnsavedChanges = true;
renderTemplateList();
if (typeof toastr !== "undefined") {
toastr.success(`已恢复「${tableName}」的内置默认模板`, "独立模式");
}
}
} else {
if (typeof toastr !== "undefined") {
toastr.warning(`${tableName}」没有内置默认模板`, "独立模式");
}
}
});
}
// 绑定文本框变更
const textarea = item.querySelector(".mm-template-textarea");
textarea.addEventListener("input", () => {
const value = textarea.value.trim();
if (value) {
// 编辑后移除 isDefault 标记,变为自定义模板
pendingTemplates[tableName] = { template: value };
} else {
delete pendingTemplates[tableName];
}
hasUnsavedChanges = true;
updateItemStatus(item, !!value, false);
});
listEl.appendChild(item);
});
}
/**
* 更新单个项目的状态显示
* @param {HTMLElement} item
* @param {boolean} isConfigured
* @param {boolean} isDefault
*/
function updateItemStatus(item, isConfigured, isDefault = false) {
const statusEl = item.querySelector(".mm-template-item-status");
if (statusEl) {
if (isConfigured) {
statusEl.textContent = "已配置";
statusEl.className = "mm-template-item-status configured";
} else if (isDefault) {
statusEl.textContent = "内置默认";
statusEl.className = "mm-template-item-status default";
} else {
statusEl.textContent = "未配置";
statusEl.className = "mm-template-item-status";
}
}
item.classList.toggle("configured", isConfigured);
item.classList.toggle("default", isDefault && !isConfigured);
}
/**
* 保存所有模板
*/
export function saveAllTemplates() {
// 清除所有现有模板
const existingTemplates = getAllIndependentTemplates();
for (const tableName of Object.keys(existingTemplates)) {
deleteIndependentTemplate(tableName);
}
// 保存新模板(只保存非默认的,即用户自定义的)
for (const [tableName, data] of Object.entries(pendingTemplates)) {
if (data?.template && !data?.isDefault) {
setIndependentTemplate(tableName, data.template);
}
}
hasUnsavedChanges = false;
// 更新设置面板中的状态显示
updateTemplateStatusDisplay();
if (typeof toastr !== "undefined") {
toastr.success("模板配置已保存", "独立模式");
}
}
/**
* 导入模板配置
* @param {File} file
*/
export async function importTemplates(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.templates || typeof data.templates !== "object") {
throw new Error("无效的配置格式");
}
// 合并导入的模板
for (const [tableName, templateData] of Object.entries(data.templates)) {
if (templateData?.template) {
pendingTemplates[tableName] = { template: templateData.template };
}
}
// 如果有标签名配置,也导入
if (data.tagName) {
setIndependentTagName(data.tagName);
const tagInput = document.getElementById("mm-table-filler-tag-name");
if (tagInput) {
tagInput.value = data.tagName;
}
}
hasUnsavedChanges = true;
renderTemplateList();
if (typeof toastr !== "undefined") {
toastr.success("配置已导入,请点击保存", "独立模式");
}
} catch (e) {
log.error("导入配置失败:", e);
if (typeof toastr !== "undefined") {
toastr.error(`导入失败: ${e.message}`, "独立模式");
}
}
}
/**
* 导出模板配置
*/
export function exportTemplates() {
const data = {
version: "1.0",
templates: pendingTemplates,
tagName: getIndependentTagName(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "independent-templates.json";
a.click();
URL.revokeObjectURL(url);
}
/**
* 更新设置面板中的模板状态显示
*/
async function updateTemplateStatusDisplay() {
const statusEl = document.getElementById("mm-table-filler-template-status");
if (!statusEl) return;
const templates = getAllIndependentTemplates();
const customCount = Object.keys(templates).length;
// 加载默认模板统计
const defaultTemplates = await loadDefaultIndependentTemplates();
const defaultCount = defaultTemplates?.templates ? Object.keys(defaultTemplates.templates).length : 0;
if (customCount > 0) {
statusEl.textContent = `已配置 ${customCount}`;
statusEl.classList.add("configured");
statusEl.classList.remove("default");
} else if (defaultCount > 0) {
statusEl.textContent = `使用默认 ${defaultCount}`;
statusEl.classList.remove("configured");
statusEl.classList.add("default");
} else {
statusEl.textContent = "未配置";
statusEl.classList.remove("configured");
statusEl.classList.remove("default");
}
}
/**
* 绑定独立模式模板弹窗事件
*/
export function bindIndependentTemplateEvents() {
// 编辑按钮(设置面板中)- 先绑定,不依赖 modal 存在
document
.getElementById("mm-table-filler-edit-templates")
?.addEventListener("click", () => {
showIndependentTemplateModal();
});
const modal = document.getElementById("mm-independent-template-modal");
if (!modal) {
log.warn("独立模式模板弹窗元素未找到,部分事件未绑定");
return;
}
// 关闭按钮
modal.querySelector(".mm-modal-close")?.addEventListener("click", () => {
if (hasUnsavedChanges && !confirm("有未保存的更改,确定关闭吗?")) {
return;
}
hideIndependentTemplateModal();
});
// 取消按钮
document
.getElementById("mm-independent-template-cancel")
?.addEventListener("click", () => {
if (hasUnsavedChanges && !confirm("有未保存的更改,确定取消吗?")) {
return;
}
hideIndependentTemplateModal();
});
// 保存按钮
document
.getElementById("mm-independent-template-save")
?.addEventListener("click", () => {
saveAllTemplates();
hideIndependentTemplateModal();
});
// 导入按钮
document
.getElementById("mm-independent-template-import")
?.addEventListener("click", () => {
const fileInput = document.getElementById("mm-independent-template-file");
if (fileInput) {
fileInput.click();
}
});
// 文件选择
document
.getElementById("mm-independent-template-file")
?.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (file) {
await importTemplates(file);
}
e.target.value = "";
});
// 导出按钮
document
.getElementById("mm-independent-template-export")
?.addEventListener("click", () => {
exportTemplates();
});
// 全部恢复默认按钮
document
.getElementById("mm-independent-template-restore-all")
?.addEventListener("click", async () => {
if (!confirm("确定将所有模板恢复为内置默认吗?自定义的模板将被覆盖。")) {
return;
}
const defaultTemplates = await loadDefaultIndependentTemplates();
if (defaultTemplates?.templates) {
pendingTemplates = {};
for (const [tableName, templateObj] of Object.entries(defaultTemplates.templates)) {
const templateContent = typeof templateObj === 'string' ? templateObj : templateObj?.template;
if (templateContent) {
pendingTemplates[tableName] = { template: templateContent, isDefault: true };
}
}
hasUnsavedChanges = true;
renderTemplateList();
if (typeof toastr !== "undefined") {
toastr.success("已恢复所有模板为内置默认", "独立模式");
}
} else {
if (typeof toastr !== "undefined") {
toastr.warning("未找到内置默认模板", "独立模式");
}
}
});
// 初始化状态显示
updateTemplateStatusDisplay();
}
/**
* 转义 HTML
* @param {string} text
* @returns {string}
*/
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
export default {
showIndependentTemplateModal,
hideIndependentTemplateModal,
bindIndependentTemplateEvents,
saveAllTemplates,
importTemplates,
exportTemplates,
};

View File

@@ -3,6 +3,84 @@
* @module ui/modals
*/
/**
* 为弹窗添加拖拽移动功能
* @param {HTMLElement} modal - 弹窗外层容器
* @param {HTMLElement} content - 弹窗内容区域(可拖拽移动的元素)
* @param {HTMLElement} header - 拖拽手柄(通常是弹窗头部)
*/
export function enableModalDrag(modal, content, header) {
if (!modal || !content || !header) return;
let isDragging = false;
let startX = 0;
let startY = 0;
let initialLeft = 0;
let initialTop = 0;
// 设置初始位置样式
content.style.position = "relative";
content.style.left = "0px";
content.style.top = "0px";
// 设置拖拽手柄样式
header.style.cursor = "move";
header.style.userSelect = "none";
const onMouseDown = (e) => {
// 忽略按钮点击
if (e.target.closest('button')) return;
isDragging = true;
startX = e.clientX || e.touches?.[0]?.clientX || 0;
startY = e.clientY || e.touches?.[0]?.clientY || 0;
initialLeft = parseInt(content.style.left) || 0;
initialTop = parseInt(content.style.top) || 0;
document.body.style.userSelect = "none";
e.preventDefault();
};
const onMouseMove = (e) => {
if (!isDragging) return;
const clientX = e.clientX || e.touches?.[0]?.clientX || 0;
const clientY = e.clientY || e.touches?.[0]?.clientY || 0;
const deltaX = clientX - startX;
const deltaY = clientY - startY;
content.style.left = `${initialLeft + deltaX}px`;
content.style.top = `${initialTop + deltaY}px`;
e.preventDefault();
};
const onMouseUp = () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = "";
}
};
header.addEventListener("mousedown", onMouseDown);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
header.addEventListener("touchstart", onMouseDown, { passive: false });
document.addEventListener("touchmove", onMouseMove, { passive: false });
document.addEventListener("touchend", onMouseUp);
// 返回清理函数
return () => {
header.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
header.removeEventListener("touchstart", onMouseDown);
document.removeEventListener("touchmove", onMouseMove);
document.removeEventListener("touchend", onMouseUp);
};
}
// 请求预览弹窗
export { showRequestPreview } from './request-preview';
@@ -84,3 +162,20 @@ export {
renderPromptPresetList,
} from './prompt-preset';
// 独立模式模板编辑弹窗
export {
showIndependentTemplateModal,
hideIndependentTemplateModal,
bindIndependentTemplateEvents,
saveAllTemplates,
importTemplates,
exportTemplates,
} from './independent-template-modal';
// 总结世界书Part配置弹窗
export {
showSummaryPartConfigModal,
hidePartConfigModal,
} from './summary-part-config';

View File

@@ -5,6 +5,7 @@
import Logger from '@core/logger';
import { getGlobalSettings, updateGlobalSettings } from '@config/config-manager';
import { enableModalDrag } from './index';
/**
* 显示请求预览弹窗
@@ -40,7 +41,6 @@ export function showRequestPreview(requests) {
content.className = "mm-modal-content mm-modal-large";
content.style.width = "100%";
content.style.maxWidth = "1000px";
content.style.height = "90vh";
content.style.maxHeight = "90vh";
content.style.overflow = "hidden";
content.style.display = "flex";
@@ -199,6 +199,9 @@ export function showRequestPreview(requests) {
header.appendChild(closeBtn);
content.appendChild(header);
// 启用弹窗拖拽移动
enableModalDrag(modal, content, header);
// 创建弹窗主体 - 可滚动区域
const body = document.createElement("div");
body.className = "mm-modal-body";

View File

@@ -4,6 +4,7 @@
*/
import { getGlobalSettings, isMultiAIAvailable } from '@config/config-manager';
import { enableModalDrag } from './index';
/**
* 显示汇总检查弹窗
@@ -40,7 +41,6 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
content.className = "mm-modal-content mm-modal-large";
content.style.width = "100%";
content.style.maxWidth = "800px";
content.style.height = "80vh";
content.style.maxHeight = "80vh";
content.style.overflow = "hidden";
content.style.display = "flex";
@@ -76,12 +76,17 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
header.appendChild(closeBtn);
content.appendChild(header);
// 启用弹窗拖拽移动
enableModalDrag(modal, content, header);
// 创建弹窗主体
const body = document.createElement("div");
body.className = "mm-modal-body";
body.style.flex = "1";
body.style.overflowY = "auto";
body.style.padding = "20px";
body.style.display = "flex";
body.style.flexDirection = "column";
// 提示信息
const hint = document.createElement("div");
@@ -126,7 +131,6 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
summaryText.style.color = "var(--mm-text)";
summaryText.style.height = editorContent ? "200px" : "300px";
summaryText.style.minHeight = "100px";
summaryText.style.maxHeight = "none";
summaryText.style.overflowY = "auto";
summaryText.style.padding = "10px";
summaryText.style.background = "var(--mm-bg-secondary)";
@@ -146,30 +150,39 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
let isResizing = false;
let startY = 0;
let startHeight = 0;
const maxScreenHeight = window.innerHeight * 0.8; // 最大高度为屏幕的80%
resizeHandle.addEventListener("mousedown", (e) => {
const onResizeMouseDown = (e) => {
isResizing = true;
startY = e.clientY;
startY = e.clientY || e.touches?.[0]?.clientY || 0;
startHeight = summaryText.offsetHeight;
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
e.preventDefault();
});
};
document.addEventListener("mousemove", (e) => {
const onResizeMouseMove = (e) => {
if (!isResizing) return;
const deltaY = e.clientY - startY;
const newHeight = Math.max(100, startHeight + deltaY);
const clientY = e.clientY || e.touches?.[0]?.clientY || 0;
const deltaY = clientY - startY;
const newHeight = Math.max(100, Math.min(maxScreenHeight, startHeight + deltaY));
summaryText.style.height = newHeight + "px";
});
};
document.addEventListener("mouseup", () => {
const onResizeMouseUp = () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
});
};
resizeHandle.addEventListener("mousedown", onResizeMouseDown);
document.addEventListener("mousemove", onResizeMouseMove);
document.addEventListener("mouseup", onResizeMouseUp);
resizeHandle.addEventListener("touchstart", onResizeMouseDown, { passive: false });
document.addEventListener("touchmove", onResizeMouseMove, { passive: false });
document.addEventListener("touchend", onResizeMouseUp);
summaryContainer.appendChild(resizableContainer);

View File

@@ -0,0 +1,501 @@
/**
* 总结世界书Part配置弹窗模块
* @module ui/modals/summary-part-config
*/
import Logger from "@core/logger";
import {
getGlobalSettings,
getSummaryPartApiConfig,
setSummaryPartApiConfig,
} from "@config/config-manager";
import { refreshWorldBookList, getSummaryParts } from "@worldbook/refresh";
import { formatCharCount } from "@worldbook/summary-splitter";
import APIAdapter from "@api/adapter";
/**
* 从API获取模型列表
* @param {string} apiUrl API地址
* @param {string} apiKey API密钥
* @param {string} format API格式
* @returns {Promise<string[]>} 模型列表
*/
async function fetchModelsFromApi(apiUrl, apiKey, format) {
let modelsUrl = apiUrl;
// 构建模型列表URL
if (format === 'openai') {
if (apiUrl.endsWith('/v1') || apiUrl.endsWith('/v1/')) {
modelsUrl = apiUrl.replace(/\/v1\/?$/, '/v1/models');
} else if (apiUrl.includes('/v1/chat/completions')) {
modelsUrl = apiUrl.replace('/v1/chat/completions', '/v1/models');
} else if (apiUrl.includes('/chat/completions')) {
modelsUrl = apiUrl.replace('/chat/completions', '/models');
} else if (!apiUrl.includes('/models')) {
modelsUrl = apiUrl.replace(/\/?$/, '/models');
}
} else if (format === 'anthropic') {
// Anthropic 不支持获取模型列表,返回常用模型
return [
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307',
];
} else if (format === 'google') {
// Google 不支持获取模型列表,返回常用模型
return [
'gemini-2.0-flash-exp',
'gemini-1.5-pro',
'gemini-1.5-flash',
'gemini-1.5-flash-8b',
'gemini-1.0-pro',
];
} else if (format === 'custom') {
throw new Error('Custom格式不支持获取模型列表请手动输入模型名称');
} else {
throw new Error('此API格式不支持获取模型列表请手动输入模型名称');
}
const headers = { 'Content-Type': 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(modelsUrl, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
let models = [];
if (data.data && Array.isArray(data.data)) {
// OpenAI 格式: { data: [{ id: "model-name" }, ...] }
models = data.data.map(m => m.id || m.name).filter(Boolean);
} else if (Array.isArray(data.models)) {
// 某些 API 格式: { models: ["model1", "model2"] }
models = data.models;
} else if (Array.isArray(data)) {
// 直接数组格式
models = data.map(m => typeof m === 'string' ? m : m.id || m.name).filter(Boolean);
}
return models.sort();
}
// 当前编辑状态
let currentBookName = null;
let currentPartId = null;
/**
* 获取当前主题
* @returns {string} 主题名称
*/
function getCurrentTheme() {
const settings = getGlobalSettings();
return settings.theme || 'default';
}
/**
* 应用主题到弹窗
* @param {HTMLElement} modal 弹窗元素
*/
function applyThemeToModal(modal) {
if (!modal) return;
const theme = getCurrentTheme();
if (theme === 'default') {
modal.removeAttribute('data-mm-theme');
} else {
modal.setAttribute('data-mm-theme', theme);
}
}
/**
* 转义HTML特殊字符
* @param {string} str 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 显示Part配置弹窗
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
*/
export function showSummaryPartConfigModal(bookName, partId) {
Logger.log(`[SummaryPartConfig] showSummaryPartConfigModal called: book=${bookName}, partId=${partId}`);
currentBookName = bookName;
currentPartId = partId;
// 获取Part信息
const parts = getSummaryParts(bookName);
Logger.log(`[SummaryPartConfig] Parts for ${bookName}:`, parts?.length || 0);
const part = parts?.find(p => p.id === partId);
if (!part) {
Logger.warn(`[SummaryPartConfig] 未找到Part: ${bookName} - ${partId}`);
return;
}
// 获取已保存的配置
const savedConfig = getSummaryPartApiConfig(bookName, partId) || {};
const apiFormat = savedConfig.apiFormat || 'openai';
// 创建弹窗HTML - 复刻原有配置弹窗样式
const modalHtml = `
<div id="mm-part-config-modal" class="mm-modal">
<div class="mm-modal-content">
<div class="mm-modal-header">
<h4>配置 AI: <span id="mm-part-config-title">${part.startFloor}-${part.endFloor}楼</span></h4>
<button class="mm-modal-close mm-btn mm-btn-icon">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="mm-modal-body">
<!-- Part信息横幅 -->
<div class="mm-part-info-banner">
<i class="fa-solid fa-layer-group"></i>
<div class="mm-part-info-details">
<div class="mm-part-info-title">${part.startFloor}-${part.endFloor}楼</div>
<div class="mm-part-info-meta">
${formatCharCount(part.charCount)} 字符 | ${escapeHtml(bookName)}
</div>
</div>
</div>
<!-- API格式 - 使用Radio按钮组 -->
<div class="mm-form-group">
<label>API 格式</label>
<div class="mm-radio-group">
<label><input type="radio" name="mm-part-api-format" value="openai" ${apiFormat === 'openai' ? 'checked' : ''} /> OpenAI 兼容</label>
<label><input type="radio" name="mm-part-api-format" value="anthropic" ${apiFormat === 'anthropic' ? 'checked' : ''} /> Anthropic</label>
<label><input type="radio" name="mm-part-api-format" value="google" ${apiFormat === 'google' ? 'checked' : ''} /> Google</label>
<label><input type="radio" name="mm-part-api-format" value="custom" ${apiFormat === 'custom' ? 'checked' : ''} /> Custom</label>
</div>
</div>
<!-- API URL -->
<div class="mm-form-group">
<label>API URL <span class="mm-required">*</span></label>
<input type="text" id="mm-part-api-url" placeholder="https://api.deepseek.com/v1" value="${escapeHtml(savedConfig.apiUrl || '')}">
<small class="mm-hint">填写到 /v1 即可,会自动补全完整路径</small>
</div>
<!-- API Key -->
<div class="mm-form-group">
<label>API Key</label>
<input type="password" id="mm-part-api-key" placeholder="sk-..." value="${escapeHtml(savedConfig.apiKey || '')}">
<small class="mm-hint">本地模型可留空</small>
</div>
<!-- 模型名称 -->
<div class="mm-form-group">
<label>模型名称 <span class="mm-required">*</span></label>
<div class="mm-model-input-row">
<select id="mm-part-model" class="mm-model-select">
<option value="" disabled ${!savedConfig.model ? 'selected' : ''}>--- 请获取模型 ---</option>
${savedConfig.model ? `<option value="${escapeHtml(savedConfig.model)}" selected>${escapeHtml(savedConfig.model)}</option>` : ''}
</select>
<button type="button" id="mm-part-fetch-models" class="mm-btn mm-btn-secondary" title="从API获取模型列表">
<i class="fa-solid fa-download"></i> 获取模型
</button>
</div>
</div>
<!-- Max Tokens 和 Temperature -->
<div class="mm-form-row">
<div class="mm-form-group">
<label>Max Tokens</label>
<input type="number" id="mm-part-max-tokens" value="${savedConfig.maxTokens || 2000}" min="100" max="128000">
</div>
<div class="mm-form-group">
<label>Temperature</label>
<input type="range" id="mm-part-temperature" value="${savedConfig.temperature || 0.5}" min="0" max="1" step="0.1">
<span id="mm-part-temperature-value">${savedConfig.temperature || 0.5}</span>
</div>
</div>
<!-- 关联性阈值 -->
<div class="mm-form-group">
<label>关联性阈值</label>
<div class="mm-form-row">
<input type="range" id="mm-part-relevance" value="${savedConfig.relevanceThreshold || 0.4}" min="0.1" max="1" step="0.1" style="flex: 1">
<span id="mm-part-relevance-value" style="min-width: 30px; text-align: center">${savedConfig.relevanceThreshold || 0.4}</span>
</div>
<small class="mm-hint">数值越小越严格,数值越大越宽松 (0.1-1.0)。占位符:<code>sulv1</code></small>
</div>
<!-- 历史事件数量 -->
<div class="mm-form-group">
<label>历史事件数量 (1-35)</label>
<input type="number" id="mm-part-max-events" value="${savedConfig.maxHistoryEvents || 10}" min="1" max="35">
<small class="mm-hint">AI 最多提取的历史事件数量。占位符:<code>sulv2</code></small>
</div>
<!-- Custom 格式选项(仅当选择 Custom 时显示) -->
<div id="mm-part-custom-format-options" class="${apiFormat === 'custom' ? '' : 'mm-hidden'}">
<div class="mm-form-group">
<label>自定义请求模板 (JSON)</label>
<textarea id="mm-part-custom-template" rows="5" placeholder='{"model": "{{model}}", "prompt": "{{system}}\n\n{{user}}"}'>${escapeHtml(savedConfig.customRequestTemplate || '')}</textarea>
<small class="mm-hint">可用变量: {{system}}, {{user}}, {{model}}, {{max_tokens}}, {{temperature}}</small>
</div>
<div class="mm-form-group">
<label>响应解析路径</label>
<input type="text" id="mm-part-response-path" placeholder="choices.0.message.content" value="${escapeHtml(savedConfig.customResponsePath || '')}">
<small class="mm-hint">用于从 API 响应中提取内容的路径choices.0.message.content</small>
</div>
</div>
</div>
<div class="mm-modal-footer">
<button type="button" id="mm-part-test-connection" class="mm-btn mm-btn-secondary">
<i class="fa-solid fa-plug"></i> 测试连接
</button>
<div class="mm-modal-footer-right">
<button type="button" id="mm-part-cancel" class="mm-btn">取消</button>
<button type="button" id="mm-part-save" class="mm-btn mm-btn-primary">
<i class="fa-solid fa-save"></i> 保存配置
</button>
</div>
</div>
</div>
</div>
`;
// 移除已存在的弹窗
document.getElementById('mm-part-config-modal')?.remove();
// 添加弹窗到页面
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 应用主题
const modal = document.getElementById('mm-part-config-modal');
applyThemeToModal(modal);
// 绑定事件
bindPartConfigEvents();
// 显示弹窗
setTimeout(() => modal?.classList.add('mm-modal-visible'), 10);
}
/**
* 绑定Part配置弹窗事件
*/
function bindPartConfigEvents() {
const modal = document.getElementById('mm-part-config-modal');
if (!modal) return;
// 关闭按钮
modal.querySelector('.mm-modal-close')?.addEventListener('click', hidePartConfigModal);
modal.querySelector('#mm-part-cancel')?.addEventListener('click', hidePartConfigModal);
// Temperature 滑块
const tempSlider = modal.querySelector('#mm-part-temperature');
const tempValue = modal.querySelector('#mm-part-temperature-value');
if (tempSlider && tempValue) {
tempSlider.addEventListener('input', (e) => {
tempValue.textContent = e.target.value;
});
}
// 关联性阈值滑块
const relevanceSlider = modal.querySelector('#mm-part-relevance');
const relevanceValue = modal.querySelector('#mm-part-relevance-value');
if (relevanceSlider && relevanceValue) {
relevanceSlider.addEventListener('input', (e) => {
relevanceValue.textContent = e.target.value;
});
}
// API 格式切换事件(控制 Custom 选项显示)
const formatRadios = modal.querySelectorAll('input[name="mm-part-api-format"]');
formatRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const customOptions = document.getElementById('mm-part-custom-format-options');
if (customOptions) {
if (e.target.value === 'custom') {
customOptions.classList.remove('mm-hidden');
} else {
customOptions.classList.add('mm-hidden');
}
}
});
});
// 获取模型列表
modal.querySelector('#mm-part-fetch-models')?.addEventListener('click', async () => {
const apiUrl = document.getElementById('mm-part-api-url')?.value?.trim();
const apiKey = document.getElementById('mm-part-api-key')?.value?.trim();
const apiFormat = document.querySelector('input[name="mm-part-api-format"]:checked')?.value || 'openai';
if (!apiUrl) {
alert('请先填写API地址');
return;
}
const btn = modal.querySelector('#mm-part-fetch-models');
const originalHtml = btn.innerHTML;
try {
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 获取中...';
btn.disabled = true;
const models = await fetchModelsFromApi(apiUrl, apiKey, apiFormat);
if (models && models.length > 0) {
const modelSelect = document.getElementById('mm-part-model');
if (modelSelect) {
const currentValue = modelSelect.value;
modelSelect.innerHTML = '<option value="" disabled>--- 请选择模型 ---</option>';
models.forEach(modelId => {
const option = document.createElement('option');
option.value = modelId;
option.textContent = modelId;
if (modelId === currentValue) {
option.selected = true;
}
modelSelect.appendChild(option);
});
// 如果之前没有选中的,选择第一个模型
if (!currentValue && models.length > 0) {
modelSelect.selectedIndex = 1;
}
}
if (window.toastr) {
window.toastr.success(`获取到 ${models.length} 个模型`, '成功');
}
} else {
alert('未获取到模型列表');
}
} catch (error) {
Logger.error('[SummaryPartConfig] 获取模型失败:', error);
alert('获取模型列表失败: ' + error.message);
} finally {
btn.innerHTML = originalHtml;
btn.disabled = false;
}
});
// 测试连接
modal.querySelector('#mm-part-test-connection')?.addEventListener('click', async () => {
const config = getFormConfig();
if (!config.apiUrl || !config.model) {
alert('请填写API地址和模型');
return;
}
const btn = modal.querySelector('#mm-part-test-connection');
const originalHtml = btn.innerHTML;
try {
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 测试中...';
btn.disabled = true;
const result = await APIAdapter.testConnection(config);
if (result.success) {
if (window.toastr) {
window.toastr.success('API连接成功', '测试通过');
} else {
alert('连接成功!');
}
} else {
alert('连接失败: ' + (result.error || '未知错误'));
}
} catch (error) {
Logger.error('[SummaryPartConfig] 测试连接失败:', error);
alert('测试失败: ' + error.message);
} finally {
btn.innerHTML = originalHtml;
btn.disabled = false;
}
});
// 保存
modal.querySelector('#mm-part-save')?.addEventListener('click', () => {
const config = getFormConfig();
if (!config.apiUrl || !config.model) {
alert('请至少填写API地址和模型');
return;
}
// 添加enabled字段
config.enabled = true;
setSummaryPartApiConfig(currentBookName, currentPartId, config);
Logger.log(`[SummaryPartConfig] 已保存 ${currentBookName} - ${currentPartId} 的配置`);
hidePartConfigModal();
refreshWorldBookList();
if (window.toastr) {
window.toastr.success('Part配置已保存', '保存成功');
}
});
}
/**
* 从表单获取配置
* @returns {object} 配置对象
*/
function getFormConfig() {
const modelEl = document.getElementById('mm-part-model');
const modelValue = modelEl?.value || '';
const apiFormat = document.querySelector('input[name="mm-part-api-format"]:checked')?.value || 'openai';
const config = {
apiFormat,
apiUrl: document.getElementById('mm-part-api-url')?.value || '',
apiKey: document.getElementById('mm-part-api-key')?.value || '',
model: modelValue,
maxTokens: parseInt(document.getElementById('mm-part-max-tokens')?.value) || 2000,
temperature: parseFloat(document.getElementById('mm-part-temperature')?.value) || 0.5,
relevanceThreshold: parseFloat(document.getElementById('mm-part-relevance')?.value) || 0.4,
maxHistoryEvents: parseInt(document.getElementById('mm-part-max-events')?.value) || 10,
responsePath: 'choices.0.message.content',
};
// Custom 格式额外字段
if (apiFormat === 'custom') {
config.customRequestTemplate = document.getElementById('mm-part-custom-template')?.value || '';
config.customResponsePath = document.getElementById('mm-part-response-path')?.value || '';
// 如果有自定义响应路径,使用它
if (config.customResponsePath) {
config.responsePath = config.customResponsePath;
}
}
return config;
}
/**
* 隐藏Part配置弹窗
*/
export function hidePartConfigModal() {
const modal = document.getElementById('mm-part-config-modal');
if (modal) {
modal.classList.remove('mm-modal-visible');
setTimeout(() => modal.remove(), 300);
}
currentBookName = null;
currentPartId = null;
}
export default {
showSummaryPartConfigModal,
hidePartConfigModal,
};

View File

@@ -49,12 +49,20 @@ export async function loadSettingsTemplate() {
const plotOptimizeModal = container.querySelector("#mm-plot-optimize-modal");
const flowConfigModal = container.querySelector("#mm-flow-config-modal");
const multiAIConfigModal = container.querySelector("#mm-multi-ai-config-modal");
const tableFillerApiModal = container.querySelector("#mm-table-filler-api-modal");
const tableFillerSelectModal = container.querySelector("#mm-table-filler-select-modal");
const independentTemplateModal = container.querySelector("#mm-independent-template-modal");
const independentTemplateFile = container.querySelector("#mm-independent-template-file");
if (settingsPanel) document.body.appendChild(settingsPanel);
if (configModal) document.body.appendChild(configModal);
if (plotOptimizeModal) document.body.appendChild(plotOptimizeModal);
if (flowConfigModal) document.body.appendChild(flowConfigModal);
if (multiAIConfigModal) document.body.appendChild(multiAIConfigModal);
if (tableFillerApiModal) document.body.appendChild(tableFillerApiModal);
if (tableFillerSelectModal) document.body.appendChild(tableFillerSelectModal);
if (independentTemplateModal) document.body.appendChild(independentTemplateModal);
if (independentTemplateFile) document.body.appendChild(independentTemplateFile);
Logger.debug("设置模板已加载");
} catch (e) {

View File

@@ -28,8 +28,23 @@ export {
refreshWorldBookList,
getWorldBooksCache,
clearWorldBooksCache,
getSummaryParts,
clearSummaryPartsCache,
} from './refresh';
// 总结世界书拆分模块
export {
parseSegments,
analyzeSummaryContent,
calculateSplitPlan,
needsSplit,
getContentStats,
formatCharCount,
matchPartConfigs,
generatePartId,
getSummaryBookContent,
} from './summary-splitter';
// 更新列表模块
export {
addUpdates,

View File

@@ -4,12 +4,17 @@
*/
import Logger from '@core/logger';
import { loadConfig } from '@config/config-manager';
import { loadConfig, isSummaryAutoSplitEnabled, getSummaryAutoSplitConfig, getSummaryPartConfigs, getSummaryConfig, isSummaryMergeDeduplicateEnabled, setSummaryPartApiConfig } from '@config/config-manager';
import { getImportedWorldBooks, classifyWorldBooks } from './api';
import { analyzeSummaryContent, formatCharCount, needsSplit, matchPartConfigs } from './summary-splitter';
import { getSummaryContent } from './parser';
import { isPartDebugEnabled, setPartDebugEnabled } from '@memory/part-debug-modal';
// 世界书缓存
let worldBooksCache = [];
let worldBooksSnapshot = null;
// Part分析缓存避免重复计算
let summaryPartsCache = {};
/**
* 创建世界书快照(用于变化检测)
@@ -186,7 +191,31 @@ export async function refreshWorldBookList() {
if (summaryBooks.length > 0) {
html += '<div class="mm-book-group">';
html += '<div class="mm-book-group-title">总结世界书</div>';
// 总结世界书标题行 - 包含自动拆分开关、去重开关和调试开关
const splitEnabled = isSummaryAutoSplitEnabled();
const deduplicateEnabled = isSummaryMergeDeduplicateEnabled();
const debugEnabled = isPartDebugEnabled();
html += `
<div class="mm-book-group-header">
<div class="mm-book-group-title">总结世界书</div>
<div style="display: flex; align-items: center; gap: 12px;">
<label class="mm-switch mm-switch-sm" title="启用自动拆分超过5万字符时拆分为多个Part并发处理">
<input type="checkbox" id="mm-summary-auto-split-toggle" ${splitEnabled ? 'checked' : ''} />
<span class="mm-switch-slider"></span>
</label>
${splitEnabled ? `
<label class="mm-icon-toggle" title="合并时去重:同一楼层保留内容最长的。关闭时相同楼层的内容会放在一起。">
<input type="checkbox" id="mm-summary-merge-deduplicate-toggle" ${deduplicateEnabled ? 'checked' : ''} />
<i class="fa-solid fa-filter"></i>
</label>
<label class="mm-icon-toggle" title="启用调试模式处理完成后显示各Part返回内容">
<input type="checkbox" id="mm-summary-part-debug-toggle" ${debugEnabled ? 'checked' : ''} />
<i class="fa-solid fa-bug"></i>
</label>
` : ''}
</div>
</div>`;
for (const book of summaryBooks) {
const bookConfig = config?.summaryConfigs?.[book.name];
const hasConfig = !!bookConfig;
@@ -197,22 +226,40 @@ export async function refreshWorldBookList() {
const statusClass = hasConfig ? "mm-chip-ok" : "mm-chip-warning";
const safeBookName = escapeHtml(book.name);
// 检查是否需要拆分(启用拆分且内容足够多)
const content = getSummaryContent(book);
const splitConfig = getSummaryAutoSplitConfig();
const shouldSplit = splitEnabled && needsSplit(content, splitConfig.targetChars);
html += `
<div class="mm-book-card">
<div class="mm-book-card" data-book="${safeBookName}">
<div class="mm-book-title">
<span class="mm-book-name">${safeBookName}</span>
<span class="mm-chip-count" style="margin-left: 8px; margin-right: auto;">${entryCount}</span>
<button class="mm-btn mm-btn-xs mm-btn-danger" data-action="remove-book" data-book="${safeBookName}" title="移除">
<i class="fa-solid fa-times"></i>
</button>
</div>`;
// 如果启用了拆分功能且内容足够显示Part列表
if (shouldSplit) {
html += renderSummaryPartsUI(book, config);
} else {
// 不启用拆分或内容不足:显示单个可点击的配置芯片
html += `
<div class="mm-chips-container">
<div class="mm-chip ${statusClass}"
data-action="edit-config"
data-category="${safeBookName}"
data-type="summary"
title="条目: ${entryCount} | 事件: ${eventsCount} | 阈值: ${relevanceThreshold} | 模型: ${apiModel}">
<span class="mm-chip-name">${safeBookName}</span>
<span class="mm-chip-count">${entryCount}</span>
<span class="mm-chip-name">${formatCharCount(content.length)} 字符</span>
</div>
<button class="mm-btn mm-btn-xs mm-btn-danger" data-action="remove-book" data-book="${safeBookName}" title="移除">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>`;
</div>`;
}
html += `</div>`;
}
html += "</div>";
}
@@ -279,4 +326,404 @@ export function getWorldBooksCache() {
export function clearWorldBooksCache() {
worldBooksCache = [];
worldBooksSnapshot = null;
summaryPartsCache = {};
}
/**
* 渲染总结世界书的Part列表UI
* @param {Object} book 世界书对象
* @param {Object} config 配置对象
* @returns {string} HTML字符串
*/
function renderSummaryPartsUI(book, config) {
const splitConfig = getSummaryAutoSplitConfig();
const content = getSummaryContent(book);
const totalChars = content.length;
// 检查是否需要拆分
if (!needsSplit(content, splitConfig.targetChars)) {
return `
<div class="mm-summary-parts-info">
<span class="mm-parts-hint">
<i class="fa-solid fa-check-circle" style="color: var(--mm-success-color);"></i>
内容约 ${formatCharCount(totalChars)} 字符,无需拆分
</span>
</div>`;
}
// 每次都重新分析Part确保实时性避免世界书内容变化后显示旧数据
const parts = analyzeSummaryContent(content, splitConfig);
// 更新缓存(供其他地方使用)
summaryPartsCache[book.name] = parts;
if (parts.length <= 1) {
return `
<div class="mm-summary-parts-info">
<span class="mm-parts-hint">
<i class="fa-solid fa-check-circle" style="color: var(--mm-success-color);"></i>
内容约 ${formatCharCount(totalChars)} 字符,无需拆分
</span>
</div>`;
}
// 获取已保存的Part配置
const savedPartConfigs = getSummaryPartConfigs(book.name);
// 获取原总结世界书配置Part 1 复用)
const originalSummaryConfig = getSummaryConfig(book.name);
// 构建已保存配置的映射(用于模糊匹配)
const savedConfigsMap = {};
if (savedPartConfigs?.parts) {
for (const p of savedPartConfigs.parts) {
if (p.id && p.apiConfig) {
savedConfigsMap[p.id] = p.apiConfig;
}
}
}
// 使用模糊匹配来保留配置
const { matched, unmatched } = matchPartConfigs(parts.slice(1), savedConfigsMap); // 跳过 Part 1
// 如果有模糊匹配成功的,自动迁移配置到新的 partId
for (const matchedPart of matched) {
if (matchedPart.matchType === 'fuzzy' && matchedPart.apiConfig) {
// 将配置迁移到新的 partId
setSummaryPartApiConfig(book.name, matchedPart.id, matchedPart.apiConfig);
Logger.log(`[Refresh] 模糊匹配迁移配置: ${matchedPart.originalPartId} -> ${matchedPart.id}`);
}
}
// 检查是否有新的未配置的 Part用于提醒
const unconfiguredParts = [];
let html = '<div class="mm-chips-container">';
for (const part of parts) {
// Part 1index=0复用原总结世界书配置其他Part使用各自的配置
let hasConfig, modelName, dataAttrs;
if (part.index === 0) {
hasConfig = !!(originalSummaryConfig?.apiUrl && originalSummaryConfig?.model && originalSummaryConfig?.enabled);
modelName = hasConfig ? escapeHtml(originalSummaryConfig.model) : '未配置';
dataAttrs = `data-category="${escapeHtml(book.name)}" data-type="summary" data-part-index="0" data-part-id="${part.id}" data-start-floor="${part.startFloor}" data-end-floor="${part.endFloor}" data-char-count="${part.charCount}" data-book-name="${escapeHtml(book.name)}"`;
if (!hasConfig) {
unconfiguredParts.push({ ...part, floorRange: `${part.startFloor}-${part.endFloor}` });
}
} else {
// 先尝试精确匹配
let savedPart = savedPartConfigs?.parts?.find(p => p.id === part.id);
// 如果精确匹配失败,尝试从模糊匹配结果中获取
if (!savedPart) {
const matchedPart = matched.find(m => m.id === part.id);
if (matchedPart?.apiConfig) {
savedPart = { apiConfig: matchedPart.apiConfig };
}
}
hasConfig = !!(savedPart?.apiConfig?.apiUrl && savedPart?.apiConfig?.model);
modelName = hasConfig ? escapeHtml(savedPart.apiConfig.model) : '未配置';
dataAttrs = `data-category="${escapeHtml(book.name)}" data-type="summary" data-part-index="${part.index}" data-part-id="${part.id}" data-start-floor="${part.startFloor}" data-end-floor="${part.endFloor}" data-char-count="${part.charCount}" data-book-name="${escapeHtml(book.name)}"`;
if (!hasConfig) {
unconfiguredParts.push({ ...part, floorRange: `${part.startFloor}-${part.endFloor}` });
}
}
const statusClass = hasConfig ? 'mm-chip-ok' : 'mm-chip-warning';
const floorRange = part.startFloor && part.endFloor
? `${part.startFloor}-${part.endFloor}`
: `Part ${part.index + 1}`;
html += `
<div class="mm-chip ${statusClass}"
data-action="edit-config"
${dataAttrs}
title="点击配置API | 模型: ${modelName}">
<span class="mm-chip-name">${floorRange}</span>
<span class="mm-chip-count">${formatCharCount(part.charCount)}</span>
</div>`;
}
html += '</div>';
// 如果有未配置的 Part显示提醒通知
if (unconfiguredParts.length > 0) {
showUnconfiguredPartsNotification(book.name, unconfiguredParts);
}
return html;
}
// 用于防止重复通知的缓存
let lastNotificationKey = '';
let lastNotificationTime = 0;
/**
* 获取当前主题
* @returns {string} 主题名称
*/
function getCurrentTheme() {
const settings = loadConfig();
return settings?.global?.theme || 'default';
}
/**
* 显示未配置Part的通知自定义右下角卡片跟随插件主题
* @param {string} bookName 世界书名称
* @param {Array} unconfiguredParts 未配置的Part列表
*/
function showUnconfiguredPartsNotification(bookName, unconfiguredParts) {
// 防止短时间内重复通知5秒内同一世界书不重复提醒
const notificationKey = `${bookName}_${unconfiguredParts.length}`;
const now = Date.now();
if (notificationKey === lastNotificationKey && now - lastNotificationTime < 5000) {
return;
}
lastNotificationKey = notificationKey;
lastNotificationTime = now;
// 移除已存在的通知
const existingNotification = document.getElementById('mm-part-config-notification');
if (existingNotification) {
existingNotification.remove();
}
// 添加样式(如果不存在)
if (!document.getElementById('mm-part-notification-styles')) {
const style = document.createElement('style');
style.id = 'mm-part-notification-styles';
style.textContent = `
@keyframes mm-notification-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes mm-notification-slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
#mm-part-config-notification {
position: fixed;
bottom: 20px;
right: 20px;
width: 320px;
max-width: calc(100vw - 40px);
background: rgba(15, 52, 96, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 3px solid #f0ad4e;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 99998;
animation: mm-notification-slide-in 0.3s ease-out;
overflow: hidden;
cursor: pointer;
}
/* 暖灰棕主题 */
#mm-part-config-notification[data-mm-theme="warm-brown"] {
background: rgba(61, 53, 46, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 淡紫薰衣草主题 */
#mm-part-config-notification[data-mm-theme="lavender"] {
background: rgba(45, 40, 56, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 森林绿主题 */
#mm-part-config-notification[data-mm-theme="forest"] {
background: rgba(37, 53, 48, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 玫瑰灰主题 */
#mm-part-config-notification[data-mm-theme="rose"] {
background: rgba(56, 40, 48, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 静谧蓝灰主题 */
#mm-part-config-notification[data-mm-theme="slate"] {
background: rgba(40, 46, 53, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 星空紫主题 */
#mm-part-config-notification[data-mm-theme="starry-purple"] {
background:
radial-gradient(1px 1px at 20px 30px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 40px 70px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 50px 160px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 100px 40px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.5), transparent),
radial-gradient(1.5px 1.5px at 160px 120px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 200px 50px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 250px 90px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 280px 140px, rgba(255,255,255,0.5), transparent),
rgba(26, 21, 37, 0.85);
border-color: rgba(138, 100, 200, 0.3);
}
/* 星空蓝主题 */
#mm-part-config-notification[data-mm-theme="starry-blue"] {
background:
radial-gradient(1px 1px at 15px 25px, rgba(255,255,255,0.8), transparent),
radial-gradient(1.5px 1.5px at 45px 65px, rgba(200,220,255,0.9), transparent),
radial-gradient(1px 1px at 75px 150px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 110px 35px, rgba(200,220,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 140px 95px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 180px 55px, rgba(200,220,255,0.5), transparent),
radial-gradient(1px 1px at 220px 110px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 260px 70px, rgba(200,220,255,0.6), transparent),
radial-gradient(1px 1px at 290px 130px, rgba(255,255,255,0.5), transparent),
rgba(16, 24, 40, 0.85);
border-color: rgba(100, 150, 220, 0.3);
}
/* 星空黑主题 */
#mm-part-config-notification[data-mm-theme="starry-black"] {
background:
radial-gradient(1px 1px at 10px 20px, rgba(255,255,255,0.9), transparent),
radial-gradient(1.5px 1.5px at 35px 75px, rgba(255,255,255,0.7), transparent),
radial-gradient(1px 1px at 60px 140px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 95px 30px, rgba(255,255,255,0.6), transparent),
radial-gradient(1.5px 1.5px at 125px 100px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 165px 60px, rgba(255,255,255,0.5), transparent),
radial-gradient(1px 1px at 195px 120px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 235px 45px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 275px 85px, rgba(255,255,255,0.8), transparent),
rgba(12, 12, 16, 0.85);
border-color: rgba(255, 255, 255, 0.15);
}
#mm-part-config-notification .mm-notification-content {
padding: 12px 14px;
}
#mm-part-config-notification .mm-notification-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
#mm-part-config-notification .mm-notification-icon {
color: #f0ad4e;
font-size: 16px;
flex-shrink: 0;
}
#mm-part-config-notification .mm-notification-title {
color: #e4e4e4;
font-weight: 600;
font-size: 13px;
flex: 1;
}
#mm-part-config-notification .mm-notification-close {
color: #a0a0a0;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
}
#mm-part-config-notification .mm-notification-close:hover {
color: #e4e4e4;
background: rgba(255, 255, 255, 0.1);
}
#mm-part-config-notification .mm-notification-body {
color: #c0c0c0;
font-size: 12px;
line-height: 1.5;
}
#mm-part-config-notification .mm-notification-parts {
color: #f0ad4e;
font-weight: 500;
margin: 4px 0;
}
#mm-part-config-notification .mm-notification-hint {
color: #888;
font-size: 11px;
margin-top: 8px;
}
#mm-part-config-notification:hover {
border-left-color: #ffc107;
}
#mm-part-config-notification.mm-notification-closing {
animation: mm-notification-slide-out 0.3s ease-in forwards;
}
/* 移动端适配 */
@media (max-width: 400px) {
#mm-part-config-notification {
bottom: 10px;
right: 10px;
width: calc(100vw - 20px);
}
}
`;
document.head.appendChild(style);
}
// 创建通知元素
const notification = document.createElement('div');
notification.id = 'mm-part-config-notification';
// 应用当前主题
const theme = getCurrentTheme();
if (theme && theme !== 'default') {
notification.setAttribute('data-mm-theme', theme);
}
const partsList = unconfiguredParts.map(p => p.floorRange).join('、');
notification.innerHTML = `
<div class="mm-notification-content">
<div class="mm-notification-header">
<span class="mm-notification-icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
<span class="mm-notification-title">拆分配置提醒</span>
<span class="mm-notification-close"><i class="fa-solid fa-times"></i></span>
</div>
<div class="mm-notification-body">
<div>总结世界书「${escapeHtml(bookName)}」有 <strong>${unconfiguredParts.length}</strong> 个拆分未配置API</div>
<div class="mm-notification-parts">${escapeHtml(partsList)}</div>
<div class="mm-notification-hint">点击此通知打开设置进行配置</div>
</div>
</div>
`;
// 关闭按钮事件
notification.querySelector('.mm-notification-close').addEventListener('click', (e) => {
e.stopPropagation();
notification.classList.add('mm-notification-closing');
setTimeout(() => notification.remove(), 300);
});
// 点击通知打开设置
notification.addEventListener('click', () => {
const settingsBtn = document.querySelector('#mm-settings-toggle');
if (settingsBtn) {
settingsBtn.click();
}
notification.classList.add('mm-notification-closing');
setTimeout(() => notification.remove(), 300);
});
document.body.appendChild(notification);
// 10秒后自动关闭
setTimeout(() => {
if (notification.parentNode) {
notification.classList.add('mm-notification-closing');
setTimeout(() => notification.remove(), 300);
}
}, 10000);
}
/**
* 获取指定世界书的Part分析结果
* @param {string} bookName 世界书名称
* @returns {Array|null} Part数组或null
*/
export function getSummaryParts(bookName) {
return summaryPartsCache[bookName] || null;
}
/**
* 清除指定世界书的Part缓存
* @param {string} bookName 世界书名称
*/
export function clearSummaryPartsCache(bookName) {
if (bookName) {
delete summaryPartsCache[bookName];
} else {
summaryPartsCache = {};
}
}

View File

@@ -0,0 +1,425 @@
/**
* 总结世界书拆分模块
* 自动检测并拆分大型总结世界书内容
* @module worldbook/summary-splitter
*/
import Logger from "@core/logger";
/**
* 默认拆分选项
*/
const DEFAULT_SPLIT_OPTIONS = {
targetChars: 50000, // 目标拆分字符数
minChars: 40000, // 最小字符数
maxChars: 60000, // 最大字符数
};
/**
* 段落信息结构
* @typedef {Object} Segment
* @property {number} startFloor - 起始楼层
* @property {number} endFloor - 结束楼层
* @property {string} content - 段落内容
* @property {number} charCount - 字符数
*/
/**
* Part信息结构
* @typedef {Object} Part
* @property {string} id - Part ID基于楼层范围
* @property {number} index - Part索引从0开始
* @property {number} startFloor - 起始楼层
* @property {number} endFloor - 结束楼层
* @property {number} charCount - 字符数
* @property {Array<Segment>} segments - 包含的段落
* @property {string} content - Part完整内容
*/
/**
* 解析总结世界书内容中的段落
* 识别格式【X楼至Y楼详细总结记录】开头以<task completed>或本条勿动结尾
* @param {string} content 总结世界书完整内容
* @returns {Array<Segment>} 段落数组
*/
export function parseSegments(content) {
if (!content || typeof content !== 'string') {
Logger.debug("[SummarySplitter] 内容为空");
return [];
}
const segments = [];
// 匹配段落的正则表达式
// 格式【X楼至Y楼详细总结记录】...内容...<task completed>X-Y</task completed>
// 或者【X楼至Y楼详细总结记录】...内容...本条勿动【前X楼总结已完成】
const segmentRegex = /【(\d+)楼至(\d+)楼[^\n]*详细总结记录】([\s\S]*?)(?:<task completed>[\d-]+<\/task completed>|本条勿动【[^\]]+】)/g;
let match;
while ((match = segmentRegex.exec(content)) !== null) {
const startFloor = parseInt(match[1], 10);
const endFloor = parseInt(match[2], 10);
const segmentContent = match[0];
segments.push({
startFloor,
endFloor,
content: segmentContent,
charCount: segmentContent.length,
});
}
// 如果正则没有匹配到,尝试备用方案:按 --- 分隔符拆分
if (segments.length === 0) {
Logger.debug("[SummarySplitter] 主正则未匹配,尝试备用方案");
return parseSegmentsByDivider(content);
}
Logger.log(`[SummarySplitter] 解析到 ${segments.length} 个段落`);
return segments;
}
/**
* 备用方案:按 --- 分隔符拆分段落
* @param {string} content 内容
* @returns {Array<Segment>} 段落数组
*/
function parseSegmentsByDivider(content) {
const segments = [];
// 按 --- 分隔
const parts = content.split(/\n---+\n/);
// 从每个部分中提取楼层信息
const floorRegex = /【(\d+)楼至(\d+)楼/;
for (const part of parts) {
const trimmedPart = part.trim();
if (!trimmedPart) continue;
const floorMatch = trimmedPart.match(floorRegex);
if (floorMatch) {
segments.push({
startFloor: parseInt(floorMatch[1], 10),
endFloor: parseInt(floorMatch[2], 10),
content: trimmedPart,
charCount: trimmedPart.length,
});
} else {
// 无法识别楼层的部分,作为单独段落处理
// 尝试从内容中推断
segments.push({
startFloor: 0,
endFloor: 0,
content: trimmedPart,
charCount: trimmedPart.length,
});
}
}
Logger.log(`[SummarySplitter] 备用方案解析到 ${segments.length} 个段落`);
return segments;
}
/**
* 生成Part ID基于楼层范围
* @param {number} startFloor 起始楼层
* @param {number} endFloor 结束楼层
* @returns {string} Part ID
*/
export function generatePartId(startFloor, endFloor) {
return `floor_${startFloor}_${endFloor}`;
}
/**
* 计算拆分方案
* 使用贪心算法尽量接近targetChars不超过maxChars
* @param {Array<Segment>} segments 段落数组
* @param {Object} options 拆分选项
* @returns {Array<Part>} Part数组
*/
export function calculateSplitPlan(segments, options = {}) {
const { targetChars, minChars, maxChars } = { ...DEFAULT_SPLIT_OPTIONS, ...options };
if (segments.length === 0) {
return [];
}
const parts = [];
let currentPart = {
segments: [],
charCount: 0,
startFloor: 0,
endFloor: 0,
};
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const newCharCount = currentPart.charCount + segment.charCount;
// 如果当前Part为空直接添加
if (currentPart.segments.length === 0) {
currentPart.segments.push(segment);
currentPart.charCount = segment.charCount;
currentPart.startFloor = segment.startFloor;
currentPart.endFloor = segment.endFloor;
continue;
}
// 判断是否应该开始新的Part
const shouldStartNewPart =
// 添加后超过最大限制
newCharCount > maxChars ||
// 当前已达到目标且下一个段落会让它远离目标
(currentPart.charCount >= targetChars && newCharCount > maxChars);
if (shouldStartNewPart && currentPart.charCount >= minChars) {
// 保存当前Part并开始新的
parts.push(finalizePart(currentPart, parts.length));
currentPart = {
segments: [segment],
charCount: segment.charCount,
startFloor: segment.startFloor,
endFloor: segment.endFloor,
};
} else {
// 继续添加到当前Part
currentPart.segments.push(segment);
currentPart.charCount = newCharCount;
currentPart.endFloor = segment.endFloor;
}
}
// 处理最后一个Part
if (currentPart.segments.length > 0) {
parts.push(finalizePart(currentPart, parts.length));
}
Logger.log(`[SummarySplitter] 计算出 ${parts.length} 个Part`);
return parts;
}
/**
* 完成Part对象的构建
* @param {Object} partData Part临时数据
* @param {number} index Part索引
* @returns {Part} 完整的Part对象
*/
function finalizePart(partData, index) {
const content = partData.segments.map(s => s.content).join('\n\n---\n\n');
return {
id: generatePartId(partData.startFloor, partData.endFloor),
index,
startFloor: partData.startFloor,
endFloor: partData.endFloor,
charCount: partData.charCount,
segments: partData.segments,
content,
};
}
/**
* 分析总结世界书内容,返回拆分方案
* @param {string} content 总结世界书完整内容
* @param {Object} options 拆分选项
* @returns {Array<Part>} Part数组
*/
export function analyzeSummaryContent(content, options = {}) {
const mergedOptions = { ...DEFAULT_SPLIT_OPTIONS, ...options };
Logger.log(`[SummarySplitter] 开始分析内容,总长度: ${content?.length || 0}`);
// 1. 解析所有段落
const segments = parseSegments(content);
if (segments.length === 0) {
Logger.warn("[SummarySplitter] 未找到可识别的段落");
return [];
}
// 2. 计算总字符数
const totalChars = segments.reduce((sum, s) => sum + s.charCount, 0);
Logger.log(`[SummarySplitter] 总字符数: ${totalChars}, 段落数: ${segments.length}`);
// 3. 如果总内容小于目标字符数,不需要拆分
if (totalChars < mergedOptions.targetChars) {
Logger.log("[SummarySplitter] 内容少于目标字符数,不需要拆分");
// 返回单个Part
return [{
id: generatePartId(
segments[0]?.startFloor || 0,
segments[segments.length - 1]?.endFloor || 0
),
index: 0,
startFloor: segments[0]?.startFloor || 0,
endFloor: segments[segments.length - 1]?.endFloor || 0,
charCount: totalChars,
segments,
content: content,
needsSplit: false,
}];
}
// 4. 计算拆分方案
const parts = calculateSplitPlan(segments, mergedOptions);
// 标记需要拆分
parts.forEach(part => {
part.needsSplit = parts.length > 1;
});
return parts;
}
/**
* 判断内容是否需要拆分
* @param {string} content 总结世界书内容
* @param {number} threshold 阈值默认5万字符
* @returns {boolean} 是否需要拆分
*/
export function needsSplit(content, threshold = 50000) {
if (!content) return false;
return content.length >= threshold;
}
/**
* 获取内容的简要统计信息
* @param {string} content 总结世界书内容
* @returns {Object} 统计信息
*/
export function getContentStats(content) {
if (!content) {
return {
totalChars: 0,
segmentCount: 0,
estimatedParts: 0,
needsSplit: false,
};
}
const totalChars = content.length;
const segments = parseSegments(content);
const estimatedParts = Math.ceil(totalChars / DEFAULT_SPLIT_OPTIONS.targetChars);
return {
totalChars,
segmentCount: segments.length,
estimatedParts: Math.max(1, estimatedParts),
needsSplit: totalChars >= DEFAULT_SPLIT_OPTIONS.targetChars,
};
}
/**
* 格式化字符数显示
* @param {number} charCount 字符数
* @returns {string} 格式化后的字符串
*/
export function formatCharCount(charCount) {
if (charCount >= 10000) {
return `${(charCount / 10000).toFixed(1)}`;
}
return `${charCount}`;
}
/**
* 匹配已保存的Part配置
* @param {Array<Part>} newParts 新的Part列表
* @param {Object} savedConfigs 已保存的配置 { partId: apiConfig }
* @returns {Object} 匹配结果 { matched: [], unmatched: [] }
*/
export function matchPartConfigs(newParts, savedConfigs = {}) {
const matched = [];
const unmatched = [];
for (const part of newParts) {
const savedConfig = savedConfigs[part.id];
if (savedConfig) {
// 完全匹配
matched.push({
...part,
apiConfig: savedConfig,
matchType: 'exact',
});
} else {
// 尝试模糊匹配(楼层范围有重叠)
const fuzzyMatch = findFuzzyMatch(part, savedConfigs);
if (fuzzyMatch) {
matched.push({
...part,
apiConfig: fuzzyMatch.config,
matchType: 'fuzzy',
originalPartId: fuzzyMatch.partId,
});
} else {
unmatched.push(part);
}
}
}
return { matched, unmatched };
}
/**
* 模糊匹配Part配置
* @param {Part} part Part对象
* @param {Object} savedConfigs 已保存的配置
* @returns {Object|null} 匹配结果
*/
function findFuzzyMatch(part, savedConfigs) {
for (const [partId, config] of Object.entries(savedConfigs)) {
// 解析 partId 获取楼层范围
const match = partId.match(/^floor_(\d+)_(\d+)$/);
if (!match) continue;
const savedStart = parseInt(match[1], 10);
const savedEnd = parseInt(match[2], 10);
// 计算重叠度
const overlapStart = Math.max(part.startFloor, savedStart);
const overlapEnd = Math.min(part.endFloor, savedEnd);
if (overlapStart <= overlapEnd) {
const overlapRange = overlapEnd - overlapStart + 1;
const partRange = part.endFloor - part.startFloor + 1;
const savedRange = savedEnd - savedStart + 1;
// 重叠超过80%认为匹配
const overlapRatio = overlapRange / Math.min(partRange, savedRange);
if (overlapRatio >= 0.8) {
return { partId, config };
}
}
}
return null;
}
/**
* 获取总结世界书的完整内容
* @param {Object} book 世界书对象
* @returns {string} 完整内容
*/
export function getSummaryBookContent(book) {
if (!book || !book.entries) return '';
// 按条目顺序合并内容
const entries = Object.values(book.entries)
.filter(e => e.disable !== true)
.sort((a, b) => (a.order || 0) - (b.order || 0));
return entries.map(e => e.content || '').join('\n\n---\n\n');
}
export default {
parseSegments,
analyzeSummaryContent,
calculateSplitPlan,
needsSplit,
getContentStats,
formatCharCount,
matchPartConfigs,
generatePartId,
getSummaryBookContent,
};

1278
style.css

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@
</div>
<!-- 作者栏区域 - 版本号需要与 package.json/manifest.json/src/index.js 同步更新 -->
<div class="mm-author-section">
<div class="mm-author-info"><span class="mm-author-text">By可乐、繁华 | v0.4.7</span> <button class="mm-paw-btn" id="mm-paw-btn" title="点我~">🐾</button></div>
<div class="mm-author-info"><span class="mm-author-text">By可乐、繁华 | v0.5.0</span> <button class="mm-paw-btn" id="mm-paw-btn" title="点我~">🐾</button></div>
<div class="mm-flower-container" id="mm-flower-container"></div>
</div>
<div class="mm-theme-switcher">

View File

@@ -545,6 +545,187 @@
</small>
</div>
</div>
<!-- Amily表格并发可折叠 -->
<div class="mm-collapse-card" id="mm-table-filler-card">
<div class="mm-collapse-header" id="mm-table-filler-toggle">
<div class="mm-collapse-title">
<i class="fa-solid fa-table-cells"></i>
<span>Amily表格并发</span>
<span class="mm-collapse-badge" id="mm-table-filler-badge">关闭</span>
</div>
<i class="fa-solid fa-chevron-down mm-collapse-arrow"></i>
</div>
<div class="mm-collapse-body">
<!-- 模式兼容性警告 -->
<div id="mm-table-filler-mode-warning" class="mm-warning-box" style="display:none;">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>仅支持 Amily2「分步填表」模式当前<strong id="mm-table-filler-current-mode">-</strong></span>
</div>
<!-- 启用开关 -->
<div class="mm-setting-item">
<label class="mm-label-with-hint">
<input type="checkbox" id="mm-table-filler-enabled" />
启用Amily表格并发
<i class="fa-solid fa-circle-question mm-hint-icon" title="拦截 Amily2 表格填充请求将7个表格拆分后并发调用API提升填表效率"></i>
</label>
</div>
<!-- 并发API配置区域 -->
<div class="mm-multi-ai-provider-section">
<div class="mm-multi-ai-section-header">
<span class="mm-multi-ai-section-title">
并发API配置
<i class="fa-solid fa-circle-question mm-hint-icon" title="自动检测Amily2表格为每个表格配置独立的API实现多模型并发"></i>
</span>
<div class="mm-section-header-right">
<span class="mm-config-model-badge" id="mm-table-filler-config-count">检测中...</span>
<button type="button" id="mm-table-filler-add-table-api" class="mm-btn mm-btn-icon mm-btn-xs" title="刷新表格列表">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
</div>
<div class="mm-multi-ai-provider-list" id="mm-table-filler-api-list">
<!-- 动态生成表格API配置项 -->
</div>
<div class="mm-table-filler-api-empty" id="mm-table-filler-api-empty">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>正在检测 Amily2 表格...</span>
</div>
</div>
<!-- 高级设置(可折叠) -->
<div class="mm-table-filler-advanced">
<div class="mm-table-filler-advanced-header" id="mm-table-filler-advanced-toggle">
<span><i class="fa-solid fa-sliders"></i> 高级设置</span>
<div class="mm-advanced-header-right">
<span class="mm-collapse-badge" id="mm-prompt-mode-badge">共享模式</span>
<i class="fa-solid fa-chevron-down mm-collapse-arrow-sm"></i>
</div>
</div>
<div class="mm-table-filler-advanced-body" id="mm-table-filler-advanced-body" style="display:none;">
<!-- 调用模式与状态(同一行) -->
<div class="mm-setting-item mm-call-mode-row">
<div class="mm-call-mode-left">
<label class="mm-label-with-hint">
调用模式
<i class="fa-solid fa-circle-question mm-hint-icon" title="自动选择优先Bus联动否则拦截模式&#10;仅拦截劫持API调用无需Amily2配合&#10;仅Bus等待Amily2主动调用"></i>
</label>
<select id="mm-table-filler-call-mode" class="mm-select mm-select-sm">
<option value="auto">自动选择</option>
<option value="intercept_only">仅拦截</option>
<option value="bus_only">仅Bus</option>
</select>
</div>
<div class="mm-call-mode-right">
<span class="mm-status-badge mm-status-badge-sm" id="mm-table-filler-bus-status">Bus: 检测中</span>
<span class="mm-status-badge mm-status-badge-sm" id="mm-table-filler-intercept-status">拦截: 检测中</span>
</div>
</div>
<!-- 重试次数 -->
<div class="mm-setting-item">
<label class="mm-label-with-hint">
失败重试次数
<i class="fa-solid fa-circle-question mm-hint-icon" title="单个表格API调用失败后的重试次数&#10;使用指数退避策略&#10;设为0表示不重试"></i>
</label>
<div class="mm-slider-row">
<input
type="range"
id="mm-table-filler-retry-count"
value="2"
min="0"
max="5"
step="1"
/>
<span id="mm-table-filler-retry-count-value">2</span>
</div>
<small class="mm-hint">API调用失败后自动重试的次数0-5次</small>
</div>
<!-- 重试延迟时间 -->
<div class="mm-setting-item">
<label class="mm-label-with-hint">
重试延迟基数
<i class="fa-solid fa-circle-question mm-hint-icon" title="每次重试前的等待时间基数(毫秒)&#10;使用指数退避第1次=基数第2次=基数×2第3次=基数×4...&#10;例如基数2000ms第1次等2秒第2次等4秒第3次等8秒"></i>
</label>
<div class="mm-slider-row">
<input
type="range"
id="mm-table-filler-retry-delay"
value="2000"
min="500"
max="10000"
step="500"
/>
<span id="mm-table-filler-retry-delay-value">2000</span>
<span class="mm-unit">ms</span>
</div>
<small class="mm-hint">重试延迟基数避免请求过于频繁500-10000毫秒</small>
</div>
<!-- 提示词模式 -->
<div class="mm-setting-item">
<div class="mm-prompt-mode-row">
<label class="mm-label-with-hint">
提示词模式
<i class="fa-solid fa-circle-question mm-hint-icon" title="共享模式复用Amily2原始提示词&#10;独立模式:导入专用预设,每个表格使用专属提示词"></i>
</label>
<div class="mm-segmented-control">
<input type="radio" name="mm-prompt-mode" id="mm-prompt-mode-shared" value="shared" checked />
<label for="mm-prompt-mode-shared">共享</label>
<input type="radio" name="mm-prompt-mode" id="mm-prompt-mode-independent" value="independent" />
<label for="mm-prompt-mode-independent">独立</label>
<span class="mm-segmented-slider"></span>
</div>
</div>
</div>
<!-- 调试模式 -->
<div class="mm-setting-item">
<div class="mm-debug-mode-row">
<label class="mm-label-with-hint">
调试模式
<i class="fa-solid fa-circle-question mm-hint-icon" title="启用后可在发送前和合并后检查提示词内容"></i>
</label>
<label class="mm-switch">
<input type="checkbox" id="mm-table-filler-debug-mode" />
<span class="mm-switch-slider"></span>
</label>
</div>
</div>
<!-- 独立模式:预设区域 -->
<div id="mm-table-filler-independent-section" style="display:none;">
<div class="mm-setting-item">
<label class="mm-label-with-hint">
独立提示词模板
<i class="fa-solid fa-circle-question mm-hint-icon" title="为每个表格配置独立的提示词模板,使用占位符注入表格数据"></i>
</label>
<div class="mm-preset-row">
<span class="mm-preset-version" id="mm-table-filler-template-status">未配置</span>
<div class="mm-preset-actions">
<button type="button" id="mm-table-filler-edit-templates" class="mm-btn mm-btn-xs mm-btn-primary" title="编辑模板">
<i class="fa-solid fa-edit"></i> 编辑
</button>
</div>
</div>
</div>
<!-- 替换标签名 -->
<div class="mm-setting-item">
<label class="mm-label-with-hint">
替换标签名
<i class="fa-solid fa-circle-question mm-hint-icon" title="独立模式会查找原始<E58E9F><E5A78B><EFBFBD>示词中的 <标签名>...</标签名> 标签将其内容替换为你配置的独立预设。默认标签名为「Instructions for filling out the form」如果Amily使用了不同的标签名请在此修改。"></i>
</label>
<input type="text" id="mm-table-filler-tag-name" class="mm-input mm-input-sm" placeholder="Instructions for filling out the form" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -730,6 +911,12 @@
</div>
<div class="mm-modal-body">
<!-- 楼层+字符信息横幅(仅总结世界书显示) -->
<div id="mm-config-part-info" class="mm-part-info-banner" style="display: none;">
<i class="fa-solid fa-layer-group"></i>
<span id="mm-config-part-info-text">-</span>
</div>
<!-- Tab 切换(仅剧情优化时显示) -->
<div id="mm-config-tabs" class="mm-config-tabs" style="display: none;">
<button id="mm-config-tab-api" class="mm-config-tab active" data-tab="api">
@@ -1193,3 +1380,169 @@
</div>
</div>
<!-- Amily表格并发 API配置弹窗 -->
<div id="mm-table-filler-api-modal" class="mm-modal">
<div class="mm-modal-content">
<div class="mm-modal-header">
<h4 id="mm-table-filler-api-title">配置API</h4>
<button class="mm-modal-close mm-btn mm-btn-icon">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="mm-modal-body">
<!-- API 格式 -->
<div class="mm-form-group">
<label>API 格式</label>
<div class="mm-radio-group">
<label><input type="radio" name="mm-table-filler-api-format" value="openai" checked /> OpenAI 兼容</label>
<label><input type="radio" name="mm-table-filler-api-format" value="anthropic" /> Anthropic</label>
<label><input type="radio" name="mm-table-filler-api-format" value="google" /> Google</label>
<label><input type="radio" name="mm-table-filler-api-format" value="custom" /> 自定义</label>
</div>
</div>
<!-- API URL -->
<div class="mm-form-group">
<label>API URL <span class="mm-required">*</span></label>
<input type="text" id="mm-table-filler-api-url" placeholder="https://api.openai.com/v1" />
</div>
<!-- API Key -->
<div class="mm-form-group">
<label>API Key</label>
<input type="password" id="mm-table-filler-api-key" placeholder="sk-..." />
</div>
<!-- 模型名称 -->
<div class="mm-form-group">
<label>模型名称 <span class="mm-required">*</span></label>
<div class="mm-model-input-row">
<select id="mm-table-filler-api-model" class="mm-model-select">
<option value="" disabled selected>--- 请获取模型 ---</option>
</select>
<button type="button" id="mm-table-filler-fetch-models" class="mm-btn mm-btn-secondary" title="从API获取模型列表">
<i class="fa-solid fa-download"></i> 获取
</button>
</div>
</div>
<!-- Max Tokens 和 Temperature -->
<div class="mm-form-row">
<div class="mm-form-group">
<label>Max Tokens</label>
<input type="number" id="mm-table-filler-api-max-tokens" value="4096" min="100" max="32000" />
</div>
<div class="mm-form-group">
<label>Temperature</label>
<input type="range" id="mm-table-filler-api-temperature" value="0.7" min="0" max="1" step="0.1" />
<span id="mm-table-filler-api-temperature-value">0.7</span>
</div>
</div>
<!-- 自定义格式选项 -->
<div id="mm-table-filler-custom-options" class="mm-hidden">
<div class="mm-form-group">
<label>自定义请求模板 (JSON)</label>
<textarea id="mm-table-filler-custom-template" rows="3" placeholder='{"model": "{{model}}", "messages": {{messages}}}'></textarea>
</div>
<div class="mm-form-group">
<label>响应解析路径</label>
<input type="text" id="mm-table-filler-response-path" placeholder="choices.0.message.content" />
</div>
</div>
<!-- 测试连接 -->
<div class="mm-form-group">
<button id="mm-table-filler-test-connection" class="mm-btn mm-btn-secondary">
<i class="fa-solid fa-link"></i> 测试连接
</button>
<span id="mm-table-filler-test-result" class="mm-test-result"></span>
</div>
</div>
<div class="mm-modal-footer">
<button id="mm-table-filler-api-cancel" class="mm-btn mm-btn-secondary">取消</button>
<button id="mm-table-filler-api-save" class="mm-btn mm-btn-primary">保存配置</button>
</div>
</div>
</div>
<!-- Amily表格并发 表格选择弹窗 -->
<div id="mm-table-filler-select-modal" class="mm-modal">
<div class="mm-modal-content mm-modal-sm">
<div class="mm-modal-header">
<h4>选择表格</h4>
<button class="mm-modal-close mm-btn mm-btn-icon">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="mm-modal-body">
<div class="mm-table-select-list" id="mm-table-filler-table-list">
<!-- 动态生成表格选择列表 -->
</div>
</div>
<div class="mm-modal-footer">
<button id="mm-table-filler-select-cancel" class="mm-btn mm-btn-secondary">取消</button>
</div>
</div>
</div>
<!-- 独立模式模板编辑弹窗 -->
<div id="mm-independent-template-modal" class="mm-modal">
<div class="mm-modal-content mm-modal-large">
<div class="mm-modal-header">
<h4><i class="fa-solid fa-edit"></i> 独立模式 - 表格提示词配置</h4>
<button class="mm-modal-close mm-btn mm-btn-icon">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="mm-modal-body">
<div class="mm-independent-template-intro">
<div class="mm-placeholder-help">
<span class="mm-placeholder-title">
<i class="fa-solid fa-code"></i> 可用占位符
<i class="fa-solid fa-circle-question mm-hint-icon" title="为每个表格配置独立的提示词模板。未配置的表格将使用共享模式处理。"></i>
</span>
<code class="mm-placeholder-item" title="单个表格完整数据块">{{tableData}}</code>
<code class="mm-placeholder-item" title="表格名称(如:角色表)">{{tableName}}</code>
<code class="mm-placeholder-item" title="表格索引号0">{{tableIndex}}</code>
</div>
</div>
<!-- 表格模板列表(手风琴折叠) -->
<div class="mm-template-list" id="mm-independent-template-list">
<!-- 动态生成表格模板项 -->
<div class="mm-template-loading">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>加载表格列表...</span>
</div>
</div>
</div>
<div class="mm-modal-footer">
<div class="mm-modal-actions-left">
<button id="mm-independent-template-import" class="mm-btn mm-btn-secondary" title="导入全部模板配置">
<i class="fa-solid fa-upload"></i> 导入
</button>
<button id="mm-independent-template-export" class="mm-btn mm-btn-secondary" title="导出全部模板配置">
<i class="fa-solid fa-download"></i> 导出
</button>
<button id="mm-independent-template-restore-all" class="mm-btn mm-btn-secondary" title="将所有模板恢复为内置默认">
<i class="fa-solid fa-rotate-left"></i> 全部恢复默认
</button>
</div>
<div class="mm-modal-actions-right">
<button id="mm-independent-template-cancel" class="mm-btn mm-btn-secondary">取消</button>
<button id="mm-independent-template-save" class="mm-btn mm-btn-primary">保存全部</button>
</div>
</div>
</div>
</div>
<!-- 独立模式模板导入文件输入 -->
<input type="file" id="mm-independent-template-file" accept=".json" style="display:none;" />

View File

@@ -57,6 +57,7 @@ module.exports = (env, argv) => {
'@hooks': path.resolve(__dirname, 'src/hooks'),
'@ui': path.resolve(__dirname, 'src/ui'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@table-filler': path.resolve(__dirname, 'src/table-filler'),
}
},
};