Compare commits
319 Commits
AutoSignIn
...
AgentToken
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c18cbb19 | ||
|
|
02b3d61c04 | ||
|
|
13c12392d0 | ||
|
|
9332e17e6c | ||
|
|
8e5bfd58c0 | ||
|
|
a52e8ad0ed | ||
|
|
21ebda74b1 | ||
|
|
50b4d2558c | ||
|
|
94e14d86d7 | ||
|
|
a6f5d3a75b | ||
|
|
ab8e7c99b7 | ||
|
|
41663d5a27 | ||
|
|
d475578bcd | ||
|
|
c6a6877ff7 | ||
|
|
eb13d0ec62 | ||
|
|
59486dbf01 | ||
|
|
c6d91a74f2 | ||
|
|
9b476c61d3 | ||
|
|
b74a36bbe2 | ||
|
|
2fd3e1e37e | ||
|
|
a6030ad068 | ||
|
|
6327c89a78 | ||
|
|
a2be00a423 | ||
|
|
230cbc2094 | ||
|
|
5e364d9535 | ||
|
|
6692937c44 | ||
|
|
1df37a5149 | ||
|
|
718323f781 | ||
|
|
0705372054 | ||
|
|
9357638adc | ||
|
|
3c5e62cf8a | ||
|
|
f9cc36f93c | ||
|
|
f370a0041a | ||
|
|
e58e64dba3 | ||
|
|
1c114b1d68 | ||
|
|
9c9bdedd01 | ||
|
|
431474b441 | ||
|
|
4323e54552 | ||
|
|
c6bdf86f1f | ||
|
|
6b65fe10fe | ||
|
|
92d1fda892 | ||
|
|
0a4884f31e | ||
|
|
baee9de032 | ||
|
|
22aa436ace | ||
|
|
aeea6abe85 | ||
|
|
13b92ddc51 | ||
|
|
c91e24a2ef | ||
|
|
e9889051b3 | ||
|
|
a8dad0a3b0 | ||
|
|
68fe2dd54c | ||
|
|
63dbaf8657 | ||
|
|
cbac2b4d41 | ||
|
|
0910b6cc47 | ||
|
|
3500876b5c | ||
|
|
32b13f5bd6 | ||
|
|
d57de9b28a | ||
|
|
9141667569 | ||
|
|
c6cc8312f7 | ||
|
|
545ac141ff | ||
|
|
9862c81477 | ||
|
|
9fb3e09042 | ||
|
|
1ad19a5b23 | ||
|
|
527327c6cb | ||
|
|
a398dcb0b8 | ||
|
|
f3232dba0a | ||
|
|
e78a371663 | ||
|
|
068838d013 | ||
|
|
57f2ad523c | ||
|
|
615f85f02b | ||
|
|
74f47c7131 | ||
|
|
87224308d6 | ||
|
|
4b413d93a8 | ||
|
|
34e72a7ae3 | ||
|
|
944af59468 | ||
|
|
0f898f283e | ||
|
|
07a4731feb | ||
|
|
d3faafe6ee | ||
|
|
8bff87f1c5 | ||
|
|
889f393d2a | ||
|
|
e008da0c2b | ||
|
|
f3d1aa1ea9 | ||
|
|
77f399ffa0 | ||
|
|
e101d5c2bd | ||
|
|
a0d25abe25 | ||
|
|
bd3f6fe2e5 | ||
|
|
7f41a8a5f2 | ||
|
|
c33e7fe9df | ||
|
|
20e18117ab | ||
|
|
750d5917a2 | ||
|
|
fc23e3639d | ||
|
|
8a5b01f58f | ||
|
|
72bb3320ac | ||
|
|
2a4002032d | ||
|
|
be12618b0f | ||
|
|
4d2bc309ac | ||
|
|
2f78083c7f | ||
|
|
f1355f3400 | ||
|
|
6a03f626be | ||
|
|
5cf62a221a | ||
|
|
9662a4c457 | ||
|
|
3ad3de299c | ||
|
|
e760cd6afa | ||
|
|
8d30ba5c69 | ||
|
|
a9b66c4f43 | ||
|
|
cdc062d681 | ||
|
|
437b2b05d4 | ||
|
|
944919fc34 | ||
|
|
1ae826cf14 | ||
|
|
f438490ca5 | ||
|
|
b938ca5bf3 | ||
|
|
028103b900 | ||
|
|
bb1f159198 | ||
|
|
6fa42abc17 | ||
|
|
95b952c27f | ||
|
|
6631d06a04 | ||
|
|
1afce8c607 | ||
|
|
82c825e349 | ||
|
|
ff7d7b1fa4 | ||
|
|
328ed9884a | ||
|
|
4d1b90abc8 | ||
|
|
c5afdfc2da | ||
|
|
fdbd5ad501 | ||
|
|
d66605ae99 | ||
|
|
145e9747a9 | ||
|
|
87e4dcd211 | ||
|
|
633c8bad97 | ||
|
|
0927d0388a | ||
|
|
323289aa74 | ||
|
|
1f80e3b078 | ||
|
|
0ac725383e | ||
|
|
659f4f2b0d | ||
|
|
d65979323e | ||
|
|
d2503648a9 | ||
|
|
fffad33cc5 | ||
|
|
ae99671190 | ||
|
|
528b938f0f | ||
|
|
722f8da96d | ||
|
|
c53a3dc152 | ||
|
|
e29f59c28c | ||
|
|
c2c1320b18 | ||
|
|
e15733b7de | ||
|
|
02a2518fce | ||
|
|
861f416aad | ||
|
|
17cf85c1c1 | ||
|
|
6dbf539d88 | ||
|
|
24b9c2ec29 | ||
|
|
9a8e939414 | ||
|
|
a6b5286bf9 | ||
|
|
490c740c54 | ||
|
|
39d64a1cf4 | ||
|
|
a0272dfcaf | ||
|
|
44d3db72b4 | ||
|
|
48b5d1018e | ||
|
|
738e224ba3 | ||
|
|
6f2a0b2213 | ||
|
|
c2ccdf2b8e | ||
|
|
adb6230eea | ||
|
|
aa89750d1f | ||
|
|
4ca2d14076 | ||
|
|
8bd590e1ea | ||
|
|
d7effcd625 | ||
|
|
a7b830e4fd | ||
|
|
5b8f5b406f | ||
|
|
69b430bdc3 | ||
|
|
00d3346dfc | ||
|
|
7452540a93 | ||
|
|
d98902e536 | ||
|
|
5ecefb4a41 | ||
|
|
814149e0f3 | ||
|
|
d306145a14 | ||
|
|
da72e1b252 | ||
|
|
b6fc76cdb7 | ||
|
|
7842375d11 | ||
|
|
f6d83a5d31 | ||
|
|
97b8e7028a | ||
|
|
cc6cc55ad0 | ||
|
|
52063367f8 | ||
|
|
0003e4382b | ||
|
|
e2cbe22e8d | ||
|
|
436983e49e | ||
|
|
8829414a47 | ||
|
|
9f46c829db | ||
|
|
0de6531aed | ||
|
|
a5a96b74e3 | ||
|
|
f7b1a027f5 | ||
|
|
bde04fd7e1 | ||
|
|
af38909f58 | ||
|
|
5ccd80c4f1 | ||
|
|
ebf407b8b2 | ||
|
|
d0be1feec5 | ||
|
|
02fbbc87b4 | ||
|
|
ce1804cd0f | ||
|
|
53da73f11e | ||
|
|
fb3d8e9c0d | ||
|
|
5039a94bbf | ||
|
|
3ae993050b | ||
|
|
0dddb4675f | ||
|
|
56abaaf31c | ||
|
|
900f4fec95 | ||
|
|
88688672db | ||
|
|
cc6b95e5a1 | ||
|
|
377808f3da | ||
|
|
1d5e44e02c | ||
|
|
ff9c35041e | ||
|
|
d9afb64d00 | ||
|
|
6d60123272 | ||
|
|
84fcc3762f | ||
|
|
77b34dba5c | ||
|
|
4d8f36f674 | ||
|
|
5ccbb412eb | ||
|
|
4a0c700e6b | ||
|
|
00c65a0983 | ||
|
|
b961a52440 | ||
|
|
707feedda2 | ||
|
|
07c6ee1341 | ||
|
|
fd360cf21d | ||
|
|
a267df9e5d | ||
|
|
8feecbcb42 | ||
|
|
4224939f30 | ||
|
|
234ceba60c | ||
|
|
5c8a6647e2 | ||
|
|
5b763dff42 | ||
|
|
ee453841df | ||
|
|
6768d2c244 | ||
|
|
cb14efcc68 | ||
|
|
7871dfd0b8 | ||
|
|
99d1bfe37e | ||
|
|
b65c1b8bf7 | ||
|
|
517a16f0a3 | ||
|
|
89bfb9750d | ||
|
|
01eac66a6a | ||
|
|
cd53b8d454 | ||
|
|
d986f45634 | ||
|
|
0ceb633d96 | ||
|
|
2965743cfe | ||
|
|
9fa02d62e2 | ||
|
|
b2bd0f3701 | ||
|
|
de0e83f830 | ||
|
|
94b6df246e | ||
|
|
6b895919a0 | ||
|
|
a9830202e8 | ||
|
|
e96eece117 | ||
|
|
107b8e408f | ||
|
|
6629aeadef | ||
|
|
b0e5680260 | ||
|
|
a322274d77 | ||
|
|
be2289739a | ||
|
|
7536a8782e | ||
|
|
4d71a24fbc | ||
|
|
85ac9dd393 | ||
|
|
75c65b96d4 | ||
|
|
7d8433b768 | ||
|
|
d66413dd7a | ||
|
|
a0c9afc3ed | ||
|
|
e0c39170e6 | ||
|
|
8e199afe24 | ||
|
|
e68d915f36 | ||
|
|
b3e78c3e5e | ||
|
|
f02b90552b | ||
|
|
e93bfc6667 | ||
|
|
131463cfbe | ||
|
|
b963398987 | ||
|
|
ed395a26a9 | ||
|
|
03a2b35930 | ||
|
|
5a642e1e51 | ||
|
|
a8813b0272 | ||
|
|
66ce816a31 | ||
|
|
241e3200f8 | ||
|
|
19f52d6217 | ||
|
|
884efaebbf | ||
|
|
b51ba3d92a | ||
|
|
ec74481160 | ||
|
|
c60a4f01aa | ||
|
|
e34cafd641 | ||
|
|
5f8bb72641 | ||
|
|
df3e42987a | ||
|
|
8a738b7684 | ||
|
|
491f40663b | ||
|
|
fe8a7c6cd2 | ||
|
|
6245940466 | ||
|
|
c86cbc473f | ||
|
|
d93665a572 | ||
|
|
250ee4ada8 | ||
|
|
dfe2247b25 | ||
|
|
858261ddcc | ||
|
|
47bf56afe4 | ||
|
|
af3956d86f | ||
|
|
a69feb73ca | ||
|
|
88b29169fc | ||
|
|
2c9e108ac4 | ||
|
|
73b2d778a0 | ||
|
|
bf67d6e567 | ||
|
|
5e9da0802d | ||
|
|
2811021996 | ||
|
|
8c0a05b2de | ||
|
|
bb070bf83e | ||
|
|
21aec36ea5 | ||
|
|
6019cf92ac | ||
|
|
42d5dd1e89 | ||
|
|
0b3313e078 | ||
|
|
5684ba056a | ||
|
|
44af7dbb78 | ||
|
|
2102a03740 | ||
|
|
0a9cadf7ab | ||
|
|
279efe8000 | ||
|
|
fd92e58f81 | ||
|
|
fe93e46e02 | ||
|
|
cbf541992f | ||
|
|
8e1d336250 | ||
|
|
12e0e2b9f5 | ||
|
|
ac914f70f3 | ||
|
|
a07b8a4f4a | ||
|
|
6960b3f7aa | ||
|
|
fe83ff1be8 | ||
|
|
6357dc8e4a | ||
|
|
f1d94d0aa3 | ||
|
|
53dd3bc796 | ||
|
|
a9d528fc05 | ||
|
|
0388c437b1 |
22
docs/FAQ.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# MoviePilot 插件常见问题
|
||||
|
||||
常见问题已从主 README 拆分为独立文档,按主题查阅即可。
|
||||
|
||||
- [1. 如何扩展消息推送渠道?](./faq/01-extend-notification-channel.md)
|
||||
- [2. 如何在插件中实现远程命令响应?](./faq/02-remote-command-handler.md)
|
||||
- [3. 如何在插件中对外暴露API?](./faq/03-expose-plugin-api.md)
|
||||
- [4. 如何在插件中注册公共定时服务?](./faq/04-register-service.md)
|
||||
- [5. 如何通过插件增强MoviePilot的识别功能?](./faq/05-enhance-recognition.md)
|
||||
- [6. 如何扩展内建索引器的索引站点?](./faq/06-extend-indexer-sites.md)
|
||||
- [7. 如何在插件中调用API接口?](./faq/07-call-api-from-plugin.md)
|
||||
- [8. 如何将插件内容显示到仪表板?](./faq/08-render-dashboard.md)
|
||||
- [9. 如何扩展探索功能的媒体数据源?](./faq/09-extend-discovery-source.md)
|
||||
- [10. 如何扩展推荐功能的媒体数据源?](./faq/10-extend-recommend-source.md)
|
||||
- [11. 如何通过插件重载实现系统模块功能?](./faq/11-override-system-module.md)
|
||||
- [12. 如何通过插件扩展支持的存储类型?](./faq/12-extend-storage-type.md)
|
||||
- [13. 如何将插件功能集成到工作流?](./faq/13-integrate-workflow.md)
|
||||
- [14. 如何在插件中通过消息持续与用户交互?](./faq/14-message-interaction.md)
|
||||
- [15. 如何在插件中使用系统级统一缓存?](./faq/15-use-system-cache.md)
|
||||
- [16. 如何在插件中注册智能体工具?](./faq/16-register-agent-tools.md)
|
||||
- [17. 如何将插件页面注册到主界面左侧导航栏?](./faq/17-register-plugin-sidebar-nav.md)
|
||||
- [18. 如何限定插件可安装的 MoviePilot 主系统版本?](./faq/18-limit-moviepilot-version.md)
|
||||
267
docs/Repository_Guide.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# MoviePilot-Plugins 仓库指南
|
||||
|
||||
本文档面向维护者和插件开发者,说明 `MoviePilot-Plugins` 在整个 MoviePilot 体系中的职责、目录约定、元数据规则、发布流程,以及与 `MoviePilot` / `MoviePilot-Frontend` 两个主仓库的边界。
|
||||
|
||||
## 1. 仓库职责
|
||||
|
||||
`MoviePilot-Plugins` 不是独立运行时,而是插件市场和插件源码仓库。
|
||||
|
||||
- `MoviePilot` 后端仓库负责:
|
||||
- 插件类加载与生命周期管理
|
||||
- 事件与链式扩展
|
||||
- 插件 API / 服务 / 仪表板注册
|
||||
- 配置、插件数据、权限控制
|
||||
- 插件安装、升级、分身、远程组件静态资源服务
|
||||
- `MoviePilot-Frontend` 前端仓库负责:
|
||||
- 插件市场与插件卡片展示
|
||||
- 插件配置页、详情页、仪表板渲染
|
||||
- Vue 联邦远程组件加载
|
||||
- 插件侧栏全页入口
|
||||
- `MoviePilot-Plugins` 负责:
|
||||
- 插件源码目录
|
||||
- 插件市场索引文件
|
||||
- 插件图标资源
|
||||
- 插件开发与维护文档
|
||||
|
||||
因此,开发插件时要避免把“宿主逻辑”误写进本仓库文档。例如:
|
||||
|
||||
- 某个 `get_api()` 为什么没有被挂载,应该先看 `MoviePilot/app/api/endpoints/plugin.py`
|
||||
- 某个 Vue 远程页面为什么没有出现在侧栏,应该先看 `MoviePilot-Frontend` 的联邦加载与菜单逻辑
|
||||
- 某个插件为什么在插件市场里没显示,才应该先看本仓库的 `package.json` / `package.v2.json`
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
本仓库当前采用如下结构:
|
||||
|
||||
```text
|
||||
MoviePilot-Plugins/
|
||||
├── plugins/ # 默认插件目录
|
||||
├── plugins.v2/ # V2 专用插件目录
|
||||
├── icons/ # 插件图标
|
||||
├── docs/ # 文档
|
||||
├── package.json # 默认插件索引
|
||||
├── package.v2.json # V2 优先插件索引
|
||||
└── .github/workflows/ # 自动发布工作流
|
||||
```
|
||||
|
||||
关键约定:
|
||||
|
||||
- 一个插件一个目录。
|
||||
- 目录名必须是插件类名的小写,例如 `class AutoSignIn` 对应目录 `autosignin/`。
|
||||
- 插件主类必须定义在该目录的 `__init__.py` 中。
|
||||
- 插件目录内可附带:
|
||||
- `requirements.txt`:额外 Python 依赖
|
||||
- `README.md`:插件专属使用说明
|
||||
- `dist/assets/`:Vue 联邦构建产物
|
||||
- 其他运行时所需静态文件
|
||||
|
||||
## 3. 元数据文件说明
|
||||
|
||||
### 3.1 `package.json`
|
||||
|
||||
默认插件索引文件,用于:
|
||||
|
||||
- 旧版兼容或默认版本插件
|
||||
- 对 V2 兼容但不需要单独维护代码目录的插件
|
||||
|
||||
如果某个默认插件也能用于 V2,需要在条目上声明:
|
||||
|
||||
```json
|
||||
{
|
||||
"MyPlugin": {
|
||||
"version": "1.2.3",
|
||||
"v2": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 `package.v2.json`
|
||||
|
||||
V2 优先插件索引文件。MoviePilot 在 V2 环境下会优先读取这里的条目;找不到时,才会回退到 `package.json` 中声明了 `"v2": true` 的兼容插件。
|
||||
|
||||
### 3.3 常用字段
|
||||
|
||||
每个索引条目通常包含:
|
||||
|
||||
- `name`:插件展示名
|
||||
- `description`:插件简介
|
||||
- `labels`:标签,多个标签使用英文逗号分隔
|
||||
- `version`:插件版本
|
||||
- `icon`:图标文件名或完整 HTTP URL
|
||||
- `author`:作者
|
||||
- `level`:用户可见级别
|
||||
- `system_version`:可安装的 MoviePilot 主系统版本范围,格式参考 pip 依赖版本约束,例如 `">=2.12.0,<3"`
|
||||
- `history`:更新日志
|
||||
- `release`:是否使用 GitHub Release 压缩包发布
|
||||
- `v2`:默认索引中的插件是否兼容 V2
|
||||
|
||||
这些字段是“插件市场展示元数据”,而不是运行时唯一真相。真正加载后的插件类仍然需要在代码里声明自己的 `plugin_name`、`plugin_desc`、`plugin_version` 等属性。两者必须同步。
|
||||
|
||||
## 4. 版本选择与加载规则
|
||||
|
||||
MoviePilot 当前的插件版本选择逻辑可以概括为:
|
||||
|
||||
1. 先确定当前宿主版本标识,例如 `v2`
|
||||
2. 优先检查 `package.v2.json` 中是否存在该插件
|
||||
3. 若不存在,再检查 `package.json`
|
||||
4. 只有当 `package.json` 中对应条目显式声明 `"v2": true` 时,才会作为 V2 兼容插件继续使用
|
||||
5. 如果条目声明了 `system_version`,安装、更新检测和本地插件同步会继续检查当前 MoviePilot 主程序版本是否落在该范围内;未声明则不检查
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同一个插件若在 `package.v2.json` 中已有专用实现,就不要再依赖 `package.json` 中的兼容声明做“隐式覆盖”。
|
||||
- 新写的 V2 专用插件,优先放 `plugins.v2/`,并把元数据写入 `package.v2.json`。
|
||||
- 真正跨版本共用一套实现时,再使用 `package.json + "v2": true` 的方式。
|
||||
- 依赖宿主新增能力的插件需要同步声明 `system_version`,否则旧版 MoviePilot 仍可能看到更新入口但安装后无法加载。
|
||||
|
||||
## 5. 与宿主仓库的协作边界
|
||||
|
||||
### 5.1 与 `MoviePilot` 后端的边界
|
||||
|
||||
本仓库只保存插件实现,不应复制宿主的公共能力。插件应优先复用后端仓库已经提供的抽象,例如:
|
||||
|
||||
- `_PluginBase`
|
||||
- `eventmanager`
|
||||
- `DownloaderHelper` / `MediaServerHelper` / `NotificationHelper`
|
||||
- `save_data()` / `get_data()` / `get_data_path()`
|
||||
- 插件 API 动态注册
|
||||
- 插件仪表板、服务、工作流动作、智能体工具扩展点
|
||||
|
||||
如果插件需要新增宿主接口,例如:
|
||||
|
||||
- 新的链式事件
|
||||
- 新的插件 API 渲染能力
|
||||
- 新的工作流动作契约
|
||||
- 新的智能体工具注入点
|
||||
|
||||
应先在 `MoviePilot` 中补齐宿主能力,再回到本仓库落插件实现。
|
||||
|
||||
### 5.2 与 `MoviePilot-Frontend` 的边界
|
||||
|
||||
插件有两种主要 UI 方式:
|
||||
|
||||
- Vuetify JSON 配置
|
||||
- Vue 联邦远程组件
|
||||
|
||||
前者的宿主渲染在 `MoviePilot-Frontend` 已经实现,插件只需要返回 JSON 结构;后者需要遵守前端仓库的联邦组件暴露规范、共享依赖规范和侧栏入口规范。
|
||||
|
||||
如果你在本仓库写了 Vue 模式插件,需要同时关注:
|
||||
|
||||
- `MoviePilot-Frontend/docs/module-federation-guide.md`
|
||||
- `MoviePilot-Frontend/src/utils/federationLoader.ts`
|
||||
- `MoviePilot-Frontend` 中与插件页面、侧栏导航、仪表板相关的组件
|
||||
|
||||
## 6. 开发一个插件时的推荐流程
|
||||
|
||||
### 6.1 先判断插件形态
|
||||
|
||||
- 只是扩展后端能力、配置项简单:优先写 Vuetify JSON 模式插件
|
||||
- 需要复杂交互或完整页面:使用 Vue 联邦模式
|
||||
- 只是给现有插件补 V2 兼容:优先评估能否复用 `package.json + "v2": true`
|
||||
- 已经与 V1 / 默认版本差异很大:直接转为 `plugins.v2/ + package.v2.json`
|
||||
|
||||
### 6.2 再落目录与元数据
|
||||
|
||||
最小步骤通常是:
|
||||
|
||||
1. 在 `plugins/` 或 `plugins.v2/` 下新建目录
|
||||
2. 在 `__init__.py` 中实现插件类
|
||||
3. 如有依赖,增加 `requirements.txt`
|
||||
4. 在 `package.json` 或 `package.v2.json` 中补齐元数据
|
||||
5. 如有插件文档,在插件目录补充 `README.md`
|
||||
6. 如有 Vue UI,构建后把产物放进 `dist/assets/`
|
||||
|
||||
### 6.3 维护版本一致性
|
||||
|
||||
发布前至少核对以下三处是否一致:
|
||||
|
||||
- 索引里的 `version`
|
||||
- 插件类里的 `plugin_version`
|
||||
- `history` 中最新一条变更说明
|
||||
|
||||
## 7. 校验建议
|
||||
|
||||
这个仓库没有独立的完整测试宿主,因此校验应该尽量贴近真实运行层。
|
||||
|
||||
### 7.1 Python 插件代码
|
||||
|
||||
建议在宿主环境里做最小校验:
|
||||
|
||||
```bash
|
||||
# 对修改过的插件文件做语法检查
|
||||
python3 -m py_compile plugins.v2/myplugin/__init__.py
|
||||
|
||||
# 或者对整个插件目录做批量编译检查
|
||||
python3 -m compileall plugins.v2/myplugin
|
||||
|
||||
# 顺手检查 diff 中是否有空白符问题
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### 7.2 Vue 远程组件
|
||||
|
||||
如果插件使用独立的前端工程,建议至少执行:
|
||||
|
||||
```bash
|
||||
# 类型检查
|
||||
yarn typecheck
|
||||
|
||||
# 构建联邦产物
|
||||
yarn build
|
||||
```
|
||||
|
||||
然后再把构建产物拷贝到插件目录中的 `dist/assets/`。
|
||||
|
||||
### 7.3 宿主联调
|
||||
|
||||
以下场景必须回到宿主仓库验证:
|
||||
|
||||
- `get_api()` 是否真正注册成功
|
||||
- `get_service()` 是否出现在服务列表
|
||||
- `get_dashboard()` / `get_dashboard_meta()` 是否正常显示
|
||||
- `get_render_mode() == "vue"` 的远程组件是否能成功加载
|
||||
- `get_sidebar_nav()` 是否正确出现在前端侧栏
|
||||
|
||||
## 8. 发布流程
|
||||
|
||||
本仓库的自动发布逻辑位于 `.github/workflows/release.yml`,当前规则如下:
|
||||
|
||||
- 只有当 `package.json` 或 `package.v2.json` 发生变更时,工作流才会触发
|
||||
- 只有索引条目中声明了 `"release": true` 的插件会参与自动打包
|
||||
- 工作流会尝试在 `plugins/<plugin_id_lower>` 和 `plugins.v2/<plugin_id_lower>` 中寻找插件目录
|
||||
- Release Tag 格式为 `插件ID_v插件版本号`
|
||||
- 压缩包文件名格式为 `插件目录小写_v插件版本号.zip`
|
||||
- 若插件目录自上一个 Tag 以来没有变化,则会跳过打包
|
||||
- 若同名 Release / Tag 已存在,工作流会先删除旧对象再重新创建
|
||||
|
||||
这意味着发布一个可下载压缩包的插件时,最少要确认:
|
||||
|
||||
1. 插件目录存在且名称正确
|
||||
2. 索引条目中已声明 `"release": true`
|
||||
3. 索引版本号与代码版本号一致
|
||||
4. 目标目录自上一个同插件 Tag 以来确实有代码变化
|
||||
|
||||
## 9. 文档维护建议
|
||||
|
||||
如果一次改动同时涉及:
|
||||
|
||||
- 插件能力扩展点变更
|
||||
- 宿主后端新增接口或新契约
|
||||
- 前端新增加载规则或侧栏行为
|
||||
|
||||
应同步更新对应仓库文档,不要只改本仓库 README。
|
||||
|
||||
推荐文档分工:
|
||||
|
||||
- 本仓库 `README.md`:总览与主入口
|
||||
- 本仓库 `docs/FAQ.md`:FAQ 索引与场景入口
|
||||
- 本仓库 `docs/Repository_Guide.md`:仓库维护与发布规则
|
||||
- 本仓库 `docs/V2_Plugin_Development.md`:V2 插件开发主文档
|
||||
- 前端仓库 `docs/module-federation-guide.md`:Vue 联邦远程组件开发规范
|
||||
|
||||
## 10. 开始之前先读哪一份
|
||||
|
||||
- 想知道“这个仓库该怎么维护、改哪个文件、怎么发布”:看本文档
|
||||
- 想直接开发一个 V2 插件:看 `docs/V2_Plugin_Development.md`
|
||||
- 想做 Vue 远程组件或侧栏全页:看前端仓库模块联邦文档
|
||||
- 想按功能场景抄现成模式:看 `docs/FAQ.md` 和 `docs/faq/` 下的独立 FAQ 文档
|
||||
102
docs/faq/01-extend-notification-channel.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 如何扩展消息推送渠道?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 注册 `NoticeMessage` 事件响应,`event_data` 包含消息中的所有数据,参考 `IYUU消息通知` 插件:
|
||||
|
||||
注册事件:
|
||||
```python
|
||||
@eventmanager.register(EventType.NoticeMessage)
|
||||
```
|
||||
|
||||
- 事件对象:
|
||||
```json
|
||||
{
|
||||
"channel": MessageChannel|None,
|
||||
"type": NotificationType|None,
|
||||
"title": str,
|
||||
"text": str,
|
||||
"image": str,
|
||||
"userid": str|int,
|
||||
}
|
||||
```
|
||||
|
||||
- MoviePilot中所有事件清单(V2版本),可以通过实现这些事情来扩展功能,同时插件之前也可以通过发送和监听事件实现联动(V1、V2事件清单有差异,且可能会变化,最新请参考源代码)。
|
||||
```python
|
||||
# 异步广播事件
|
||||
class EventType(Enum):
|
||||
# 插件需要重载
|
||||
PluginReload = "plugin.reload"
|
||||
# 触发插件动作
|
||||
PluginAction = "plugin.action"
|
||||
# 插件触发事件
|
||||
PluginTriggered = "plugin.triggered"
|
||||
# 执行命令
|
||||
CommandExcute = "command.excute"
|
||||
# 站点已删除
|
||||
SiteDeleted = "site.deleted"
|
||||
# 站点已更新
|
||||
SiteUpdated = "site.updated"
|
||||
# 站点已刷新
|
||||
SiteRefreshed = "site.refreshed"
|
||||
# 转移完成
|
||||
TransferComplete = "transfer.complete"
|
||||
# 下载已添加
|
||||
DownloadAdded = "download.added"
|
||||
# 删除历史记录
|
||||
HistoryDeleted = "history.deleted"
|
||||
# 删除下载源文件
|
||||
DownloadFileDeleted = "downloadfile.deleted"
|
||||
# 删除下载任务
|
||||
DownloadDeleted = "download.deleted"
|
||||
# 收到用户外来消息
|
||||
UserMessage = "user.message"
|
||||
# 收到Webhook消息
|
||||
WebhookMessage = "webhook.message"
|
||||
# 发送消息通知
|
||||
NoticeMessage = "notice.message"
|
||||
# 订阅已添加
|
||||
SubscribeAdded = "subscribe.added"
|
||||
# 订阅已调整
|
||||
SubscribeModified = "subscribe.modified"
|
||||
# 订阅已删除
|
||||
SubscribeDeleted = "subscribe.deleted"
|
||||
# 订阅已完成
|
||||
SubscribeComplete = "subscribe.complete"
|
||||
# 系统错误
|
||||
SystemError = "system.error"
|
||||
# 刮削元数据
|
||||
MetadataScrape = "metadata.scrape"
|
||||
# 模块需要重载
|
||||
ModuleReload = "module.reload"
|
||||
|
||||
|
||||
# 同步链式事件
|
||||
class ChainEventType(Enum):
|
||||
# 名称识别
|
||||
NameRecognize = "name.recognize"
|
||||
# 认证验证
|
||||
AuthVerification = "auth.verification"
|
||||
# 认证拦截
|
||||
AuthIntercept = "auth.intercept"
|
||||
# 命令注册
|
||||
CommandRegister = "command.register"
|
||||
# 整理重命名
|
||||
TransferRename = "transfer.rename"
|
||||
# 整理拦截
|
||||
TransferIntercept = "transfer.intercept"
|
||||
# 资源选择
|
||||
ResourceSelection = "resource.selection"
|
||||
# 资源下载
|
||||
ResourceDownload = "resource.download"
|
||||
# 发现数据源
|
||||
DiscoverSource = "discover.source"
|
||||
# 媒体识别转换
|
||||
MediaRecognizeConvert = "media.recognize.convert"
|
||||
# 推荐数据源
|
||||
RecommendSource = "recommend.source"
|
||||
# 工作流执行
|
||||
WorkflowExecution = "workflow.execution"
|
||||
# 存储操作选择
|
||||
StorageOperSelection = "storage.operation"
|
||||
```
|
||||
30
docs/faq/02-remote-command-handler.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 如何在插件中实现远程命令响应?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 实现 `get_command()` 方法,按以下格式返回命令列表:
|
||||
```json
|
||||
[{
|
||||
"cmd": "/douban_sync", // 动作ID,必须以/开始
|
||||
"event": EventType.PluginAction,// 事件类型,固定值
|
||||
"desc": "命令名称",
|
||||
"category": "命令菜单(微信)",
|
||||
"data": {
|
||||
"action": "douban_sync" // 动作标识
|
||||
}
|
||||
}]
|
||||
```
|
||||
|
||||
- 注册 `PluginAction` 事件响应,根据 `event_data.action` 是否为插件设定的动作标识来判断是否为本插件事件:
|
||||
|
||||
注册事件:
|
||||
```python
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
```
|
||||
|
||||
事件判定:
|
||||
```python
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "douban_sync":
|
||||
return
|
||||
```
|
||||
17
docs/faq/03-expose-plugin-api.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 如何在插件中对外暴露API?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 实现 `get_api()` 方法,按以下格式返回API列表:
|
||||
```json
|
||||
[{
|
||||
"path": "/refresh_by_domain", // API路径,必须以/开始
|
||||
"endpoint": self.refresh_by_domain, // API响应方法
|
||||
"methods": ["GET"], // 请求方式:GET/POST/PUT/DELETE
|
||||
"summary": "刷新站点数据", // API名称
|
||||
"description": "刷新对应域名的站点数据", // API描述
|
||||
}]
|
||||
```
|
||||
注意:在插件中暴露API接口时注意安全控制,推荐使用`settings.API_TOKEN`进行身份验证。
|
||||
|
||||
- 在对应的方法中实现API响应方法逻辑,通过 `http://localhost:3001/docs` 查看API文档和调试
|
||||
15
docs/faq/04-register-service.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 如何在插件中注册公共定时服务?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 注册公共定时服务后,可以在`设定-服务`中查看运行状态和手动启动,更加便捷。
|
||||
- 实现 `get_service()` 方法,按以下格式返回服务注册信息:
|
||||
```json
|
||||
[{
|
||||
"id": "服务ID", // 不能与其它服务ID重复
|
||||
"name": "服务名称", // 显示在服务列表中的名称
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx, // 服务方法
|
||||
"kwargs": {} // 定时器参数,参考APScheduler
|
||||
}]
|
||||
```
|
||||
33
docs/faq/05-enhance-recognition.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 如何通过插件增强MoviePilot的识别功能?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- V1按如下步骤实现,V2版本直接实现对应链式事件即可,参考ChatGPT插件。注意:只有主程序无法识别时才会触发。
|
||||
- 注册 `NameRecognize` 事件,实现识别逻辑。
|
||||
```python
|
||||
@eventmanager.register(EventType.NameRecognize)
|
||||
```
|
||||
|
||||
- 完成识别后发送 `NameRecognizeResult` 事件,将识别结果注入主程序
|
||||
```python
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognizeResult,
|
||||
{
|
||||
'title': title, # 原传入标题
|
||||
'name': str, # 识别的名称
|
||||
'year': str, # 识别的年份
|
||||
'season': int, # 识别的季号
|
||||
'episode': int, # 识别的集号
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- 注意:识别请求需要在15秒内响应,否则结果会被丢弃;**插件未启用或参数不完整时应立即回复空结果事件,避免主程序等待;** 多个插件开启识别功能时,以先收到的识别结果事件为准。
|
||||
```python
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognizeResult,
|
||||
{
|
||||
'title': title # 结果只含原标题,代表空识别结果事件
|
||||
}
|
||||
)
|
||||
```
|
||||
259
docs/faq/06-extend-indexer-sites.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# 如何扩展内建索引器的索引站点?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
- 通过调用 `SitesHelper().add_indexer(domain: str, indexer: dict)` 方法,新增或修改内建索引器的支持范围,其中`indexer`为站点配置Json,格式示例如下:
|
||||
|
||||
示例一:
|
||||
```json
|
||||
{
|
||||
"id": "nyaa",
|
||||
"name": "Nyaa",
|
||||
"domain": "https://nyaa.si/",
|
||||
"encoding": "UTF-8",
|
||||
"public": true,
|
||||
"proxy": true,
|
||||
"result_num": 100,
|
||||
"timeout": 30,
|
||||
"search": {
|
||||
"paths": [
|
||||
{
|
||||
"path": "?f=0&c=0_0&q={keyword}",
|
||||
"method": "get"
|
||||
}
|
||||
]
|
||||
},
|
||||
"browse": {
|
||||
"path": "?p={page}",
|
||||
"start": 1
|
||||
},
|
||||
"torrents": {
|
||||
"list": {
|
||||
"selector": "table.torrent-list > tbody > tr"
|
||||
},
|
||||
"fields": {
|
||||
"id": {
|
||||
"selector": "a[href*=\"/view/\"]",
|
||||
"attribute": "href",
|
||||
"filters": [
|
||||
{
|
||||
"name": "re_search",
|
||||
"args": [
|
||||
"\\d+",
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"selector": "td:nth-child(2) > a"
|
||||
},
|
||||
"details": {
|
||||
"selector": "td:nth-child(2) > a",
|
||||
"attribute": "href"
|
||||
},
|
||||
"download": {
|
||||
"selector": "td:nth-child(3) > a[href*=\"/download/\"]",
|
||||
"attribute": "href"
|
||||
},
|
||||
"date_added": {
|
||||
"selector": "td:nth-child(5)"
|
||||
},
|
||||
"size": {
|
||||
"selector": "td:nth-child(4)"
|
||||
},
|
||||
"seeders": {
|
||||
"selector": "td:nth-child(6)"
|
||||
},
|
||||
"leechers": {
|
||||
"selector": "td:nth-child(7)"
|
||||
},
|
||||
"grabs": {
|
||||
"selector": "td:nth-child(8)"
|
||||
},
|
||||
"downloadvolumefactor": {
|
||||
"case": {
|
||||
"*": 0
|
||||
}
|
||||
},
|
||||
"uploadvolumefactor": {
|
||||
"case": {
|
||||
"*": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
示例二:
|
||||
```json
|
||||
{
|
||||
"id": "xxx",
|
||||
"name": "站点名称",
|
||||
"domain": "https://www.xxx.com/",
|
||||
"ext_domains": [
|
||||
"https://www.xxx1.com/",
|
||||
"https://www.xxx2.com/"
|
||||
],
|
||||
"encoding": "UTF-8",
|
||||
"public": false,
|
||||
"search": {
|
||||
"paths": [
|
||||
{
|
||||
"path": "torrents.php",
|
||||
"method": "get"
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"search": "{keyword}",
|
||||
"search_area": 4
|
||||
},
|
||||
"batch": {
|
||||
"delimiter": " ",
|
||||
"space_replace": "_"
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"movie": [
|
||||
{
|
||||
"id": 401,
|
||||
"cat": "Movies",
|
||||
"desc": "Movies电影"
|
||||
},
|
||||
{
|
||||
"id": 405,
|
||||
"cat": "Anime",
|
||||
"desc": "Animations动漫"
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
"cat": "Documentary",
|
||||
"desc": "Documentaries纪录片"
|
||||
}
|
||||
],
|
||||
"tv": [
|
||||
{
|
||||
"id": 402,
|
||||
"cat": "TV",
|
||||
"desc": "TV Series电视剧"
|
||||
},
|
||||
{
|
||||
"id": 403,
|
||||
"cat": "TV",
|
||||
"desc": "TV Shows综艺"
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
"cat": "Documentary",
|
||||
"desc": "Documentaries纪录片"
|
||||
},
|
||||
{
|
||||
"id": 405,
|
||||
"cat": "Anime",
|
||||
"desc": "Animations动漫"
|
||||
}
|
||||
]
|
||||
},
|
||||
"torrents": {
|
||||
"list": {
|
||||
"selector": "table.torrents > tr:has(\"table.torrentname\")"
|
||||
},
|
||||
"fields": {
|
||||
"id": {
|
||||
"selector": "a[href*=\"details.php?id=\"]",
|
||||
"attribute": "href",
|
||||
"filters": [
|
||||
{
|
||||
"name": "re_search",
|
||||
"args": [
|
||||
"\\d+",
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"title_default": {
|
||||
"selector": "a[href*=\"details.php?id=\"]"
|
||||
},
|
||||
"title_optional": {
|
||||
"optional": true,
|
||||
"selector": "a[title][href*=\"details.php?id=\"]",
|
||||
"attribute": "title"
|
||||
},
|
||||
"title": {
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
|
||||
},
|
||||
"details": {
|
||||
"selector": "a[href*=\"details.php?id=\"]",
|
||||
"attribute": "href"
|
||||
},
|
||||
"download": {
|
||||
"selector": "a[href*=\"download.php?id=\"]",
|
||||
"attribute": "href"
|
||||
},
|
||||
"imdbid": {
|
||||
"selector": "div.imdb_100 > a",
|
||||
"attribute": "href",
|
||||
"filters": [
|
||||
{
|
||||
"name": "re_search",
|
||||
"args": [
|
||||
"tt\\d+",
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"date_elapsed": {
|
||||
"selector": "td:nth-child(4) > span",
|
||||
"optional": true
|
||||
},
|
||||
"date_added": {
|
||||
"selector": "td:nth-child(4) > span",
|
||||
"attribute": "title",
|
||||
"optional": true
|
||||
},
|
||||
"size": {
|
||||
"selector": "td:nth-child(5)"
|
||||
},
|
||||
"seeders": {
|
||||
"selector": "td:nth-child(6)"
|
||||
},
|
||||
"leechers": {
|
||||
"selector": "td:nth-child(7)"
|
||||
},
|
||||
"grabs": {
|
||||
"selector": "td:nth-child(8)"
|
||||
},
|
||||
"downloadvolumefactor": {
|
||||
"case": {
|
||||
"img.pro_free": 0,
|
||||
"img.pro_free2up": 0,
|
||||
"img.pro_50pctdown": 0.5,
|
||||
"img.pro_50pctdown2up": 0.5,
|
||||
"img.pro_30pctdown": 0.3,
|
||||
"*": 1
|
||||
}
|
||||
},
|
||||
"uploadvolumefactor": {
|
||||
"case": {
|
||||
"img.pro_50pctdown2up": 2,
|
||||
"img.pro_free2up": 2,
|
||||
"img.pro_2up": 2,
|
||||
"*": 1
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"selector": "td:nth-child(2) > table > tr > td.embedded > span[style]",
|
||||
"contents": -1
|
||||
},
|
||||
"labels": {
|
||||
"selector": "td:nth-child(2) > table > tr > td.embedded > span.tags"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- 需要注意的是,如果你没有完成用户认证,通过插件配置进去的索引站点也是无法正常使用的。
|
||||
- **请不要添加对黄赌毒站点的支持,否则随时封闭接口。**
|
||||
23
docs/faq/07-call-api-from-plugin.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 如何在插件中调用API接口?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v1.8.4+` 版本)**
|
||||
- 在插件的数据页面支持`GET/POST`API接口调用,可调用插件自身、主程序或其它插件的API。
|
||||
- 在`get_page`中定义好元素的事件,以及相应的API参数,具体可参考插件`豆瓣想看`:
|
||||
```json
|
||||
{
|
||||
"component": "VDialogCloseBtn", // 触发事件的元素
|
||||
"events": {
|
||||
"click": { // 点击事件
|
||||
"api": "plugin/DoubanSync/delete_history", // API的相对路径
|
||||
"method": "get", // GET/POST
|
||||
"params": {
|
||||
// API上送参数
|
||||
"doubanid": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- 每次API调用完成后,均会自动刷新一次插件数据页。
|
||||
47
docs/faq/08-render-dashboard.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 如何将插件内容显示到仪表板?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v1.8.7+` 版本)**
|
||||
- 将插件的内容显示到仪表盘,并支持定义占据的单元格大小,插件产生的仪表板仅管理员可见。
|
||||
- 1. 根据插件需要展示的Widget内容规划展示内容的样式和规格,也可设计多个规格样式并提供配置项供用户选择。
|
||||
- 2. 实现 `get_dashboard_meta` 方法,定义仪表板key及名称,支持一个插件有多个仪表板:
|
||||
```python
|
||||
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
|
||||
"""
|
||||
获取插件仪表盘元信息
|
||||
返回示例:
|
||||
[{
|
||||
"key": "dashboard1", // 仪表盘的key,在当前插件范围唯一
|
||||
"name": "仪表盘1" // 仪表盘的名称
|
||||
}, {
|
||||
"key": "dashboard2",
|
||||
"name": "仪表盘2"
|
||||
}]
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 3. 实现 `get_dashboard` 方法,根据key返回仪表盘的详细配置信息,包括仪表盘的cols列配置(适配不同屏幕),以及仪表盘的页面配置json,具体可参考插件`站点数据统计`:
|
||||
```python
|
||||
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
|
||||
"""
|
||||
获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据)
|
||||
1、col配置参考:
|
||||
{
|
||||
"cols": 12, "md": 6
|
||||
}
|
||||
2、全局配置参考:
|
||||
{
|
||||
"refresh": 10, // 自动刷新时间,单位秒
|
||||
"border": True, // 是否显示边框,默认True,为False时取消组件边框和边距,由插件自行控制
|
||||
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
|
||||
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
|
||||
}
|
||||
3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
||||
|
||||
kwargs参数可获取的值:1、user_agent:浏览器UA
|
||||
|
||||
:param key: 仪表盘key,根据指定的key返回相应的仪表盘数据,缺省时返回一个固定的仪表盘数据(兼容旧版)
|
||||
"""
|
||||
pass
|
||||
```
|
||||
61
docs/faq/09-extend-discovery-source.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 如何扩展探索功能的媒体数据源?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.2.7+` 版本)**
|
||||
- 探索功能仅内置`TheMovieDb`、`豆瓣`和`Bangumi`数据源,可通过插件扩展探索功能的数据源范围,按以下方法开发插件(参考`TheTVDB探索`插件):
|
||||
- 1. 实现`ChainEventType.DiscoverSource`链式事件响应,将额外的媒体数据源塞入事件数据`extra_sources`数组中(注意:如果事件中已经有其它数据源,需要叠加而不是替换,避免影响其它插件塞入的数据)
|
||||
|
||||
- `name`:数据源名称
|
||||
- `mediaid_prefix`:数据源的唯一ID
|
||||
- `api_path`:数据获取API相对路径,需要在插件中实现API接口功能,GET模式接收过滤参数(注意:page参数默认需要有),返回`List[schemas.MediaInfo])`格式数据(注意:mediaid_prefix和media_id需要赋值,用于唯一索引媒体详细信息和转换媒体数据)。
|
||||
- `filter_params`:数据源过滤参数名的字典,相关参数会传入插件API的GET请求中
|
||||
- `filter_ui`:数据过滤选项的UI配置json,与插件配置表单方式一致
|
||||
- `depends`: UI依赖关系字典Dict[str, list],关过滤条件存在依赖关系时需要设置,以便上级条件变化时清空下级条件值
|
||||
|
||||
```python
|
||||
class DiscoverMediaSource(BaseModel):
|
||||
"""
|
||||
探索媒体数据源的基类
|
||||
"""
|
||||
name: str = Field(..., description="数据源名称")
|
||||
mediaid_prefix: str = Field(..., description="媒体ID的前缀,不含:")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
filter_params: Optional[Dict[str, Any]] = Field(default=None, description="过滤参数")
|
||||
filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置")
|
||||
|
||||
class DiscoverSourceEventData(ChainEventData):
|
||||
"""
|
||||
DiscoverSource 事件的数据模型
|
||||
Attributes:
|
||||
# 输出参数
|
||||
extra_sources (List[DiscoverMediaSource]): 额外媒体数据源
|
||||
"""
|
||||
# 输出参数
|
||||
extra_sources: List[DiscoverMediaSource] = Field(default_factory=list, description="额外媒体数据源")
|
||||
```
|
||||
|
||||
- 2. 实现`ChainEventType.MediaRecognizeConvert`链式事件响应(**可选**,如不实现则默认按标题重新识别媒体信息),根据媒体ID和转换类型,返回TheMovieDb或豆瓣的媒体数据,将转换后的数据注入事件数据`media_dict`中,可参考`app/chain/media.py`中的`get_tmdbinfo_by_bangumiid`。
|
||||
|
||||
- `mediaid`:媒体ID,格式为`mediaid_prefix:media_id`,如 tmdb:12345、douban:1234567
|
||||
- `convert_type`:转换类型,仅支持:themoviedb/douban,需要转换为对应的媒体数据并返回
|
||||
- `media_dict`:转换后的媒体数据,格式为`TheMovieDb/豆瓣`的媒体数据
|
||||
|
||||
```python
|
||||
class MediaRecognizeConvertEventData(ChainEventData):
|
||||
"""
|
||||
MediaRecognizeConvert 事件的数据模型
|
||||
Attributes:
|
||||
# 输入参数
|
||||
mediaid (str): 媒体ID,格式为`前缀:ID值`,如 tmdb:12345、douban:1234567
|
||||
convert_type (str): 转换类型 仅支持:themoviedb/douban,需要转换为对应的媒体数据并返回
|
||||
# 输出参数
|
||||
media_dict (dict): TheMovieDb/豆瓣的媒体数据
|
||||
"""
|
||||
# 输入参数
|
||||
mediaid: str = Field(..., description="媒体ID")
|
||||
convert_type: str = Field(..., description="转换类型(themoviedb/douban)")
|
||||
# 输出参数
|
||||
media_dict: dict = Field(default=dict, description="转换后的媒体信息(TheMovieDb/豆瓣)")
|
||||
```
|
||||
- 3. 启用插件后,点击探索功能将自动生成额外的数据源标签及页面,页面中选择不同的过滤条件时会重新触发API请求。
|
||||
28
docs/faq/10-extend-recommend-source.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 如何扩展推荐功能的媒体数据源?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.2.8+` 版本)**
|
||||
- 实现`ChainEventType.RecommendSource`链式事件响应,将额外的媒体数据源塞入事件数据`extra_sources`数组中(注意:如果事件中已经有其它数据源,需要叠加而不是替换,避免影响其它插件塞入的数据)
|
||||
|
||||
- `name`:数据源名称
|
||||
- `api_path`:数据获取API相对路径,需要在插件中实现API接口功能,GET模式接收过滤参数(注意:page参数默认需要有),返回`List[schemas.MediaInfo])`格式数据,参考`app/api/endpoints/recommend.py` 中的 `tmdb_trending`。
|
||||
|
||||
```python
|
||||
class RecommendMediaSource(BaseModel):
|
||||
"""
|
||||
推荐媒体数据源的基类
|
||||
"""
|
||||
name: str = Field(..., description="数据源名称")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
|
||||
class RecommendSourceEventData(ChainEventData):
|
||||
"""
|
||||
RecommendSource 事件的数据模型
|
||||
Attributes:
|
||||
# 输出参数
|
||||
extra_sources (List[RecommendMediaSource]): 额外媒体数据源
|
||||
"""
|
||||
# 输出参数
|
||||
extra_sources: List[RecommendMediaSource] = Field(default_factory=list, description="额外媒体数据源")
|
||||
```
|
||||
19
docs/faq/11-override-system-module.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 如何通过插件重载实现系统模块功能?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.4.4+` 版本)**
|
||||
- MoviePilot中通过`chain`层实现业务逻辑,在`modules`中实现各自独立的功能模块。`chain`处理链通过查找`modules`中实现了所需方法(比如: post_message)的所有模块并按一定的规则执行,从而编排各模块能力来实现复杂的业务功能。v2.4.4+版本中赋于插件胁持系统模块的能力,可以通过插件来重新实现系统所有内置模块的功能,比如支持新的下载器、媒体服务器等(在用户界面中配合新增自定义下载器和媒体服务器)。
|
||||
- 1. 在插件中实现`get_module`方法,申明插件要重载的模块方法。所有可用的模块方法名参考`chain`目录下的处理链文件(run_module方法的第一个参数),公共处理在`chain/__init__.py`中,方法入参和出参需要保持一致。
|
||||
```python
|
||||
def get_module(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
|
||||
{
|
||||
"id1": self.xxx1,
|
||||
"id2": self.xxx2,
|
||||
}
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 2. 在插件中实现声名的方法逻辑,处理链执行时,会优先处理插件声明的方法。如果插件方法未实现或者返回`None`,将继续执行下一个插件或者系统模块的相同声明方法;如果对应的方法需要返回是的列表对象,则会执行所有插件和系统模块的方法后将结果组合返回。
|
||||
319
docs/faq/12-extend-storage-type.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# 如何通过插件扩展支持的存储类型?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.4.4+` 版本)**
|
||||
- 1. 用户在系统设定存储中新增自定义存储,并设定一个自定义类型和名称,该类型与插件绑定,用于插件判断使用。或者在插件启动时直接注册自定义存储。
|
||||
```python
|
||||
# 检查是否有xxx网盘选项,如没有则自动添加
|
||||
storage_helper = StorageHelper()
|
||||
storages = StorageHelper().get_storagies()
|
||||
if not any(s.type == "xxx" for s in storages):
|
||||
# 添加存储配置
|
||||
storage_helper.add_storage("xxx", name="xxx网盘", conf={})
|
||||
```
|
||||
- 2. 在插件的存储操作类中,实现以下对应的文件操作(具体可参考:`app/modules/filemanager/storages/__init__.py`),不支持的可跳过
|
||||
```python
|
||||
class XxxApi:
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
:param name: 目录名
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取父目录
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path).parent)
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
删除文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path:
|
||||
"""
|
||||
下载文件,保存到本地,返回本地临时文件地址
|
||||
:param fileitem: 文件项
|
||||
:param path: 文件保存路径
|
||||
"""
|
||||
pass
|
||||
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 上传后文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
pass
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
pass
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
硬链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
软链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 3. 实现 `ChainEventType.StorageOperSelection`链式事件响应,根据传入的存储对象名称判断是否为该插件支持的存储,如是则返回存储操作对象
|
||||
```python
|
||||
@eventmanager.register(ChainEventType.StorageOperSelection)
|
||||
def storage_oper_selection(self, event: Event):
|
||||
"""
|
||||
监听存储选择事件,返回当前类为操作对象
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
event_data: StorageOperSelectionEventData = event.event_data
|
||||
if event_data.storage == "xxx":
|
||||
event_data.storage_oper = self.api # api为插件的存储操作对象
|
||||
```
|
||||
|
||||
- 4. 参考 [《如何通过插件重载实现系统模块功能?》](./11-override-system-module.md) 实现 `get_module`,在插件中声明和实现以下模块方法(具体可参考:`app/modules/filemanager/__init__.py`),其实就是对上一步的方法再做一下封装:
|
||||
```python
|
||||
def get_module(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
|
||||
{
|
||||
"id1": self.xxx1,
|
||||
"id2": self.xxx2,
|
||||
}
|
||||
"""
|
||||
return {
|
||||
"list_files": self.list_files,
|
||||
"any_files": self.any_files,
|
||||
"download_file": self.download_file,
|
||||
"upload_file": self.upload_file,
|
||||
"delete_file": self.delete_file,
|
||||
"rename_file": self.rename_file,
|
||||
"get_file_item": self.get_file_item,
|
||||
"get_parent_item": self.get_parent_item,
|
||||
"snapshot_storage": self.snapshot_storage,
|
||||
"storage_usage": self.storage_usage,
|
||||
"support_transtype": self.support_transtype
|
||||
}
|
||||
|
||||
def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:
|
||||
"""
|
||||
查询当前目录下所有目录和文件
|
||||
"""
|
||||
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
def __get_files(_item: FileItem, _r: Optional[bool] = False):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = self.api.list(_item)
|
||||
if _items:
|
||||
if _r:
|
||||
for t in _items:
|
||||
if t.type == "dir":
|
||||
__get_files(t, _r)
|
||||
else:
|
||||
result.append(t)
|
||||
else:
|
||||
result.extend(_items)
|
||||
|
||||
# 返回结果
|
||||
result = []
|
||||
__get_files(fileitem, recursion)
|
||||
|
||||
return result
|
||||
|
||||
def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]:
|
||||
"""
|
||||
查询当前目录下是否存在指定扩展名任意文件
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
def __any_file(_item: FileItem):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = self.api.list(_item)
|
||||
if _items:
|
||||
if not extensions:
|
||||
return True
|
||||
for t in _items:
|
||||
if (t.type == "file"
|
||||
and t.extension
|
||||
and f".{t.extension.lower()}" in extensions):
|
||||
return True
|
||||
elif t.type == "dir":
|
||||
if __any_file(t):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 返回结果
|
||||
return __any_file(fileitem)
|
||||
|
||||
def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
下载文件
|
||||
:param fileitem: 文件项
|
||||
:param path: 本地保存路径
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.download(fileitem, path)
|
||||
|
||||
def upload_file(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 保存目录项
|
||||
:param path: 本地文件路径
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.upload(fileitem, path, new_name)
|
||||
|
||||
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
|
||||
"""
|
||||
删除文件或目录
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.delete(fileitem)
|
||||
|
||||
def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]:
|
||||
"""
|
||||
重命名文件或目录
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.rename(fileitem, name)
|
||||
|
||||
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
根据路径获取文件项
|
||||
"""
|
||||
if storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.get_item(path)
|
||||
|
||||
def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取上级目录项
|
||||
"""
|
||||
if fileitem.storage != "xxx":
|
||||
return None
|
||||
|
||||
return self.api.get_parent(fileitem)
|
||||
|
||||
def snapshot_storage(self, storage: str, path: Path) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
快照存储
|
||||
"""
|
||||
if storage != "xxx":
|
||||
return None
|
||||
|
||||
files_info = {}
|
||||
|
||||
def __snapshot_file(_fileitm: schemas.FileItem):
|
||||
"""
|
||||
递归获取文件信息
|
||||
"""
|
||||
if _fileitm.type == "dir":
|
||||
for sub_file in self.api.list(_fileitm):
|
||||
__snapshot_file(sub_file)
|
||||
else:
|
||||
files_info[_fileitm.path] = _fileitm.size
|
||||
|
||||
fileitem = self.api.get_item(path)
|
||||
if not fileitem:
|
||||
return {}
|
||||
|
||||
__snapshot_file(fileitem)
|
||||
|
||||
return files_info
|
||||
|
||||
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
return self.api.usage()
|
||||
|
||||
@staticmethod
|
||||
def support_transtype(storage: str) -> Optional[dict]:
|
||||
"""
|
||||
获取支持的整理方式
|
||||
"""
|
||||
return {
|
||||
"move": "移动",
|
||||
"copy": "复制"
|
||||
}
|
||||
```
|
||||
24
docs/faq/13-integrate-workflow.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 如何将插件功能集成到工作流?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 v2.4.8+ 版本)**
|
||||
- 插件实现以下接口,声明插件支持的动作实现
|
||||
```python
|
||||
def get_actions(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件工作流动作
|
||||
[{
|
||||
"id": "动作ID",
|
||||
"name": "动作名称",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 需要附加传递的参数
|
||||
}]
|
||||
|
||||
对实现函数的要求:
|
||||
1、函数的第一个参数固定为 ActionContent 实例,如需要传递额外参数,在kwargs中定义
|
||||
2、函数的返回:执行状态 True / False,更新后的 ActionContent 实例
|
||||
"""
|
||||
pass
|
||||
```
|
||||
- 编辑工作流流程,添加`调用插件`组件,选择该插件的对应动作,将插件的功能串接到工作流程中
|
||||
162
docs/faq/14-message-interaction.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 如何在插件中通过消息持续与用户交互?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 v2.5.7+ 版本)**
|
||||
- 插件可以通过实现命令响应和消息按钮回调实现与用户的持续交互对话,支持多轮对话和菜单式操作,适用于支持按钮回调的通知渠道(如Telegram、Slack等)。
|
||||
|
||||
- 1. 实现远程命令响应,参考 [《如何在插件中实现远程命令响应?》](./02-remote-command-handler.md) 实现 `get_command()` 方法和 `PluginAction` 事件响应:
|
||||
```python
|
||||
def get_command(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件远程命令
|
||||
"""
|
||||
return [{
|
||||
"cmd": "/interactive_demo",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "交互演示",
|
||||
"category": "插件交互",
|
||||
"data": {
|
||||
"action": "interactive_demo"
|
||||
}
|
||||
}]
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def command_action(self, event: Event):
|
||||
"""
|
||||
远程命令响应
|
||||
"""
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "interactive_demo":
|
||||
return
|
||||
|
||||
# 获取用户信息
|
||||
channel = event_data.get("channel")
|
||||
source = event_data.get("source")
|
||||
user = event_data.get("user")
|
||||
|
||||
# 发送带有交互按钮的消息
|
||||
self._send_main_menu(channel, source, user)
|
||||
```
|
||||
|
||||
- 2. 注册 `MessageAction` 事件响应,处理用户的按钮回调:
|
||||
```python
|
||||
@eventmanager.register(EventType.MessageAction)
|
||||
def message_action(self, event: Event):
|
||||
"""
|
||||
处理消息按钮回调
|
||||
"""
|
||||
event_data = event.event_data
|
||||
if not event_data:
|
||||
return
|
||||
|
||||
# 检查是否为本插件的回调
|
||||
plugin_id = event_data.get("plugin_id")
|
||||
if plugin_id != self.__class__.__name__:
|
||||
return
|
||||
|
||||
# 获取回调数据
|
||||
text = event_data.get("text", "")
|
||||
channel = event_data.get("channel")
|
||||
source = event_data.get("source")
|
||||
userid = event_data.get("userid")
|
||||
# 获取原始消息ID和聊天ID(用于直接更新原消息)
|
||||
original_message_id = event_data.get("original_message_id")
|
||||
original_chat_id = event_data.get("original_chat_id")
|
||||
|
||||
# 根据回调内容处理不同的交互
|
||||
if text == "menu1":
|
||||
self._handle_menu1(channel, source, userid, original_message_id, original_chat_id)
|
||||
elif text == "menu2":
|
||||
self._handle_menu2(channel, source, userid, original_message_id, original_chat_id)
|
||||
elif text == "back":
|
||||
self._send_main_menu(channel, source, userid, original_message_id, original_chat_id)
|
||||
elif text.startswith("action_"):
|
||||
action_id = text.replace("action_", "")
|
||||
self._handle_action(action_id, channel, source, userid, original_message_id, original_chat_id)
|
||||
```
|
||||
|
||||
- 3. 实现具体的交互处理方法,在消息中使用 `[PLUGIN]插件ID|内容` 格式的按钮:
|
||||
```python
|
||||
def _send_main_menu(self, channel, source, userid, original_message_id=None, original_chat_id=None):
|
||||
"""
|
||||
发送主菜单
|
||||
"""
|
||||
buttons = [
|
||||
[
|
||||
{"text": "🎬 媒体管理", "callback_data": f"[PLUGIN]{self.__class__.__name__}|menu1"},
|
||||
{"text": "⚙️ 系统设置", "callback_data": f"[PLUGIN]{self.__class__.__name__}|menu2"}
|
||||
],
|
||||
[
|
||||
{"text": "📊 查看状态", "callback_data": f"[PLUGIN]{self.__class__.__name__}|status"}
|
||||
]
|
||||
]
|
||||
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="🤖 插件交互演示",
|
||||
text="请选择要执行的操作:",
|
||||
userid=userid,
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
|
||||
def _handle_menu1(self, channel, source, userid, original_message_id, original_chat_id):
|
||||
"""
|
||||
处理媒体管理菜单
|
||||
"""
|
||||
buttons = [
|
||||
[
|
||||
{"text": "🔍 搜索媒体", "callback_data": f"[PLUGIN]{self.__class__.__name__}|action_search"},
|
||||
{"text": "📥 下载管理", "callback_data": f"[PLUGIN]{self.__class__.__name__}|action_download"}
|
||||
],
|
||||
[
|
||||
{"text": "🔙 返回主菜单", "callback_data": f"[PLUGIN]{self.__class__.__name__}|back"}
|
||||
]
|
||||
]
|
||||
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="🎬 媒体管理",
|
||||
text="选择媒体管理功能:",
|
||||
userid=userid,
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
|
||||
def _handle_action(self, action_id, channel, source, userid, original_message_id, original_chat_id):
|
||||
"""
|
||||
处理具体动作
|
||||
"""
|
||||
if action_id == "search":
|
||||
# 执行搜索逻辑
|
||||
result = "搜索功能已执行"
|
||||
elif action_id == "download":
|
||||
# 执行下载逻辑
|
||||
result = "下载管理已开启"
|
||||
else:
|
||||
result = "未知操作"
|
||||
|
||||
# 发送执行结果并提供返回按钮
|
||||
buttons = [
|
||||
[{"text": "🔙 返回主菜单", "callback_data": f"[PLUGIN]{self.__class__.__name__}|back"}]
|
||||
]
|
||||
|
||||
self.post_message(
|
||||
channel=channel,
|
||||
title="✅ 操作完成",
|
||||
text=result,
|
||||
userid=userid,
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
```
|
||||
|
||||
- 注意事项:
|
||||
- 回调按钮的 `callback_data` 必须使用 `[PLUGIN]插件ID|内容` 格式,其中插件ID为插件类名
|
||||
- 只有支持按钮回调的通知渠道(如Telegram、Slack)才能使用此功能
|
||||
- 建议在交互中保存用户状态数据,以支持复杂的多步骤操作
|
||||
- 可以结合插件数据存储功能保存用户的交互历史和偏好设置
|
||||
186
docs/faq/15-use-system-cache.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 如何在插件中使用系统级统一缓存?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.7.4+` 版本)**
|
||||
- MoviePilot提供了统一的缓存系统,支持内存缓存、文件系统缓存和Redis缓存自动管理,当有Redis时优先使用Redis,否则使用内存或文件系统。插件可以通过系统提供的缓存接口实现高效的缓存管理,无需关心系统设置。
|
||||
|
||||
- 1. 使用缓存装饰器:
|
||||
```python
|
||||
from app.core.cache import cached
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
@cached(region="my_plugin", ttl=3600)
|
||||
def get_data(self, key: str):
|
||||
"""
|
||||
使用缓存装饰器,缓存结果1小时
|
||||
"""
|
||||
# 复杂的计算或网络请求
|
||||
return expensive_operation(key)
|
||||
|
||||
@cached(region="my_plugin_async", ttl=1800, skip_none=True)
|
||||
async def get_async_data(self, key: str):
|
||||
"""
|
||||
异步函数缓存,跳过None值
|
||||
"""
|
||||
return await async_expensive_operation(key)
|
||||
```
|
||||
|
||||
- 2. 使用TTLCache类:
|
||||
```python
|
||||
from app.core.cache import TTLCache
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 创建缓存实例,最大128项,TTL 30分钟
|
||||
self.cache = TTLCache(region="my_plugin", maxsize=128, ttl=1800)
|
||||
|
||||
def process_data(self, key: str):
|
||||
# 检查缓存
|
||||
if key in self.cache:
|
||||
return self.cache[key]
|
||||
|
||||
# 计算并缓存结果
|
||||
result = expensive_operation(key)
|
||||
self.cache[key] = result
|
||||
return result
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清理插件缓存
|
||||
"""
|
||||
self.cache.clear()
|
||||
```
|
||||
|
||||
- 3. 使用文件缓存后端(适用于大文件缓存):
|
||||
```python
|
||||
from app.core.cache import FileCache, AsyncFileCache
|
||||
from pathlib import Path
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 获取文件缓存后端,支持Redis和文件系统
|
||||
self.file_cache = FileCache(
|
||||
base=Path("/tmp/my_plugin_cache"),
|
||||
ttl=86400 # 24小时
|
||||
)
|
||||
|
||||
def cache_large_file(self, key: str, data: bytes):
|
||||
"""
|
||||
缓存大文件数据
|
||||
"""
|
||||
self.file_cache.set(key, data, region="large_files")
|
||||
|
||||
def get_cached_file(self, key: str) -> Optional[bytes]:
|
||||
"""
|
||||
获取缓存的文件数据
|
||||
"""
|
||||
return self.file_cache.get(key, region="large_files")
|
||||
|
||||
async def async_cache_operations(self):
|
||||
"""
|
||||
异步文件缓存操作
|
||||
"""
|
||||
async_cache = AsyncFileCache(
|
||||
base=Path("/tmp/my_plugin_async_cache"),
|
||||
ttl=3600
|
||||
)
|
||||
|
||||
# 异步设置缓存
|
||||
await async_cache.set("async_key", b"async_data", region="async_files")
|
||||
|
||||
# 异步获取缓存
|
||||
data = await async_cache.get("async_key", region="async_files")
|
||||
|
||||
await async_cache.close()
|
||||
```
|
||||
|
||||
- 4. 直接使用缓存后端(高级用法):
|
||||
```python
|
||||
from app.core.cache import Cache
|
||||
|
||||
class MyPlugin(_PluginBase):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 直接获取缓存后端实例,系统自动选择Redis或内存缓存
|
||||
self.cache_backend = Cache(maxsize=256, ttl=3600)
|
||||
|
||||
def custom_cache_operation(self, key: str, value: Any):
|
||||
"""
|
||||
自定义缓存操作
|
||||
"""
|
||||
# 设置缓存
|
||||
self.cache_backend.set(key, value, region="custom_region")
|
||||
|
||||
# 检查缓存是否存在
|
||||
if self.cache_backend.exists(key, region="custom_region"):
|
||||
# 获取缓存
|
||||
cached_value = self.cache_backend.get(key, region="custom_region")
|
||||
return cached_value
|
||||
|
||||
return None
|
||||
|
||||
def iterate_cache_items(self):
|
||||
"""
|
||||
遍历缓存项
|
||||
"""
|
||||
for key, value in self.cache_backend.items(region="custom_region"):
|
||||
print(f"缓存键: {key}, 值: {value}")
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
清理缓存
|
||||
"""
|
||||
self.cache_backend.clear(region="custom_region")
|
||||
self.cache_backend.close()
|
||||
```
|
||||
|
||||
- 5. 缓存装饰器参数说明:
|
||||
```python
|
||||
@cached(
|
||||
region="my_plugin", # 缓存区域,用于隔离不同插件的缓存
|
||||
maxsize=512, # 最大缓存条目数(仅内存缓存有效)
|
||||
ttl=1800, # 缓存存活时间(秒)
|
||||
skip_none=True, # 是否跳过None值缓存
|
||||
skip_empty=False # 是否跳过空值缓存(空列表、空字典等)
|
||||
)
|
||||
def my_function(self, param):
|
||||
pass
|
||||
```
|
||||
|
||||
- 6. 缓存管理功能:
|
||||
```python
|
||||
class MyPlugin(_PluginBase):
|
||||
@cached(region="my_plugin")
|
||||
def cached_function(self, param):
|
||||
return expensive_operation(param)
|
||||
|
||||
def clear_my_cache(self):
|
||||
"""
|
||||
清理指定区域的缓存
|
||||
"""
|
||||
self.cached_function.cache_clear()
|
||||
|
||||
def get_cache_info(self):
|
||||
"""
|
||||
获取缓存信息
|
||||
"""
|
||||
cache_region = self.cached_function.cache_region
|
||||
return f"缓存区域: {cache_region}"
|
||||
```
|
||||
|
||||
- 7. 缓存后端自动选择:
|
||||
- 系统会根据配置自动选择缓存后端:
|
||||
- `CACHE_BACKEND_TYPE=redis`:使用Redis作为缓存后端
|
||||
- `CACHE_BACKEND_TYPE=memory`:使用内存缓存(cachetools)
|
||||
- 插件代码无需修改,系统会自动处理缓存后端的切换
|
||||
|
||||
- 8. 最佳实践:
|
||||
- 为每个插件使用独立的缓存区域(region),避免缓存键冲突
|
||||
- 合理设置TTL,避免缓存过期时间过长导致数据过期
|
||||
- 对于频繁访问的数据使用较长的TTL,对于实时性要求高的数据使用较短的TTL
|
||||
- 使用`skip_none=True`避免缓存无意义的None值
|
||||
- 大文件或二进制数据建议使用文件缓存后端
|
||||
- 在插件卸载时清理相关缓存,避免内存泄漏
|
||||
103
docs/faq/16-register-agent-tools.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 如何在插件中注册智能体工具?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
**(仅支持 `v2.8.0+` 版本)**
|
||||
- MoviePilot的AI智能体功能支持通过插件扩展工具能力,插件可以注册自定义工具供智能体调用,实现更丰富的功能扩展。
|
||||
- 1. 实现 `get_agent_tools()` 方法,返回工具类列表:
|
||||
```python
|
||||
def get_agent_tools(self) -> List[Type]:
|
||||
"""
|
||||
获取插件智能体工具
|
||||
返回工具类列表,每个工具类必须继承自 MoviePilotTool
|
||||
"""
|
||||
return [MyCustomTool, AnotherTool]
|
||||
```
|
||||
|
||||
- 2. 创建工具类,必须继承自 `MoviePilotTool` 并实现相关要求:
|
||||
```python
|
||||
from typing import Optional, Type
|
||||
from pydantic import BaseModel, Field
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.chain import ToolChain
|
||||
|
||||
class MyToolInput(BaseModel):
|
||||
"""工具输入参数模型"""
|
||||
explanation: str = Field(..., description="工具使用说明")
|
||||
query: str = Field(..., description="查询内容")
|
||||
limit: Optional[int] = Field(10, description="返回结果数量限制")
|
||||
|
||||
class MyCustomTool(MoviePilotTool):
|
||||
"""自定义工具示例"""
|
||||
# 工具名称,用于智能体识别和调用
|
||||
name: str = "my_custom_tool"
|
||||
|
||||
# 工具描述,用于智能体理解工具功能,建议详细描述工具用途和使用场景
|
||||
description: str = "This tool is used to perform custom operations. Use it when you need to query or process specific data."
|
||||
|
||||
# 输入参数模型,定义工具接收的参数及其类型和说明
|
||||
args_schema: Type[BaseModel] = MyToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据订阅参数生成友好的提示消息"""
|
||||
pass
|
||||
|
||||
async def run(self, query: str, limit: Optional[int] = None, **kwargs) -> str:
|
||||
"""
|
||||
实现工具的核心逻辑(异步方法)
|
||||
:param query: 查询内容
|
||||
:param limit: 结果数量限制
|
||||
:param kwargs: 其他参数,包含 explanation(工具使用说明)
|
||||
:return: 工具执行结果,返回字符串格式
|
||||
"""
|
||||
try:
|
||||
# 获取上下文信息(系统自动注入)
|
||||
session_id = self._session_id
|
||||
user_id = self._user_id
|
||||
channel = self._channel
|
||||
source = self._source
|
||||
username = self._username
|
||||
|
||||
# 执行工具逻辑
|
||||
result = await self._perform_operation(query, limit)
|
||||
|
||||
# 可以通过 send_tool_message 发送消息给用户
|
||||
await self.send_tool_message(f"操作完成: {result}", title="工具执行")
|
||||
|
||||
# 返回执行结果
|
||||
return f"成功处理查询 '{query}',返回 {len(result)} 条结果"
|
||||
except Exception as e:
|
||||
return f"执行失败: {str(e)}"
|
||||
|
||||
async def _perform_operation(self, query: str, limit: int):
|
||||
"""内部方法,执行具体操作"""
|
||||
# 实现具体业务逻辑
|
||||
pass
|
||||
```
|
||||
|
||||
- 3. 工具类可用的上下文属性和方法:
|
||||
- `self._session_id`: 当前会话ID
|
||||
- `self._user_id`: 用户ID
|
||||
- `self._channel`: 消息渠道(如 Telegram、Slack 等)
|
||||
- `self._source`: 消息来源
|
||||
- `self._username`: 用户名
|
||||
- `self.send_tool_message(message: str, title: str = "")`: 发送消息给用户
|
||||
- `ToolChain()`: 访问处理链功能,可调用系统其他功能
|
||||
|
||||
- 4. 工具类实现要求:
|
||||
- **必须继承自 `app.agent.tools.base.MoviePilotTool`**
|
||||
- **必须实现 `run` 方法**(异步方法),接收参数并返回字符串结果
|
||||
- **必须实现 `get_tool_message` 方法**,以显示友好的工具执行提示给用户
|
||||
- **必须定义 `name` 属性**(字符串),工具的唯一标识
|
||||
- **必须定义 `description` 属性**(字符串),详细描述工具功能,帮助智能体理解何时使用该工具
|
||||
- **可选定义 `args_schema` 属性**(Pydantic模型类),用于定义输入参数的结构和验证
|
||||
|
||||
- 5. 注意事项:
|
||||
- 工具的描述(`description`)应该清晰明确,帮助智能体理解工具的功能和使用场景
|
||||
- 工具的参数模型(`args_schema`)应该包含详细的字段描述,帮助智能体正确构造参数
|
||||
- 工具执行结果应该返回有意义的字符串,便于智能体理解和向用户展示
|
||||
- 工具可以通过 `send_tool_message` 方法向用户发送实时消息,提升交互体验
|
||||
- 工具类在初始化时会自动注入会话和用户信息,可以通过私有属性访问
|
||||
- 如果工具需要访问插件实例,需要自行通过 `PluginManager` 获取
|
||||
- 工具执行时间应该尽量短,避免阻塞智能体的响应
|
||||
- 建议在工具执行过程中添加适当的错误处理和日志记录
|
||||
147
docs/faq/17-register-plugin-sidebar-nav.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 17. 如何将插件页面注册到主界面左侧导航栏?
|
||||
|
||||
返回 [README](../../README.md) | [FAQ 索引](../FAQ.md)
|
||||
|
||||
插件进入左侧导航栏走的是 **Vue 远程组件全页入口**,不是 `get_page()` 的详情弹窗。完整链路是:插件后端声明 Vue 渲染模式和侧栏入口,MoviePilot 后端通过 `GET /api/v1/plugin/sidebar_nav` 聚合,前端把入口插入对应分组并跳转到 `#/plugin-app/<PluginID>/<nav_key>`,再加载插件暴露的 `AppPage` 组件。
|
||||
|
||||
## 1. 后端插件要做什么?
|
||||
|
||||
插件必须同时满足这些条件:
|
||||
|
||||
- 插件已启用,`get_state()` 返回 `True`。
|
||||
- `get_render_mode()` 返回 `("vue", "dist/assets")` 或你的实际构建产物目录。
|
||||
- 实现 `get_sidebar_nav()` 并返回一个列表。
|
||||
- 插件目录下存在前端构建产物,至少包含 `remoteEntry.js` 和被暴露的组件文件。
|
||||
|
||||
示例:
|
||||
|
||||
```python
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
def get_render_mode(self) -> Tuple[str, str]:
|
||||
"""
|
||||
声明插件使用 Vue 远程组件渲染,并指定构建产物目录。
|
||||
"""
|
||||
return "vue", "dist/assets"
|
||||
|
||||
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
声明插件在主界面左侧导航栏中的全页入口。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "我的插件",
|
||||
"icon": "mdi-puzzle",
|
||||
"section": "system",
|
||||
"permission": "manage",
|
||||
"order": 10,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 是否必填 | 说明 |
|
||||
|------|----------|------|
|
||||
| `nav_key` | 否 | 当前插件内的入口标识,默认 `main`;会进入 URL 路径段 |
|
||||
| `title` | 否 | 侧栏显示标题,未填时使用插件名称 |
|
||||
| `icon` | 否 | MDI 图标名,未填时使用 `mdi-puzzle` |
|
||||
| `section` | 否 | 侧栏分组:`start` / `discovery` / `subscribe` / `organize` / `system`,无效值会归入 `system` |
|
||||
| `permission` | 否 | 菜单权限:`subscribe` / `discovery` / `search` / `manage` / `admin`,未填则不额外限制 |
|
||||
| `order` | 否 | 同组内排序,数值越小越靠前 |
|
||||
|
||||
注意:
|
||||
|
||||
- `nav_key` 不能包含 `/`、`?`、`#`、空格;建议使用 `main`、`settings`、`history`、`my_tool` 这类稳定值。
|
||||
- `get_page()` 只影响插件管理里的详情弹窗;要出现在主界面左侧导航,必须实现 `get_sidebar_nav()`。
|
||||
- 如果插件依赖这个新前端能力,建议在 `package.json` / `package.v2.json` 中用 `system_version` 限定最低 MoviePilot 版本。
|
||||
|
||||
## 2. 前端远程组件要暴露什么?
|
||||
|
||||
前端工程需要在模块联邦里暴露全页组件:
|
||||
|
||||
```typescript
|
||||
federation({
|
||||
name: 'MyPlugin',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`AppPage.vue` 会收到主应用传入的 `api`、`pluginId`、`navKey`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
pluginId: { type: String, default: '' },
|
||||
navKey: { type: String, default: 'main' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
{{ props.pluginId }} / {{ props.navKey }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
如果页面需要调用插件后端 API,后端 `get_api()` 建议使用 `auth: "bear"`,前端通过传入的 `api` 调用:
|
||||
|
||||
```typescript
|
||||
const rows = await props.api.get(`plugin/${props.pluginId}/history`)
|
||||
```
|
||||
|
||||
## 3. 多个导航入口怎么做?
|
||||
|
||||
`get_sidebar_nav()` 可以返回多条记录:
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
声明同一插件的多个左侧导航入口。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "处理面板",
|
||||
"icon": "mdi-view-dashboard",
|
||||
"section": "organize",
|
||||
"permission": "manage",
|
||||
"order": 20,
|
||||
},
|
||||
{
|
||||
"nav_key": "settings",
|
||||
"title": "处理设置",
|
||||
"icon": "mdi-cog",
|
||||
"section": "system",
|
||||
"permission": "manage",
|
||||
"order": 21,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
前端加载规则:
|
||||
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
|-----------|----------------------|
|
||||
| `main` 或省略 | `./AppPage` -> `./Page` |
|
||||
| 其它,例如 `settings` | `./AppPageSettings` -> `./AppPage` -> `./Page` |
|
||||
| 其它,例如 `my_tool` | `./AppPageMyTool` -> `./AppPage` -> `./Page` |
|
||||
|
||||
也就是说你可以只暴露一个 `./AppPage`,在组件内根据 `navKey` 分支渲染;也可以为不同入口分别暴露 `./AppPageSettings`、`./AppPageHistory` 等组件。
|
||||
|
||||
## 4. 排查清单
|
||||
|
||||
- `GET /api/v1/plugin/sidebar_nav` 是否能看到你的插件入口。
|
||||
- `GET /api/v1/plugin/remotes?token=moviepilot` 是否能看到你的插件远程组件入口。
|
||||
- 插件是否启用,且 `get_render_mode()` 是否返回 `vue`。
|
||||
- `dist/assets/remoteEntry.js` 是否实际安装到了插件运行目录。
|
||||
- `nav_key` 是否包含非法字符,或和前端暴露名不匹配。
|
||||
- 当前用户是否有 `permission` 声明的权限;超级用户默认拥有全部权限。
|
||||
- 前端侧栏会缓存 `plugin/sidebar_nav` 结果,插件启停或变更入口后建议刷新页面重新加载。
|
||||
26
docs/faq/18-limit-moviepilot-version.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 18. 如何限定插件可安装的 MoviePilot 主系统版本?
|
||||
|
||||
如果插件依赖某个 MoviePilot 主程序版本才提供的后端接口、前端能力、事件字段或运行时模块,应在对应的 `package.json` / `package.v2.json` 条目中增加 `system_version` 字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"MyPlugin": {
|
||||
"name": "我的插件",
|
||||
"version": "1.0.0",
|
||||
"system_version": ">=2.12.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `system_version` 的格式参考 pip 依赖版本范围,也就是 PEP 440 specifier,例如 `">=2.12.0"`、`">=2.12.0,<3"`、`"~=2.12"`。
|
||||
- MoviePilot 当前版本号带 `v` 前缀也可以正常比较,因此 `>=v2.12.0` 和 `>=2.12.0` 都能解析;文档和索引中推荐写不带 `v` 的形式。
|
||||
- 未定义 `system_version` 时,宿主不做主系统版本检查,保持旧插件兼容。
|
||||
- 如果当前主系统版本不满足范围,插件市场会显示不兼容提示,安装和更新都会被拒绝。
|
||||
|
||||
发布插件时,建议在以下场景补充该字段:
|
||||
|
||||
- 插件调用了新版本才存在的后端 API、helper、chain、module 或事件字段。
|
||||
- 插件的 Vue 远程组件依赖新版本前端才支持的加载、侧栏、仪表板或渲染行为。
|
||||
- 插件换用了新版本 MoviePilot 才内置或才稳定可用的系统能力,例如浏览器运行时、统一缓存、智能体工具注册等。
|
||||
BIN
icons/AliDnsDDNS.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 35 KiB |
BIN
icons/agentresourceofficer.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
31
icons/agentresourceofficer.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Agent Resource Officer">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="88" y1="52" x2="424" y2="460" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f766e"/>
|
||||
<stop offset="0.55" stop-color="#155e75"/>
|
||||
<stop offset="1" stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="chip" x1="150" y1="142" x2="362" y2="370" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#dbeafe"/>
|
||||
</linearGradient>
|
||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="20" flood-color="#06233a" flood-opacity="0.28"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<path d="M105 315c50 75 151 100 233 58 61-31 92-89 90-150" fill="none" stroke="#67e8f9" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M408 178c-51-67-149-87-226-48-55 28-86 78-91 133" fill="none" stroke="#bbf7d0" stroke-width="18" stroke-linecap="round" opacity="0.54"/>
|
||||
<circle cx="98" cy="268" r="19" fill="#a7f3d0"/>
|
||||
<circle cx="414" cy="176" r="19" fill="#bae6fd"/>
|
||||
<g filter="url(#softShadow)">
|
||||
<rect x="138" y="150" width="236" height="222" rx="58" fill="url(#chip)"/>
|
||||
<rect x="176" y="206" width="160" height="91" rx="38" fill="#0f172a" opacity="0.9"/>
|
||||
<circle cx="217" cy="250" r="15" fill="#22d3ee"/>
|
||||
<circle cx="295" cy="250" r="15" fill="#86efac"/>
|
||||
<path d="M220 320h72" stroke="#0f172a" stroke-width="17" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M256 150v-42" stroke="#dbeafe" stroke-width="18" stroke-linecap="round"/>
|
||||
<circle cx="256" cy="93" r="23" fill="#dbeafe"/>
|
||||
</g>
|
||||
<path d="M160 395h192" stroke="#e0f2fe" stroke-width="20" stroke-linecap="round" opacity="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/airecoginzerforwarder.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
27
icons/airecoginzerforwarder.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="44" y1="36" x2="206" y2="222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#42D0FF"/>
|
||||
<stop offset="1" stop-color="#0EA5E9"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="46" y="50" width="164" height="152" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#0B4A69" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<circle cx="128" cy="128" r="106" fill="url(#bg)"/>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="68" y="72" width="96" height="96" rx="24" fill="white"/>
|
||||
<rect x="86" y="96" width="60" height="12" rx="6" fill="#0E7490"/>
|
||||
<rect x="86" y="116" width="40" height="12" rx="6" fill="#38BDF8"/>
|
||||
<rect x="86" y="136" width="52" height="12" rx="6" fill="#7DD3FC"/>
|
||||
</g>
|
||||
|
||||
<path d="M160 124C173.333 124 184 113.333 184 100C184 86.6667 173.333 76 160 76" stroke="white" stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M173 62L195 84L173 106" stroke="white" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<circle cx="183" cy="154" r="25" fill="#0C4A6E"/>
|
||||
<path d="M171 154H195" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M183 142V166" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
icons/airecognizerenhancer.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
31
icons/airecognizerenhancer.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="AI Recognizer Enhancer">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="74" y1="54" x2="438" y2="458" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1e3a8a"/>
|
||||
<stop offset="0.52" stop-color="#6d28d9"/>
|
||||
<stop offset="1" stop-color="#be185d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="lens" x1="143" y1="149" x2="369" y2="375" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#e0e7ff"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#16072f" flood-opacity="0.34"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<path d="M116 172h280" stroke="#fdf2f8" stroke-width="20" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M116 340h280" stroke="#bfdbfe" stroke-width="20" stroke-linecap="round" opacity="0.48"/>
|
||||
<path d="M137 126l46 92M239 126l46 92M341 126l46 92" stroke="#f0abfc" stroke-width="18" stroke-linecap="round" opacity="0.72"/>
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M96 256c39-67 94-101 160-101s121 34 160 101c-39 67-94 101-160 101S135 323 96 256Z" fill="url(#lens)"/>
|
||||
<circle cx="256" cy="256" r="70" fill="#111827"/>
|
||||
<circle cx="256" cy="256" r="42" fill="#38bdf8"/>
|
||||
<circle cx="274" cy="238" r="15" fill="#f8fafc"/>
|
||||
</g>
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M165 256h-34M381 256h-34M256 141v-34M256 405v-34" stroke="#d9f99d" stroke-width="18"/>
|
||||
<path d="M177 360l-24 24M359 153l-24 24M177 153l-24-24M359 360l24 24" stroke="#fde68a" stroke-width="14" opacity="0.9"/>
|
||||
</g>
|
||||
<path d="M210 418h92" stroke="#ffffff" stroke-width="18" stroke-linecap="round" opacity="0.78"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/feishucommandbridgelong.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
icons/hdhive.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
icons/quark.ico
Normal file
|
After Width: | Height: | Size: 66 KiB |
390
package.json
@@ -26,7 +26,7 @@
|
||||
"name": "AI字幕自动生成(v2)",
|
||||
"description": "使用whisper自动生成视频文件字幕,使用大模型翻译字幕成中文。",
|
||||
"labels": "字幕",
|
||||
"version": "2.3",
|
||||
"version": "2.5.1",
|
||||
"icon": "autosubtitles.jpeg",
|
||||
"author": "TimoYoung",
|
||||
"level": 1,
|
||||
@@ -38,7 +38,9 @@
|
||||
"v2.0": "1.引入任务队列 2.支持监听媒体入库自动生成字幕 3.增加任务状态展示界面",
|
||||
"v2.1": "支持清除历史记录",
|
||||
"v2.2": "fix",
|
||||
"v2.3": "支持独立的大模型调用配置"
|
||||
"v2.3": "支持独立的大模型调用配置",
|
||||
"v2.5": "适配openai api v1",
|
||||
"v2.5.1": "更新依赖"
|
||||
}
|
||||
},
|
||||
"CustomSites": {
|
||||
@@ -112,11 +114,13 @@
|
||||
"name": "目录监控",
|
||||
"description": "监控目录文件发生变化时实时整理到媒体库。",
|
||||
"labels": "文件整理",
|
||||
"version": "2.4",
|
||||
"version": "2.5.1",
|
||||
"icon": "directory.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.5.1": "过滤下载临时文件和不可整理文件,避免目录监控误触发整理",
|
||||
"v2.5": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v2.4": "修复目录监控不使用ChatGPT辅助识别问题",
|
||||
"v2.3": "特殊场景下补充转移成功历史记录",
|
||||
"v2.2": "更新目录设置说明",
|
||||
@@ -174,11 +178,12 @@
|
||||
"name": "媒体文件同步删除",
|
||||
"description": "同步删除历史记录、源文件和下载任务。",
|
||||
"labels": "文件整理",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.2",
|
||||
"icon": "mediasyncdel.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.7.2": "兼容windows路径",
|
||||
"v1.7.1": "修复删除剧集辅种失败报错问题",
|
||||
"v1.7": "修复重新整理被一并删除问题",
|
||||
"v1.6": "修复删除辅种",
|
||||
@@ -189,12 +194,13 @@
|
||||
"name": "自定义Hosts",
|
||||
"description": "修改系统hosts文件,加速网络访问。",
|
||||
"labels": "网络",
|
||||
"version": "1.2",
|
||||
"version": "1.2.1",
|
||||
"icon": "hosts.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.2.1": "更新依赖",
|
||||
"v1.2": "支持写入注释",
|
||||
"v1.1": "关闭插件时自动恢复系统hosts"
|
||||
}
|
||||
@@ -217,12 +223,14 @@
|
||||
"name": "Cloudflare IP优选",
|
||||
"description": "🌩 测试 Cloudflare CDN 延迟和速度,自动优选IP。",
|
||||
"labels": "网络,站点",
|
||||
"version": "1.4",
|
||||
"version": "1.5.1",
|
||||
"icon": "cloudflare.jpg",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.5.1": "更新依赖",
|
||||
"v1.5": "适配CloudflareSpeedTest新版名称",
|
||||
"v1.4": "修复立即运行一次",
|
||||
"v1.3": "调整插件开启状态判断条件",
|
||||
"v1.2": "增强API安全性"
|
||||
@@ -319,11 +327,13 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "1.9.11",
|
||||
"version": "1.9.13",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.9.13": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v1.9.12": "修复海豹不能辅种的问题",
|
||||
"v1.9.11": "修复馒头不能辅种的问题",
|
||||
"v1.9.10": "Revert 辅种结束后,一起开始所有辅种后暂停的种子(排除了出错的种子)",
|
||||
"v1.9.9": "修复qb辅种结束后自动开始暂停的种子",
|
||||
@@ -348,11 +358,12 @@
|
||||
"name": "青蛙辅种助手",
|
||||
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
|
||||
"labels": "做种",
|
||||
"version": "2.4",
|
||||
"version": "2.4.1",
|
||||
"icon": "qingwa.png",
|
||||
"author": "233@qingwa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.4.1": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v2.4": "支持qbittorrent 5",
|
||||
"v2.2": "站点停用后会同步暂停对该站点的辅种",
|
||||
"v2.3": "站点辅种支持代理"
|
||||
@@ -362,11 +373,13 @@
|
||||
"name": "整理VCB动漫压制组作品",
|
||||
"description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件",
|
||||
"labels": "文件整理,识别",
|
||||
"version": "1.8.2.1",
|
||||
"version": "1.8.2.4",
|
||||
"icon": "vcbmonitor.png",
|
||||
"author": "pixel@qingwa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.8.2.4": "过滤下载临时文件和不可整理文件,避免目录监控误触发整理",
|
||||
"v1.8.2.3": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v1.8.2.1": "修复日志输出&同步目录监控插件功能",
|
||||
"v1.8.2": "提高识别率",
|
||||
"v1.8.1": "重构插件,测试版",
|
||||
@@ -463,12 +476,16 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "1.4.1",
|
||||
"version": "2.0.3",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
@@ -477,11 +494,12 @@
|
||||
"name": "演职人员刮削",
|
||||
"description": "刮削演职人员图片以及中文名称。",
|
||||
"labels": "媒体库,刮削",
|
||||
"version": "1.4",
|
||||
"version": "1.4.1",
|
||||
"icon": "actor.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.4.1": "修复异常报错问题",
|
||||
"v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题",
|
||||
"v1.3": "修复v1.8.5版本后刮削报错问题"
|
||||
}
|
||||
@@ -490,11 +508,13 @@
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "1.4",
|
||||
"version": "1.5.1",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.5.1": "修复版本号比较逻辑",
|
||||
"v1.5": "修复版本描述为空时的报错",
|
||||
"v1.4": "兼容更新内容带版本号的情况",
|
||||
"v1.3": "增加前端版本更新检查,需要主程序升级至v1.8.4+版本"
|
||||
}
|
||||
@@ -560,12 +580,13 @@
|
||||
"name": "TMDB剧集组刮削",
|
||||
"description": "从TMDB剧集组刮削季集的实际顺序。",
|
||||
"labels": "刮削",
|
||||
"version": "2.6",
|
||||
"version": "2.6.1",
|
||||
"icon": "Element_A.png",
|
||||
"author": "叮叮当",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.6.1": "修复异常报错日志",
|
||||
"v2.6": "修复无法获取媒体库中季0的问题",
|
||||
"v2.5": "修复当媒体服务器中剧集的季不完整时会中断的问题",
|
||||
"v2.3": "修复v2版本无法读取媒体库的问题",
|
||||
@@ -622,12 +643,14 @@
|
||||
"name": "清理硬链接",
|
||||
"description": "监控目录内文件被删除时,同步删除监控目录内所有和它硬链接的文件",
|
||||
"labels": "文件整理",
|
||||
"version": "2.2",
|
||||
"version": "2.3.1",
|
||||
"icon": "Ombi_A.png",
|
||||
"author": "DzAvril",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.3.1": "优化下载临时文件过滤,避免清理硬链接处理未完成文件",
|
||||
"v2.3": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v2.2": "修复直接删除文件夹导致的插件崩溃的bug",
|
||||
"v2.1": "联动删除历史记录",
|
||||
"v2.0": "联动删除种子,需安装插件[下载器助手]并打开监听源文件事件",
|
||||
@@ -641,12 +664,14 @@
|
||||
"name": "实时硬链接",
|
||||
"description": "监控目录文件变化,实时硬链接。",
|
||||
"labels": "文件整理",
|
||||
"version": "1.6",
|
||||
"version": "1.7.1",
|
||||
"icon": "Linkace_C.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.7.1": "过滤下载临时文件,避免实时硬链接处理未完成文件",
|
||||
"v1.7": "目录监控改用watchfiles,移除旧监控依赖",
|
||||
"v1.6": "增强API安全性"
|
||||
}
|
||||
},
|
||||
@@ -667,12 +692,14 @@
|
||||
"name": "共享识别词",
|
||||
"description": "从Github、Etherpad等远程文件中获取共享识别词并应用。",
|
||||
"labels": "识别",
|
||||
"version": "2.3",
|
||||
"version": "2.4.1",
|
||||
"icon": "words.png",
|
||||
"author": "honue",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.4.1": "官方etherpad共享识别词因总是有人恶意修改导致识别异常,移除官方共享支持",
|
||||
"v2.4": "支持 JSON 格式远程识别词集合订阅",
|
||||
"v2.3": "更换默认共享识别词地址"
|
||||
}
|
||||
},
|
||||
@@ -770,8 +797,7 @@
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
},
|
||||
"v2": true
|
||||
}
|
||||
},
|
||||
"FeiShuMsg": {
|
||||
"name": "飞书机器人消息通知",
|
||||
@@ -801,13 +827,15 @@
|
||||
"name": "ntfy消息推送",
|
||||
"description": "支持使用ntfy发送消息通知。",
|
||||
"labels": "消息通知",
|
||||
"version": "1.1",
|
||||
"version": "1.3",
|
||||
"icon": "Ntfy_A.png",
|
||||
"author": "lethargicScribe",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.1": "添加Token认证和用户动作"
|
||||
"v1.1": "添加Token认证和用户动作",
|
||||
"v1.2": "修复 ntfy 通知图标链接失效的问题",
|
||||
"v1.3": "修复标题或文本为空时,通知发送失败的问题"
|
||||
}
|
||||
},
|
||||
"GotifyMsg": {
|
||||
@@ -838,7 +866,6 @@
|
||||
"icon": "Macos_Sierra.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4.1": "修复Bing壁纸命名问题",
|
||||
"v1.3": "适配MoviePilot v2.5.3+版本",
|
||||
@@ -850,12 +877,13 @@
|
||||
"name": "MoviePilot服务器监控",
|
||||
"description": "在仪表板中实时显示MoviePilot公共服务器状态。",
|
||||
"labels": "仪表板",
|
||||
"version": "1.2",
|
||||
"version": "1.3",
|
||||
"icon": "Duplicati_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.3": "增加HTTP/DNS/TLS探测、请求速率、连接占比和异常兜底展示",
|
||||
"v1.2": "优化数量示",
|
||||
"v1.1": "增加详情界面显示"
|
||||
}
|
||||
@@ -943,11 +971,14 @@
|
||||
"name": "钉钉机器人",
|
||||
"description": "支持使用钉钉机器人发送消息通知。",
|
||||
"labels": "消息通知,钉钉机器人",
|
||||
"version": "1.12",
|
||||
"version": "1.13",
|
||||
"icon": "Dingding_A.png",
|
||||
"author": "nnlegenda",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.13": "优化钉钉消息换行"
|
||||
}
|
||||
},
|
||||
"DynamicWeChat": {
|
||||
"name": "动态企微可信IP",
|
||||
@@ -957,7 +988,6 @@
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.7.3": "修复检测登录的元素",
|
||||
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
|
||||
@@ -1040,5 +1070,313 @@
|
||||
"author": "cddjr",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
},
|
||||
"AliDnsDDNS": {
|
||||
"name": "阿里云 DDNS",
|
||||
"description": "定时检测公网 IP,自动更新阿里云 DNS 解析记录,支持泛域名(* 记录)及 IPv6(AAAA)。",
|
||||
"labels": "网络",
|
||||
"version": "1.0",
|
||||
"icon": "AliDnsDDNS.png",
|
||||
"author": "dtzsghnr",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.0": "初始版本,支持 IPv4/IPv6、泛域名、多记录配置、更新历史详情页"
|
||||
}
|
||||
},
|
||||
"AIRecognizerEnhancer": {
|
||||
"name": "AI识别增强",
|
||||
"description": "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。",
|
||||
"labels": "AI,识别,LLM,本地兜底,MoviePilot,TMDB",
|
||||
"version": "0.1.12",
|
||||
"icon": "airecognizerenhancer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.1.12": "兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复最新版 MP 下插件加载失败问题。",
|
||||
"0.1.11": "同步运行态版本,保持本地结构化识别、失败样本闭环和识别词建议能力一致。",
|
||||
"0.1.10": "新增识别词建议模型退化时的精确规则兜底,保证批量建议/批量写入在上游异常时仍能尽量落地。",
|
||||
"0.1.9": "新增失败样本精简摘要接口,并让批量建议/批量写入附带低 token 文本摘要,便于智能体直接消费。",
|
||||
"0.1.8": "新增失败样本批量建议与批量写入接口,可一次处理一批失败样本,进一步减少人工逐条操作。",
|
||||
"0.1.7": "新增失败样本批量复查接口,可批量重跑样本并在确认修复后批量出队。",
|
||||
"0.1.6": "新增失败样本复查接口,可按当前识别词与当前识别器重跑样本,并在确认修复后自动出队。",
|
||||
"0.1.5": "新增失败样本出队动作,支持按索引移除单条样本,并在写入识别词后自动移除已处理样本。",
|
||||
"0.1.4": "新增失败样本洞察接口,自动归纳重复问题、失败原因和优先处理样本,帮助更快挑出值得写识别词的样本。",
|
||||
"0.1.3": "新增失败样本摘要、样本清理、样本去重和保留上限控制,让样本工作流更适合长期运行与智能体使用。",
|
||||
"0.1.2": "新增按失败样本直接生成建议和直接写入规则的快捷 API,进一步缩短从失败样本到 CustomIdentifiers 的闭环。",
|
||||
"0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。",
|
||||
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
|
||||
}
|
||||
},
|
||||
"AgentResourceOfficer": {
|
||||
"name": "Agent影视助手",
|
||||
"description": "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
|
||||
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
|
||||
"version": "0.2.71",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.2.71": "新增流媒体推荐:聚合 Netflix、Disney+、Apple TV+、Prime Video 四大平台,基于 TMDB discover 按热度/评分推荐本月上新、近期热门电影和剧集;结果页改为只读列表,仅支持显式前缀触发。",
|
||||
"0.2.70": "最后一轮主线收口:取消标题级云盘转存/云盘搜索入口,统一保留前缀搜索与编号续接;修复 PT 指定集/最新集筛选、下载路径透传、分页与旧别名拦截,并同步外部智能体 Skill/命令文档。",
|
||||
"0.2.69": "修复外部智能体跨机器接入暴露的问题:补齐 115 直转依赖,Cookie 修复改为通过远端 MoviePilot API 安全写回,安装 Skill 时自动准备浏览器 Cookie 工具依赖,并增强 PanSou 跨机提示与 MP 推荐空结果回退。",
|
||||
"0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。",
|
||||
"0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。",
|
||||
"0.2.66": "为 request_templates 增加三类入口的 entry_playbooks,直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。",
|
||||
"0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。",
|
||||
"0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。",
|
||||
"0.2.63": "为 compact 顶层短命令增加执行语义字段:command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred;外部智能体现在可以机械判断 直接读 还是 先确认再写。",
|
||||
"0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。",
|
||||
"0.2.61": "为 compact 失败回执增加统一 error_summary;外部智能体现在可以直接读取失败标签、建议说明,以及 preferred_command / compact_commands 这样的最短恢复命令。",
|
||||
"0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commands;mp_recent_activity 也补齐 followup_summary,外部智能体可直接读取最短下一步命令。",
|
||||
"0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。",
|
||||
"0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。",
|
||||
"0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary;执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。",
|
||||
"0.2.56": "把评分后的确认提示下沉为统一 decision 摘要,score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。",
|
||||
"0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。",
|
||||
"0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。",
|
||||
"0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose,并让生命周期/执行后追踪统一返回 diagnosis_summary。",
|
||||
"0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup,而不是退回会话检查或新任务。",
|
||||
"0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe,外部智能体可以通过低 token 模板直接续接执行后追踪。",
|
||||
"0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint,方便外部智能体一跳续接执行后追踪。",
|
||||
"0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。",
|
||||
"0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。",
|
||||
"0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint,让外部智能体执行计划后能直接读取建议下一步。",
|
||||
"0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck,并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。",
|
||||
"0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。",
|
||||
"0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。",
|
||||
"0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。",
|
||||
"0.2.42": "补齐 compact session/recover 协议里的 action_templates;外部智能体读取会话状态或恢复入口时,也能拿到完整的结构化下一步模板。",
|
||||
"0.2.41": "补齐 PT 只读会话的 action_templates;下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。",
|
||||
"0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。",
|
||||
"0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。",
|
||||
"0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。",
|
||||
"0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe,外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。",
|
||||
"0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。",
|
||||
"0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。",
|
||||
"0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。",
|
||||
"0.2.33": "统一 MP 原生命令前缀解析;下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。",
|
||||
"0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。",
|
||||
"0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary,避免外部智能体误读上下文。",
|
||||
"0.2.30": "细化评分风险结构:hard_risk_reasons 表示真正阻断自动化的风险,risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。",
|
||||
"0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。",
|
||||
"0.2.28": "插件展示名统一改为 Agent影视助手,并同步仓库文档、Skill 文案和兼容插件引用。",
|
||||
"0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。",
|
||||
"0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id,再确认执行。",
|
||||
"0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。",
|
||||
"0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。",
|
||||
"0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。",
|
||||
"0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。",
|
||||
"0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。",
|
||||
"0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。",
|
||||
"0.2.19": "新增 MP 搜索结果详情入口;MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。",
|
||||
"0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。",
|
||||
"0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。",
|
||||
"0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。",
|
||||
"0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。",
|
||||
"0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。",
|
||||
"0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。",
|
||||
"0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。",
|
||||
"0.2.11": "MP 下载/订阅命令支持无空格自然写法,例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”;自然语言写入默认生成 plan_id,确认后才执行。",
|
||||
"0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”,飞书与智能体可不用结构化 mode 参数。",
|
||||
"0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。",
|
||||
"0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。",
|
||||
"0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。",
|
||||
"0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。",
|
||||
"0.2.05": "新增低 token score_summary,帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。",
|
||||
"0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status,并在未初始化时优先提示保存偏好。",
|
||||
"0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。",
|
||||
"0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。",
|
||||
"0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。",
|
||||
"0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。",
|
||||
"0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。",
|
||||
"0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。",
|
||||
"0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。",
|
||||
"0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。",
|
||||
"0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state,避免把已禁用但仍加载的旧插件误判为冲突。",
|
||||
"0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议,方便判断是否能从旧桥接迁移。",
|
||||
"0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。",
|
||||
"0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。",
|
||||
"0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。",
|
||||
"0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health,支持内置智能助手检查飞书入口状态。",
|
||||
"0.1.108": "内置可选飞书入口 Channel,并为 assistant 回执补充 write_effect/error_code 标准字段。",
|
||||
"0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。",
|
||||
"0.1.106": "assistant/startup 会带 recommended_request_templates,外部智能体启动后可直接按推荐参数拉取低 token 模板流程。",
|
||||
"0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。",
|
||||
"0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message,方便外部智能体在写入前提示用户确认。",
|
||||
"0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。",
|
||||
"0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。",
|
||||
"0.1.101": "推荐调用会带 url_template,外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。",
|
||||
"0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey,避免外部智能体误用 Bearer 鉴权。",
|
||||
"0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。",
|
||||
"0.1.98": "recommended_recipe_detail 会带 first_call,直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。",
|
||||
"0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail,直接给出推荐流程的首个模板、确认模板和写入模板,外部智能体可直接照此编排。",
|
||||
"0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason,外部智能体不必再自己挑选最适合的 recipe。",
|
||||
"0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds,自检也会验证这些汇总特征。",
|
||||
"0.1.94": "assistant/request_templates 回执会带场景化 recipes,外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。",
|
||||
"0.1.93": "assistant/request_templates 回执会带 recommended_sequence,直接给出推荐调用顺序,外部智能体可以少做一层启动编排。",
|
||||
"0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_seconds,execution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates,方便外部智能体决定缓存策略。",
|
||||
"0.1.91": "assistant/request_templates 支持 include_templates=false,可只返回模板名、无效项和执行策略,进一步减少 token。",
|
||||
"0.1.90": "请求模板协议增加 schema_version=request_templates.v1,startup/toolbox 也携带 request_templates_schema_version,方便外部智能体做兼容判断。",
|
||||
"0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。",
|
||||
"0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation,外部智能体可区分只读、dry-run、计划写入和真实执行动作。",
|
||||
"0.1.87": "request_templates 每个模板都会带 description,外部智能体可以直接判断模板用途,减少额外解释和 token 消耗。",
|
||||
"0.1.86": "request_templates 每个模板都会带 tool_args,区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。",
|
||||
"0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。",
|
||||
"0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit,方便结构化智能体直接用 body 请求过滤模板。",
|
||||
"0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。",
|
||||
"0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names;原生 Tool 同步支持 names 参数。",
|
||||
"0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool,外部智能体可只拉请求模板而不拉完整启动包。",
|
||||
"0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates,并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。",
|
||||
"0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body,外部智能体无需猜维护调用方式。",
|
||||
"0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history,方便外部智能体审计维护动作;GET dry-run 仍不写历史。",
|
||||
"0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。",
|
||||
"0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run,即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。",
|
||||
"0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。",
|
||||
"0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。",
|
||||
"0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain,支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。",
|
||||
"0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions,外部智能体可直接判断是否值得做低风险维护清理。",
|
||||
"0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all,避免外部智能体把全部计划数误判为待执行计划数。",
|
||||
"0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。",
|
||||
"0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。",
|
||||
"0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates,外部智能体可拿启动包直接执行推荐恢复动作。",
|
||||
"0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup,一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议,减少外部智能体开场多次探测。",
|
||||
"0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck,便于外部智能体开场自检协议健康。",
|
||||
"0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool,让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。",
|
||||
"0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。",
|
||||
"0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。",
|
||||
"0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。",
|
||||
"0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true,外部智能体原样回放模板即可保持低 token。",
|
||||
"0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。",
|
||||
"0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。",
|
||||
"0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。",
|
||||
"0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。",
|
||||
"0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。",
|
||||
"0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。",
|
||||
"0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。",
|
||||
"0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。",
|
||||
"0.1.52": "assistant/recover 新增 compact=true 低 token 回执,agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。",
|
||||
"0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。",
|
||||
"0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。",
|
||||
"0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan,外部智能体可按恢复协议直接续跑。",
|
||||
"0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session,便于从会话列表直接恢复。",
|
||||
"0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。",
|
||||
"0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作,action_templates.action_body 可原样回传执行计划。",
|
||||
"0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。",
|
||||
"0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。",
|
||||
"0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。",
|
||||
"0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。",
|
||||
"0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。",
|
||||
"0.1.40": "新增 assistant/history 与 history Tool,记录最近批量动作和预设工作流执行摘要,便于外部智能体判断进度、排障和恢复上下文。",
|
||||
"0.1.39": "新增 assistant/readiness 与 readiness Tool,外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示,再决定是否开始执行。",
|
||||
"0.1.38": "新增 assistant/workflow 与 run_workflow Tool,外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。",
|
||||
"0.1.37": "新增 assistant/actions 与 execute_actions Tool,外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。",
|
||||
"0.1.36": "新增 assistant/action 与 execute_action Tool,外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。",
|
||||
"0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates,外部智能体可直接按返回模板继续调用,不再自己拼下一步参数。",
|
||||
"0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。",
|
||||
"0.1.33": "新增活跃会话列表 API 与原生 Tool,并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。",
|
||||
"0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。",
|
||||
"0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。",
|
||||
"0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool,便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。",
|
||||
"0.1.29": "新增 Agent影视助手 帮助 Tool,并让统一智能入口在空输入或帮助语义下直接返回推荐用法,降低 MP 智能助手首次调用门槛。",
|
||||
"0.1.28": "新增 Agent影视助手 统一智能入口原生 Tool:smart_entry / smart_pick,MP 智能助手可直接复用飞书同款处理/选择主链。",
|
||||
"0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool,避免仍显示骨架态提示。",
|
||||
"0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。",
|
||||
"0.1.25": "新增 115 待处理任务标准 API:查看、继续、取消,便于飞书、CLI 与外部脚本直接调用。",
|
||||
"0.1.24": "新增 115 待处理任务原生 Agent Tool:查看、继续、取消,MP 智能助手可直接调用待处理任务能力。",
|
||||
"0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。",
|
||||
"0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。",
|
||||
"0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。",
|
||||
"0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录,可自动继续上次未完成的 115 操作。",
|
||||
"0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。",
|
||||
"0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。",
|
||||
"0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。",
|
||||
"0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。",
|
||||
"0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。",
|
||||
"0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。",
|
||||
"0.1.13": "新增 115 扫码登录原生 Agent Tool,智能助手可直接发起二维码并轮询登录状态。",
|
||||
"0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。",
|
||||
"0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。",
|
||||
"0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本,115 健康检查已验证可用。",
|
||||
"0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。",
|
||||
"0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。",
|
||||
"0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API,让 Agent影视助手 开始承接用户态能力。",
|
||||
"0.1.6": "新增 Agent影视助手 自己的智能入口 API,支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。",
|
||||
"0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。",
|
||||
"0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。",
|
||||
"0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。",
|
||||
"0.1.2": "新增原生 Agent Tool:影巢会话搜索、会话继续选择、通用分享链接路由。",
|
||||
"0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。",
|
||||
"0.1.0": "首个可用版本,已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。"
|
||||
}
|
||||
},
|
||||
"FeishuCommandBridgeLong": {
|
||||
"name": "飞书命令桥接",
|
||||
"description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。",
|
||||
"labels": "飞书,长连接,115,影巢,夸克,智能体,命令",
|
||||
"version": "0.5.26",
|
||||
"icon": "feishucommandbridgelong.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。",
|
||||
"0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。",
|
||||
"0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。",
|
||||
"0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。",
|
||||
"0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。",
|
||||
"0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。",
|
||||
"0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。",
|
||||
"0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。",
|
||||
"0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片,也支持回复检查115登录继续轮询 Agent影视助手 会话。",
|
||||
"0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。",
|
||||
"0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。",
|
||||
"0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。",
|
||||
"0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。",
|
||||
"0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手,影巢/115/夸克开始走新主干。",
|
||||
"0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。",
|
||||
"0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。",
|
||||
"0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。",
|
||||
"0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。",
|
||||
"0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。",
|
||||
"0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。",
|
||||
"0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。",
|
||||
"0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。",
|
||||
"0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。",
|
||||
"0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。",
|
||||
"0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。",
|
||||
"0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。",
|
||||
"0.5.0": "新增处理/选择双命令与智能体 API,统一分流夸克链接、115 链接与影巢搜索解锁流程。",
|
||||
"0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。",
|
||||
"0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。",
|
||||
"0.2.3": "统一插件身份为 FeishuCommandBridgeLong,修复插件市场安装状态匹配。",
|
||||
"0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。"
|
||||
}
|
||||
},
|
||||
"HdhiveOpenApi": {
|
||||
"name": "影巢 OpenAPI",
|
||||
"description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。",
|
||||
"labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到",
|
||||
"version": "0.3.0",
|
||||
"icon": "hdhive.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。"
|
||||
}
|
||||
},
|
||||
"QuarkShareSaver": {
|
||||
"name": "夸克分享转存",
|
||||
"description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。",
|
||||
"labels": "夸克,Quark,分享,转存,网盘,智能体,飞书",
|
||||
"version": "0.1.0",
|
||||
"icon": "quark.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
575
package.v2.json
@@ -24,11 +24,13 @@
|
||||
"name": "站点刷流",
|
||||
"description": "自动托管刷流,将会提高对应站点的访问频率。",
|
||||
"labels": "刷流,仪表板",
|
||||
"version": "4.3.3",
|
||||
"version": "4.3.5",
|
||||
"icon": "brush.jpg",
|
||||
"author": "jxxghp,InfinityPacer",
|
||||
"author": "jxxghp,InfinityPacer,Seed680",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v4.3.5": "提升匹配规则时的健壮性",
|
||||
"v4.3.4": "添加RSS支持配置选项",
|
||||
"v4.3.2": "增加'删除促销结束的未完成下载'功能",
|
||||
"v4.3.1": "修复了一些细节问题",
|
||||
"v4.3": "支持带宽采样并计算平均值,以优化刷流效率",
|
||||
@@ -42,12 +44,15 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.7",
|
||||
"version": "2.8.2",
|
||||
"icon": "signin.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.8.2": "优化站点 Rousi Pro 签到失败提示信息",
|
||||
"v2.8.1": "更新站点 Rousi Pro 签到接口",
|
||||
"v2.8": "适配站点 Rousi Pro",
|
||||
"v2.7": "站点请求使用站点设置的超时时间",
|
||||
"v2.6": "感谢madrays佬提供的UI!",
|
||||
"v2.5.4": "增加保号风险提示",
|
||||
@@ -61,11 +66,15 @@
|
||||
"name": "下载任务分类与标签",
|
||||
"description": "自动给下载任务分类与打站点标签、剧集名称标签",
|
||||
"labels": "下载管理",
|
||||
"version": "2.2",
|
||||
"version": "2.6",
|
||||
"icon": "Youtube-dl_B.png",
|
||||
"author": "叮叮当",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.6": "增加站点/剧名前缀功能",
|
||||
"v2.5": "优化采用公共服务自动清理未使用标签",
|
||||
"v2.4": "增加自动清理未使用标签",
|
||||
"v2.3": "增加tracker映射配置",
|
||||
"v2.2": "MoviePilot V2 版本下载任务分类与标签插件"
|
||||
}
|
||||
},
|
||||
@@ -88,11 +97,17 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.6",
|
||||
"version": "1.8.2.2",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.8.2.2": "修复emby多条相同新入库消息推送多次的问题",
|
||||
"v1.8.2.1": "修复多集时有概率图片获取失败的问题;修复emby测试通知类型接收失败的问题",
|
||||
"v1.8.1": "修复单集剧情信息有概率获取失败的问题",
|
||||
"v1.8": "当整理路径中没有tmdbid时,会尝试从媒体服务器中获取",
|
||||
"v1.7.1": "未获取到tmdb信息则按原有逻辑发送;电影显示海报",
|
||||
"v1.7": "对TV剧集入库事件进行聚合,避免消息轰炸。更新后如果打不开插件,请重置插件",
|
||||
"v1.6": "查询剧集图片兼容没有季集信息的情况",
|
||||
"v1.5": "支持独立控制媒体服务器通知",
|
||||
"v1.4": "MoviePilot V2 版本媒体库服务器通知插件"
|
||||
@@ -102,12 +117,14 @@
|
||||
"name": "ChatGPT",
|
||||
"description": "消息交互支持与ChatGPT对话。",
|
||||
"labels": "消息通知,识别",
|
||||
"version": "2.1.7",
|
||||
"version": "2.1.9",
|
||||
"icon": "Chatgpt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.1.7":"独立安装OpenAi SDK依赖",
|
||||
"v2.1.9": "更新依赖库",
|
||||
"v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题",
|
||||
"v2.1.7": "独立安装OpenAi SDK依赖",
|
||||
"v2.1.6": "支持自定义辅助识别提示词",
|
||||
"v2.1.5": "兼容一些模型返回json数据信息用markdown语法包裹的情况",
|
||||
"v2.1.4": "不处理http链接",
|
||||
@@ -123,11 +140,12 @@
|
||||
"name": "自动转移做种",
|
||||
"description": "定期转移下载器中的做种任务到另一个下载器。",
|
||||
"labels": "做种",
|
||||
"version": "1.10.2",
|
||||
"version": "1.10.3",
|
||||
"icon": "seed.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.10.3": "更新依赖库",
|
||||
"v1.10.2": "增加保留原标签和原分类的选项",
|
||||
"v1.10.1": "优化“立即运行一次”按钮位置",
|
||||
"v1.10": "支持跳过校验(仅支持 qBittorrent)",
|
||||
@@ -183,11 +201,16 @@
|
||||
"name": "演职人员刮削",
|
||||
"description": "刮削演职人员图片以及中文名称。",
|
||||
"labels": "媒体库,刮削",
|
||||
"version": "2.2",
|
||||
"version": "2.2.4",
|
||||
"icon": "actor.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">2.12.4",
|
||||
"history": {
|
||||
"v2.2.4": "改为监听元数据刮削事件并增加实时防重,修复媒体服务器过滤和空人物保存问题",
|
||||
"v2.2.3": "简繁转换依赖改用 zhconv-rs,需要 MoviePilot >2.12.4",
|
||||
"v2.2.2": "修复异常日志问题",
|
||||
"v2.2.1": "优化错误数据兼容处理",
|
||||
"v2.2": "修改使用自定义图片域名时无法下载图片的问题",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2 版本",
|
||||
@@ -241,11 +264,13 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "2.14",
|
||||
"version": "2.16",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp,CKun",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.16": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v2.15": "修复海豹不能辅种的问题",
|
||||
"v2.14": "修复馒头不能辅种的问题",
|
||||
"v2.13": "开启跳过校验后需手动开启自动开始",
|
||||
"v2.12": "增加qb下载器分类复用配置",
|
||||
@@ -267,11 +292,13 @@
|
||||
"name": "青蛙辅种助手",
|
||||
"description": "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。",
|
||||
"labels": "做种",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.3",
|
||||
"icon": "qingwa.png",
|
||||
"author": "233@qingwa",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v3.0.3": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v3.0.2": "更新依赖库",
|
||||
"v3.0.1": "遗漏了一个私有属性",
|
||||
"v3.0": "兼容MoviePilot V2 版本"
|
||||
}
|
||||
@@ -351,15 +378,29 @@
|
||||
"v2.0": "适配新的目录结构变化,短剧分类名称调整为配置目录路径,升级后需要重新调整设置后才能使用。"
|
||||
}
|
||||
},
|
||||
"MultiClass": {
|
||||
"name": "视频多级分类",
|
||||
"description": "支持视频多级分类",
|
||||
"labels": "文件整理",
|
||||
"version": "0.1",
|
||||
"icon": "Calibreweb_B.png",
|
||||
"author": "liuhangbin",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v0.1": "视频多级分类插件, 目前仅支持电影按评分,年代,系列分类。"
|
||||
}
|
||||
},
|
||||
"MoviePilotUpdateNotify": {
|
||||
"name": "MoviePilot更新推送",
|
||||
"description": "MoviePilot推送release更新通知、自动重启。",
|
||||
"labels": "消息通知,自动更新",
|
||||
"version": "2.2",
|
||||
"version": "2.3.1",
|
||||
"icon": "Moviepilot_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.3.1": "修复版本号比较逻辑",
|
||||
"v2.3": "修复版本描述为空时的报错",
|
||||
"v2.2": "支持 MoviePilot v2.5.0+",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2"
|
||||
@@ -420,11 +461,16 @@
|
||||
"name": "绕过Trackers",
|
||||
"description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash。",
|
||||
"labels": "工具",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.3",
|
||||
"icon": "Clash_A.png",
|
||||
"author": "wumode",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.5.3": "修复 Rousi 种子获取问题",
|
||||
"v1.5.2": "支持从站点首页获取最新 Trackers",
|
||||
"v1.5.1": "新增 Tracker",
|
||||
"v1.5.0": "新增 Page 界面; 支持通过`/check_ip` 命令检查IP; 改进 UI",
|
||||
"v1.4.3": "修复 bug",
|
||||
"v1.4.2": "修复插件动作",
|
||||
"v1.4.1": "修复通知类型错误",
|
||||
"v1.4": "异步查询DNS",
|
||||
@@ -438,11 +484,23 @@
|
||||
"name": "IMDb源",
|
||||
"description": "让探索,推荐和媒体识别支持IMDb数据源。",
|
||||
"labels": "探索",
|
||||
"version": "1.5.6",
|
||||
"version": "1.6.9",
|
||||
"icon": "IMDb_IOS-OSX_App.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"system_version": ">2.12.4",
|
||||
"history": {
|
||||
"v1.6.9": "简繁转换依赖改用 zhconv-rs,需要 MoviePilot >2.12.4",
|
||||
"v1.6.8": "兼容 MoviePilot v2.11.0 识别链新增 share_meta 参数,修复辅助识别模式下刮削报错",
|
||||
"v1.6.7": "优化界面显示; 增加榜单排名显示; 添加制作公司过滤项",
|
||||
"v1.6.6": "优化主页组件链接跳转",
|
||||
"v1.6.5": "仪表盘组件支持图片缓存",
|
||||
"v1.6.4": "为元数据增加背景图",
|
||||
"v1.6.3": "优化媒体识别速度; 适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
|
||||
"v1.6.2": "修复 API 查询错误重试问题",
|
||||
"v1.6.1": "添加中文主屏幕组件; 修复 bug",
|
||||
"v1.5.8": "修改UA",
|
||||
"v1.5.7": "改进异常处理",
|
||||
"v1.5.6": "固定仪表盘组件海报比例; 修复 bug",
|
||||
"v1.5.5": "修复初始化错误",
|
||||
"v1.5.4": "改进媒体识别",
|
||||
@@ -454,7 +512,7 @@
|
||||
"v1.4.3": "为仪表盘组件添加缓存",
|
||||
"v1.4.2": "优化小屏幕组件显示",
|
||||
"v1.4.1": "优化亮色主题显示",
|
||||
"v1.4.0":"添加仪表盘组件: IMDb 编辑精选",
|
||||
"v1.4.0": "添加仪表盘组件: IMDb 编辑精选",
|
||||
"v1.3.3": "修复依赖问题",
|
||||
"v1.3.2": "更新 API query hash",
|
||||
"v1.3.1": "修复按日期排序错误",
|
||||
@@ -468,12 +526,32 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "1.3.2",
|
||||
"version": "2.1.6",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.1.6": "修复依赖冲突",
|
||||
"v2.1.5": "优化仪表盘连接鉴权;优化订阅更新提示",
|
||||
"v2.1.4": "支持 xhttp 协议",
|
||||
"v2.1.3": "修复代理删除问题",
|
||||
"v2.1.2": "修复规则集序列化错误",
|
||||
"v2.1.1": "增强数据管理功能",
|
||||
"v2.0.10": "适配 MoviePilot 2.8.4",
|
||||
"v2.0.9": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
|
||||
"v2.0.8": "修复已知问题",
|
||||
"v2.0.7": "修复子规则比较错误",
|
||||
"v2.0.6": "修复已知问题; 改进对代理组的配置和验证",
|
||||
"v2.0.5": "完善了对嵌套逻辑规则和子规则的配置和验证",
|
||||
"v2.0.4": "修复已知问题; 使用异步调度器; 显示规则更改日期",
|
||||
"v2.0.3": "修复已知问题",
|
||||
"v2.0.2": "修复分享链接转换问题",
|
||||
"v2.0.1": "支持独立的订阅链接配置, 覆写代理组和出站代理; 优化数据结构; 修复分享链接解析问题",
|
||||
"v1.4.2": "优化移动端 UI; 支持显示节点链接",
|
||||
"v1.4.1": "修复配置模板保存错误, 请重新配置Clash模板",
|
||||
"v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards",
|
||||
"v1.3.3": "通过emoji识别国家; 按国家分组节点; mrs格式支持",
|
||||
"v1.3.2": "注册插件动作",
|
||||
"v1.3.1": "支持配置 Hosts",
|
||||
"v1.2.8": "改进导入界面",
|
||||
@@ -496,11 +574,21 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.5",
|
||||
"icon": "LexiAnnot.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.2.5": "langchain 1.x 兼容 (主程序版本需高于 2.9.17)",
|
||||
"v1.2.4": "增强数据校验",
|
||||
"v1.2.3": "优化提示词",
|
||||
"v1.2.1": "改进字幕样式获取方法",
|
||||
"v1.2.0": "引入大模型候选词决策和词义丰富处理链; 支持读取系统智能体配置; 添加智能体工具; 优化通知样式; 改进 UI",
|
||||
"v1.1.4": "优化字幕选择决策",
|
||||
"v1.1.3": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
|
||||
"v1.1.2": "使用子进程避免 spaCy 模型常驻内存",
|
||||
"v1.1.1": "添加任务页面; 改进 spaCy 模型加载逻辑",
|
||||
"v1.1.0": "支持考试词汇标注; 优化分词处理; 修复错误",
|
||||
"v1.0.1": "合并连字符词; 避免ARM平台依赖问题",
|
||||
"v1.0": "新增LexiAnnot"
|
||||
}
|
||||
@@ -517,5 +605,458 @@
|
||||
"v1.0.0": "首个版本,新增MeoW消息通知",
|
||||
"v1.0.1": "优化代码,修复运行一次按钮没办法自动关闭的问题"
|
||||
}
|
||||
},
|
||||
"BugReporter": {
|
||||
"name": "Bug反馈",
|
||||
"description": "自动上报异常,协助开发者发现和解决问题。",
|
||||
"labels": "开发",
|
||||
"version": "1.5.1",
|
||||
"icon": "Alist_encrypt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.5": "更换上报端点",
|
||||
"v1.4": "仅上报包含异常堆栈的错误,普通日志不再上报",
|
||||
"v1.3": "减少网络异常信息上送",
|
||||
"v1.2": "优化上报信息量",
|
||||
"v1.1": "加强脱敏处理"
|
||||
}
|
||||
},
|
||||
"TmdbWallpaper": {
|
||||
"name": "登录壁纸本地化",
|
||||
"description": "将MoviePilot的登录壁纸下载到本地。",
|
||||
"labels": "壁纸,本地化",
|
||||
"version": "1.4.2",
|
||||
"icon": "Macos_Sierra.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.4.2": "适配MoviePilot v2.8.8+",
|
||||
"v1.4.1": "MoviePilot V2 版本登录壁纸本地化插件"
|
||||
}
|
||||
},
|
||||
"DailySummary": {
|
||||
"name": "活动总结",
|
||||
"description": "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看",
|
||||
"labels": "通知",
|
||||
"version": "2.0.0",
|
||||
"icon": "Bark_A.png",
|
||||
"author": "yuhoye",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
|
||||
}
|
||||
},
|
||||
"DynamicWeChat": {
|
||||
"name": "动态企微可信IP",
|
||||
"description": "修改企微应用可信IP,支持Srever酱等第三方通知。验证码以?结尾发送到企业微信应用",
|
||||
"labels": "消息通知",
|
||||
"version": "2.0.1",
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v2.0.1": "修复企业微信后台页面语言未稳定切换为中文导致无法匹配配置按钮的问题。",
|
||||
"v2.0.0": "V2 专用大版本改用 CloakBrowser 启动企业微信浏览器流程,默认插件不再声明 V2 兼容。",
|
||||
"v1.7.3": "修复检测登录的元素",
|
||||
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
|
||||
"v1.7.1": "允许使用'||wan2'选项及无法使用'立即检测一次'",
|
||||
"v1.7.0": "使用第三方通知时可IP变动后通知,拟支持多网络出口检查。",
|
||||
"v1.6.0": "忽略因网络波动导致获取ip错误。自定义的类合并为helper.py。后续核心功能没问题将不再更新",
|
||||
"v1.5.2": "可以从指定url获取ip,修复不使用cc时cookie失效过快,v1可配置第三方为备用通知,server酱可以将文本发送到server3,二维码给服务号",
|
||||
"v1.5.1": "修复v2微信通知,可以指定微信通知ID",
|
||||
"v1.5.0": "支持企微应用通知和第Serve酱等第三方推送。按要求修改插件名称",
|
||||
"v1.4.1": "完善面板说明",
|
||||
"v1.4.0": "修复强制更改IP时配置面板延时过长的问题。庆祝v2进入正式版,显示了一个没用的参数"
|
||||
}
|
||||
},
|
||||
"InvitesSignin": {
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "3.0.0",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v3.0.0": "V2 专用大版本的浏览器仿真改用 CloakBrowser 获取页面,默认插件不再声明 V2 兼容。",
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
},
|
||||
"ContractCheck": {
|
||||
"name": "契约检查",
|
||||
"description": "定时检查保种契约达成情况。",
|
||||
"labels": "做种",
|
||||
"version": "2.0.0",
|
||||
"icon": "contract.png",
|
||||
"author": "DzAvril",
|
||||
"level": 1,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v2.0.0": "V2 专用大版本的渲染模式改用 CloakBrowser 获取站点页面,默认插件不再声明 V2 兼容。",
|
||||
"v1.4.1": "增加站点猪猪",
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
}
|
||||
},
|
||||
"TvFirstWatch": {
|
||||
"name": "首播试看",
|
||||
"description": "定时抓取RSS,只下载电视剧前N集,自动跳过合集和过大文件。",
|
||||
"labels": "订阅,RSS",
|
||||
"version": "1.0.0",
|
||||
"icon": "rss.png",
|
||||
"author": "Raymond38324",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.0.0": "首次发布"
|
||||
}
|
||||
},
|
||||
"WechatClawBot": {
|
||||
"name": "WechatClawBot消息推送",
|
||||
"description": "支持使用微信(通过ClawBot)发送消息通知。",
|
||||
"labels": "消息通知,微信",
|
||||
"version": "0.2.2",
|
||||
"icon": "Wechat_A.png",
|
||||
"author": "mijjjj",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v0.2.2": "修复重复推送消息问题,增加入站消息去重并修正轮询更新提取逻辑",
|
||||
"v0.2.1": "修复详情页状态信息换行显示问题",
|
||||
"v0.2.0": "优化配置页UI布局,修复回复消息携带多余类型前缀的问题",
|
||||
"v0.1.0": "初始版本"
|
||||
}
|
||||
},
|
||||
"AIRecognizerEnhancer": {
|
||||
"name": "AI识别增强",
|
||||
"description": "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。",
|
||||
"labels": "AI,识别,LLM,本地兜底,MoviePilot,TMDB",
|
||||
"version": "0.1.12",
|
||||
"icon": "airecognizerenhancer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.1.12": "兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,修复最新版 MP 下插件加载失败问题。",
|
||||
"0.1.11": "同步运行态版本,保持本地结构化识别、失败样本闭环和识别词建议能力一致。",
|
||||
"0.1.10": "新增识别词建议模型退化时的精确规则兜底,保证批量建议/批量写入在上游异常时仍能尽量落地。",
|
||||
"0.1.9": "新增失败样本精简摘要接口,并让批量建议/批量写入附带低 token 文本摘要,便于智能体直接消费。",
|
||||
"0.1.8": "新增失败样本批量建议与批量写入接口,可一次处理一批失败样本,进一步减少人工逐条操作。",
|
||||
"0.1.7": "新增失败样本批量复查接口,可批量重跑样本并在确认修复后批量出队。",
|
||||
"0.1.6": "新增失败样本复查接口,可按当前识别词与当前识别器重跑样本,并在确认修复后自动出队。",
|
||||
"0.1.5": "新增失败样本出队动作,支持按索引移除单条样本,并在写入识别词后自动移除已处理样本。",
|
||||
"0.1.4": "新增失败样本洞察接口,自动归纳重复问题、失败原因和优先处理样本,帮助更快挑出值得写识别词的样本。",
|
||||
"0.1.3": "新增失败样本摘要、样本清理、样本去重和保留上限控制,让样本工作流更适合长期运行与智能体使用。",
|
||||
"0.1.2": "新增按失败样本直接生成建议和直接写入规则的快捷 API,进一步缩短从失败样本到 CustomIdentifiers 的闭环。",
|
||||
"0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。",
|
||||
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
|
||||
}
|
||||
},
|
||||
"AgentResourceOfficer": {
|
||||
"name": "Agent影视助手",
|
||||
"description": "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
|
||||
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
|
||||
"version": "0.2.73",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"system_version": ">2.12.4",
|
||||
"history": {
|
||||
"0.2.73": "整理历史标题分词依赖改用 jieba-next,需要 MoviePilot >2.12.4。",
|
||||
"0.2.72": "影巢自动登录兜底流程改用 CloakBrowser,移除插件对 Playwright 浏览器调用的直接依赖。",
|
||||
"0.2.71": "新增流媒体推荐:聚合 Netflix、Disney+、Apple TV+、Prime Video 四大平台,基于 TMDB discover 按热度/评分推荐本月上新、近期热门电影和剧集;结果页改为只读列表,仅支持显式前缀触发。",
|
||||
"0.2.70": "最后一轮主线收口:取消标题级云盘转存/云盘搜索入口,统一保留前缀搜索与编号续接;修复 PT 指定集/最新集筛选、下载路径透传、分页与旧别名拦截,并同步外部智能体 Skill/命令文档。",
|
||||
"0.2.69": "修复外部智能体跨机器接入暴露的问题:补齐 115 直转依赖,Cookie 修复改为通过远端 MoviePilot API 安全写回,安装 Skill 时自动准备浏览器 Cookie 工具依赖,并增强 PanSou 跨机提示与 MP 推荐空结果回退。",
|
||||
"0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。",
|
||||
"0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。",
|
||||
"0.2.66": "为 request_templates 增加三类入口的 entry_playbooks,直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。",
|
||||
"0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。",
|
||||
"0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。",
|
||||
"0.2.63": "为 compact 顶层短命令增加执行语义字段:command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred;外部智能体现在可以机械判断 直接读 还是 先确认再写。",
|
||||
"0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。",
|
||||
"0.2.61": "为 compact 失败回执增加统一 error_summary;外部智能体现在可以直接读取失败标签、建议说明,以及 preferred_command / compact_commands 这样的最短恢复命令。",
|
||||
"0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commands;mp_recent_activity 也补齐 followup_summary,外部智能体可直接读取最短下一步命令。",
|
||||
"0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。",
|
||||
"0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。",
|
||||
"0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary;执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。",
|
||||
"0.2.56": "把评分后的确认提示下沉为统一 decision 摘要,score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。",
|
||||
"0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。",
|
||||
"0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。",
|
||||
"0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose,并让生命周期/执行后追踪统一返回 diagnosis_summary。",
|
||||
"0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup,而不是退回会话检查或新任务。",
|
||||
"0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe,外部智能体可以通过低 token 模板直接续接执行后追踪。",
|
||||
"0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint,方便外部智能体一跳续接执行后追踪。",
|
||||
"0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。",
|
||||
"0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。",
|
||||
"0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint,让外部智能体执行计划后能直接读取建议下一步。",
|
||||
"0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck,并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。",
|
||||
"0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。",
|
||||
"0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。",
|
||||
"0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。",
|
||||
"0.2.42": "补齐 compact session/recover 协议里的 action_templates;外部智能体读取会话状态或恢复入口时,也能拿到完整的结构化下一步模板。",
|
||||
"0.2.41": "补齐 PT 只读会话的 action_templates;下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。",
|
||||
"0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。",
|
||||
"0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。",
|
||||
"0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。",
|
||||
"0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe,外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。",
|
||||
"0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。",
|
||||
"0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。",
|
||||
"0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。",
|
||||
"0.2.33": "统一 MP 原生命令前缀解析;下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。",
|
||||
"0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。",
|
||||
"0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary,避免外部智能体误读上下文。",
|
||||
"0.2.30": "细化评分风险结构:hard_risk_reasons 表示真正阻断自动化的风险,risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。",
|
||||
"0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。",
|
||||
"0.2.28": "插件展示名统一改为 Agent影视助手,并同步仓库文档、Skill 文案和兼容插件引用。",
|
||||
"0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。",
|
||||
"0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id,再确认执行。",
|
||||
"0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。",
|
||||
"0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。",
|
||||
"0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。",
|
||||
"0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。",
|
||||
"0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。",
|
||||
"0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。",
|
||||
"0.2.19": "新增 MP 搜索结果详情入口;MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。",
|
||||
"0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。",
|
||||
"0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。",
|
||||
"0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。",
|
||||
"0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。",
|
||||
"0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。",
|
||||
"0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。",
|
||||
"0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。",
|
||||
"0.2.11": "MP 下载/订阅命令支持无空格自然写法,例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”;自然语言写入默认生成 plan_id,确认后才执行。",
|
||||
"0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”,飞书与智能体可不用结构化 mode 参数。",
|
||||
"0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。",
|
||||
"0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。",
|
||||
"0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。",
|
||||
"0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。",
|
||||
"0.2.05": "新增低 token score_summary,帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。",
|
||||
"0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status,并在未初始化时优先提示保存偏好。",
|
||||
"0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。",
|
||||
"0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。",
|
||||
"0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。",
|
||||
"0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。",
|
||||
"0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。",
|
||||
"0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。",
|
||||
"0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。",
|
||||
"0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。",
|
||||
"0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state,避免把已禁用但仍加载的旧插件误判为冲突。",
|
||||
"0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议,方便判断是否能从旧桥接迁移。",
|
||||
"0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。",
|
||||
"0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。",
|
||||
"0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。",
|
||||
"0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health,支持内置智能助手检查飞书入口状态。",
|
||||
"0.1.108": "内置可选飞书入口 Channel,并为 assistant 回执补充 write_effect/error_code 标准字段。",
|
||||
"0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。",
|
||||
"0.1.106": "assistant/startup 会带 recommended_request_templates,外部智能体启动后可直接按推荐参数拉取低 token 模板流程。",
|
||||
"0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。",
|
||||
"0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message,方便外部智能体在写入前提示用户确认。",
|
||||
"0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。",
|
||||
"0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。",
|
||||
"0.1.101": "推荐调用会带 url_template,外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。",
|
||||
"0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey,避免外部智能体误用 Bearer 鉴权。",
|
||||
"0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。",
|
||||
"0.1.98": "recommended_recipe_detail 会带 first_call,直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。",
|
||||
"0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail,直接给出推荐流程的首个模板、确认模板和写入模板,外部智能体可直接照此编排。",
|
||||
"0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason,外部智能体不必再自己挑选最适合的 recipe。",
|
||||
"0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds,自检也会验证这些汇总特征。",
|
||||
"0.1.94": "assistant/request_templates 回执会带场景化 recipes,外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。",
|
||||
"0.1.93": "assistant/request_templates 回执会带 recommended_sequence,直接给出推荐调用顺序,外部智能体可以少做一层启动编排。",
|
||||
"0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_seconds,execution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates,方便外部智能体决定缓存策略。",
|
||||
"0.1.91": "assistant/request_templates 支持 include_templates=false,可只返回模板名、无效项和执行策略,进一步减少 token。",
|
||||
"0.1.90": "请求模板协议增加 schema_version=request_templates.v1,startup/toolbox 也携带 request_templates_schema_version,方便外部智能体做兼容判断。",
|
||||
"0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。",
|
||||
"0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation,外部智能体可区分只读、dry-run、计划写入和真实执行动作。",
|
||||
"0.1.87": "request_templates 每个模板都会带 description,外部智能体可以直接判断模板用途,减少额外解释和 token 消耗。",
|
||||
"0.1.86": "request_templates 每个模板都会带 tool_args,区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。",
|
||||
"0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。",
|
||||
"0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit,方便结构化智能体直接用 body 请求过滤模板。",
|
||||
"0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。",
|
||||
"0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names;原生 Tool 同步支持 names 参数。",
|
||||
"0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool,外部智能体可只拉请求模板而不拉完整启动包。",
|
||||
"0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates,并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。",
|
||||
"0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body,外部智能体无需猜维护调用方式。",
|
||||
"0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history,方便外部智能体审计维护动作;GET dry-run 仍不写历史。",
|
||||
"0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。",
|
||||
"0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run,即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。",
|
||||
"0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。",
|
||||
"0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。",
|
||||
"0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain,支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。",
|
||||
"0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions,外部智能体可直接判断是否值得做低风险维护清理。",
|
||||
"0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all,避免外部智能体把全部计划数误判为待执行计划数。",
|
||||
"0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。",
|
||||
"0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。",
|
||||
"0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates,外部智能体可拿启动包直接执行推荐恢复动作。",
|
||||
"0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup,一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议,减少外部智能体开场多次探测。",
|
||||
"0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck,便于外部智能体开场自检协议健康。",
|
||||
"0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool,让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。",
|
||||
"0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。",
|
||||
"0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。",
|
||||
"0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。",
|
||||
"0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true,外部智能体原样回放模板即可保持低 token。",
|
||||
"0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。",
|
||||
"0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。",
|
||||
"0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。",
|
||||
"0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。",
|
||||
"0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。",
|
||||
"0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。",
|
||||
"0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。",
|
||||
"0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。",
|
||||
"0.1.52": "assistant/recover 新增 compact=true 低 token 回执,agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。",
|
||||
"0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。",
|
||||
"0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。",
|
||||
"0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan,外部智能体可按恢复协议直接续跑。",
|
||||
"0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session,便于从会话列表直接恢复。",
|
||||
"0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。",
|
||||
"0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作,action_templates.action_body 可原样回传执行计划。",
|
||||
"0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。",
|
||||
"0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。",
|
||||
"0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。",
|
||||
"0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。",
|
||||
"0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。",
|
||||
"0.1.40": "新增 assistant/history 与 history Tool,记录最近批量动作和预设工作流执行摘要,便于外部智能体判断进度、排障和恢复上下文。",
|
||||
"0.1.39": "新增 assistant/readiness 与 readiness Tool,外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示,再决定是否开始执行。",
|
||||
"0.1.38": "新增 assistant/workflow 与 run_workflow Tool,外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。",
|
||||
"0.1.37": "新增 assistant/actions 与 execute_actions Tool,外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。",
|
||||
"0.1.36": "新增 assistant/action 与 execute_action Tool,外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。",
|
||||
"0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates,外部智能体可直接按返回模板继续调用,不再自己拼下一步参数。",
|
||||
"0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。",
|
||||
"0.1.33": "新增活跃会话列表 API 与原生 Tool,并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。",
|
||||
"0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。",
|
||||
"0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。",
|
||||
"0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool,便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。",
|
||||
"0.1.29": "新增 Agent影视助手 帮助 Tool,并让统一智能入口在空输入或帮助语义下直接返回推荐用法,降低 MP 智能助手首次调用门槛。",
|
||||
"0.1.28": "新增 Agent影视助手 统一智能入口原生 Tool:smart_entry / smart_pick,MP 智能助手可直接复用飞书同款处理/选择主链。",
|
||||
"0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool,避免仍显示骨架态提示。",
|
||||
"0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。",
|
||||
"0.1.25": "新增 115 待处理任务标准 API:查看、继续、取消,便于飞书、CLI 与外部脚本直接调用。",
|
||||
"0.1.24": "新增 115 待处理任务原生 Agent Tool:查看、继续、取消,MP 智能助手可直接调用待处理任务能力。",
|
||||
"0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。",
|
||||
"0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。",
|
||||
"0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。",
|
||||
"0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录,可自动继续上次未完成的 115 操作。",
|
||||
"0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。",
|
||||
"0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。",
|
||||
"0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。",
|
||||
"0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。",
|
||||
"0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。",
|
||||
"0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。",
|
||||
"0.1.13": "新增 115 扫码登录原生 Agent Tool,智能助手可直接发起二维码并轮询登录状态。",
|
||||
"0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。",
|
||||
"0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。",
|
||||
"0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本,115 健康检查已验证可用。",
|
||||
"0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。",
|
||||
"0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。",
|
||||
"0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API,让 Agent影视助手 开始承接用户态能力。",
|
||||
"0.1.6": "新增 Agent影视助手 自己的智能入口 API,支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。",
|
||||
"0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。",
|
||||
"0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。",
|
||||
"0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。",
|
||||
"0.1.2": "新增原生 Agent Tool:影巢会话搜索、会话继续选择、通用分享链接路由。",
|
||||
"0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。",
|
||||
"0.1.0": "首个可用版本,已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。"
|
||||
}
|
||||
},
|
||||
"FeishuCommandBridgeLong": {
|
||||
"name": "飞书命令桥接",
|
||||
"description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。",
|
||||
"labels": "飞书,长连接,115,影巢,夸克,智能体,命令",
|
||||
"version": "0.5.26",
|
||||
"icon": "feishucommandbridgelong.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。",
|
||||
"0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。",
|
||||
"0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。",
|
||||
"0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。",
|
||||
"0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。",
|
||||
"0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。",
|
||||
"0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。",
|
||||
"0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。",
|
||||
"0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片,也支持回复检查115登录继续轮询 Agent影视助手 会话。",
|
||||
"0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。",
|
||||
"0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。",
|
||||
"0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。",
|
||||
"0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。",
|
||||
"0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手,影巢/115/夸克开始走新主干。",
|
||||
"0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。",
|
||||
"0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。",
|
||||
"0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。",
|
||||
"0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。",
|
||||
"0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。",
|
||||
"0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。",
|
||||
"0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。",
|
||||
"0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。",
|
||||
"0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。",
|
||||
"0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。",
|
||||
"0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。",
|
||||
"0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。",
|
||||
"0.5.0": "新增处理/选择双命令与智能体 API,统一分流夸克链接、115 链接与影巢搜索解锁流程。",
|
||||
"0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。",
|
||||
"0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。",
|
||||
"0.2.3": "统一插件身份为 FeishuCommandBridgeLong,修复插件市场安装状态匹配。",
|
||||
"0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。"
|
||||
}
|
||||
},
|
||||
"HdhiveOpenApi": {
|
||||
"name": "影巢 OpenAPI",
|
||||
"description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。",
|
||||
"labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到",
|
||||
"version": "0.3.0",
|
||||
"icon": "hdhive.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。"
|
||||
}
|
||||
},
|
||||
"QuarkShareSaver": {
|
||||
"name": "夸克分享转存",
|
||||
"description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。",
|
||||
"labels": "夸克,Quark,分享,转存,网盘,智能体,飞书",
|
||||
"version": "0.1.0",
|
||||
"icon": "quark.ico",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。"
|
||||
}
|
||||
},
|
||||
"AutoAuction": {
|
||||
"name": "朱雀交易行自动上架",
|
||||
"description": "自动上架上传或灵石到交易行,支持定时任务和历史记录",
|
||||
"labels": "交易行自动化",
|
||||
"version": "1.0.1",
|
||||
"icon": "auction.png",
|
||||
"author": "no_reply",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.0.0": "初始版本,支持交易行自动上架和定时任务",
|
||||
"v1.0.1": "修复定时任务重复触发问题"
|
||||
}
|
||||
},
|
||||
"AgentTokens": {
|
||||
"name": "Agent Tokens 管理",
|
||||
"description": "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。",
|
||||
"labels": "Agent,AI,系统",
|
||||
"version": "1.0.2",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">=2.13.0",
|
||||
"release": true,
|
||||
"history": {
|
||||
"v1.0.2": "修复UI界面显示不全及前端路由报错问题",
|
||||
"v1.0.1": "新增 Agent Tokens 配额管理、供应商优先级切换和用量展示"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
223
plugins.v2/agentresourceofficer/ARCHITECTURE.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Agent影视助手架构草案
|
||||
|
||||
`Agent影视助手` 是重构后的资源工作流主插件,重点不是把旧代码简单拼一起,而是把职责重新压平。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 一个插件承接“搜索 -> 选择 -> 解锁 -> 转存 -> 签到/用户态 -> 远程入口”
|
||||
- 智能体、飞书、CLI、后续 MP Agent Tool 共享同一套执行服务
|
||||
- 会话交互与底层执行解耦,避免继续把大量业务逻辑堆在消息入口层
|
||||
|
||||
## 模块分层
|
||||
|
||||
### 1. adapters
|
||||
|
||||
负责不同外部入口和外部平台接入:
|
||||
|
||||
- `feishu`
|
||||
- `hdhive`
|
||||
- `quark`
|
||||
- `pansou`
|
||||
- 后续 `agent_tool`
|
||||
|
||||
原则:
|
||||
|
||||
- 只负责协议和输入输出转换
|
||||
- 不负责复杂业务编排
|
||||
|
||||
### 2. services
|
||||
|
||||
负责核心业务能力:
|
||||
|
||||
- `search_service`
|
||||
- `unlock_service`
|
||||
- `transfer_service`
|
||||
- `signin_service`
|
||||
- `user_service`
|
||||
|
||||
原则:
|
||||
|
||||
- 统一返回结构
|
||||
- 尽量不感知飞书、页面、CLI 等具体入口
|
||||
|
||||
### 3. session
|
||||
|
||||
负责交互上下文:
|
||||
|
||||
- 搜索候选缓存
|
||||
- 翻页状态
|
||||
- 选择上下文
|
||||
- 详情/审查补充信息(已支持候选页按需补主演)
|
||||
|
||||
原则:
|
||||
|
||||
- 入口层共享同一套会话数据
|
||||
- 后续优先支持内存 + 轻量持久化
|
||||
|
||||
### 4. models
|
||||
|
||||
负责统一数据模型:
|
||||
|
||||
- 搜索候选
|
||||
- 资源条目
|
||||
- 解锁结果
|
||||
- 转存结果
|
||||
- 用户信息
|
||||
|
||||
目标:
|
||||
|
||||
- 减少旧插件之间字段名不一致的问题
|
||||
|
||||
## 首期配置模型
|
||||
|
||||
### 基础
|
||||
|
||||
- `enabled`
|
||||
- `notify`
|
||||
- `debug`
|
||||
|
||||
### 影巢
|
||||
|
||||
- `hdhive_base_url`
|
||||
- `hdhive_api_key`
|
||||
- `hdhive_default_path`
|
||||
- `hdhive_candidate_page_size`
|
||||
|
||||
### 夸克
|
||||
|
||||
- `quark_cookie`
|
||||
- `quark_default_path`
|
||||
- `quark_timeout`
|
||||
- `quark_auto_import_cookiecloud`
|
||||
|
||||
### 飞书
|
||||
|
||||
- `feishu_enabled`
|
||||
- `feishu_app_id`
|
||||
- `feishu_app_secret`
|
||||
- `feishu_verification_token`
|
||||
- `feishu_allow_all`
|
||||
- `feishu_allowed_chat_ids`
|
||||
- `feishu_allowed_user_ids`
|
||||
|
||||
### 智能体 / 工具层预留
|
||||
|
||||
- `agent_tools_enabled`
|
||||
- `tool_debug`
|
||||
|
||||
## 迁移映射
|
||||
|
||||
### 从 `QuarkShareSaver`
|
||||
|
||||
优先迁入:
|
||||
|
||||
- 分享链接解析
|
||||
- 目录创建
|
||||
- 转存执行
|
||||
- CookieCloud 自动导入
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/quark_transfer.py`
|
||||
|
||||
### 从 `P115StrmHelper` 协同层
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/p115_transfer.py`
|
||||
|
||||
### 从 `HdhiveOpenApi`
|
||||
|
||||
随后迁入:
|
||||
|
||||
- 搜索
|
||||
- 候选解析
|
||||
- 解锁
|
||||
- 用户信息
|
||||
- 配额
|
||||
- 分享管理
|
||||
|
||||
当前已开始拆出:
|
||||
|
||||
- `services/hdhive_openapi.py`
|
||||
|
||||
### 从 `HDHiveDailySign`
|
||||
|
||||
补入:
|
||||
|
||||
- 普通签到
|
||||
- 赌狗签到
|
||||
- 自动登录与状态记录
|
||||
|
||||
### 从 `FeishuCommandBridgeLong`
|
||||
|
||||
最后收口:
|
||||
|
||||
- 飞书长连接入口
|
||||
- 自然语言别名解析
|
||||
- 搜索/选择会话衔接
|
||||
|
||||
## 暂不迁入的内容
|
||||
|
||||
- `P115StrmHelper` 仍作为 115 落地执行层保留,不直接并入 `Agent影视助手`
|
||||
|
||||
> 更新说明:PT 搜索、下载、订阅、推荐、入库追踪相关工作流已经收口到 `Agent影视助手` 主线,不再依赖旧桥接插件作为主入口。
|
||||
|
||||
## P115StrmHelper 兼容补丁
|
||||
|
||||
新版 MoviePilot 移除了旧版 `TransferOverwriteCheck` 事件时,部分 `P115StrmHelper` 版本会因为导入 `TransferOverwriteCheckEventData` 失败而无法加载,进而导致 115 自动转存不可用。
|
||||
|
||||
仓库提供了幂等补丁脚本:
|
||||
|
||||
```bash
|
||||
MP_CONTAINER=moviepilot-v2 ./scripts/patch-p115strmhelper-mp-compat.sh
|
||||
```
|
||||
|
||||
补丁只跳过缺失事件的注册,不改动 `P115StrmHelper` 的分享转存主流程。运行环境已验证 `AgentResourceOfficer` 的 `p115/health` 可返回 `p115_ready=true`。
|
||||
|
||||
## 115 轻量直转层
|
||||
|
||||
`Agent影视助手` 从 `0.1.17` 开始支持 115 分享链接轻量直转 + 扫码会话登录:
|
||||
|
||||
- 支持生成和轮询 `p115client` 同款 115 扫码二维码,拿到 `UID / CID / SEID / KID` 这类客户端会话后自动写回插件配置
|
||||
- 配置扫码得到的 115 会话时,直接用该会话创建 115 客户端并调用 `share_receive`
|
||||
- 未配置独立扫码会话时,优先复用已加载的 115 客户端,不再必须走 `sharetransferhelper`
|
||||
- 直转失败时回退 `P115StrmHelper` 的分享转存主流程
|
||||
|
||||
这个能力只负责“分享链接落到 115 目标目录”。STRM 生成、302、增量/全量同步、媒体库整理仍保持由 `P115StrmHelper` 承担。
|
||||
这里特意没有走网页版 CookieCloud,也没有直接拿 MP 系统内置的 `u115` OAuth Token 来代替扫码会话,因为分享转存链路仍然更适合复用 `p115client` 的客户端会话模型。
|
||||
|
||||
## 首个里程碑
|
||||
|
||||
第一个可用版本只追求三件事:
|
||||
|
||||
1. 夸克分享链接直接转存
|
||||
2. 影巢搜索并解锁
|
||||
3. 飞书调用同一套执行服务
|
||||
|
||||
当前进度:
|
||||
|
||||
- 已拆出夸克执行服务
|
||||
- 已拆出影巢基础 OpenAPI 服务
|
||||
- 已拆出 115 转存执行服务
|
||||
- 已补上 Agent影视助手 自己的统一智能入口(assistant route / pick)
|
||||
- 主插件已具备:
|
||||
- 夸克健康检查
|
||||
- 夸克转存
|
||||
- 影巢健康检查
|
||||
- 影巢搜索
|
||||
- 影巢关键词候选搜索
|
||||
- 影巢解锁
|
||||
- 115 依赖健康检查
|
||||
- 115 分享转存
|
||||
- 影巢解锁后自动路由到夸克执行层
|
||||
- 影巢解锁后自动路由到 115 执行层
|
||||
- 影巢会话搜索与按编号继续选择
|
||||
- 盘搜搜索与按编号继续执行
|
||||
- 统一智能入口对直链、盘搜、影巢三类输入的会话分流
|
||||
- 原生 Agent Tool 直接发起和轮询 115 扫码登录
|
||||
- 智能入口 `assistant/route` 可直接理解 `115登录` / `检查115登录`
|
||||
- 扫码登录成功后可直接返回 115 运行状态摘要,便于飞书与 MP 智能助手继续执行
|
||||
- 智能入口与原生 Agent Tool 都可直接返回 `115状态` 摘要,不依赖是否存在待检查会话
|
||||
- 待继续的 115 任务已具备轻量持久化、时间/重试/错误摘要,并提供查看、继续、取消三个原生 Agent Tool 和标准 API
|
||||
- `115状态` / `检查115登录` / `115帮助` 统一补充下一步建议,减少人工猜测下一条命令
|
||||
224
plugins.v2/agentresourceofficer/README.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Agent影视助手
|
||||
|
||||
`Agent影视助手` 是这个仓库的主线插件,重点解决一件事:
|
||||
|
||||
把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。
|
||||
|
||||
当前版本:`0.2.71`
|
||||
|
||||
当前 helper 版本:`0.1.51`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.71
|
||||
|
||||
如果你是第一次用这个仓库,先把这个插件跑通就够了。
|
||||
|
||||
---
|
||||
|
||||
## 适合谁
|
||||
|
||||
- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。
|
||||
- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。
|
||||
- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。
|
||||
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。
|
||||
- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。
|
||||
|
||||
---
|
||||
|
||||
## 两种主要用法
|
||||
|
||||
### 1. 不使用外部智能体,只用飞书命令入口
|
||||
|
||||
如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。
|
||||
|
||||
配好后,直接在飞书里发:
|
||||
|
||||
```text
|
||||
盘搜搜索 片名
|
||||
影巢搜索 片名
|
||||
转存 片名
|
||||
夸克转存 片名
|
||||
下载 片名
|
||||
更新检查 片名
|
||||
115登录
|
||||
影巢签到
|
||||
```
|
||||
|
||||
这种用法更像 TG / 企业微信机器人入口:飞书负责收消息,插件负责执行。
|
||||
|
||||
### 2. 使用外部智能体
|
||||
|
||||
如果你要接 `OpenClaw`、`Hermes`、`WorkBuddy`,建议安装 `agent-resource-officer skill / helper`。
|
||||
|
||||
外部智能体负责理解用户需求和展示结果;资源搜索、转存、下载、签到、Cookie 修复都交给插件。
|
||||
|
||||
重点文档:
|
||||
|
||||
- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md)
|
||||
- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)
|
||||
- 全部命令:`docs/ALL_COMMANDS.md`
|
||||
|
||||
### MCP 和 Skill 怎么分工
|
||||
|
||||
如果你的智能体客户端支持 MoviePilot 官方 MCP,可以一起接。
|
||||
|
||||
- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。
|
||||
- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、115/夸克转存、PT 编号下载、翻页、盘搜/影巢详情和 Cookie 修复。
|
||||
- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。
|
||||
|
||||
MCP 地址通常是:
|
||||
|
||||
```text
|
||||
http://你的MP地址:3000/api/v1/mcp
|
||||
X-API-KEY=你的 MoviePilot API_TOKEN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 搜索
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `搜索 <片名>` | 默认优先走 MP/PT;如果 MP/PT 已关闭,再按当前启用源回退 |
|
||||
| `盘搜搜索 <片名>` | 先查盘搜;盘搜没结果时按开关补查影巢 |
|
||||
| `影巢搜索 <片名>` | 先查影巢;影巢没结果时按开关补查盘搜 |
|
||||
| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 |
|
||||
| `盘搜更新检查 <片名>` | 只看盘搜侧更新资源 |
|
||||
| `影巢更新检查 <片名>` | 只看影巢侧更新资源 |
|
||||
|
||||
补充:
|
||||
|
||||
- `搜索 第 3 集`、`搜索 E03` 这类带集数线索的写法,会直接按 MP/PT 搜索,不再回退到云盘。
|
||||
- `检查 大君夫人`、`检查大君夫人` 这类写法,会按更新检查处理;但 `检查115登录` 仍然保留为 115 登录检查。
|
||||
- `更新检查 xx 剧` / `检查 xx 剧` 这类带剧集意图的写法,会按 MP/PT 搜索;云盘侧更新检查请显式使用 `盘搜更新检查` 或 `影巢更新检查`。
|
||||
|
||||
### 转存 / 下载
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `转存 <片名>` | 默认等同 `115转存 <片名>` |
|
||||
| `115转存 <片名>` | 搜索后优先转存到 115 |
|
||||
| `夸克转存 <片名>` | 搜索后优先转存到夸克 |
|
||||
| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先找片并列出 PT 候选 |
|
||||
|
||||
注意:
|
||||
|
||||
- `转存 <片名>` 默认是 115,不会自动改成夸克。
|
||||
- 只有明确说 `夸克转存 <片名>` 才走夸克。
|
||||
- `下载 <片名>` 是 PT 下载,不是云盘转存。
|
||||
- PT 搜索结果里直接回编号会立即下载。
|
||||
- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。
|
||||
- 云盘/影巢结果才有详情卡;想看详情用 `选择 1 详情` 或 `详情 1`。
|
||||
|
||||
### 选择 / 翻页
|
||||
|
||||
```text
|
||||
1
|
||||
下载1
|
||||
选择 1 详情
|
||||
n
|
||||
```
|
||||
|
||||
- `1`:PT 结果里直接下载;云盘结果里继续转存/解锁。
|
||||
- `下载1`:给第 1 条 PT 结果生成下载计划。
|
||||
- `选择 1 详情` / `详情 1`:只用于云盘/影巢详情。
|
||||
- `n`:下一页。
|
||||
|
||||
完整命令见:`docs/ALL_COMMANDS.md`
|
||||
|
||||
---
|
||||
|
||||
## 主要能力
|
||||
|
||||
### 云盘资源
|
||||
|
||||
- 盘搜搜索
|
||||
- 影巢搜索 / 解锁
|
||||
- `云盘搜索` 已废弃,收到后只会提示改用 `盘搜搜索` / `影巢搜索`
|
||||
- 115 转存
|
||||
- 夸克转存
|
||||
- 更新检查
|
||||
- 编号选择、详情、翻页
|
||||
- 智能建议与候选推荐
|
||||
|
||||
### MoviePilot 原生能力
|
||||
|
||||
- MP / PT 搜索
|
||||
- PT 下载计划
|
||||
- 订阅
|
||||
- 下载任务
|
||||
- 下载历史
|
||||
- 入库历史
|
||||
- 站点状态 / 下载器状态
|
||||
- 热门探索 / 推荐
|
||||
|
||||
### 账号与修复
|
||||
|
||||
- 115 扫码登录 / 状态检查
|
||||
- 影巢签到 / 签到日志
|
||||
- 影巢 Cookie 修复
|
||||
- 夸克 Cookie 修复
|
||||
|
||||
`115登录` / `115转存` 现在不再强依赖 `P115StrmHelper`;有它时更适合做 115 整理、STRM 和旧登录态复用,没有它也可以直接扫码后完成 115 转存。
|
||||
|
||||
Cookie 修复会用到本机浏览器登录态。如果 MoviePilot 在 NAS、智能体在电脑上,修复命令读取的是智能体电脑上的浏览器 Cookie,再写回 NAS 上的 MoviePilot。
|
||||
|
||||
---
|
||||
|
||||
## 和旧插件的关系
|
||||
|
||||
`Agent影视助手` 是把旧的分散能力收成一条主线。
|
||||
|
||||
| 旧插件 | 主要用途 | 现在建议 |
|
||||
|---|---|---|
|
||||
| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 |
|
||||
| `HdhiveOpenApi` | 影巢独立能力 | 主能力已收进 Agent影视助手 |
|
||||
| `QuarkShareSaver` | 夸克独立转存 | 主能力已收进 Agent影视助手 |
|
||||
| `HDHiveDailySign` | 旧影巢签到兜底 | 新环境优先走 Agent影视助手修复链 |
|
||||
|
||||
旧组合仍然能用,但更适合兼容老环境;新装建议优先用 `Agent影视助手`。
|
||||
|
||||
---
|
||||
|
||||
## 新手最容易踩的坑
|
||||
|
||||
### 外部智能体乱改命令
|
||||
|
||||
常见错误:
|
||||
|
||||
- 把 `盘搜搜索`、`影巢搜索`、`MP搜索` 这些明确命令改写成别的入口
|
||||
- 把 `下载` 当成云盘转存
|
||||
- 把云盘详情当成直接选择,或把 PT 编号下载当成详情
|
||||
- 重排插件返回的编号
|
||||
|
||||
解决方式:让智能体安装并读取 `agent-resource-officer skill`。长线程跑偏时,直接对智能体说:
|
||||
|
||||
```text
|
||||
校准影视技能
|
||||
```
|
||||
|
||||
外部智能体收到这句时,应先检查并拉取 `MoviePilot-Plugins` 仓库最新版,再重新加载影视技能规则;如果工作区有本地改动,就跳过自动拉取并说明原因。
|
||||
|
||||
### 跨机器地址填错
|
||||
|
||||
如果 MoviePilot 在 NAS,智能体在电脑上,`ARO_BASE_URL` 要填 NAS 地址:
|
||||
|
||||
```text
|
||||
ARO_BASE_URL=http://你的NAS地址:3000
|
||||
```
|
||||
|
||||
不要填 `127.0.0.1`,那只代表智能体自己这台机器。
|
||||
|
||||
### 夸克失败不一定是 Cookie 失效
|
||||
|
||||
分享受限、分享者封禁、`41031` 不一定是 Cookie 问题。只有明确提示登录态失效时,才优先走夸克 Cookie 修复。
|
||||
|
||||
---
|
||||
|
||||
## 进一步阅读
|
||||
|
||||
- [插件安装说明](../docs/PLUGIN_INSTALL.md)
|
||||
- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md)
|
||||
- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)
|
||||
- 全部命令:`docs/ALL_COMMANDS.md`
|
||||
27976
plugins.v2/agentresourceofficer/__init__.py
Normal file
870
plugins.v2/agentresourceofficer/agenttool.py
Normal file
@@ -0,0 +1,870 @@
|
||||
from typing import Optional, Type
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.core.plugin import PluginManager
|
||||
|
||||
from .schemas import (
|
||||
AssistantCapabilitiesToolInput,
|
||||
AssistantExecuteActionToolInput,
|
||||
AssistantExecuteActionsToolInput,
|
||||
AssistantExecutePlanToolInput,
|
||||
AssistantHistoryToolInput,
|
||||
AssistantHelpToolInput,
|
||||
AssistantMaintainToolInput,
|
||||
AssistantPickToolInput,
|
||||
AssistantPreferencesToolInput,
|
||||
AssistantPlansClearToolInput,
|
||||
AssistantPlansToolInput,
|
||||
AssistantPulseToolInput,
|
||||
AssistantReadinessToolInput,
|
||||
AssistantRecoverToolInput,
|
||||
AssistantRequestTemplatesToolInput,
|
||||
AssistantRouteToolInput,
|
||||
AssistantSessionClearToolInput,
|
||||
AssistantSessionsClearToolInput,
|
||||
AssistantSessionsToolInput,
|
||||
AssistantSessionStateToolInput,
|
||||
AssistantSelfcheckToolInput,
|
||||
AssistantStartupToolInput,
|
||||
AssistantToolboxToolInput,
|
||||
AssistantWorkflowToolInput,
|
||||
FeishuChannelHealthToolInput,
|
||||
HDHiveSearchSessionToolInput,
|
||||
HDHiveSessionPickToolInput,
|
||||
P115CancelPendingToolInput,
|
||||
P115PendingToolInput,
|
||||
P115QRCodeCheckToolInput,
|
||||
P115QRCodeStartToolInput,
|
||||
P115ResumePendingToolInput,
|
||||
P115StatusToolInput,
|
||||
ShareRouteToolInput,
|
||||
)
|
||||
|
||||
|
||||
def _get_plugin():
|
||||
return PluginManager().running_plugins.get("AgentResourceOfficer")
|
||||
|
||||
|
||||
class HDHiveSearchSessionTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_hdhive_search"
|
||||
description: str = "Search HDHive by title, return candidate titles and a reusable session_id for the next selection step."
|
||||
args_schema: Type[BaseModel] = HDHiveSearchSessionToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
keyword = kwargs.get("keyword", "")
|
||||
return f"正在通过 Agent影视助手搜索影巢候选:{keyword}"
|
||||
|
||||
async def run(self, keyword: str, media_type: str = "auto", year: str = None, path: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_hdhive_search_session(
|
||||
keyword=keyword,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
target_path=path,
|
||||
)
|
||||
|
||||
|
||||
class HDHiveSessionPickTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_hdhive_pick"
|
||||
description: str = "Continue a previous HDHive session by selecting either a candidate title or a resource item."
|
||||
args_schema: Type[BaseModel] = HDHiveSessionPickToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session_id = kwargs.get("session_id", "")
|
||||
choice = kwargs.get("choice", "")
|
||||
return f"正在继续 Agent影视助手 会话:{session_id},选择 {choice}"
|
||||
|
||||
async def run(self, session_id: str, choice: int = 0, path: str = None, action: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_hdhive_pick_session(
|
||||
session_id=session_id,
|
||||
index=choice,
|
||||
target_path=path,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
class ShareRouteTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_route_share"
|
||||
description: str = "Route a 115 or Quark share link into the configured transfer pipeline and save it into the target path."
|
||||
args_schema: Type[BaseModel] = ShareRouteToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 路由分享链接"
|
||||
|
||||
async def run(self, url: str, path: str = None, access_code: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_route_share(
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
target_path=path,
|
||||
)
|
||||
|
||||
|
||||
class AssistantRouteTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_smart_entry"
|
||||
description: str = "Use the unified Agent影视助手 smart entry for HDHive search, PanSou search, 115 login, or direct 115/Quark share links."
|
||||
args_schema: Type[BaseModel] = AssistantRouteToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
text = kwargs.get("text") or kwargs.get("keyword") or kwargs.get("url") or kwargs.get("action") or ""
|
||||
return f"正在通过 Agent影视助手 统一入口处理:{text}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
text: str = None,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
path: str = None,
|
||||
mode: str = None,
|
||||
keyword: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
client_type: str = None,
|
||||
action: str = None,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_route(
|
||||
text=text,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
target_path=path,
|
||||
mode=mode,
|
||||
keyword=keyword,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
client_type=client_type,
|
||||
action=action,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPickTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_smart_pick"
|
||||
description: str = "Continue the unified Agent影视助手 smart-entry session by choosing an item, requesting details, or moving to the next page."
|
||||
args_schema: Type[BaseModel] = AssistantPickToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
choice = kwargs.get("choice", 0)
|
||||
action = kwargs.get("action", "")
|
||||
tail = f"动作 {action}" if action else f"选择 {choice}"
|
||||
return f"正在继续 Agent影视助手 统一会话:{session},{tail}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
choice: int = 0,
|
||||
action: str = None,
|
||||
mode: str = None,
|
||||
path: str = None,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_pick(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
index=choice,
|
||||
action=action,
|
||||
mode=mode,
|
||||
target_path=path,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantHelpTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_help"
|
||||
description: str = "Show the recommended Agent影视助手 workflow for MoviePilot Agent, including smart-entry examples, pick examples, and 115 login guidance."
|
||||
args_schema: Type[BaseModel] = AssistantHelpToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 使用帮助"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_help(session=session, session_id=session_id)
|
||||
|
||||
|
||||
class AssistantCapabilitiesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_capabilities"
|
||||
description: str = "Show the current Agent影视助手 execution capabilities, supported structured smart-entry fields, defaults, and recommended call patterns for external agents."
|
||||
args_schema: Type[BaseModel] = AssistantCapabilitiesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 能力说明"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_capabilities(compact=compact)
|
||||
|
||||
|
||||
class AssistantReadinessTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_readiness"
|
||||
description: str = "Check whether Agent影视助手 is ready for external agents, including version, services, suggested entrypoints, and startup warnings."
|
||||
args_schema: Type[BaseModel] = AssistantReadinessToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 启动就绪状态"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_readiness(compact=compact)
|
||||
|
||||
|
||||
class FeishuChannelHealthTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_feishu_health"
|
||||
description: str = "Check Agent影视助手 built-in Feishu Channel status, including whether it is enabled, running, and configured."
|
||||
args_schema: Type[BaseModel] = FeishuChannelHealthToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 内置飞书入口状态"
|
||||
|
||||
async def run(self, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_feishu_health(compact=compact)
|
||||
|
||||
|
||||
class AssistantPulseTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_pulse"
|
||||
description: str = "Return a compact Agent影视助手 startup pulse: version, service readiness, warnings, and best recovery hint for external agents."
|
||||
args_schema: Type[BaseModel] = AssistantPulseToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 轻量启动状态"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_pulse()
|
||||
|
||||
|
||||
class AssistantStartupTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_startup"
|
||||
description: str = "Return one compact startup bundle for external agents: pulse, self-check result, key tools, endpoints, defaults, and recovery hint."
|
||||
args_schema: Type[BaseModel] = AssistantStartupToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 启动聚合信息"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_startup()
|
||||
|
||||
|
||||
class AssistantMaintainTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_maintain"
|
||||
description: str = "Inspect or execute low-risk Agent影视助手 maintenance: clear stale assistant sessions and executed saved plans."
|
||||
args_schema: Type[BaseModel] = AssistantMaintainToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在检查 Agent影视助手 维护建议"
|
||||
|
||||
async def run(self, execute: bool = False, limit: int = 100, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_maintain(execute=execute, limit=limit)
|
||||
|
||||
|
||||
class AssistantToolboxTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_toolbox"
|
||||
description: str = "Return a compact Agent影视助手 toolbox manifest: recommended tools, endpoints, workflows, actions, defaults, and command examples."
|
||||
args_schema: Type[BaseModel] = AssistantToolboxToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 轻量工具清单"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_toolbox()
|
||||
|
||||
|
||||
class AssistantRequestTemplatesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_request_templates"
|
||||
description: str = "Return compact HTTP request templates for external agents to call Agent影视助手 assistant endpoints without guessing request bodies."
|
||||
args_schema: Type[BaseModel] = AssistantRequestTemplatesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在读取 Agent影视助手 请求模板"
|
||||
|
||||
async def run(self, limit: int = 100, names: str = None, recipe: str = None, include_templates: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_request_templates(
|
||||
limit=limit,
|
||||
names=names,
|
||||
recipe=recipe,
|
||||
include_templates=include_templates,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSelfcheckTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_selfcheck"
|
||||
description: str = "Run a compact Agent影视助手 protocol self-check for compact templates, boolean parsing, and basic assistant protocol health."
|
||||
args_schema: Type[BaseModel] = AssistantSelfcheckToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在执行 Agent影视助手 协议自检"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_selfcheck()
|
||||
|
||||
|
||||
class AssistantHistoryTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_history"
|
||||
description: str = "Show recent Agent影视助手 assistant executions so external agents can debug progress, retries, and the last completed action."
|
||||
args_schema: Type[BaseModel] = AssistantHistoryToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 最近执行历史"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_history(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecuteActionTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_action"
|
||||
description: str = "Execute a named Agent影视助手 action template directly, so external agents can reuse action_templates without manually mapping each next step."
|
||||
args_schema: Type[BaseModel] = AssistantExecuteActionToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在执行 Agent影视助手 动作模板:{kwargs.get('name', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
choice: int = None,
|
||||
path: str = None,
|
||||
keyword: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
client_type: str = None,
|
||||
source: str = None,
|
||||
kind: str = None,
|
||||
has_pending_p115: bool = None,
|
||||
stale_only: bool = False,
|
||||
all_sessions: bool = False,
|
||||
limit: int = 100,
|
||||
plan_id: str = None,
|
||||
prefer_unexecuted: bool = True,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_action(
|
||||
name=name,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
choice=choice,
|
||||
target_path=path,
|
||||
keyword=keyword,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
client_type=client_type,
|
||||
source=source,
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
stale_only=stale_only,
|
||||
all_sessions=all_sessions,
|
||||
limit=limit,
|
||||
plan_id=plan_id,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecuteActionsTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_actions"
|
||||
description: str = "Execute a sequence of Agent影视助手 action templates in one request, so external agents can reduce round trips and reuse action_templates directly."
|
||||
args_schema: Type[BaseModel] = AssistantExecuteActionsToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
actions = kwargs.get("actions") or []
|
||||
return f"正在批量执行 Agent影视助手 动作模板:{len(actions)} 步"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
actions: list,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_actions(
|
||||
actions=actions,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantWorkflowTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_run_workflow"
|
||||
description: str = "Run a preset Agent影视助手 workflow such as pansou_transfer, hdhive_unlock, mp_search_best, mp_search_detail, mp_search_download, mp_subscribe, mp_recommend, share_transfer, or p115_status with compact inputs."
|
||||
args_schema: Type[BaseModel] = AssistantWorkflowToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在运行 Agent影视助手 预设工作流:{kwargs.get('name', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
name: str,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
keyword: str = None,
|
||||
choice: int = None,
|
||||
candidate_choice: int = None,
|
||||
resource_choice: int = None,
|
||||
path: str = None,
|
||||
url: str = None,
|
||||
access_code: str = None,
|
||||
media_type: str = None,
|
||||
year: str = None,
|
||||
client_type: str = None,
|
||||
source: str = None,
|
||||
limit: int = 20,
|
||||
dry_run: bool = False,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_workflow(
|
||||
name=name,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
keyword=keyword,
|
||||
choice=choice,
|
||||
candidate_choice=candidate_choice,
|
||||
resource_choice=resource_choice,
|
||||
target_path=path,
|
||||
share_url=url,
|
||||
access_code=access_code,
|
||||
media_type=media_type,
|
||||
year=year,
|
||||
client_type=client_type,
|
||||
source=source,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPreferencesTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_preferences"
|
||||
description: str = "Read, save, or reset Agent影视助手 source preferences for scoring cloud-drive and PT results before automated actions."
|
||||
args_schema: Type[BaseModel] = AssistantPreferencesToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
if kwargs.get("reset"):
|
||||
return "正在重置 Agent影视助手 智能体偏好画像"
|
||||
if kwargs.get("preferences"):
|
||||
return "正在保存 Agent影视助手 智能体偏好画像"
|
||||
return "正在读取 Agent影视助手 智能体偏好画像"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = "default",
|
||||
session_id: str = None,
|
||||
user_key: str = None,
|
||||
preferences: dict = None,
|
||||
reset: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_preferences(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
user_key=user_key,
|
||||
preferences=preferences,
|
||||
reset=reset,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantExecutePlanTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_execute_plan"
|
||||
description: str = "Execute a saved Agent影视助手 dry-run workflow plan by plan_id, or recover the latest plan by session/session_id."
|
||||
args_schema: Type[BaseModel] = AssistantExecutePlanToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return f"正在执行 Agent影视助手 已保存计划:{kwargs.get('plan_id', '') or kwargs.get('session_id', '') or kwargs.get('session', '')}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plan_id: str = None,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
prefer_unexecuted: bool = True,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_execute_plan(
|
||||
plan_id=plan_id,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPlansTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_plans"
|
||||
description: str = "List saved Agent影视助手 dry-run workflow plans so agents can recover and execute the right plan_id."
|
||||
args_schema: Type[BaseModel] = AssistantPlansToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 已保存计划"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
executed: bool = None,
|
||||
include_actions: bool = False,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_plans(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
executed=executed,
|
||||
include_actions=include_actions,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantPlansClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_plans_clear"
|
||||
description: str = "Clear saved Agent影视助手 workflow plans by plan_id, session, executed state, or all_plans."
|
||||
args_schema: Type[BaseModel] = AssistantPlansClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在清理 Agent影视助手 已保存计划"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
plan_id: str = None,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
executed: bool = None,
|
||||
all_plans: bool = False,
|
||||
limit: int = 100,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_plans_clear(
|
||||
plan_id=plan_id,
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
executed=executed,
|
||||
all_plans=all_plans,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantRecoverTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_recover"
|
||||
description: str = "Inspect the best Agent影视助手 recovery action, or execute it directly, so external agents can resume work through one stable entrypoint."
|
||||
args_schema: Type[BaseModel] = AssistantRecoverToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
target = kwargs.get("session_id") or kwargs.get("session") or "全局"
|
||||
action = "并直接恢复" if kwargs.get("execute") else "恢复建议"
|
||||
return f"正在查看 Agent影视助手 {target} 的{action}"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
execute: bool = False,
|
||||
prefer_unexecuted: bool = True,
|
||||
stop_on_error: bool = True,
|
||||
include_raw_results: bool = False,
|
||||
compact: bool = True,
|
||||
limit: int = 20,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_recover(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
execute=execute,
|
||||
prefer_unexecuted=prefer_unexecuted,
|
||||
stop_on_error=stop_on_error,
|
||||
include_raw_results=include_raw_results,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSessionStateTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_session_state"
|
||||
description: str = "Inspect the current Agent影视助手 assistant session, including stage, current page, selected candidate, and pending 115 task."
|
||||
args_schema: Type[BaseModel] = AssistantSessionStateToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
return f"正在查看 Agent影视助手 会话状态:{session}"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, compact: bool = True, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_session_state(session=session, session_id=session_id, compact=compact)
|
||||
|
||||
|
||||
class AssistantSessionClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_session_clear"
|
||||
description: str = "Clear the current Agent影视助手 assistant session cache."
|
||||
args_schema: Type[BaseModel] = AssistantSessionClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
session = kwargs.get("session", "default")
|
||||
return f"正在清理 Agent影视助手 会话:{session}"
|
||||
|
||||
async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_session_clear(session=session, session_id=session_id)
|
||||
|
||||
|
||||
class AssistantSessionsTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_sessions"
|
||||
description: str = "List active Agent影视助手 assistant sessions so external agents can recover, inspect, and resume the right workflow."
|
||||
args_schema: Type[BaseModel] = AssistantSessionsToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在查看 Agent影视助手 活跃会话列表"
|
||||
|
||||
async def run(self, kind: str = None, has_pending_p115: bool = None, compact: bool = True, limit: int = 20, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_sessions(
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
compact=compact,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class AssistantSessionsClearTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_sessions_clear"
|
||||
description: str = "Clear one or more Agent影视助手 assistant sessions by session_id, session name, filters, or full reset."
|
||||
args_schema: Type[BaseModel] = AssistantSessionsClearToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在清理 Agent影视助手 活跃会话"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
session: str = None,
|
||||
session_id: str = None,
|
||||
kind: str = None,
|
||||
has_pending_p115: bool = None,
|
||||
stale_only: bool = False,
|
||||
all_sessions: bool = False,
|
||||
limit: int = 100,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_assistant_sessions_clear(
|
||||
session=session,
|
||||
session_id=session_id,
|
||||
kind=kind,
|
||||
has_pending_p115=has_pending_p115,
|
||||
stale_only=stale_only,
|
||||
all_sessions=all_sessions,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
class P115QRCodeStartTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_qrcode_start"
|
||||
description: str = "Generate a 115 login QR code using the p115client-compatible client session flow."
|
||||
args_schema: Type[BaseModel] = P115QRCodeStartToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
client_type = kwargs.get("client_type", "alipaymini")
|
||||
return f"正在通过 Agent影视助手 生成 115 扫码二维码:{client_type}"
|
||||
|
||||
async def run(self, client_type: str = "alipaymini", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_qrcode_start(client_type=client_type)
|
||||
|
||||
|
||||
class P115QRCodeCheckTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_qrcode_check"
|
||||
description: str = "Check the status of a previous 115 QR-code login and save the client session when login succeeds."
|
||||
args_schema: Type[BaseModel] = P115QRCodeCheckToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 检查 115 扫码状态"
|
||||
|
||||
async def run(self, uid: str, time: str, sign: str, client_type: str = "alipaymini", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_qrcode_check(
|
||||
uid=uid,
|
||||
time_value=time,
|
||||
sign=sign,
|
||||
client_type=client_type,
|
||||
)
|
||||
|
||||
|
||||
class P115StatusTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_status"
|
||||
description: str = "Show the current 115 transfer readiness, default target path, and current session source."
|
||||
args_schema: Type[BaseModel] = P115StatusToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 查看 115 当前状态"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_status()
|
||||
|
||||
|
||||
class P115PendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_pending"
|
||||
description: str = "Show the pending 115 transfer task for an assistant session, including target path, retry count, and last error."
|
||||
args_schema: Type[BaseModel] = P115PendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 查看待继续的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_pending(session=session)
|
||||
|
||||
|
||||
class P115ResumePendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_resume_pending"
|
||||
description: str = "Retry the pending 115 transfer task for an assistant session."
|
||||
args_schema: Type[BaseModel] = P115ResumePendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 继续待处理的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_resume(session=session)
|
||||
|
||||
|
||||
class P115CancelPendingTool(MoviePilotTool):
|
||||
name: str = "agent_resource_officer_p115_cancel_pending"
|
||||
description: str = "Cancel and clear the pending 115 transfer task for an assistant session."
|
||||
args_schema: Type[BaseModel] = P115CancelPendingToolInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
return "正在通过 Agent影视助手 取消待处理的 115 任务"
|
||||
|
||||
async def run(self, session: str = "default", **kwargs) -> str:
|
||||
plugin = _get_plugin()
|
||||
if not plugin:
|
||||
return "Agent影视助手 插件未运行"
|
||||
return await plugin.tool_p115_cancel(session=session)
|
||||
1936
plugins.v2/agentresourceofficer/feishu_channel.py
Normal file
4
plugins.v2/agentresourceofficer/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests
|
||||
cloudscraper
|
||||
lark-oapi>=1.4.0
|
||||
p115client==0.0.8.4.8
|
||||
259
plugins.v2/agentresourceofficer/schemas.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HDHiveSearchSessionToolInput(BaseModel):
|
||||
keyword: str = Field(..., description="要搜索的影片或剧集名称")
|
||||
media_type: str = Field(default="auto", description="媒体类型,auto / movie / tv;不确定时用 auto")
|
||||
year: Optional[str] = Field(default=None, description="可选年份,用于缩小候选范围")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用默认目录")
|
||||
|
||||
|
||||
class HDHiveSessionPickToolInput(BaseModel):
|
||||
session_id: str = Field(..., description="上一步搜索返回的会话 ID")
|
||||
choice: int = Field(default=0, description="当前阶段要选择的编号,从 1 开始;详情或翻页时可为 0")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用会话中的目录")
|
||||
action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页")
|
||||
|
||||
|
||||
class ShareRouteToolInput(BaseModel):
|
||||
url: str = Field(..., description="115 或夸克分享链接")
|
||||
path: Optional[str] = Field(default=None, description="目标目录")
|
||||
access_code: Optional[str] = Field(default=None, description="提取码,可选")
|
||||
|
||||
|
||||
class AssistantRouteToolInput(BaseModel):
|
||||
text: Optional[str] = Field(default=None, description="统一智能入口文本,例如 盘搜搜索 片名、影巢搜索 片名、115登录 或直接粘贴 115/夸克分享链接")
|
||||
session: Optional[str] = Field(default="default", description="会话标识,用于关联后续选择、115 待任务与扫码续跑")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,适合外部智能体按 sessions 列表中的精确会话继续使用")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则按当前模式使用默认目录")
|
||||
mode: Optional[str] = Field(default=None, description="结构化模式:mp / pansou / hdhive")
|
||||
keyword: Optional[str] = Field(default=None, description="结构化搜索关键词")
|
||||
url: Optional[str] = Field(default=None, description="结构化分享链接,支持 115 / 夸克")
|
||||
access_code: Optional[str] = Field(default=None, description="结构化提取码")
|
||||
media_type: Optional[str] = Field(default=None, description="结构化媒体类型:auto / movie / tv")
|
||||
year: Optional[str] = Field(default=None, description="结构化年份")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
action: Optional[str] = Field(default=None, description="结构化动作:p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel / assistant_help")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPickToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识,需与上一步统一智能入口保持一致")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
choice: int = Field(default=0, description="选择的编号,从 1 开始;详情或翻页时可为 0")
|
||||
action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页")
|
||||
mode: Optional[str] = Field(default=None, description="推荐列表后续搜索方式:mp / hdhive / pansou")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录,不填则沿用会话目录")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantHelpToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="可选会话标识;如该会话存在待继续的 115 任务,帮助里会附带任务摘要")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
|
||||
|
||||
class AssistantSessionStateToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话当前状态")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantSessionClearToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则清理 default 会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
|
||||
|
||||
class AssistantCapabilitiesToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantReadinessToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class FeishuChannelHealthToolInput(BaseModel):
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPulseToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantStartupToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantMaintainToolInput(BaseModel):
|
||||
execute: Optional[bool] = Field(default=False, description="是否立即执行低风险维护;默认只返回建议")
|
||||
limit: Optional[int] = Field(default=100, description="单次最多清理多少条")
|
||||
|
||||
|
||||
class AssistantToolboxToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantRequestTemplatesToolInput(BaseModel):
|
||||
limit: Optional[int] = Field(default=100, description="模板中批量类请求默认 limit,范围由插件限制")
|
||||
names: Optional[str] = Field(default=None, description="可选模板名,多个用逗号或空格分隔,例如 maintain_execute,workflow_dry_run")
|
||||
recipe: Optional[str] = Field(default=None, description="可选推荐流程名或别名,例如 plan / maintain / continue / bootstrap")
|
||||
include_templates: Optional[bool] = Field(default=True, description="是否返回完整模板内容;关闭时只返回名称、无效项和执行策略")
|
||||
|
||||
|
||||
class AssistantSelfcheckToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class AssistantHistoryToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近执行记录")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条执行记录")
|
||||
|
||||
|
||||
class AssistantExecuteActionToolInput(BaseModel):
|
||||
name: str = Field(..., description="要执行的动作模板名,例如 pick_pansou_result / candidate_next_page / resume_pending_115")
|
||||
session: Optional[str] = Field(default="default", description="可选会话名")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
choice: Optional[int] = Field(default=None, description="需要选择编号时传入")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录")
|
||||
keyword: Optional[str] = Field(default=None, description="搜索类动作使用的关键词")
|
||||
media_type: Optional[str] = Field(default=None, description="搜索类动作使用的媒体类型")
|
||||
year: Optional[str] = Field(default=None, description="搜索类动作使用的年份")
|
||||
url: Optional[str] = Field(default=None, description="直链类动作使用的分享链接")
|
||||
access_code: Optional[str] = Field(default=None, description="可选提取码")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar")
|
||||
kind: Optional[str] = Field(default=None, description="批量清理会话时的类型过滤")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="批量清理会话时是否仅清理带待继续 115 的会话")
|
||||
stale_only: Optional[bool] = Field(default=False, description="批量清理会话时是否只清理过期会话")
|
||||
all_sessions: Optional[bool] = Field(default=False, description="批量清理会话时是否清理全部会话")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理会话时的最多处理条数")
|
||||
plan_id: Optional[str] = Field(default=None, description="计划动作使用的 plan_id")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="计划动作未指定 plan_id 时是否优先选择未执行计划")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantExecuteActionsToolInput(BaseModel):
|
||||
actions: List[Dict[str, Any]] = Field(..., description="动作模板执行数组,每项可直接复用 action_templates 里的 action_body")
|
||||
session: Optional[str] = Field(default="default", description="批量动作默认会话名;子动作未显式传 session/session_id 时自动继承")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否立即停止后续执行")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带每一步原始返回;默认关闭以减少 token 与负载")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantWorkflowToolInput(BaseModel):
|
||||
name: str = Field(..., description="预设工作流名,例如 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / mp_search / mp_search_download / mp_subscribe / mp_recommend / mp_recommend_search / share_transfer / p115_status")
|
||||
session: Optional[str] = Field(default="default", description="工作流会话名")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
keyword: Optional[str] = Field(default=None, description="搜索关键词")
|
||||
choice: Optional[int] = Field(default=None, description="通用选择编号,盘搜转存默认使用 1")
|
||||
candidate_choice: Optional[int] = Field(default=None, description="影巢候选影片编号")
|
||||
resource_choice: Optional[int] = Field(default=None, description="影巢资源编号")
|
||||
path: Optional[str] = Field(default=None, description="可选目标目录")
|
||||
url: Optional[str] = Field(default=None, description="分享链接")
|
||||
access_code: Optional[str] = Field(default=None, description="提取码")
|
||||
media_type: Optional[str] = Field(default=None, description="媒体类型,auto / movie / tv")
|
||||
mode: Optional[str] = Field(default=None, description="推荐后续搜索方式,mp / hdhive / pansou")
|
||||
year: Optional[str] = Field(default=None, description="年份")
|
||||
client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型")
|
||||
source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar")
|
||||
limit: Optional[int] = Field(default=20, description="推荐数量上限")
|
||||
dry_run: Optional[bool] = Field(default=False, description="只生成工作流计划,不实际执行")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPreferencesToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="偏好画像会话名;建议外部智能体固定传自己的用户会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
user_key: Optional[str] = Field(default=None, description="可选用户键;用于跨 session 共享同一套偏好")
|
||||
preferences: Optional[Dict[str, Any]] = Field(default=None, description="要保存的偏好画像;不传则只读取")
|
||||
reset: Optional[bool] = Field(default=False, description="是否重置偏好画像")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantExecutePlanToolInput(BaseModel):
|
||||
plan_id: Optional[str] = Field(default=None, description="可选 dry_run 返回的 plan_id;不传时可按 session/session_id 自动选择最近计划")
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;未传 plan_id 时可按会话自动选择最近计划")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="自动选计划时是否优先只选未执行计划")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
|
||||
|
||||
class AssistantPlansToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近计划")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
executed: Optional[bool] = Field(default=None, description="可选过滤:true 只看已执行,false 只看未执行")
|
||||
include_actions: Optional[bool] = Field(default=False, description="是否附带计划动作明细;默认关闭以减少 token")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条计划")
|
||||
|
||||
|
||||
class AssistantPlansClearToolInput(BaseModel):
|
||||
plan_id: Optional[str] = Field(default=None, description="可选计划 ID;传入时只清理这一条")
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;按会话清理")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
executed: Optional[bool] = Field(default=None, description="可选过滤:true 只清理已执行,false 只清理未执行")
|
||||
all_plans: Optional[bool] = Field(default=False, description="清理全部计划;未指定 plan_id/session/session_id/executed 时需要显式打开")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条")
|
||||
|
||||
|
||||
class AssistantRecoverToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;不传则自动从全局活跃会话和待执行计划里挑选最佳恢复项")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session")
|
||||
execute: Optional[bool] = Field(default=False, description="是否直接执行推荐恢复动作;默认只返回恢复建议")
|
||||
prefer_unexecuted: Optional[bool] = Field(default=True, description="执行保存计划时是否优先选择未执行计划")
|
||||
stop_on_error: Optional[bool] = Field(default=True, description="执行恢复动作时遇到失败是否停止")
|
||||
include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果;默认关闭以减少 token")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启,只返回恢复所需关键字段")
|
||||
limit: Optional[int] = Field(default=20, description="全局恢复扫描时最多查看多少个会话")
|
||||
|
||||
|
||||
class AssistantSessionsToolInput(BaseModel):
|
||||
kind: Optional[str] = Field(default=None, description="按会话类型过滤,例如 assistant_pansou / assistant_hdhive / assistant_p115_login")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="是否只看带待继续 115 任务的会话")
|
||||
compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启")
|
||||
limit: Optional[int] = Field(default=20, description="最多返回多少条活跃会话摘要")
|
||||
|
||||
|
||||
class AssistantSessionsClearToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default=None, description="可选会话名;只清理这一个会话")
|
||||
session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID;只清理这一个会话")
|
||||
kind: Optional[str] = Field(default=None, description="按会话类型批量清理")
|
||||
has_pending_p115: Optional[bool] = Field(default=None, description="是否只清理带待继续 115 任务的会话")
|
||||
stale_only: Optional[bool] = Field(default=False, description="只清理已过期但仍残留的 assistant 会话")
|
||||
all_sessions: Optional[bool] = Field(default=False, description="清理全部 assistant 会话;用于重置外部智能体状态")
|
||||
limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条")
|
||||
|
||||
|
||||
class P115QRCodeStartToolInput(BaseModel):
|
||||
client_type: Optional[str] = Field(default="alipaymini", description="115 扫码客户端类型,默认 alipaymini")
|
||||
|
||||
|
||||
class P115QRCodeCheckToolInput(BaseModel):
|
||||
uid: str = Field(..., description="上一步二维码返回的 uid")
|
||||
time: str = Field(..., description="上一步二维码返回的 time")
|
||||
sign: str = Field(..., description="上一步二维码返回的 sign")
|
||||
client_type: Optional[str] = Field(default="alipaymini", description="客户端类型,需与生成二维码时保持一致")
|
||||
|
||||
|
||||
class P115StatusToolInput(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class P115PendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话")
|
||||
|
||||
|
||||
class P115ResumePendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则继续 default 会话的待处理 115 任务")
|
||||
|
||||
|
||||
class P115CancelPendingToolInput(BaseModel):
|
||||
session: Optional[str] = Field(default="default", description="会话标识;不填则取消 default 会话的待处理 115 任务")
|
||||
1
plugins.v2/agentresourceofficer/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service modules for Agent影视助手."""
|
||||
1114
plugins.v2/agentresourceofficer/services/hdhive_openapi.py
Normal file
818
plugins.v2/agentresourceofficer/services/p115_transfer.py
Normal file
@@ -0,0 +1,818 @@
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
except Exception:
|
||||
settings = None
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
except Exception:
|
||||
PluginManager = None
|
||||
|
||||
|
||||
class P115TransferService:
|
||||
"""Reusable 115 share transfer execution layer for Agent影视助手."""
|
||||
|
||||
CLIENT_COOKIE_REQUIRED_KEYS = {"UID", "CID", "SEID"}
|
||||
QR_CLIENT_TYPES = {
|
||||
"web",
|
||||
"android",
|
||||
"115android",
|
||||
"ios",
|
||||
"115ios",
|
||||
"alipaymini",
|
||||
"wechatmini",
|
||||
"115ipad",
|
||||
"tv",
|
||||
"qandroid",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_target_path: str = "/待整理",
|
||||
cookie: str = "",
|
||||
prefer_direct: bool = True,
|
||||
) -> None:
|
||||
self.default_target_path = self.normalize_pan_path(default_target_path) or "/待整理"
|
||||
self.cookie = self.normalize_text(cookie)
|
||||
self.prefer_direct = bool(prefer_direct)
|
||||
|
||||
def set_cookie(self, cookie: str = "") -> None:
|
||||
self.cookie = self.normalize_text(cookie)
|
||||
|
||||
@staticmethod
|
||||
def normalize_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
@staticmethod
|
||||
def normalize_pan_path(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if not text.startswith("/"):
|
||||
text = f"/{text}"
|
||||
return text.rstrip("/") or "/"
|
||||
|
||||
@staticmethod
|
||||
def _ensure_helper_import_paths() -> None:
|
||||
candidate_dirs = [
|
||||
"/app/app/plugins",
|
||||
"/config/plugins",
|
||||
]
|
||||
for base in candidate_dirs:
|
||||
path = Path(base)
|
||||
if path.exists():
|
||||
text = str(path)
|
||||
if text not in sys.path:
|
||||
sys.path.append(text)
|
||||
|
||||
@staticmethod
|
||||
def is_115_share_url(url: str) -> bool:
|
||||
host = urlparse(url).netloc.lower()
|
||||
return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host
|
||||
|
||||
def ensure_115_share_url(self, url: str, access_code: str = "") -> str:
|
||||
clean_url = self.normalize_text(url)
|
||||
if not clean_url:
|
||||
return ""
|
||||
access_code = self.normalize_text(access_code)
|
||||
parsed = urlparse(clean_url)
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
if access_code and "password" not in query:
|
||||
query["password"] = access_code
|
||||
clean_url = urlunparse(parsed._replace(query=urlencode(query)))
|
||||
return clean_url
|
||||
|
||||
@staticmethod
|
||||
def _extract_115_payload(url: str) -> Tuple[str, str]:
|
||||
clean_url = str(url or "").strip()
|
||||
if not clean_url:
|
||||
return "", ""
|
||||
try:
|
||||
from p115client.util import share_extract_payload
|
||||
|
||||
payload = share_extract_payload(clean_url) or {}
|
||||
return str(payload.get("share_code") or "").strip(), str(payload.get("receive_code") or "").strip()
|
||||
except Exception:
|
||||
parsed = urlparse(clean_url)
|
||||
share_code = ""
|
||||
match = re.search(r"/s/([^/?#]+)", parsed.path or "")
|
||||
if match:
|
||||
share_code = match.group(1).strip()
|
||||
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
receive_code = str(query.get("password") or query.get("receive_code") or query.get("pwd") or "").strip()
|
||||
return share_code, receive_code
|
||||
|
||||
@classmethod
|
||||
def parse_cookie_pairs(cls, cookie: str) -> Dict[str, str]:
|
||||
pairs: Dict[str, str] = {}
|
||||
for part in cls.normalize_text(cookie).strip(";").split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, value = part.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key and value:
|
||||
pairs[key] = value
|
||||
return pairs
|
||||
|
||||
@classmethod
|
||||
def validate_client_cookie(cls, cookie: str) -> Tuple[bool, str]:
|
||||
if not cls.normalize_text(cookie):
|
||||
return False, "未配置独立 115 Cookie"
|
||||
pairs = cls.parse_cookie_pairs(cookie)
|
||||
missing = sorted(cls.CLIENT_COOKIE_REQUIRED_KEYS - set(pairs))
|
||||
if missing:
|
||||
return False, f"当前 115 Cookie 缺少 {'/'.join(missing)},看起来不是扫码客户端 Cookie;不建议使用网页版 Cookie"
|
||||
return True, ""
|
||||
|
||||
def cookie_state(self) -> Dict[str, Any]:
|
||||
configured = bool(self.normalize_text(self.cookie))
|
||||
pairs = self.parse_cookie_pairs(self.cookie)
|
||||
cookie_keys = sorted(pairs.keys())
|
||||
if not configured:
|
||||
return {
|
||||
"configured": False,
|
||||
"valid": False,
|
||||
"mode": "none",
|
||||
"cookie_keys": [],
|
||||
"message": "未配置独立 115 会话。新环境请先发“115登录”扫码;P115StrmHelper 仅作为旧环境兼容 fallback。",
|
||||
}
|
||||
cookie_ok, cookie_message = self.validate_client_cookie(self.cookie)
|
||||
return {
|
||||
"configured": True,
|
||||
"valid": cookie_ok,
|
||||
"mode": "client_cookie" if cookie_ok else "invalid_cookie",
|
||||
"cookie_keys": cookie_keys,
|
||||
"message": "" if cookie_ok else cookie_message,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def normalize_qrcode_client_type(cls, client_type: Any) -> str:
|
||||
text = cls.normalize_text(client_type).lower()
|
||||
return text if text in cls.QR_CLIENT_TYPES else "alipaymini"
|
||||
|
||||
@staticmethod
|
||||
def jsonable(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool, list, dict)):
|
||||
return value
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return value.model_dump()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "__dict__"):
|
||||
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return str(value)
|
||||
|
||||
def tz_now(self) -> datetime:
|
||||
if settings is not None:
|
||||
try:
|
||||
return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai")))
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any, default: int = -1) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _response_error(resp: Any) -> str:
|
||||
if not isinstance(resp, dict):
|
||||
return str(resp or "")
|
||||
for key in ("error", "message", "msg", "errno"):
|
||||
value = resp.get(key)
|
||||
if value not in (None, ""):
|
||||
return str(value)
|
||||
return str(resp)
|
||||
|
||||
@classmethod
|
||||
def _is_already_saved_message(cls, value: Any) -> bool:
|
||||
text = cls.normalize_text(value)
|
||||
return any(
|
||||
marker in text
|
||||
for marker in (
|
||||
"已经转存",
|
||||
"已转存",
|
||||
"已经保存",
|
||||
"已保存",
|
||||
"already",
|
||||
"exist",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _response_ok(resp: Any) -> bool:
|
||||
if not isinstance(resp, dict):
|
||||
return False
|
||||
if resp.get("state") is True:
|
||||
return True
|
||||
if resp.get("code") in (0, "0") and resp.get("state") not in (False, 0):
|
||||
return True
|
||||
if resp.get("errno") in (0, "0") and resp.get("state") not in (False, 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _p115_request_kwargs(*, app: bool = False) -> Dict[str, Any]:
|
||||
try:
|
||||
P115TransferService._ensure_helper_import_paths()
|
||||
from app.plugins.p115strmhelper.core.config import configer
|
||||
|
||||
return configer.get_ios_ua_app(app=app) or {}
|
||||
except Exception:
|
||||
try:
|
||||
P115TransferService._ensure_helper_import_paths()
|
||||
from p115strmhelper.core.config import configer
|
||||
|
||||
return configer.get_ios_ua_app(app=app) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_servicer_from_loaded_plugin() -> Tuple[Optional[Any], Optional[str]]:
|
||||
if PluginManager is None:
|
||||
return None, "PluginManager 不可用"
|
||||
try:
|
||||
plugin = PluginManager().running_plugins.get("P115StrmHelper")
|
||||
except Exception as exc:
|
||||
return None, f"读取 P115StrmHelper 运行态失败: {exc}"
|
||||
if not plugin:
|
||||
return None, "P115StrmHelper 未加载"
|
||||
|
||||
module_names = []
|
||||
plugin_module = getattr(plugin.__class__, "__module__", "") or ""
|
||||
if plugin_module:
|
||||
module_names.append(f"{plugin_module}.service")
|
||||
module_names.extend(
|
||||
[
|
||||
"app.plugins.p115strmhelper.service",
|
||||
"p115strmhelper.service",
|
||||
]
|
||||
)
|
||||
|
||||
for module_name in module_names:
|
||||
try:
|
||||
self._ensure_helper_import_paths()
|
||||
module = sys.modules.get(module_name) or importlib.import_module(module_name)
|
||||
servicer = getattr(module, "servicer", None)
|
||||
if servicer is not None:
|
||||
return servicer, None
|
||||
except Exception:
|
||||
continue
|
||||
return None, "P115StrmHelper 运行态已加载,但未找到 service.servicer"
|
||||
|
||||
def _get_loaded_p115_client(self) -> Tuple[Optional[Any], str]:
|
||||
servicer, helper_error = self._resolve_servicer_from_loaded_plugin()
|
||||
if not servicer:
|
||||
return None, helper_error or "P115StrmHelper 未加载"
|
||||
client = getattr(servicer, "client", None)
|
||||
if not client:
|
||||
return None, "P115StrmHelper 未登录 115 或客户端不可用"
|
||||
return client, "p115strmhelper_client"
|
||||
|
||||
def _get_cookie_p115_client(self) -> Tuple[Optional[Any], str]:
|
||||
if not self.cookie:
|
||||
return None, "未配置独立 115 Cookie"
|
||||
cookie_ok, cookie_message = self.validate_client_cookie(self.cookie)
|
||||
if not cookie_ok:
|
||||
return None, cookie_message
|
||||
try:
|
||||
from p115client import P115Client
|
||||
|
||||
return P115Client(
|
||||
self.cookie,
|
||||
check_for_relogin=False,
|
||||
ensure_cookies=False,
|
||||
console_qrcode=False,
|
||||
), "direct_cookie"
|
||||
except Exception as exc:
|
||||
return None, f"独立 115 Cookie 初始化失败: {exc}"
|
||||
|
||||
@classmethod
|
||||
def create_qrcode_login(cls, client_type: str = "alipaymini") -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_client_type = cls.normalize_qrcode_client_type(client_type)
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
resp = P115Client.login_qrcode_token()
|
||||
check_response(resp)
|
||||
resp_info = resp.get("data", {}) if isinstance(resp, dict) else {}
|
||||
uid = str(resp_info.get("uid") or "")
|
||||
qrcode_time = str(resp_info.get("time") or "")
|
||||
sign = str(resp_info.get("sign") or "")
|
||||
qrcode = P115Client.login_qrcode(uid)
|
||||
if not isinstance(qrcode, (bytes, bytearray)):
|
||||
return False, {}, "获取二维码失败:返回内容类型异常"
|
||||
return True, {
|
||||
"uid": uid,
|
||||
"time": qrcode_time,
|
||||
"sign": sign,
|
||||
"client_type": final_client_type,
|
||||
"tips": "请使用 115 App 扫码登录",
|
||||
"qrcode": f"data:image/png;base64,{b64encode(qrcode).decode('utf-8')}",
|
||||
}, "success"
|
||||
except Exception as exc:
|
||||
return False, {}, f"获取 115 登录二维码失败: {exc}"
|
||||
|
||||
@classmethod
|
||||
def check_qrcode_login(
|
||||
cls,
|
||||
*,
|
||||
uid: str,
|
||||
time_value: str,
|
||||
sign: str,
|
||||
client_type: str = "alipaymini",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_client_type = cls.normalize_qrcode_client_type(client_type)
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
payload = {"uid": uid, "time": time_value, "sign": sign}
|
||||
resp = P115Client.login_qrcode_scan_status(payload)
|
||||
if not isinstance(resp, dict):
|
||||
return False, {}, "检查二维码状态失败:返回内容类型异常"
|
||||
check_response(resp)
|
||||
status_code = (resp.get("data") or {}).get("status")
|
||||
except Exception as exc:
|
||||
return False, {}, f"检查二维码状态失败: {exc}"
|
||||
|
||||
if status_code == 0:
|
||||
return True, {"status": "waiting", "client_type": final_client_type}, "等待扫码"
|
||||
if status_code == 1:
|
||||
return True, {"status": "scanned", "client_type": final_client_type}, "已扫码,等待确认"
|
||||
if status_code == -1 or status_code is None:
|
||||
return False, {"status": "expired", "client_type": final_client_type}, "二维码已过期"
|
||||
if status_code == -2:
|
||||
return False, {"status": "cancelled", "client_type": final_client_type}, "用户取消登录"
|
||||
if status_code != 2:
|
||||
return False, {"status": "unknown", "client_type": final_client_type}, f"未知二维码状态: {status_code}"
|
||||
|
||||
try:
|
||||
from p115client import P115Client, check_response
|
||||
|
||||
resp = P115Client.login_qrcode_scan_result(uid, app=final_client_type)
|
||||
if not isinstance(resp, dict):
|
||||
return False, {}, "获取登录结果失败:返回内容类型异常"
|
||||
check_response(resp)
|
||||
except Exception as exc:
|
||||
return False, {}, f"获取登录结果失败: {exc}"
|
||||
|
||||
cookie_data = (resp.get("data") or {}).get("cookie") if isinstance(resp, dict) else None
|
||||
if not isinstance(cookie_data, dict):
|
||||
return False, {}, "登录成功但未返回 Cookie"
|
||||
cookie = "; ".join(f"{name}={value}" for name, value in cookie_data.items() if name and value).strip()
|
||||
cookie_ok, cookie_message = cls.validate_client_cookie(cookie)
|
||||
if not cookie_ok:
|
||||
return False, {}, cookie_message
|
||||
return True, {
|
||||
"status": "success",
|
||||
"client_type": final_client_type,
|
||||
"cookie": cookie,
|
||||
"cookie_keys": sorted(cls.parse_cookie_pairs(cookie).keys()),
|
||||
}, "登录成功"
|
||||
|
||||
def get_direct_client(self) -> Tuple[Optional[Any], str, str]:
|
||||
client, source = self._get_cookie_p115_client()
|
||||
if client:
|
||||
return client, source, ""
|
||||
cookie_error = source
|
||||
client, source = self._get_loaded_p115_client()
|
||||
if client:
|
||||
return client, source, ""
|
||||
return None, "none", source or cookie_error
|
||||
|
||||
@classmethod
|
||||
def _import_servicer_fallback(cls) -> Tuple[Optional[Any], Optional[str]]:
|
||||
last_error = ""
|
||||
for module_name in [
|
||||
"app.plugins.p115strmhelper.service",
|
||||
"p115strmhelper.service",
|
||||
]:
|
||||
try:
|
||||
cls._ensure_helper_import_paths()
|
||||
service_module = importlib.import_module(module_name)
|
||||
servicer = getattr(service_module, "servicer", None)
|
||||
if servicer is not None:
|
||||
return servicer, None
|
||||
last_error = f"{module_name} 未暴露 servicer"
|
||||
except Exception as exc:
|
||||
last_error = f"{module_name} 导入失败: {exc}"
|
||||
return None, last_error or "P115StrmHelper 未安装或无法导入"
|
||||
|
||||
def get_share_helper(self) -> Tuple[Optional[Any], Optional[str]]:
|
||||
servicer, helper_error = self._resolve_servicer_from_loaded_plugin()
|
||||
if not servicer:
|
||||
servicer, helper_error = self._import_servicer_fallback()
|
||||
if not servicer:
|
||||
return None, f"P115StrmHelper 未安装或无法导入: {helper_error}"
|
||||
if not servicer:
|
||||
return None, "P115StrmHelper 未初始化"
|
||||
if not getattr(servicer, "client", None):
|
||||
return None, "P115StrmHelper 未登录 115 或客户端不可用"
|
||||
helper = getattr(servicer, "sharetransferhelper", None)
|
||||
if not helper:
|
||||
return None, "P115StrmHelper 分享转存模块不可用"
|
||||
return helper, None
|
||||
|
||||
def health(self) -> Tuple[bool, Dict[str, Any], str]:
|
||||
cookie_state = self.cookie_state()
|
||||
direct_client, direct_source, direct_error = self.get_direct_client()
|
||||
direct_ready = direct_client is not None
|
||||
helper, helper_error = self.get_share_helper()
|
||||
helper_ready = bool(helper and not helper_error)
|
||||
ready = direct_ready or helper_ready
|
||||
message = "" if ready else direct_error or helper_error or "115 转存不可用"
|
||||
return ready, {
|
||||
"ready": ready,
|
||||
"direct_ready": direct_ready,
|
||||
"direct_source": direct_source if direct_ready else "",
|
||||
"direct_message": "" if direct_ready else direct_error,
|
||||
"helper_ready": helper_ready,
|
||||
"helper_message": "" if helper_ready else helper_error,
|
||||
"cookie_state": cookie_state,
|
||||
"message": message or "success",
|
||||
}, message
|
||||
|
||||
def _get_or_create_path_cid(self, client: Any, path: str) -> int:
|
||||
return self._get_path_cid(client, path, create=True)
|
||||
|
||||
def _get_path_cid(self, client: Any, path: str, *, create: bool = True) -> int:
|
||||
target_path = self.normalize_pan_path(path) or "/"
|
||||
if target_path == "/":
|
||||
return 0
|
||||
get_kwargs = self._p115_request_kwargs(app=False)
|
||||
mkdir_kwargs = self._p115_request_kwargs(app=True)
|
||||
try:
|
||||
resp = client.fs_dir_getid(target_path, **get_kwargs)
|
||||
pid = self._safe_int(resp.get("id") if isinstance(resp, dict) else None, -1)
|
||||
if pid > 0:
|
||||
return pid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not create:
|
||||
return -1
|
||||
|
||||
try:
|
||||
resp = client.fs_makedirs_app(target_path, pid=0, **mkdir_kwargs)
|
||||
cid = self._safe_int(resp.get("cid") if isinstance(resp, dict) else None, -1)
|
||||
if cid >= 0:
|
||||
return cid
|
||||
if self._response_ok(resp):
|
||||
cid = self._safe_int((resp.get("data") or {}).get("cid") if isinstance(resp.get("data"), dict) else None, -1)
|
||||
if cid >= 0:
|
||||
return cid
|
||||
raise RuntimeError(self._response_error(resp))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"无法创建或定位 115 目录 {target_path}: {exc}") from exc
|
||||
|
||||
def list_directory_current_layer(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"path": target_path,
|
||||
"items": [],
|
||||
"file_count": 0,
|
||||
"folder_count": 0,
|
||||
"removed_count": 0,
|
||||
"message": "",
|
||||
}
|
||||
client, source, client_error = self.get_direct_client()
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 客户端"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
|
||||
cid = self._get_path_cid(client, target_path, create=False)
|
||||
if cid < 0:
|
||||
result["ok"] = True
|
||||
result["direct_source"] = source
|
||||
result["message"] = "115 默认目录不存在,视为空目录"
|
||||
return True, result, result["message"]
|
||||
|
||||
payload = {
|
||||
"cid": int(cid),
|
||||
"limit": 1150,
|
||||
"offset": 0,
|
||||
"show_dir": 1,
|
||||
"cur": 1,
|
||||
"count_folders": 1,
|
||||
}
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
try:
|
||||
while True:
|
||||
resp = client.fs_files(payload, **self._p115_request_kwargs(app=False))
|
||||
if not isinstance(resp, dict):
|
||||
result["message"] = "读取 115 目录失败:返回内容异常"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
batch = resp.get("data") or []
|
||||
total = self._safe_int(resp.get("count"), total)
|
||||
for entry in batch:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fid = self._safe_int(entry.get("fid"), -1)
|
||||
item_cid = self._safe_int(entry.get("cid"), -1)
|
||||
is_dir = fid < 0
|
||||
item_id = item_cid if is_dir else fid
|
||||
if item_id < 0:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": item_id,
|
||||
"name": self.normalize_text(entry.get("n") or entry.get("fn") or entry.get("file_name")),
|
||||
"is_dir": is_dir,
|
||||
"type": "folder" if is_dir else "file",
|
||||
"raw": entry,
|
||||
}
|
||||
)
|
||||
payload["offset"] = int(payload["offset"]) + len(batch)
|
||||
if not batch or len(batch) < int(payload["limit"]) or int(payload["offset"]) >= total:
|
||||
break
|
||||
except Exception as exc:
|
||||
result["message"] = f"读取 115 目录失败: {exc}"
|
||||
result["direct_source"] = source
|
||||
return False, result, result["message"]
|
||||
|
||||
file_count = len([item for item in items if not item.get("is_dir")])
|
||||
folder_count = len([item for item in items if item.get("is_dir")])
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"direct_source": source,
|
||||
"cid": cid,
|
||||
"items": items,
|
||||
"file_count": file_count,
|
||||
"folder_count": folder_count,
|
||||
"message": "success",
|
||||
}
|
||||
)
|
||||
return True, result, "success"
|
||||
|
||||
def delete_items(self, items: list[dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
client, source, client_error = self.get_direct_client()
|
||||
result = {
|
||||
"ok": False,
|
||||
"direct_source": source,
|
||||
"removed_count": 0,
|
||||
"message": "",
|
||||
}
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 客户端"
|
||||
return False, result, result["message"]
|
||||
|
||||
ids = [str(self._safe_int(item.get("id"), -1)) for item in items or [] if self._safe_int(item.get("id"), -1) >= 0]
|
||||
if not ids:
|
||||
result.update({"ok": True, "message": "115 默认目录当前层已是空目录"})
|
||||
return True, result, result["message"]
|
||||
|
||||
try:
|
||||
resp = client.fs_delete(ids, **self._p115_request_kwargs(app=False))
|
||||
except Exception as exc:
|
||||
result["message"] = f"删除 115 目录内容失败: {exc}"
|
||||
return False, result, result["message"]
|
||||
|
||||
if not self._response_ok(resp):
|
||||
result["message"] = self._response_error(resp) or "删除 115 目录内容失败"
|
||||
result["raw"] = self.jsonable(resp)
|
||||
return False, result, result["message"]
|
||||
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"removed_count": len(ids),
|
||||
"message": "115 默认目录已清空当前层",
|
||||
"raw": self.jsonable(resp),
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
|
||||
def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
listed_ok, listed_result, listed_message = self.list_directory_current_layer(target_path)
|
||||
if not listed_ok:
|
||||
return False, listed_result, listed_message
|
||||
|
||||
items = listed_result.get("items") or []
|
||||
if not items:
|
||||
listed_result["message"] = "115 默认目录当前层已是空目录"
|
||||
return True, listed_result, listed_result["message"]
|
||||
|
||||
delete_ok, delete_result, delete_message = self.delete_items(items)
|
||||
merged = dict(listed_result)
|
||||
merged.update(
|
||||
{
|
||||
"ok": delete_ok,
|
||||
"removed_count": delete_result.get("removed_count", 0),
|
||||
"direct_source": delete_result.get("direct_source", listed_result.get("direct_source")),
|
||||
"delete_raw": delete_result.get("raw"),
|
||||
"message": delete_message,
|
||||
}
|
||||
)
|
||||
return delete_ok, merged, delete_message
|
||||
|
||||
def transfer_share_direct(
|
||||
self,
|
||||
*,
|
||||
url: str = "",
|
||||
access_code: str = "",
|
||||
path: str = "",
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
share_url = self.ensure_115_share_url(url or "", access_code or "")
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"trigger": trigger,
|
||||
"strategy": "direct",
|
||||
"path": transfer_path,
|
||||
"url": share_url,
|
||||
"message": "",
|
||||
"data": {},
|
||||
}
|
||||
if not share_url:
|
||||
result["message"] = "没有可用于 115 转存的分享链接"
|
||||
return False, result, result["message"]
|
||||
if not self.is_115_share_url(share_url):
|
||||
result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115"
|
||||
return False, result, result["message"]
|
||||
|
||||
share_code, receive_code = self._extract_115_payload(share_url)
|
||||
if not share_code or not receive_code:
|
||||
result["message"] = "解析 115 分享链接失败,缺少分享码或提取码"
|
||||
return False, result, result["message"]
|
||||
|
||||
client, source, client_error = self.get_direct_client()
|
||||
if not client:
|
||||
result["message"] = client_error or "没有可用的 115 直转客户端"
|
||||
result["data"] = {"direct_source": source}
|
||||
return False, result, result["message"]
|
||||
|
||||
try:
|
||||
parent_id = self._get_or_create_path_cid(client, transfer_path)
|
||||
except Exception as exc:
|
||||
result["message"] = str(exc)
|
||||
result["data"] = {"direct_source": source}
|
||||
return False, result, result["message"]
|
||||
|
||||
payload = {
|
||||
"share_code": share_code,
|
||||
"receive_code": receive_code,
|
||||
"file_id": 0,
|
||||
"cid": int(parent_id),
|
||||
"is_check": 0,
|
||||
}
|
||||
try:
|
||||
resp = client.share_receive(payload, **self._p115_request_kwargs(app=False))
|
||||
except Exception as exc:
|
||||
result["message"] = f"调用 115 直转接口失败: {exc}"
|
||||
result["data"] = {"direct_source": source, "parent_id": parent_id}
|
||||
return False, result, result["message"]
|
||||
|
||||
if not self._response_ok(resp):
|
||||
result["message"] = self._response_error(resp) or "115 直转失败"
|
||||
result["data"] = {
|
||||
"direct_source": source,
|
||||
"parent_id": parent_id,
|
||||
"raw": self.jsonable(resp),
|
||||
}
|
||||
if self._is_already_saved_message(result["message"]):
|
||||
result["ok"] = True
|
||||
result["message"] = "115 直转已存在"
|
||||
return True, result, result["message"]
|
||||
return False, result, result["message"]
|
||||
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"message": "115 直转成功",
|
||||
"data": {
|
||||
"direct_source": source,
|
||||
"share_code": share_code,
|
||||
"receive_code": receive_code,
|
||||
"save_parent": transfer_path,
|
||||
"parent_id": parent_id,
|
||||
"raw": self.jsonable(resp),
|
||||
},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
|
||||
def transfer_share(
|
||||
self,
|
||||
*,
|
||||
url: str = "",
|
||||
access_code: str = "",
|
||||
path: str = "",
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理"
|
||||
share_url = self.ensure_115_share_url(url or "", access_code or "")
|
||||
result = {
|
||||
"time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ok": False,
|
||||
"trigger": trigger,
|
||||
"path": transfer_path,
|
||||
"url": share_url,
|
||||
"message": "",
|
||||
"data": {},
|
||||
}
|
||||
if not share_url:
|
||||
result["message"] = "没有可用于 115 转存的分享链接"
|
||||
return False, result, result["message"]
|
||||
if not self.is_115_share_url(share_url):
|
||||
result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115"
|
||||
return False, result, result["message"]
|
||||
|
||||
if self.prefer_direct:
|
||||
direct_ok, direct_result, direct_message = self.transfer_share_direct(
|
||||
url=share_url,
|
||||
access_code=access_code,
|
||||
path=transfer_path,
|
||||
trigger=trigger,
|
||||
)
|
||||
if direct_ok:
|
||||
return True, direct_result, direct_message
|
||||
result["data"]["direct_fallback"] = direct_result
|
||||
|
||||
helper, helper_error = self.get_share_helper()
|
||||
if helper_error or not helper:
|
||||
direct_error = ((result.get("data") or {}).get("direct_fallback") or {}).get("message")
|
||||
result["message"] = (
|
||||
"115 转存不可用:请先发“115登录”完成扫码,或检查 115 直转依赖。"
|
||||
f" 直转状态:{direct_error or '未知'};兼容 fallback:{helper_error or '不可用'}"
|
||||
)
|
||||
return False, result, result["message"]
|
||||
|
||||
try:
|
||||
transfer_result = helper.add_share_115(
|
||||
share_url,
|
||||
notify=False,
|
||||
pan_path=transfer_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
result["message"] = f"调用 P115StrmHelper 转存失败: {exc}"
|
||||
return False, result, result["message"]
|
||||
|
||||
if not transfer_result or not transfer_result[0]:
|
||||
error_message = ""
|
||||
if isinstance(transfer_result, tuple):
|
||||
if len(transfer_result) > 2:
|
||||
error_message = self.normalize_text(transfer_result[2])
|
||||
elif len(transfer_result) > 1:
|
||||
error_message = self.normalize_text(transfer_result[1])
|
||||
if self._is_already_saved_message(error_message):
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"strategy": "p115strmhelper",
|
||||
"message": "115 转存已存在",
|
||||
"data": {"raw": self.jsonable(transfer_result)},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
result["message"] = error_message or "115 转存失败"
|
||||
result["data"] = {"raw": self.jsonable(transfer_result)}
|
||||
return False, result, result["message"]
|
||||
|
||||
media_info = transfer_result[1] if len(transfer_result) > 1 else None
|
||||
save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path
|
||||
parent_id = transfer_result[3] if len(transfer_result) > 3 else None
|
||||
result.update(
|
||||
{
|
||||
"ok": True,
|
||||
"strategy": "p115strmhelper",
|
||||
"message": "115 转存成功",
|
||||
"data": {
|
||||
"media_info": self.jsonable(media_info),
|
||||
"save_parent": save_parent,
|
||||
"parent_id": parent_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
return True, result, result["message"]
|
||||
675
plugins.v2/agentresourceofficer/services/quark_transfer.py
Normal file
@@ -0,0 +1,675 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlparse, urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
|
||||
from app.log import logger
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
except Exception:
|
||||
settings = None
|
||||
|
||||
|
||||
class QuarkTransferService:
|
||||
"""
|
||||
Reusable execution layer migrated out of QuarkShareSaver.
|
||||
|
||||
This service intentionally focuses on transfer execution and directory
|
||||
resolution. UI, plugin form logic, and entry adapters stay outside.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cookie: str = "",
|
||||
timeout: int = 30,
|
||||
default_target_path: str = "/飞书",
|
||||
auto_import_cookiecloud: bool = True,
|
||||
cookie_refresh_callback: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
self.cookie = self.clean_text(cookie)
|
||||
self.timeout = max(10, self.safe_int(timeout, 30))
|
||||
self.default_target_path = self.normalize_path(default_target_path or "/飞书")
|
||||
self.auto_import_cookiecloud = auto_import_cookiecloud
|
||||
self.cookie_refresh_callback = cookie_refresh_callback
|
||||
self.path_cache: Dict[str, str] = {"/": "0"}
|
||||
|
||||
@staticmethod
|
||||
def clean_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
@staticmethod
|
||||
def safe_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def normalize_path(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "/"
|
||||
if not text.startswith("/"):
|
||||
text = f"/{text}"
|
||||
text = re.sub(r"/+", "/", text)
|
||||
return text.rstrip("/") or "/"
|
||||
|
||||
@staticmethod
|
||||
def extract_url(raw_text: str) -> str:
|
||||
match = re.search(r"https?://[^\s<>\"']+", raw_text)
|
||||
if match:
|
||||
return match.group(0).rstrip(".,);]")
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]:
|
||||
raw = cls.clean_text(share_text)
|
||||
share_url = cls.extract_url(raw) or raw
|
||||
parsed = urlparse(share_url)
|
||||
pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path)
|
||||
pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else ""
|
||||
|
||||
code = cls.clean_text(access_code)
|
||||
if not code:
|
||||
query = dict(parse_qsl(parsed.query))
|
||||
code = cls.clean_text(query.get("pwd") or query.get("passcode") or query.get("code"))
|
||||
if not code and raw:
|
||||
for token in raw.replace(share_url, " ").split():
|
||||
text = token.strip()
|
||||
if not text:
|
||||
continue
|
||||
if "=" in text:
|
||||
key, value = text.split("=", 1)
|
||||
if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}:
|
||||
code = cls.clean_text(value)
|
||||
break
|
||||
elif len(text) <= 8 and not text.startswith("/"):
|
||||
code = text
|
||||
break
|
||||
|
||||
return share_url, pwd_id, code
|
||||
|
||||
@staticmethod
|
||||
def is_quark_share_url(share_url: str) -> bool:
|
||||
hostname = urlparse(share_url).hostname or ""
|
||||
hostname = hostname.lower().strip(".")
|
||||
return hostname.endswith("quark.cn")
|
||||
|
||||
@classmethod
|
||||
def validate_share_url(cls, share_url: str) -> Tuple[bool, str]:
|
||||
if not share_url:
|
||||
return False, "未识别到有效夸克分享链接"
|
||||
if cls.is_quark_share_url(share_url):
|
||||
return True, ""
|
||||
hostname = urlparse(share_url).hostname or "未知域名"
|
||||
return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接"
|
||||
|
||||
def set_cookie(self, cookie: str) -> None:
|
||||
self.cookie = self.clean_text(cookie)
|
||||
|
||||
def _tz_now(self) -> datetime:
|
||||
if settings is not None:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai")))
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now()
|
||||
|
||||
def _build_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Cookie": self.cookie,
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/137.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Origin": "https://pan.quark.cn",
|
||||
"Referer": "https://pan.quark.cn/",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _common_params() -> Dict[str, Any]:
|
||||
now = int(time.time() * 1000)
|
||||
return {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"__dt": random.randint(100, 9999),
|
||||
"__t": now,
|
||||
}
|
||||
|
||||
def _refresh_cookie(self) -> bool:
|
||||
if not self.auto_import_cookiecloud or not self.cookie_refresh_callback:
|
||||
return False
|
||||
try:
|
||||
cookie = self.clean_text(self.cookie_refresh_callback())
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}")
|
||||
return False
|
||||
if not cookie:
|
||||
return False
|
||||
self.cookie = cookie
|
||||
return True
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
allow_cookie_retry: bool = True,
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
final_url = url
|
||||
if params:
|
||||
query = urlencode([(key, "" if value is None else value) for key, value in params.items()])
|
||||
final_url = f"{url}?{query}" if query else url
|
||||
|
||||
payload = None
|
||||
if json_body is not None:
|
||||
payload = json.dumps(json_body).encode("utf-8")
|
||||
|
||||
try:
|
||||
request = UrlRequest(
|
||||
url=final_url,
|
||||
data=payload,
|
||||
headers=self._build_headers(),
|
||||
method=method.upper(),
|
||||
)
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
status_code = getattr(response, "status", 200)
|
||||
raw_body = response.read()
|
||||
except HTTPError as exc:
|
||||
status_code = exc.code
|
||||
raw_body = exc.read() if hasattr(exc, "read") else b""
|
||||
except URLError as exc:
|
||||
return False, {}, f"请求失败: {exc.reason}"
|
||||
except Exception as exc:
|
||||
return False, {}, f"请求失败: {exc}"
|
||||
|
||||
try:
|
||||
data = json.loads(raw_body.decode("utf-8"))
|
||||
except Exception:
|
||||
text = raw_body.decode("utf-8", errors="ignore")[:300]
|
||||
return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}"
|
||||
|
||||
if status_code in {401, 403} and allow_cookie_retry and self._refresh_cookie():
|
||||
return self._request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json_body=json_body,
|
||||
allow_cookie_retry=False,
|
||||
)
|
||||
|
||||
if status_code != 200:
|
||||
if isinstance(data, dict):
|
||||
code = self.clean_text(data.get("code"))
|
||||
detail = self.clean_text(data.get("message") or data.get("msg"))
|
||||
if detail:
|
||||
if code:
|
||||
return False, data, f"HTTP {status_code} [{code}]: {detail}"
|
||||
return False, data, f"HTTP {status_code}: {detail}"
|
||||
return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}"
|
||||
|
||||
if isinstance(data, dict):
|
||||
message = str(data.get("message") or data.get("msg") or "").strip()
|
||||
ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok"
|
||||
if ok:
|
||||
return True, data, ""
|
||||
return False, data, message or "接口返回失败"
|
||||
|
||||
return False, {}, "接口返回格式错误"
|
||||
|
||||
def get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]:
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token",
|
||||
params=self._common_params(),
|
||||
json_body={"pwd_id": pwd_id, "passcode": access_code or ""},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
stoken = self.clean_text((data.get("data") or {}).get("stoken"))
|
||||
if not stoken:
|
||||
return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效"
|
||||
return True, stoken, ""
|
||||
|
||||
def get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]:
|
||||
items: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
params = self._common_params()
|
||||
params.update(
|
||||
{
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"pdir_fid": "0",
|
||||
"force": "0",
|
||||
"_page": str(page),
|
||||
"_size": "50",
|
||||
"_sort": "file_type:asc,updated_at:desc",
|
||||
}
|
||||
)
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, [], message
|
||||
|
||||
payload = data.get("data") or {}
|
||||
meta = data.get("metadata") or {}
|
||||
current = payload.get("list") or []
|
||||
for item in current:
|
||||
items.append(
|
||||
{
|
||||
"fid": str(item.get("fid") or ""),
|
||||
"file_name": str(item.get("file_name") or ""),
|
||||
"dir": bool(item.get("dir")),
|
||||
"file_type": item.get("file_type"),
|
||||
"pdir_fid": str(item.get("pdir_fid") or ""),
|
||||
"share_fid_token": str(item.get("share_fid_token") or ""),
|
||||
}
|
||||
)
|
||||
|
||||
total = self.safe_int(meta.get("_total"), 0)
|
||||
count = self.safe_int(meta.get("_count"), len(current))
|
||||
size = max(1, self.safe_int(meta.get("_size"), 50))
|
||||
if total <= len(items) or count < size:
|
||||
break
|
||||
page += 1
|
||||
|
||||
if not items:
|
||||
return False, [], "分享链接为空,或当前账号无权查看内容"
|
||||
return True, items, ""
|
||||
|
||||
def list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]:
|
||||
page = 1
|
||||
result: List[Dict[str, Any]] = []
|
||||
while True:
|
||||
params = {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"pdir_fid": parent_fid,
|
||||
"_page": page,
|
||||
"_size": 100,
|
||||
"_fetch_total": 1,
|
||||
"_fetch_sub_dirs": 0,
|
||||
"_sort": "file_type:asc,updated_at:desc",
|
||||
}
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/file/sort",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, [], message
|
||||
|
||||
current = ((data.get("data") or {}).get("list")) or []
|
||||
for item in current:
|
||||
result.append(
|
||||
{
|
||||
"fid": str(item.get("fid") or ""),
|
||||
"name": str(item.get("file_name") or ""),
|
||||
"dir": int(item.get("file_type") or 0) == 0,
|
||||
"size": item.get("size") or 0,
|
||||
"updated_at": item.get("updated_at") or 0,
|
||||
"raw": item,
|
||||
}
|
||||
)
|
||||
if len(current) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return True, result, ""
|
||||
|
||||
def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
source_items = [item for item in (items or []) if isinstance(item, dict)]
|
||||
|
||||
def build_fids(candidates: List[Dict[str, Any]]) -> List[str]:
|
||||
result: List[str] = []
|
||||
for item in candidates:
|
||||
fid = self.clean_text(item.get("fid"))
|
||||
if fid:
|
||||
result.append(fid)
|
||||
return result
|
||||
|
||||
def item_label(item: Dict[str, Any]) -> str:
|
||||
return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid"))
|
||||
|
||||
def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]:
|
||||
fids = build_fids(candidates)
|
||||
if not fids:
|
||||
return False, {}, "默认目录当前层没有可删除项目"
|
||||
payloads = [
|
||||
{
|
||||
"action_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": [{"fid": fid} for fid in fids],
|
||||
},
|
||||
{
|
||||
"action_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": fids,
|
||||
},
|
||||
{
|
||||
# Some web scripts historically used this misspelled key.
|
||||
"actoin_type": 2,
|
||||
"exclude_fids": [],
|
||||
"filelist": fids,
|
||||
},
|
||||
]
|
||||
last_data: Dict[str, Any] = {}
|
||||
last_message = ""
|
||||
for index, payload in enumerate(payloads, start=1):
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/file/delete",
|
||||
params={
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
},
|
||||
json_body=payload,
|
||||
)
|
||||
if ok:
|
||||
if isinstance(data, dict):
|
||||
data["delete_payload_variant"] = index
|
||||
return True, data, ""
|
||||
last_data = data if isinstance(data, dict) else {}
|
||||
last_message = message or last_message
|
||||
return False, last_data, last_message or "夸克删除失败"
|
||||
|
||||
filelist: List[Dict[str, Any]] = []
|
||||
for item in source_items:
|
||||
fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else ""
|
||||
if fid:
|
||||
filelist.append({"fid": fid})
|
||||
if not filelist:
|
||||
return False, {}, "默认目录当前层没有可删除项目"
|
||||
|
||||
ok, data, message = call_delete(source_items)
|
||||
if ok:
|
||||
data["deleted_count"] = len(filelist)
|
||||
data["delete_mode"] = "batch"
|
||||
return True, data, ""
|
||||
|
||||
if len(source_items) <= 1:
|
||||
return False, data, message or "夸克删除失败"
|
||||
|
||||
deleted_count = 0
|
||||
failed_items: List[Dict[str, Any]] = []
|
||||
for item in source_items:
|
||||
single_ok, single_data, single_message = call_delete([item])
|
||||
if single_ok:
|
||||
deleted_count += 1
|
||||
continue
|
||||
failed_items.append({
|
||||
"fid": self.clean_text(item.get("fid")),
|
||||
"name": item_label(item),
|
||||
"message": single_message or "删除失败",
|
||||
"result": single_data,
|
||||
})
|
||||
|
||||
result = {
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items[:20],
|
||||
"delete_mode": "single_fallback",
|
||||
"batch_error": message or "夸克批量删除失败",
|
||||
"batch_result": data,
|
||||
}
|
||||
if failed_items:
|
||||
return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败"
|
||||
return True, result, ""
|
||||
|
||||
def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]:
|
||||
ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path)
|
||||
if not ok:
|
||||
return False, {}, target_fid or "定位夸克目录失败"
|
||||
|
||||
ok, children, message = self.list_children(target_fid)
|
||||
if not ok:
|
||||
return False, {}, message or "读取夸克目录失败"
|
||||
|
||||
files = [item for item in children if not bool(item.get("dir"))]
|
||||
folders = [item for item in children if bool(item.get("dir"))]
|
||||
if not children:
|
||||
return True, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"removed_count": 0,
|
||||
"file_count": 0,
|
||||
"folder_count": 0,
|
||||
"items": [],
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, "默认目录当前层为空"
|
||||
|
||||
ok, delete_result, message = self.delete_items(children)
|
||||
removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0)
|
||||
if not ok:
|
||||
return False, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"file_count": len(files),
|
||||
"folder_count": len(folders),
|
||||
"removed_count": removed_count,
|
||||
"items": [self.clean_text(item.get("name")) for item in children[:20]],
|
||||
"failed_items": (delete_result or {}).get("failed_items") or [],
|
||||
"delete_result": delete_result,
|
||||
}, message or "夸克清空默认目录失败"
|
||||
|
||||
return True, {
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"removed_count": removed_count,
|
||||
"file_count": len(files),
|
||||
"folder_count": len(folders),
|
||||
"items": [self.clean_text(item.get("name")) for item in children[:20]],
|
||||
"delete_result": delete_result,
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}, "success"
|
||||
|
||||
def find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
|
||||
ok, items, message = self.list_children(parent_fid)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
for item in items:
|
||||
if item.get("dir") and item.get("name") == name:
|
||||
return True, str(item.get("fid") or ""), ""
|
||||
return True, "", ""
|
||||
|
||||
def create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]:
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://pan.quark.cn/1/clouddrive/file/create",
|
||||
json_body={
|
||||
"pdir_fid": parent_fid,
|
||||
"file_name": name,
|
||||
"dir_path": "",
|
||||
"dir_init_lock": False,
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
folder = data.get("data") or {}
|
||||
folder_id = self.clean_text(folder.get("fid") or folder.get("file_id"))
|
||||
if not folder_id:
|
||||
return False, "", "创建目录成功但未返回 fid"
|
||||
return True, folder_id, ""
|
||||
|
||||
def ensure_target_dir(self, path: str) -> Tuple[bool, str, str]:
|
||||
normalized = self.normalize_path(path or self.default_target_path)
|
||||
if normalized == "/":
|
||||
return True, "0", normalized
|
||||
cached = self.path_cache.get(normalized)
|
||||
if cached:
|
||||
return True, cached, normalized
|
||||
|
||||
current_fid = "0"
|
||||
built = ""
|
||||
for part in [segment for segment in normalized.split("/") if segment]:
|
||||
built = f"{built}/{part}" if built else f"/{part}"
|
||||
cached = self.path_cache.get(built)
|
||||
if cached:
|
||||
current_fid = cached
|
||||
continue
|
||||
|
||||
ok, found_fid, message = self.find_child_dir(current_fid, part)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
if not found_fid:
|
||||
ok, found_fid, message = self.create_folder(current_fid, part)
|
||||
if not ok:
|
||||
return False, "", f"创建目录失败 {built}: {message}"
|
||||
self.path_cache[built] = found_fid
|
||||
current_fid = found_fid
|
||||
return True, current_fid, normalized
|
||||
|
||||
def create_save_task(
|
||||
self,
|
||||
pwd_id: str,
|
||||
stoken: str,
|
||||
items: List[Dict[str, Any]],
|
||||
to_pdir_fid: str,
|
||||
) -> Tuple[bool, str, str]:
|
||||
fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")]
|
||||
fid_token_list = [
|
||||
str(item.get("share_fid_token") or "")
|
||||
for item in items
|
||||
if item.get("fid") and item.get("share_fid_token")
|
||||
]
|
||||
if not fid_list or len(fid_list) != len(fid_token_list):
|
||||
return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存"
|
||||
|
||||
params = self._common_params()
|
||||
ok, data, message = self._request(
|
||||
"POST",
|
||||
"https://drive.quark.cn/1/clouddrive/share/sharepage/save",
|
||||
params=params,
|
||||
json_body={
|
||||
"fid_list": fid_list,
|
||||
"fid_token_list": fid_token_list,
|
||||
"to_pdir_fid": to_pdir_fid,
|
||||
"pwd_id": pwd_id,
|
||||
"stoken": stoken,
|
||||
"pdir_fid": "0",
|
||||
"scene": "link",
|
||||
},
|
||||
)
|
||||
if not ok:
|
||||
return False, "", message
|
||||
|
||||
task_id = self.clean_text((data.get("data") or {}).get("task_id"))
|
||||
if not task_id:
|
||||
return False, "", "未获取到转存任务 ID"
|
||||
return True, task_id, ""
|
||||
|
||||
def wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]:
|
||||
for index in range(retry):
|
||||
time.sleep(1.0 if index == 0 else 1.5)
|
||||
params = {
|
||||
"pr": "ucpro",
|
||||
"fr": "pc",
|
||||
"uc_param_str": "",
|
||||
"task_id": task_id,
|
||||
"retry_index": index,
|
||||
"__dt": 21192,
|
||||
"__t": int(time.time() * 1000),
|
||||
}
|
||||
ok, data, message = self._request(
|
||||
"GET",
|
||||
"https://drive-pc.quark.cn/1/clouddrive/task",
|
||||
params=params,
|
||||
)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
task = data.get("data") or {}
|
||||
status = self.safe_int(task.get("status"), -1)
|
||||
if status == 2:
|
||||
return True, task, ""
|
||||
if status in {3, 4, 5, 6, 7}:
|
||||
return False, task, self.clean_text(task.get("message")) or "夸克任务执行失败"
|
||||
|
||||
return False, {}, "等待夸克转存任务超时"
|
||||
|
||||
def check_cookie(self) -> Tuple[bool, str]:
|
||||
ok, _, message = self.list_children("0")
|
||||
if ok:
|
||||
return True, ""
|
||||
return False, message or "Cookie 校验失败"
|
||||
|
||||
def transfer_share(
|
||||
self,
|
||||
share_text: str,
|
||||
access_code: str = "",
|
||||
target_path: str = "",
|
||||
*,
|
||||
trigger: str = "Agent影视助手",
|
||||
) -> Tuple[bool, Dict[str, Any], str]:
|
||||
share_url, pwd_id, final_code = self.extract_share_info(share_text, access_code)
|
||||
ok, message = self.validate_share_url(share_url)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
if not pwd_id:
|
||||
return False, {}, "未识别到有效夸克分享链接"
|
||||
if not self.cookie:
|
||||
self._refresh_cookie()
|
||||
if not self.cookie:
|
||||
return False, {}, "未配置夸克 Cookie"
|
||||
|
||||
ok, stoken, message = self.get_stoken(pwd_id, final_code)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, share_items, message = self.get_share_items(pwd_id, stoken)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path)
|
||||
if not ok:
|
||||
return False, {}, target_fid
|
||||
|
||||
ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid)
|
||||
if not ok:
|
||||
return False, {}, message
|
||||
|
||||
ok, task, message = self.wait_task(task_id)
|
||||
if not ok:
|
||||
return False, {"task_id": task_id}, message
|
||||
|
||||
item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")]
|
||||
result = {
|
||||
"share_url": share_url,
|
||||
"pwd_id": pwd_id,
|
||||
"access_code": final_code,
|
||||
"target_path": normalized_path,
|
||||
"target_fid": target_fid,
|
||||
"task_id": task_id,
|
||||
"saved_count": len(share_items),
|
||||
"items": item_names[:20],
|
||||
"task": task,
|
||||
"trigger": trigger,
|
||||
"time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
return True, result, "success"
|
||||
388
plugins.v2/agentresourceofficer/services/streaming_recommend.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
流媒体推荐 — TMDB Discover 直连服务
|
||||
|
||||
直接调用 TMDB discover API,不走 MoviePilot RecommendChain。
|
||||
原因:RecommendChain 不支持 with_watch_providers + 时间窗口组合筛选。
|
||||
|
||||
支持的能力:
|
||||
- 按流媒体平台聚合(Netflix / Disney+ / Apple TV+ / Prime Video)
|
||||
- 严格按时间窗口过滤(本月 / 近N天)
|
||||
- 按热度 + 评分 + 投票人数综合排序
|
||||
- 区分电影 / 剧集 / 全部
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
|
||||
# TMDB Watch Provider ID 映射
|
||||
PROVIDER_MAP: Dict[str, int] = {
|
||||
"netflix": 8,
|
||||
"disney": 337,
|
||||
"disney+": 337,
|
||||
"apple": 384,
|
||||
"apple tv+": 384,
|
||||
"prime": 10,
|
||||
"prime video": 10,
|
||||
"amazon": 10,
|
||||
}
|
||||
|
||||
# 默认聚合平台
|
||||
DEFAULT_PROVIDER_IDS: List[int] = [8, 337, 384, 10]
|
||||
|
||||
# 默认地区(CN / US)
|
||||
DEFAULT_WATCH_REGION = "US"
|
||||
|
||||
# 综合排序权重
|
||||
SCORE_WEIGHTS = {
|
||||
"popularity": 0.4,
|
||||
"vote_average": 0.35,
|
||||
"vote_count_norm": 0.15,
|
||||
"freshness": 0.1,
|
||||
}
|
||||
|
||||
|
||||
class StreamingRecommendService:
|
||||
"""TMDB Discover 直连,返回流媒体推荐列表"""
|
||||
|
||||
def __init__(self, tmdb_api_key: str):
|
||||
self._api_key = tmdb_api_key.strip()
|
||||
|
||||
# ─── 公开入口 ────────────────────────────────────────────────
|
||||
|
||||
async def query(
|
||||
self,
|
||||
*,
|
||||
media_type: str = "all",
|
||||
intent: str = "hot",
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
window_days: int = 90,
|
||||
providers: Optional[List[int]] = None,
|
||||
watch_region: str = DEFAULT_WATCH_REGION,
|
||||
limit: int = 15,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
查询流媒体推荐。
|
||||
|
||||
返回:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str,
|
||||
"items": [ { index, title, year, media_type, release_date,
|
||||
popularity, vote_average, vote_count,
|
||||
providers_str, reason } ],
|
||||
"query_params": { ... },
|
||||
}
|
||||
"""
|
||||
if not self._api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "TMDB API Key 未配置,无法查询流媒体推荐。",
|
||||
"items": [],
|
||||
"query_params": {},
|
||||
}
|
||||
|
||||
provider_ids = providers or DEFAULT_PROVIDER_IDS
|
||||
media_type = (media_type or "all").lower()
|
||||
intent = (intent or "hot").lower()
|
||||
|
||||
# ── 时间窗口 ──
|
||||
final_start, final_end = self._resolve_time_range(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
window_days=window_days,
|
||||
)
|
||||
|
||||
# ── 分别查电影和剧集 ──
|
||||
all_items: List[Dict[str, Any]] = []
|
||||
|
||||
if media_type in ("movie", "all"):
|
||||
movie_items = await self._discover(
|
||||
media_category="movie",
|
||||
intent=intent,
|
||||
start_date=final_start,
|
||||
end_date=final_end,
|
||||
provider_ids=provider_ids,
|
||||
watch_region=watch_region,
|
||||
limit=limit if media_type == "movie" else limit * 2,
|
||||
)
|
||||
all_items.extend(movie_items)
|
||||
|
||||
if media_type in ("tv", "all"):
|
||||
tv_items = await self._discover(
|
||||
media_category="tv",
|
||||
intent=intent,
|
||||
start_date=final_start,
|
||||
end_date=final_end,
|
||||
provider_ids=provider_ids,
|
||||
watch_region=watch_region,
|
||||
limit=limit if media_type == "tv" else limit * 2,
|
||||
)
|
||||
all_items.extend(tv_items)
|
||||
|
||||
# ── 综合排序并截断 ──
|
||||
ranked = self._rank(all_items, intent=intent)
|
||||
trimmed = ranked[:limit]
|
||||
|
||||
# ── 编号 & 推荐理由 ──
|
||||
for idx, item in enumerate(trimmed, start=1):
|
||||
item["index"] = idx
|
||||
item["reason"] = self._generate_reason(item, intent)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "",
|
||||
"items": trimmed,
|
||||
"query_params": {
|
||||
"media_type": media_type,
|
||||
"intent": intent,
|
||||
"start_date": final_start,
|
||||
"end_date": final_end,
|
||||
"provider_ids": provider_ids,
|
||||
"watch_region": watch_region,
|
||||
"count": len(trimmed),
|
||||
},
|
||||
}
|
||||
|
||||
# ─── TMDB Discover 直连 ──────────────────────────────────────
|
||||
|
||||
async def _discover(
|
||||
self,
|
||||
*,
|
||||
media_category: str,
|
||||
intent: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
provider_ids: List[int],
|
||||
watch_region: str,
|
||||
limit: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
调用 TMDB discover/movie 或 discover/tv。
|
||||
返回标准化的条目列表。
|
||||
"""
|
||||
endpoint = "movie" if media_category == "movie" else "tv"
|
||||
date_field = (
|
||||
"primary_release_date.gte" if media_category == "movie"
|
||||
else "first_air_date.gte"
|
||||
)
|
||||
date_field_end = (
|
||||
"primary_release_date.lte" if media_category == "movie"
|
||||
else "first_air_date.lte"
|
||||
)
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"api_key": self._api_key,
|
||||
"language": "zh-CN",
|
||||
"sort_by": "popularity.desc",
|
||||
"watch_region": watch_region,
|
||||
"with_watch_providers": "|".join(str(p) for p in provider_ids),
|
||||
"with_watch_monetization_types": "flatrate",
|
||||
"vote_count.gte": self._min_vote_count(intent),
|
||||
"page": 1,
|
||||
}
|
||||
|
||||
# 严格时间过滤
|
||||
if start_date:
|
||||
params[date_field] = start_date
|
||||
if end_date:
|
||||
params[date_field_end] = end_date
|
||||
|
||||
url = f"https://api.themoviedb.org/3/discover/{endpoint}?" + urlencode(params)
|
||||
|
||||
try:
|
||||
request = UrlRequest(url=url, headers={"Accept": "application/json"})
|
||||
with urlopen(request, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
return []
|
||||
|
||||
raw_results = payload.get("results") or []
|
||||
if not isinstance(raw_results, list):
|
||||
return []
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for raw in raw_results:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
item = self._normalize_item(raw, media_category)
|
||||
if item:
|
||||
items.append(item)
|
||||
|
||||
return items[:limit * 2]
|
||||
|
||||
def _normalize_item(self, raw: Dict[str, Any], media_category: str) -> Optional[Dict[str, Any]]:
|
||||
"""把 TMDB 原始条目转为标准格式"""
|
||||
title = (
|
||||
raw.get("title")
|
||||
or raw.get("name")
|
||||
or raw.get("original_title")
|
||||
or raw.get("original_name")
|
||||
or ""
|
||||
).strip()
|
||||
if not title:
|
||||
return None
|
||||
|
||||
release_date = raw.get("release_date") or raw.get("primary_release_date") or raw.get("first_air_date") or ""
|
||||
year = str(release_date)[:4] if release_date else ""
|
||||
|
||||
popularity = float(raw.get("popularity") or 0)
|
||||
vote_average = float(raw.get("vote_average") or 0)
|
||||
vote_count = int(raw.get("vote_count") or 0)
|
||||
|
||||
# 处理 media_type
|
||||
raw_type = raw.get("media_type") or ""
|
||||
if media_category == "movie":
|
||||
display_type = "电影"
|
||||
elif media_category == "tv":
|
||||
display_type = "剧集"
|
||||
else:
|
||||
display_type = "电影" if raw_type == "movie" else "剧集"
|
||||
|
||||
# provider_ids 从原数据获取
|
||||
provider_ids_raw = raw.get("origin_country") or []
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"year": year,
|
||||
"media_type": display_type,
|
||||
"release_date": release_date,
|
||||
"popularity": round(popularity, 1),
|
||||
"vote_average": round(vote_average, 1),
|
||||
"vote_count": vote_count,
|
||||
"tmdb_id": raw.get("id"),
|
||||
"provider_ids_raw": provider_ids_raw,
|
||||
}
|
||||
|
||||
# ─── 综合排序 ────────────────────────────────────────────────
|
||||
|
||||
def _rank(self, items: List[Dict[str, Any]], intent: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
按综合分排序。
|
||||
intent 影响权重:big_titles 偏重评分,hot 偏重热度,new 偏重新鲜度。
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
weights = dict(SCORE_WEIGHTS)
|
||||
if intent == "big_titles":
|
||||
weights["vote_average"] = 0.45
|
||||
weights["popularity"] = 0.25
|
||||
weights["vote_count_norm"] = 0.2
|
||||
weights["freshness"] = 0.1
|
||||
elif intent == "new":
|
||||
weights["freshness"] = 0.3
|
||||
weights["popularity"] = 0.3
|
||||
weights["vote_average"] = 0.25
|
||||
weights["vote_count_norm"] = 0.15
|
||||
|
||||
# 归一化基准
|
||||
max_pop = max((i.get("popularity") or 0) for i in items) or 1
|
||||
max_votes = max((i.get("vote_count") or 0) for i in items) or 1
|
||||
|
||||
today = date.today()
|
||||
|
||||
def score(item: Dict[str, Any]) -> float:
|
||||
pop = (item.get("popularity") or 0) / max_pop
|
||||
avg = (item.get("vote_average") or 0) / 10.0
|
||||
vc = (item.get("vote_count") or 0) / max_votes
|
||||
# 新鲜度:发布越近分越高(90天内线性衰减)
|
||||
try:
|
||||
rd = item.get("release_date") or ""
|
||||
days_ago = (today - date.fromisoformat(rd[:10])).days if rd and len(rd) >= 10 else 180
|
||||
except Exception:
|
||||
days_ago = 180
|
||||
freshness = max(0.0, 1.0 - days_ago / 180.0)
|
||||
return (
|
||||
weights["popularity"] * pop
|
||||
+ weights["vote_average"] * avg
|
||||
+ weights["vote_count_norm"] * vc
|
||||
+ weights["freshness"] * freshness
|
||||
)
|
||||
|
||||
items.sort(key=score, reverse=True)
|
||||
return items
|
||||
|
||||
# ─── 推荐理由 ────────────────────────────────────────────────
|
||||
|
||||
def _generate_reason(self, item: Dict[str, Any], intent: str) -> str:
|
||||
"""基于数据生成一句话推荐理由,不经过 LLM"""
|
||||
avg = item.get("vote_average") or 0
|
||||
pop = item.get("popularity") or 0
|
||||
votes = item.get("vote_count") or 0
|
||||
|
||||
if intent == "big_titles":
|
||||
if avg >= 8.0 and votes >= 500:
|
||||
return "高口碑大作"
|
||||
if avg >= 7.0 and votes >= 200:
|
||||
return "口碑不错"
|
||||
return "值得关注"
|
||||
|
||||
if intent == "new":
|
||||
if pop >= 500:
|
||||
return "新上线即爆"
|
||||
if avg >= 7.0:
|
||||
return "新上线口碑佳"
|
||||
return "新上线"
|
||||
|
||||
# hot
|
||||
if avg >= 8.0 and pop >= 600:
|
||||
return "口碑热度双高"
|
||||
if pop >= 800:
|
||||
return "热度爆棚"
|
||||
if avg >= 7.5:
|
||||
return "口碑出色"
|
||||
if votes >= 1000:
|
||||
return "大众高关注"
|
||||
return "近期热门"
|
||||
|
||||
# ─── 时间范围 ────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _resolve_time_range(
|
||||
*,
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
window_days: int = 90,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
给定起止日期或窗口天数,返回严格时间范围(YYYY-MM-DD)。
|
||||
- specific_month / this_month:严格按自然月
|
||||
- recent:从今天往前推 window_days 天
|
||||
- last_month:上一个自然月
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
# 如果都给了就直接用
|
||||
if start_date and end_date:
|
||||
return start_date[:10], end_date[:10]
|
||||
|
||||
# 如果只给了 start_date,end_date 默认今天
|
||||
if start_date and not end_date:
|
||||
return start_date[:10], today.isoformat()
|
||||
|
||||
# 如果只给了 end_date,start_date 往前推 window_days
|
||||
if end_date and not start_date:
|
||||
try:
|
||||
end_d = date.fromisoformat(end_date[:10])
|
||||
except Exception:
|
||||
end_d = today
|
||||
start_d = end_d - timedelta(days=window_days)
|
||||
return start_d.isoformat(), end_d.isoformat()
|
||||
|
||||
# 都没给:默认最近 window_days
|
||||
start_d = today - timedelta(days=window_days)
|
||||
return start_d.isoformat(), today.isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _min_vote_count(intent: str) -> int:
|
||||
"""不同 intent 的最低投票人数门槛"""
|
||||
if intent == "big_titles":
|
||||
return 300
|
||||
if intent == "new":
|
||||
return 30
|
||||
return 100
|
||||
481
plugins.v2/agenttokens/__init__.py
Normal file
@@ -0,0 +1,481 @@
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import Body
|
||||
|
||||
from app import schemas
|
||||
from app.api.endpoints.plugin import register_plugin_api
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
|
||||
|
||||
class AgentTokens(_PluginBase):
|
||||
"""
|
||||
Agent Tokens 管理插件。
|
||||
|
||||
通过 Agent LLM 供应商链式事件按优先级选择仍有 token 余量的供应商,
|
||||
并通过 Agent Tokens 用量广播事件回写实际消耗。
|
||||
"""
|
||||
|
||||
plugin_name = "Agent Tokens 管理"
|
||||
plugin_desc = "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。"
|
||||
plugin_icon = "agentresourceofficer.png"
|
||||
plugin_version = "1.0.2"
|
||||
plugin_author = "jxxghp"
|
||||
author_url = "https://github.com/jxxghp"
|
||||
plugin_config_prefix = "agenttokens_"
|
||||
plugin_order = 45
|
||||
auth_level = 1
|
||||
|
||||
DATA_KEY_USAGE = "usage"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
"""
|
||||
初始化插件配置,补齐供应商稳定 ID 以便后续用量能持续关联。
|
||||
"""
|
||||
self._usage_lock = threading.RLock()
|
||||
config = config or {}
|
||||
self._enabled = bool(config.get("enabled"))
|
||||
self._show_sidebar_nav = bool(config.get("show_sidebar_nav", True))
|
||||
self._providers = self._normalize_providers(config.get("providers") or [])
|
||||
self._save_config()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
返回插件是否已启用。
|
||||
"""
|
||||
return bool(getattr(self, "_enabled", False))
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
当前插件不注册远程命令。
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册 Vue 界面需要调用的插件 API。
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"path": "/status",
|
||||
"endpoint": self.get_status,
|
||||
"methods": ["GET"],
|
||||
"auth": "bear",
|
||||
"summary": "获取 Agent Tokens 状态",
|
||||
},
|
||||
{
|
||||
"path": "/config",
|
||||
"endpoint": self.save_config_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "保存 Agent Tokens 配置",
|
||||
},
|
||||
{
|
||||
"path": "/usage/reset",
|
||||
"endpoint": self.reset_usage_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "重置指定供应商用量",
|
||||
},
|
||||
{
|
||||
"path": "/usage/reset_all",
|
||||
"endpoint": self.reset_all_usage_api,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "重置全部供应商用量",
|
||||
},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_render_mode() -> Tuple[str, str]:
|
||||
"""
|
||||
声明插件使用 Vue 联邦组件渲染。
|
||||
"""
|
||||
return "vue", "dist/assets"
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
Vue 模式下返回默认配置模型。
|
||||
"""
|
||||
return [], self._current_config()
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
Vue 模式下详情页由远程 Page 组件渲染。
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
|
||||
"""
|
||||
声明一个用量概览仪表板组件。
|
||||
"""
|
||||
return [{"key": "usage", "name": "Agent Tokens 管理"}] if self.get_state() else []
|
||||
|
||||
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
|
||||
"""
|
||||
返回 Vue 仪表板组件的布局与标题配置。
|
||||
"""
|
||||
if not self.get_state():
|
||||
return None
|
||||
return (
|
||||
{"cols": 12, "md": 6},
|
||||
{
|
||||
"title": "Agent Tokens 管理",
|
||||
"subtitle": "LLM 配额使用情况",
|
||||
"refresh": 30,
|
||||
"border": True,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 Agent Tokens 管理页注册到主界面侧栏。
|
||||
"""
|
||||
if not self.get_state() or not getattr(self, "_show_sidebar_nav", True):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "Agent Tokens 管理",
|
||||
"icon": "mdi-key-chain",
|
||||
"section": "system",
|
||||
"permission": "manage",
|
||||
"order": 46,
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
插件无后台服务,停用时无需清理额外资源。
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _to_int(value: Any, default: int = 0) -> int:
|
||||
"""
|
||||
将配置或事件中的数字字段安全转为整数。
|
||||
"""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> str:
|
||||
"""
|
||||
清理配置中的文本字段,避免空白值参与供应商选择。
|
||||
"""
|
||||
return str(value or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _event_get(event_data: Any, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
兼容读取 Pydantic 事件模型或字典中的字段。
|
||||
"""
|
||||
if isinstance(event_data, dict):
|
||||
return event_data.get(key, default)
|
||||
return getattr(event_data, key, default)
|
||||
|
||||
@staticmethod
|
||||
def _event_set(event_data: Any, key: str, value: Any) -> None:
|
||||
"""
|
||||
兼容写入 Pydantic 事件模型或字典中的字段。
|
||||
"""
|
||||
if isinstance(event_data, dict):
|
||||
event_data[key] = value
|
||||
else:
|
||||
setattr(event_data, key, value)
|
||||
|
||||
@classmethod
|
||||
def _normalize_provider(cls, provider: dict, index: int) -> dict:
|
||||
"""
|
||||
标准化单个供应商配置,并为旧配置补齐稳定 ID。
|
||||
"""
|
||||
provider = provider or {}
|
||||
provider_id = cls._clean_text(provider.get("id")) or uuid.uuid4().hex
|
||||
token_limit = max(cls._to_int(provider.get("token_limit"), 0), 0)
|
||||
used_tokens = max(cls._to_int(provider.get("used_tokens"), 0), 0)
|
||||
priority = cls._to_int(provider.get("priority"), index + 1)
|
||||
return {
|
||||
"id": provider_id,
|
||||
"enabled": bool(provider.get("enabled", True)),
|
||||
"name": cls._clean_text(provider.get("name")) or f"Provider {index + 1}",
|
||||
"provider": cls._clean_text(
|
||||
provider.get("provider") or provider.get("llm_provider")
|
||||
) or "openai",
|
||||
"base_url": cls._clean_text(provider.get("base_url")),
|
||||
"api_key": cls._clean_text(provider.get("api_key")),
|
||||
"model": cls._clean_text(provider.get("model")),
|
||||
"token_limit": token_limit,
|
||||
"used_tokens": used_tokens,
|
||||
"priority": priority,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _normalize_providers(cls, providers: list) -> List[dict]:
|
||||
"""
|
||||
标准化供应商列表并按优先级排序。
|
||||
"""
|
||||
normalized = [
|
||||
cls._normalize_provider(provider, index)
|
||||
for index, provider in enumerate(providers or [])
|
||||
if isinstance(provider, dict)
|
||||
]
|
||||
return sorted(normalized, key=lambda item: (item["priority"], item["name"]))
|
||||
|
||||
@staticmethod
|
||||
def _mask_api_key(api_key: str) -> str:
|
||||
"""
|
||||
生成 API Key 的脱敏展示文本。
|
||||
"""
|
||||
if not api_key:
|
||||
return ""
|
||||
if len(api_key) <= 8:
|
||||
return "****"
|
||||
return f"{api_key[:4]}...{api_key[-4:]}"
|
||||
|
||||
def _current_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
返回当前插件配置快照。
|
||||
"""
|
||||
return {
|
||||
"enabled": bool(getattr(self, "_enabled", False)),
|
||||
"show_sidebar_nav": bool(getattr(self, "_show_sidebar_nav", True)),
|
||||
"providers": list(getattr(self, "_providers", [])),
|
||||
}
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""
|
||||
保存当前插件配置,确保供应商 ID 的补齐结果能持久化。
|
||||
"""
|
||||
self.update_config(self._current_config())
|
||||
|
||||
def _load_usage(self) -> Dict[str, dict]:
|
||||
"""
|
||||
读取已记录的供应商用量。
|
||||
"""
|
||||
usage = self.get_data(self.DATA_KEY_USAGE) or {}
|
||||
return usage if isinstance(usage, dict) else {}
|
||||
|
||||
def _save_usage(self, usage: Dict[str, dict]) -> None:
|
||||
"""
|
||||
保存供应商用量数据。
|
||||
"""
|
||||
self.save_data(self.DATA_KEY_USAGE, usage or {})
|
||||
|
||||
def _provider_usage(self, provider: dict, usage: Optional[Dict[str, dict]] = None) -> dict:
|
||||
"""
|
||||
汇总供应商的手工初始用量和 Agent 实际记录用量。
|
||||
"""
|
||||
usage = usage if usage is not None else self._load_usage()
|
||||
provider_usage = usage.get(provider["id"], {}) or {}
|
||||
recorded_total = self._to_int(provider_usage.get("total_tokens"), 0)
|
||||
manual_used = self._to_int(provider.get("used_tokens"), 0)
|
||||
total_used = manual_used + recorded_total
|
||||
token_limit = self._to_int(provider.get("token_limit"), 0)
|
||||
remaining = None if token_limit <= 0 else max(token_limit - total_used, 0)
|
||||
percent = 0
|
||||
if token_limit > 0:
|
||||
percent = min(round(total_used * 100 / token_limit, 2), 100)
|
||||
return {
|
||||
"input_tokens": self._to_int(provider_usage.get("input_tokens"), 0),
|
||||
"output_tokens": self._to_int(provider_usage.get("output_tokens"), 0),
|
||||
"recorded_tokens": recorded_total,
|
||||
"manual_used_tokens": manual_used,
|
||||
"total_tokens": total_used,
|
||||
"token_limit": token_limit,
|
||||
"remaining_tokens": remaining,
|
||||
"usage_percent": percent,
|
||||
"model_call_count": self._to_int(provider_usage.get("model_call_count"), 0),
|
||||
"runs": self._to_int(provider_usage.get("runs"), 0),
|
||||
"success_count": self._to_int(provider_usage.get("success_count"), 0),
|
||||
"failure_count": self._to_int(provider_usage.get("failure_count"), 0),
|
||||
"last_used_at": provider_usage.get("last_used_at"),
|
||||
"last_error": provider_usage.get("last_error"),
|
||||
"exhausted": token_limit > 0 and total_used >= token_limit,
|
||||
}
|
||||
|
||||
def _provider_status_rows(self) -> List[dict]:
|
||||
"""
|
||||
构建前端展示用的供应商状态列表。
|
||||
"""
|
||||
usage = self._load_usage()
|
||||
rows = []
|
||||
for provider in getattr(self, "_providers", []):
|
||||
provider_usage = self._provider_usage(provider, usage)
|
||||
rows.append({
|
||||
**provider,
|
||||
"masked_api_key": self._mask_api_key(provider.get("api_key", "")),
|
||||
"usage": provider_usage,
|
||||
})
|
||||
return rows
|
||||
|
||||
def _summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
汇总当前供应商数量和 token 使用情况。
|
||||
"""
|
||||
rows = self._provider_status_rows()
|
||||
enabled_rows = [row for row in rows if row.get("enabled")]
|
||||
available_rows = [
|
||||
row for row in enabled_rows
|
||||
if not row["usage"].get("exhausted")
|
||||
and row.get("api_key")
|
||||
and row.get("model")
|
||||
and row.get("base_url")
|
||||
]
|
||||
return {
|
||||
"enabled": self.get_state(),
|
||||
"provider_count": len(rows),
|
||||
"enabled_count": len(enabled_rows),
|
||||
"available_count": len(available_rows),
|
||||
"total_limit": sum(row["usage"]["token_limit"] for row in rows),
|
||||
"total_used": sum(row["usage"]["total_tokens"] for row in rows),
|
||||
}
|
||||
|
||||
def _select_provider(self) -> Optional[dict]:
|
||||
"""
|
||||
按优先级选择第一个启用且未耗尽 token 配额的供应商。
|
||||
"""
|
||||
usage = self._load_usage()
|
||||
for provider in getattr(self, "_providers", []):
|
||||
if not provider.get("enabled"):
|
||||
continue
|
||||
if not provider.get("api_key") or not provider.get("model") or not provider.get("base_url"):
|
||||
continue
|
||||
provider_usage = self._provider_usage(provider, usage)
|
||||
if provider_usage["exhausted"]:
|
||||
continue
|
||||
return provider
|
||||
return None
|
||||
|
||||
def get_status(self) -> schemas.Response:
|
||||
"""
|
||||
获取插件配置、供应商用量和概览统计。
|
||||
"""
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data={
|
||||
"config": self._current_config(),
|
||||
"providers": self._provider_status_rows(),
|
||||
"summary": self._summary(),
|
||||
},
|
||||
)
|
||||
|
||||
def save_config_api(self, config: dict = Body(...)) -> schemas.Response:
|
||||
"""
|
||||
保存前端提交的供应商配置。
|
||||
"""
|
||||
try:
|
||||
self._enabled = bool(config.get("enabled"))
|
||||
self._show_sidebar_nav = bool(config.get("show_sidebar_nav", True))
|
||||
self._providers = self._normalize_providers(config.get("providers") or [])
|
||||
self._save_config()
|
||||
return schemas.Response(success=True, data=self.get_status().data)
|
||||
except Exception as err:
|
||||
logger.error(f"保存 Agent Tokens 配置失败: {err}")
|
||||
return schemas.Response(success=False, message=str(err))
|
||||
|
||||
def reset_usage_api(self, payload: Optional[dict] = Body(default=None)) -> schemas.Response:
|
||||
"""
|
||||
重置指定供应商的已记录用量。
|
||||
"""
|
||||
payload = payload or {}
|
||||
provider_id = self._clean_text(payload.get("provider_id"))
|
||||
if not provider_id:
|
||||
return schemas.Response(success=False, message="缺少 provider_id")
|
||||
with self._usage_lock:
|
||||
usage = self._load_usage()
|
||||
usage.pop(provider_id, None)
|
||||
self._save_usage(usage)
|
||||
return schemas.Response(success=True, data=self.get_status().data)
|
||||
|
||||
def reset_all_usage_api(self) -> schemas.Response:
|
||||
"""
|
||||
重置所有供应商的已记录用量。
|
||||
"""
|
||||
with self._usage_lock:
|
||||
self._save_usage({})
|
||||
return schemas.Response(success=True, data=self.get_status().data)
|
||||
|
||||
@eventmanager.register(ChainEventType.AgentLLMProvider, priority=50)
|
||||
def select_llm_provider(self, event: Event):
|
||||
"""
|
||||
响应 Agent LLM 供应商链式事件,写入当前可用供应商配置。
|
||||
"""
|
||||
if not self.get_state() or not event or not event.event_data:
|
||||
return
|
||||
if self._event_get(event.event_data, "selected_provider_id"):
|
||||
return
|
||||
|
||||
provider = self._select_provider()
|
||||
if not provider:
|
||||
logger.info("Agent Tokens 没有可用供应商,Agent 将使用系统 LLM 配置")
|
||||
return
|
||||
|
||||
self._event_set(event.event_data, "provider", provider.get("provider") or "openai")
|
||||
self._event_set(event.event_data, "base_url", provider.get("base_url"))
|
||||
self._event_set(event.event_data, "api_key", provider.get("api_key"))
|
||||
self._event_set(event.event_data, "model", provider.get("model"))
|
||||
self._event_set(event.event_data, "base_url_preset", None)
|
||||
self._event_set(event.event_data, "selected_provider_id", provider.get("id"))
|
||||
self._event_set(event.event_data, "selected_provider_name", provider.get("name"))
|
||||
self._event_set(event.event_data, "source", self.__class__.__name__)
|
||||
|
||||
@eventmanager.register(EventType.AgentTokensUsage)
|
||||
def record_tokens_usage(self, event: Event):
|
||||
"""
|
||||
响应 Agent Tokens 用量广播事件,累计记录到对应供应商。
|
||||
"""
|
||||
if not self.get_state() or not event or not event.event_data:
|
||||
return
|
||||
|
||||
provider_id = self._clean_text(
|
||||
self._event_get(event.event_data, "selected_provider_id")
|
||||
)
|
||||
if not provider_id:
|
||||
return
|
||||
|
||||
input_tokens = max(self._to_int(self._event_get(event.event_data, "input_tokens"), 0), 0)
|
||||
output_tokens = max(self._to_int(self._event_get(event.event_data, "output_tokens"), 0), 0)
|
||||
total_tokens = max(self._to_int(self._event_get(event.event_data, "total_tokens"), 0), 0)
|
||||
if total_tokens <= 0:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
with self._usage_lock:
|
||||
usage = self._load_usage()
|
||||
record = usage.setdefault(provider_id, {})
|
||||
record["input_tokens"] = self._to_int(record.get("input_tokens"), 0) + input_tokens
|
||||
record["output_tokens"] = self._to_int(record.get("output_tokens"), 0) + output_tokens
|
||||
record["total_tokens"] = self._to_int(record.get("total_tokens"), 0) + total_tokens
|
||||
record["model_call_count"] = self._to_int(
|
||||
record.get("model_call_count"), 0
|
||||
) + max(self._to_int(self._event_get(event.event_data, "model_call_count"), 0), 0)
|
||||
record["runs"] = self._to_int(record.get("runs"), 0) + 1
|
||||
if bool(self._event_get(event.event_data, "success", False)):
|
||||
record["success_count"] = self._to_int(record.get("success_count"), 0) + 1
|
||||
record["last_error"] = None
|
||||
else:
|
||||
record["failure_count"] = self._to_int(record.get("failure_count"), 0) + 1
|
||||
record["last_error"] = self._clean_text(self._event_get(event.event_data, "error"))
|
||||
record["last_model"] = self._clean_text(self._event_get(event.event_data, "model"))
|
||||
record["last_used_at"] = (
|
||||
self._clean_text(self._event_get(event.event_data, "finished_at"))
|
||||
or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
usage[provider_id] = record
|
||||
self._save_usage(usage)
|
||||
|
||||
@eventmanager.register(EventType.PluginReload)
|
||||
def reload(self, event: Event):
|
||||
"""
|
||||
插件重载后重新注册动态 API。
|
||||
"""
|
||||
if event.event_data.get("plugin_id") == self.__class__.__name__:
|
||||
register_plugin_api(plugin_id=self.__class__.__name__)
|
||||
17
plugins.v2/agenttokens/dist/assets/__federation_expose_AppPage-BjNAU01R.css
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
.agenttokens-page[data-v-f613ca6c] {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.gap-2[data-v-f613ca6c] {
|
||||
gap: 8px;
|
||||
}
|
||||
.progress-cell[data-v-f613ca6c] {
|
||||
min-width: 140px;
|
||||
}
|
||||
.truncate-cell[data-v-f613ca6c] {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
756
plugins.v2/agenttokens/dist/assets/__federation_expose_AppPage-CpDG627M.js
vendored
Normal file
@@ -0,0 +1,756 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { _ as _export_sfc } from './_plugin-vue_export-helper-pcqpp-6-.js';
|
||||
|
||||
const {openBlock:_openBlock,createElementBlock:_createElementBlock,createCommentVNode:_createCommentVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,createTextVNode:_createTextVNode,withCtx:_withCtx,createElementVNode:_createElementVNode,toDisplayString:_toDisplayString,createBlock:_createBlock,renderList:_renderList,Fragment:_Fragment} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-page pa-4" };
|
||||
const _hoisted_2 = { class: "d-flex align-center gap-2 mb-4 flex-wrap" };
|
||||
const _hoisted_3 = {
|
||||
key: 0,
|
||||
class: "text-h5 font-weight-medium"
|
||||
};
|
||||
const _hoisted_4 = { class: "text-h5" };
|
||||
const _hoisted_5 = { class: "text-h5" };
|
||||
const _hoisted_6 = { class: "text-h5" };
|
||||
const _hoisted_7 = { class: "d-flex flex-column" };
|
||||
const _hoisted_8 = { class: "progress-cell" };
|
||||
const _hoisted_9 = { class: "text-right" };
|
||||
const _hoisted_10 = { key: 0 };
|
||||
const _hoisted_11 = { class: "d-flex justify-end mb-3 gap-2" };
|
||||
const _hoisted_12 = { class: "truncate-cell" };
|
||||
const _hoisted_13 = { class: "text-right" };
|
||||
const _hoisted_14 = { key: 0 };
|
||||
|
||||
const {computed,onMounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'AppPage',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'AgentTokens',
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(__props) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
// 构造 API 基础路径。
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`);
|
||||
const config = computed(() => status.value.config || { enabled: false, providers: [] });
|
||||
const providerRows = computed(() => status.value.providers || []);
|
||||
const summary = computed(() => status.value.summary || {});
|
||||
|
||||
const providerTypeOptions = [
|
||||
{ title: 'OpenAI Compatible', value: 'openai' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
{ title: 'Google Gemini', value: 'google' },
|
||||
{ title: 'Anthropic Compatible', value: 'anthropic' },
|
||||
{ title: 'ChatGPT', value: 'chatgpt' },
|
||||
];
|
||||
|
||||
// 构建一个新的供应商默认配置。
|
||||
function createProvider() {
|
||||
return {
|
||||
id: '',
|
||||
enabled: true,
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
token_limit: 0,
|
||||
used_tokens: 0,
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 格式化 token 数字,保持表格紧凑可读。
|
||||
function formatTokens(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 根据供应商状态返回 Vuetify 颜色。
|
||||
function rowStatusColor(row) {
|
||||
if (!row.enabled) return 'default'
|
||||
if (row.usage?.exhausted) return 'error'
|
||||
if (!row.api_key || !row.base_url || !row.model) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 根据供应商状态返回短标签。
|
||||
function rowStatusText(row) {
|
||||
if (!row.enabled) return '停用'
|
||||
if (row.usage?.exhausted) return '耗尽'
|
||||
if (!row.api_key || !row.base_url || !row.model) return '缺配置'
|
||||
return '可用'
|
||||
}
|
||||
|
||||
// 从插件 API 拉取当前配置和用量状态。
|
||||
async function loadStatus() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/status`);
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} catch (err) {
|
||||
error.value = err?.message || '加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存完整插件配置并刷新服务端标准化后的状态。
|
||||
async function saveConfig() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const payload = {
|
||||
enabled: Boolean(config.value.enabled),
|
||||
show_sidebar_nav: Boolean(config.value.show_sidebar_nav),
|
||||
providers: [...(config.value.providers || [])],
|
||||
};
|
||||
const response = await props.api.post(`${pluginBase.value}/config`, payload);
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} catch (err) {
|
||||
error.value = err?.message || '保存失败';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
const nextPriority = Math.max(0, ...(config.value.providers || []).map(item => Number(item.priority || 0))) + 1;
|
||||
editedProvider.value = { ...createProvider(), priority: nextPriority };
|
||||
editorIndex.value = -1;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...config.value.providers[index] };
|
||||
editorIndex.value = index;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回配置列表。
|
||||
function commitProvider() {
|
||||
const providers = [...(config.value.providers || [])];
|
||||
const normalized = {
|
||||
...editedProvider.value,
|
||||
token_limit: Number(editedProvider.value.token_limit || 0),
|
||||
used_tokens: Number(editedProvider.value.used_tokens || 0),
|
||||
priority: Number(editedProvider.value.priority || providers.length + 1),
|
||||
};
|
||||
if (editorIndex.value >= 0) {
|
||||
providers.splice(editorIndex.value, 1, normalized);
|
||||
} else {
|
||||
providers.push(normalized);
|
||||
}
|
||||
status.value.config = { ...config.value, providers };
|
||||
showEditor.value = false;
|
||||
}
|
||||
|
||||
// 从配置列表中移除一个供应商。
|
||||
function removeProvider(index) {
|
||||
const providers = [...(config.value.providers || [])];
|
||||
providers.splice(index, 1);
|
||||
status.value.config = { ...config.value, providers };
|
||||
}
|
||||
|
||||
// 重置指定供应商的运行记录。
|
||||
async function resetUsage(providerId) {
|
||||
if (!providerId) return
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset`, { provider_id: providerId });
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置全部供应商的运行记录。
|
||||
async function resetAllUsage() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset_all`, {});
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStatus);
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VSheet = _resolveComponent("VSheet");
|
||||
const _component_VCol = _resolveComponent("VCol");
|
||||
const _component_VSwitch = _resolveComponent("VSwitch");
|
||||
const _component_VRow = _resolveComponent("VRow");
|
||||
const _component_VTab = _resolveComponent("VTab");
|
||||
const _component_VTabs = _resolveComponent("VTabs");
|
||||
const _component_VProgressLinear = _resolveComponent("VProgressLinear");
|
||||
const _component_VChip = _resolveComponent("VChip");
|
||||
const _component_VTable = _resolveComponent("VTable");
|
||||
const _component_VWindowItem = _resolveComponent("VWindowItem");
|
||||
const _component_VWindow = _resolveComponent("VWindow");
|
||||
const _component_VCardTitle = _resolveComponent("VCardTitle");
|
||||
const _component_VTextField = _resolveComponent("VTextField");
|
||||
const _component_VSelect = _resolveComponent("VSelect");
|
||||
const _component_VCardText = _resolveComponent("VCardText");
|
||||
const _component_VCardActions = _resolveComponent("VCardActions");
|
||||
const _component_VCard = _resolveComponent("VCard");
|
||||
const _component_VDialog = _resolveComponent("VDialog");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createElementVNode("div", _hoisted_2, [
|
||||
(!__props.hideTitle)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_3, "Agent Tokens 管理"))
|
||||
: _createCommentVNode("", true),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-refresh",
|
||||
variant: "text",
|
||||
loading: _ctx.loading,
|
||||
onClick: loadStatus
|
||||
}, null, 8, ["loading"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-content-save",
|
||||
color: "primary",
|
||||
loading: _ctx.saving,
|
||||
onClick: saveConfig
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
|
||||
_createTextVNode("保存", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}, 8, ["loading"])
|
||||
]),
|
||||
(_ctx.error)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 0,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
class: "mb-4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(_ctx.error), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
_createVNode(_component_VRow, { class: "mb-2" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
sm: "6",
|
||||
md: "3"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "pa-4 h-100"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_cache[15] || (_cache[15] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
|
||||
_createElementVNode("div", _hoisted_4, _toDisplayString(summary.value.available_count || 0) + " / " + _toDisplayString(summary.value.enabled_count || 0), 1)
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
sm: "6",
|
||||
md: "3"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "pa-4 h-100"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_cache[16] || (_cache[16] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "累计使用", -1)),
|
||||
_createElementVNode("div", _hoisted_5, _toDisplayString(formatTokens(summary.value.total_used)), 1)
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
sm: "6",
|
||||
md: "3"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "pa-4 h-100"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_cache[17] || (_cache[17] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "总额度", -1)),
|
||||
_createElementVNode("div", _hoisted_6, _toDisplayString(formatTokens(summary.value.total_limit)), 1)
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
sm: "6",
|
||||
md: "3"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "pa-4 h-100 d-flex align-center"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_7, [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: _ctx.status.config.enabled,
|
||||
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((_ctx.status.config.enabled) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
label: "启用插件"
|
||||
}, null, 8, ["modelValue"]),
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: _ctx.status.config.show_sidebar_nav,
|
||||
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((_ctx.status.config.show_sidebar_nav) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
density: "compact",
|
||||
label: "侧边栏入口"
|
||||
}, null, 8, ["modelValue"])
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VTabs, {
|
||||
modelValue: _ctx.activeTab,
|
||||
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((_ctx.activeTab) = $event)),
|
||||
density: "comfortable",
|
||||
class: "mb-3"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTab, { value: "usage" }, {
|
||||
default: _withCtx(() => [...(_cache[18] || (_cache[18] = [
|
||||
_createTextVNode("用量", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VTab, { value: "config" }, {
|
||||
default: _withCtx(() => [...(_cache[19] || (_cache[19] = [
|
||||
_createTextVNode("配置", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"]),
|
||||
_createVNode(_component_VWindow, {
|
||||
modelValue: _ctx.activeTab,
|
||||
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((_ctx.activeTab) = $event))
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VWindowItem, { value: "usage" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: ""
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTable, { density: "comfortable" }, {
|
||||
default: _withCtx(() => [
|
||||
_cache[21] || (_cache[21] = _createElementVNode("thead", null, [
|
||||
_createElementVNode("tr", null, [
|
||||
_createElementVNode("th", null, "优先级"),
|
||||
_createElementVNode("th", null, "名称"),
|
||||
_createElementVNode("th", null, "模型"),
|
||||
_createElementVNode("th", null, "已用"),
|
||||
_createElementVNode("th", null, "余量"),
|
||||
_createElementVNode("th", null, "进度"),
|
||||
_createElementVNode("th", null, "状态"),
|
||||
_createElementVNode("th", { class: "text-right" }, "操作")
|
||||
])
|
||||
], -1)),
|
||||
_createElementVNode("tbody", null, [
|
||||
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(providerRows.value, (row) => {
|
||||
return (_openBlock(), _createElementBlock("tr", {
|
||||
key: row.id
|
||||
}, [
|
||||
_createElementVNode("td", null, _toDisplayString(row.priority), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.name), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.model), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(formatTokens(row.usage?.total_tokens)), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.usage?.remaining_tokens === null ? '不限' : formatTokens(row.usage?.remaining_tokens)), 1),
|
||||
_createElementVNode("td", _hoisted_8, [
|
||||
_createVNode(_component_VProgressLinear, {
|
||||
"model-value": row.usage?.usage_percent || 0,
|
||||
color: rowStatusColor(row),
|
||||
height: "8",
|
||||
rounded: ""
|
||||
}, null, 8, ["model-value", "color"])
|
||||
]),
|
||||
_createElementVNode("td", null, [
|
||||
_createVNode(_component_VChip, {
|
||||
size: "small",
|
||||
color: rowStatusColor(row),
|
||||
variant: "tonal"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(rowStatusText(row)), 1)
|
||||
]),
|
||||
_: 2
|
||||
}, 1032, ["color"])
|
||||
]),
|
||||
_createElementVNode("td", _hoisted_9, [
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-backup-restore",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
onClick: $event => (resetUsage(row.id))
|
||||
}, null, 8, ["onClick"])
|
||||
])
|
||||
]))
|
||||
}), 128)),
|
||||
(!providerRows.value.length)
|
||||
? (_openBlock(), _createElementBlock("tr", _hoisted_10, [...(_cache[20] || (_cache[20] = [
|
||||
_createElementVNode("td", {
|
||||
colspan: "8",
|
||||
class: "text-center text-medium-emphasis py-8"
|
||||
}, "暂无供应商", -1)
|
||||
]))]))
|
||||
: _createCommentVNode("", true)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VWindowItem, { value: "config" }, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_11, [
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-plus",
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
onClick: addProvider
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[22] || (_cache[22] = [
|
||||
_createTextVNode("新增", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-backup-restore",
|
||||
color: "warning",
|
||||
variant: "tonal",
|
||||
onClick: resetAllUsage
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[23] || (_cache[23] = [
|
||||
_createTextVNode("重置用量", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: ""
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTable, { density: "comfortable" }, {
|
||||
default: _withCtx(() => [
|
||||
_cache[25] || (_cache[25] = _createElementVNode("thead", null, [
|
||||
_createElementVNode("tr", null, [
|
||||
_createElementVNode("th", null, "启用"),
|
||||
_createElementVNode("th", null, "优先级"),
|
||||
_createElementVNode("th", null, "名称"),
|
||||
_createElementVNode("th", null, "类型"),
|
||||
_createElementVNode("th", null, "地址"),
|
||||
_createElementVNode("th", null, "Key"),
|
||||
_createElementVNode("th", null, "模型"),
|
||||
_createElementVNode("th", null, "额度"),
|
||||
_createElementVNode("th", { class: "text-right" }, "操作")
|
||||
])
|
||||
], -1)),
|
||||
_createElementVNode("tbody", null, [
|
||||
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(config.value.providers, (row, index) => {
|
||||
return (_openBlock(), _createElementBlock("tr", {
|
||||
key: row.id || index
|
||||
}, [
|
||||
_createElementVNode("td", null, [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: row.enabled,
|
||||
"onUpdate:modelValue": $event => ((row.enabled) = $event),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
density: "compact"
|
||||
}, null, 8, ["modelValue", "onUpdate:modelValue"])
|
||||
]),
|
||||
_createElementVNode("td", null, _toDisplayString(row.priority), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.name), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.provider), 1),
|
||||
_createElementVNode("td", _hoisted_12, _toDisplayString(row.base_url), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(providerRows.value[index]?.masked_api_key || '****'), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.model), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.token_limit > 0 ? formatTokens(row.token_limit) : '不限'), 1),
|
||||
_createElementVNode("td", _hoisted_13, [
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-pencil",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
onClick: $event => (editProvider(index))
|
||||
}, null, 8, ["onClick"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-delete",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
color: "error",
|
||||
onClick: $event => (removeProvider(index))
|
||||
}, null, 8, ["onClick"])
|
||||
])
|
||||
]))
|
||||
}), 128)),
|
||||
(!config.value.providers?.length)
|
||||
? (_openBlock(), _createElementBlock("tr", _hoisted_14, [...(_cache[24] || (_cache[24] = [
|
||||
_createElementVNode("td", {
|
||||
colspan: "9",
|
||||
class: "text-center text-medium-emphasis py-8"
|
||||
}, "暂无供应商", -1)
|
||||
]))]))
|
||||
: _createCommentVNode("", true)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"]),
|
||||
_createVNode(_component_VDialog, {
|
||||
modelValue: _ctx.showEditor,
|
||||
"onUpdate:modelValue": _cache[13] || (_cache[13] = $event => ((_ctx.showEditor) = $event)),
|
||||
"max-width": "760"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCard, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardTitle, null, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(_ctx.editorIndex >= 0 ? '编辑供应商' : '新增供应商'), 1)
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardText, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VRow, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "8"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: _ctx.editedProvider.name,
|
||||
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((_ctx.editedProvider.name) = $event)),
|
||||
label: "名称",
|
||||
variant: "outlined",
|
||||
density: "comfortable"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: _ctx.editedProvider.priority,
|
||||
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((_ctx.editedProvider.priority) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "优先级",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSelect, {
|
||||
modelValue: _ctx.editedProvider.provider,
|
||||
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((_ctx.editedProvider.provider) = $event)),
|
||||
items: providerTypeOptions,
|
||||
label: "类型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: _ctx.editedProvider.model,
|
||||
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((_ctx.editedProvider.model) = $event)),
|
||||
label: "模型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: _ctx.editedProvider.base_url,
|
||||
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((_ctx.editedProvider.base_url) = $event)),
|
||||
label: "API 地址",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: _ctx.editedProvider.api_key,
|
||||
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((_ctx.editedProvider.api_key) = $event)),
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: _ctx.editedProvider.token_limit,
|
||||
"onUpdate:modelValue": _cache[10] || (_cache[10] = $event => ((_ctx.editedProvider.token_limit) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "Token 额度",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: _ctx.editedProvider.used_tokens,
|
||||
"onUpdate:modelValue": _cache[11] || (_cache[11] = $event => ((_ctx.editedProvider.used_tokens) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "初始已用",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardActions, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
variant: "text",
|
||||
onClick: _cache[12] || (_cache[12] = $event => (_ctx.showEditor = false))
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[26] || (_cache[26] = [
|
||||
_createTextVNode("取消", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
color: "primary",
|
||||
onClick: commitProvider
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[27] || (_cache[27] = [
|
||||
_createTextVNode("确定", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const AppPage = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-f613ca6c"]]);
|
||||
|
||||
export { AppPage as default };
|
||||
4
plugins.v2/agenttokens/dist/assets/__federation_expose_Config-BRhIQuJm.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.gap-2[data-v-961b3d8f] {
|
||||
gap: 8px;
|
||||
}
|
||||
469
plugins.v2/agenttokens/dist/assets/__federation_expose_Config-FSeNNeNu.js
vendored
Normal file
@@ -0,0 +1,469 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { _ as _export_sfc } from './_plugin-vue_export-helper-pcqpp-6-.js';
|
||||
|
||||
const {createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,createVNode:_createVNode,createElementVNode:_createElementVNode,renderList:_renderList,Fragment:_Fragment,openBlock:_openBlock,createElementBlock:_createElementBlock,toDisplayString:_toDisplayString,createCommentVNode:_createCommentVNode} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-config" };
|
||||
const _hoisted_2 = { class: "pa-4" };
|
||||
const _hoisted_3 = { class: "d-flex align-center mb-4 gap-2 flex-wrap" };
|
||||
const _hoisted_4 = { class: "text-right" };
|
||||
const _hoisted_5 = { key: 0 };
|
||||
const _hoisted_6 = { class: "pa-4 d-flex justify-end" };
|
||||
|
||||
const {onMounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Config',
|
||||
props: {
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['save', 'close', 'switch'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] });
|
||||
const showEditor = ref(false);
|
||||
const editorIndex = ref(-1);
|
||||
const editedProvider = ref(createProvider());
|
||||
|
||||
const providerTypeOptions = [
|
||||
{ title: 'OpenAI Compatible', value: 'openai' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
{ title: 'Google Gemini', value: 'google' },
|
||||
{ title: 'Anthropic Compatible', value: 'anthropic' },
|
||||
{ title: 'ChatGPT', value: 'chatgpt' },
|
||||
];
|
||||
|
||||
// 构建一个新的供应商默认配置。
|
||||
function createProvider() {
|
||||
return {
|
||||
id: '',
|
||||
enabled: true,
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
token_limit: 0,
|
||||
used_tokens: 0,
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 生成深拷贝配置,避免直接修改父组件传入对象。
|
||||
function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config || { enabled: false, show_sidebar_nav: true, providers: [] }))
|
||||
}
|
||||
|
||||
// 格式化 token 数字。
|
||||
function formatTokens(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
const nextPriority = Math.max(0, ...(localConfig.value.providers || []).map(item => Number(item.priority || 0))) + 1;
|
||||
editedProvider.value = { ...createProvider(), priority: nextPriority };
|
||||
editorIndex.value = -1;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...localConfig.value.providers[index] };
|
||||
editorIndex.value = index;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回本地配置。
|
||||
function commitProvider() {
|
||||
const providers = [...(localConfig.value.providers || [])];
|
||||
const provider = {
|
||||
...editedProvider.value,
|
||||
token_limit: Number(editedProvider.value.token_limit || 0),
|
||||
used_tokens: Number(editedProvider.value.used_tokens || 0),
|
||||
priority: Number(editedProvider.value.priority || providers.length + 1),
|
||||
};
|
||||
if (editorIndex.value >= 0) {
|
||||
providers.splice(editorIndex.value, 1, provider);
|
||||
} else {
|
||||
providers.push(provider);
|
||||
}
|
||||
localConfig.value.providers = providers;
|
||||
showEditor.value = false;
|
||||
}
|
||||
|
||||
// 移除一个供应商配置。
|
||||
function removeProvider(index) {
|
||||
const providers = [...(localConfig.value.providers || [])];
|
||||
providers.splice(index, 1);
|
||||
localConfig.value.providers = providers;
|
||||
}
|
||||
|
||||
// 通知宿主保存 Vue 配置。
|
||||
function saveConfig() {
|
||||
emit('save', cloneConfig(localConfig.value));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localConfig.value = cloneConfig(props.initialConfig);
|
||||
if (localConfig.value.show_sidebar_nav === undefined) {
|
||||
localConfig.value.show_sidebar_nav = true;
|
||||
}
|
||||
if (!Array.isArray(localConfig.value.providers)) {
|
||||
localConfig.value.providers = [];
|
||||
}
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VToolbarTitle = _resolveComponent("VToolbarTitle");
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VToolbar = _resolveComponent("VToolbar");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
const _component_VSwitch = _resolveComponent("VSwitch");
|
||||
const _component_VTable = _resolveComponent("VTable");
|
||||
const _component_VSheet = _resolveComponent("VSheet");
|
||||
const _component_VCardTitle = _resolveComponent("VCardTitle");
|
||||
const _component_VTextField = _resolveComponent("VTextField");
|
||||
const _component_VCol = _resolveComponent("VCol");
|
||||
const _component_VSelect = _resolveComponent("VSelect");
|
||||
const _component_VRow = _resolveComponent("VRow");
|
||||
const _component_VCardText = _resolveComponent("VCardText");
|
||||
const _component_VCardActions = _resolveComponent("VCardActions");
|
||||
const _component_VCard = _resolveComponent("VCard");
|
||||
const _component_VDialog = _resolveComponent("VDialog");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createVNode(_component_VToolbar, {
|
||||
density: "comfortable",
|
||||
color: "transparent"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VToolbarTitle, null, {
|
||||
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
|
||||
_createTextVNode("Agent Tokens 配置", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-close",
|
||||
variant: "text",
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VDivider),
|
||||
_createElementVNode("div", _hoisted_2, [
|
||||
_createElementVNode("div", _hoisted_3, [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: localConfig.value.enabled,
|
||||
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((localConfig.value.enabled) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
label: "启用插件"
|
||||
}, null, 8, ["modelValue"]),
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: localConfig.value.show_sidebar_nav,
|
||||
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((localConfig.value.show_sidebar_nav) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
label: "显示侧边栏入口"
|
||||
}, null, 8, ["modelValue"]),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-database-eye",
|
||||
variant: "tonal",
|
||||
onClick: _cache[3] || (_cache[3] = $event => (emit('switch')))
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[15] || (_cache[15] = [
|
||||
_createTextVNode("用量", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-plus",
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
onClick: addProvider
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[16] || (_cache[16] = [
|
||||
_createTextVNode("新增", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: ""
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTable, { density: "comfortable" }, {
|
||||
default: _withCtx(() => [
|
||||
_cache[18] || (_cache[18] = _createElementVNode("thead", null, [
|
||||
_createElementVNode("tr", null, [
|
||||
_createElementVNode("th", null, "启用"),
|
||||
_createElementVNode("th", null, "优先级"),
|
||||
_createElementVNode("th", null, "名称"),
|
||||
_createElementVNode("th", null, "类型"),
|
||||
_createElementVNode("th", null, "模型"),
|
||||
_createElementVNode("th", null, "额度"),
|
||||
_createElementVNode("th", { class: "text-right" }, "操作")
|
||||
])
|
||||
], -1)),
|
||||
_createElementVNode("tbody", null, [
|
||||
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(localConfig.value.providers, (row, index) => {
|
||||
return (_openBlock(), _createElementBlock("tr", {
|
||||
key: row.id || index
|
||||
}, [
|
||||
_createElementVNode("td", null, [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: row.enabled,
|
||||
"onUpdate:modelValue": $event => ((row.enabled) = $event),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
density: "compact"
|
||||
}, null, 8, ["modelValue", "onUpdate:modelValue"])
|
||||
]),
|
||||
_createElementVNode("td", null, _toDisplayString(row.priority), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.name), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.provider), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.model), 1),
|
||||
_createElementVNode("td", null, _toDisplayString(row.token_limit > 0 ? formatTokens(row.token_limit) : '不限'), 1),
|
||||
_createElementVNode("td", _hoisted_4, [
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-pencil",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
onClick: $event => (editProvider(index))
|
||||
}, null, 8, ["onClick"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-delete",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
color: "error",
|
||||
onClick: $event => (removeProvider(index))
|
||||
}, null, 8, ["onClick"])
|
||||
])
|
||||
]))
|
||||
}), 128)),
|
||||
(!localConfig.value.providers.length)
|
||||
? (_openBlock(), _createElementBlock("tr", _hoisted_5, [...(_cache[17] || (_cache[17] = [
|
||||
_createElementVNode("td", {
|
||||
colspan: "7",
|
||||
class: "text-center text-medium-emphasis py-8"
|
||||
}, "暂无供应商", -1)
|
||||
]))]))
|
||||
: _createCommentVNode("", true)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(_component_VDivider),
|
||||
_createElementVNode("div", _hoisted_6, [
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-content-save",
|
||||
color: "primary",
|
||||
onClick: saveConfig
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[19] || (_cache[19] = [
|
||||
_createTextVNode("保存", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(_component_VDialog, {
|
||||
modelValue: showEditor.value,
|
||||
"onUpdate:modelValue": _cache[13] || (_cache[13] = $event => ((showEditor).value = $event)),
|
||||
"max-width": "760"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCard, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardTitle, null, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(editorIndex.value >= 0 ? '编辑供应商' : '新增供应商'), 1)
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardText, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VRow, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "8"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: editedProvider.value.name,
|
||||
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((editedProvider.value.name) = $event)),
|
||||
label: "名称",
|
||||
variant: "outlined",
|
||||
density: "comfortable"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: editedProvider.value.priority,
|
||||
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((editedProvider.value.priority) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "优先级",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSelect, {
|
||||
modelValue: editedProvider.value.provider,
|
||||
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((editedProvider.value.provider) = $event)),
|
||||
items: providerTypeOptions,
|
||||
label: "类型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: editedProvider.value.model,
|
||||
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((editedProvider.value.model) = $event)),
|
||||
label: "模型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: editedProvider.value.base_url,
|
||||
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((editedProvider.value.base_url) = $event)),
|
||||
label: "API 地址",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: editedProvider.value.api_key,
|
||||
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((editedProvider.value.api_key) = $event)),
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: editedProvider.value.token_limit,
|
||||
"onUpdate:modelValue": _cache[10] || (_cache[10] = $event => ((editedProvider.value.token_limit) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "Token 额度",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTextField, {
|
||||
modelValue: editedProvider.value.used_tokens,
|
||||
"onUpdate:modelValue": _cache[11] || (_cache[11] = $event => ((editedProvider.value.used_tokens) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "初始已用",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardActions, null, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
variant: "text",
|
||||
onClick: _cache[12] || (_cache[12] = $event => (showEditor.value = false))
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[20] || (_cache[20] = [
|
||||
_createTextVNode("取消", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
color: "primary",
|
||||
onClick: commitProvider
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[21] || (_cache[21] = [
|
||||
_createTextVNode("确定", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const Config = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-961b3d8f"]]);
|
||||
|
||||
export { Config as default };
|
||||
144
plugins.v2/agenttokens/dist/assets/__federation_expose_Dashboard-BPSul9jL.js
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode,toDisplayString:_toDisplayString,resolveComponent:_resolveComponent,createVNode:_createVNode,renderList:_renderList,Fragment:_Fragment,openBlock:_openBlock,createElementBlock:_createElementBlock,createTextVNode:_createTextVNode,withCtx:_withCtx,createBlock:_createBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-dashboard" };
|
||||
const _hoisted_2 = { class: "d-flex align-center mb-3" };
|
||||
const _hoisted_3 = { class: "text-h5" };
|
||||
const _hoisted_4 = { class: "text-caption text-medium-emphasis mb-3" };
|
||||
const _hoisted_5 = { class: "text-caption" };
|
||||
|
||||
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Dashboard',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(__props) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const loading = ref(false);
|
||||
const status = ref({ providers: [], summary: {} });
|
||||
let timer = null;
|
||||
|
||||
const summary = computed(() => status.value.summary || {});
|
||||
const providers = computed(() => status.value.providers || []);
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 格式化 token 数字。
|
||||
function formatTokens(value) {
|
||||
const numberValue = Number(value || 0);
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 读取仪表板所需的精简状态。
|
||||
async function loadStatus() {
|
||||
if (!props.allowRefresh) return
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await props.api.get('plugin/AgentTokens/status');
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus();
|
||||
timer = window.setInterval(loadStatus, 30000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VProgressLinear = _resolveComponent("VProgressLinear");
|
||||
const _component_VIcon = _resolveComponent("VIcon");
|
||||
const _component_VListItem = _resolveComponent("VListItem");
|
||||
const _component_VList = _resolveComponent("VList");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createElementVNode("div", _hoisted_2, [
|
||||
_createElementVNode("div", null, [
|
||||
_cache[0] || (_cache[0] = _createElementVNode("div", { class: "text-subtitle-2" }, "Agent Tokens 管理", -1)),
|
||||
_createElementVNode("div", _hoisted_3, _toDisplayString(summary.value.available_count || 0) + " / " + _toDisplayString(summary.value.enabled_count || 0), 1)
|
||||
]),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-refresh",
|
||||
variant: "text",
|
||||
size: "small",
|
||||
loading: loading.value,
|
||||
onClick: loadStatus
|
||||
}, null, 8, ["loading"])
|
||||
]),
|
||||
_createVNode(_component_VProgressLinear, {
|
||||
"model-value": summary.value.total_limit ? Math.min((summary.value.total_used || 0) * 100 / summary.value.total_limit, 100) : 0,
|
||||
color: "primary",
|
||||
height: "8",
|
||||
rounded: "",
|
||||
class: "mb-3"
|
||||
}, null, 8, ["model-value"]),
|
||||
_createElementVNode("div", _hoisted_4, _toDisplayString(formatTokens(summary.value.total_used)) + " / " + _toDisplayString(summary.value.total_limit ? formatTokens(summary.value.total_limit) : '不限'), 1),
|
||||
_createVNode(_component_VList, {
|
||||
density: "compact",
|
||||
class: "py-0"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(providers.value.slice(0, 4), (row) => {
|
||||
return (_openBlock(), _createBlock(_component_VListItem, {
|
||||
key: row.id,
|
||||
title: row.name,
|
||||
subtitle: row.model
|
||||
}, {
|
||||
prepend: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
color: row.usage?.exhausted ? 'error' : 'success',
|
||||
size: "small"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle'), 1)
|
||||
]),
|
||||
_: 2
|
||||
}, 1032, ["color"])
|
||||
]),
|
||||
append: _withCtx(() => [
|
||||
_createElementVNode("span", _hoisted_5, _toDisplayString(formatTokens(row.usage?.total_tokens)), 1)
|
||||
]),
|
||||
_: 2
|
||||
}, 1032, ["title", "subtitle"]))
|
||||
}), 128))
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
64
plugins.v2/agenttokens/dist/assets/__federation_expose_Page-4pktUk8J.js
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import AppPage from './__federation_expose_AppPage-CpDG627M.js';
|
||||
|
||||
const {createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,createVNode:_createVNode,openBlock:_openBlock,createElementBlock:_createElementBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-page-wrapper" };
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Page',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VToolbarTitle = _resolveComponent("VToolbarTitle");
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VToolbar = _resolveComponent("VToolbar");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createVNode(_component_VToolbar, {
|
||||
density: "comfortable",
|
||||
color: "transparent"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VToolbarTitle, null, {
|
||||
default: _withCtx(() => [...(_cache[1] || (_cache[1] = [
|
||||
_createTextVNode("Agent Tokens 数据", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-close",
|
||||
variant: "text",
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VDivider),
|
||||
_createVNode(AppPage, {
|
||||
api: __props.api,
|
||||
"plugin-id": "AgentTokens",
|
||||
"hide-title": ""
|
||||
}, null, 8, ["api"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
418
plugins.v2/agenttokens/dist/assets/__federation_fn_import-JrT3xvdd.js
vendored
Normal file
@@ -0,0 +1,418 @@
|
||||
const buildIdentifier = "[0-9A-Za-z-]+";
|
||||
const build = `(?:\\+(${buildIdentifier}(?:\\.${buildIdentifier})*))`;
|
||||
const numericIdentifier = "0|[1-9]\\d*";
|
||||
const numericIdentifierLoose = "[0-9]+";
|
||||
const nonNumericIdentifier = "\\d*[a-zA-Z-][a-zA-Z0-9-]*";
|
||||
const preReleaseIdentifierLoose = `(?:${numericIdentifierLoose}|${nonNumericIdentifier})`;
|
||||
const preReleaseLoose = `(?:-?(${preReleaseIdentifierLoose}(?:\\.${preReleaseIdentifierLoose})*))`;
|
||||
const preReleaseIdentifier = `(?:${numericIdentifier}|${nonNumericIdentifier})`;
|
||||
const preRelease = `(?:-(${preReleaseIdentifier}(?:\\.${preReleaseIdentifier})*))`;
|
||||
const xRangeIdentifier = `${numericIdentifier}|x|X|\\*`;
|
||||
const xRangePlain = `[v=\\s]*(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:\\.(${xRangeIdentifier})(?:${preRelease})?${build}?)?)?`;
|
||||
const hyphenRange = `^\\s*(${xRangePlain})\\s+-\\s+(${xRangePlain})\\s*$`;
|
||||
const mainVersionLoose = `(${numericIdentifierLoose})\\.(${numericIdentifierLoose})\\.(${numericIdentifierLoose})`;
|
||||
const loosePlain = `[v=\\s]*${mainVersionLoose}${preReleaseLoose}?${build}?`;
|
||||
const gtlt = "((?:<|>)?=?)";
|
||||
const comparatorTrim = `(\\s*)${gtlt}\\s*(${loosePlain}|${xRangePlain})`;
|
||||
const loneTilde = "(?:~>?)";
|
||||
const tildeTrim = `(\\s*)${loneTilde}\\s+`;
|
||||
const loneCaret = "(?:\\^)";
|
||||
const caretTrim = `(\\s*)${loneCaret}\\s+`;
|
||||
const star = "(<|>)?=?\\s*\\*";
|
||||
const caret = `^${loneCaret}${xRangePlain}$`;
|
||||
const mainVersion = `(${numericIdentifier})\\.(${numericIdentifier})\\.(${numericIdentifier})`;
|
||||
const fullPlain = `v?${mainVersion}${preRelease}?${build}?`;
|
||||
const tilde = `^${loneTilde}${xRangePlain}$`;
|
||||
const xRange = `^${gtlt}\\s*${xRangePlain}$`;
|
||||
const comparator = `^${gtlt}\\s*(${fullPlain})$|^$`;
|
||||
const gte0 = "^\\s*>=\\s*0.0.0\\s*$";
|
||||
function parseRegex(source) {
|
||||
return new RegExp(source);
|
||||
}
|
||||
function isXVersion(version) {
|
||||
return !version || version.toLowerCase() === "x" || version === "*";
|
||||
}
|
||||
function pipe(...fns) {
|
||||
return (x) => {
|
||||
return fns.reduce((v, f) => f(v), x);
|
||||
};
|
||||
}
|
||||
function extractComparator(comparatorString) {
|
||||
return comparatorString.match(parseRegex(comparator));
|
||||
}
|
||||
function combineVersion(major, minor, patch, preRelease2) {
|
||||
const mainVersion2 = `${major}.${minor}.${patch}`;
|
||||
if (preRelease2) {
|
||||
return `${mainVersion2}-${preRelease2}`;
|
||||
}
|
||||
return mainVersion2;
|
||||
}
|
||||
function parseHyphen(range) {
|
||||
return range.replace(
|
||||
parseRegex(hyphenRange),
|
||||
(_range, from, fromMajor, fromMinor, fromPatch, _fromPreRelease, _fromBuild, to, toMajor, toMinor, toPatch, toPreRelease) => {
|
||||
if (isXVersion(fromMajor)) {
|
||||
from = "";
|
||||
} else if (isXVersion(fromMinor)) {
|
||||
from = `>=${fromMajor}.0.0`;
|
||||
} else if (isXVersion(fromPatch)) {
|
||||
from = `>=${fromMajor}.${fromMinor}.0`;
|
||||
} else {
|
||||
from = `>=${from}`;
|
||||
}
|
||||
if (isXVersion(toMajor)) {
|
||||
to = "";
|
||||
} else if (isXVersion(toMinor)) {
|
||||
to = `<${+toMajor + 1}.0.0-0`;
|
||||
} else if (isXVersion(toPatch)) {
|
||||
to = `<${toMajor}.${+toMinor + 1}.0-0`;
|
||||
} else if (toPreRelease) {
|
||||
to = `<=${toMajor}.${toMinor}.${toPatch}-${toPreRelease}`;
|
||||
} else {
|
||||
to = `<=${to}`;
|
||||
}
|
||||
return `${from} ${to}`.trim();
|
||||
}
|
||||
);
|
||||
}
|
||||
function parseComparatorTrim(range) {
|
||||
return range.replace(parseRegex(comparatorTrim), "$1$2$3");
|
||||
}
|
||||
function parseTildeTrim(range) {
|
||||
return range.replace(parseRegex(tildeTrim), "$1~");
|
||||
}
|
||||
function parseCaretTrim(range) {
|
||||
return range.replace(parseRegex(caretTrim), "$1^");
|
||||
}
|
||||
function parseCarets(range) {
|
||||
return range.trim().split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.replace(
|
||||
parseRegex(caret),
|
||||
(_, major, minor, patch, preRelease2) => {
|
||||
if (isXVersion(major)) {
|
||||
return "";
|
||||
} else if (isXVersion(minor)) {
|
||||
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
|
||||
} else if (isXVersion(patch)) {
|
||||
if (major === "0") {
|
||||
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.0 <${+major + 1}.0.0-0`;
|
||||
}
|
||||
} else if (preRelease2) {
|
||||
if (major === "0") {
|
||||
if (minor === "0") {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${minor}.${+patch + 1}-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${+major + 1}.0.0-0`;
|
||||
}
|
||||
} else {
|
||||
if (major === "0") {
|
||||
if (minor === "0") {
|
||||
return `>=${major}.${minor}.${patch} <${major}.${minor}.${+patch + 1}-0`;
|
||||
} else {
|
||||
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
}
|
||||
return `>=${major}.${minor}.${patch} <${+major + 1}.0.0-0`;
|
||||
}
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseTildes(range) {
|
||||
return range.trim().split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.replace(
|
||||
parseRegex(tilde),
|
||||
(_, major, minor, patch, preRelease2) => {
|
||||
if (isXVersion(major)) {
|
||||
return "";
|
||||
} else if (isXVersion(minor)) {
|
||||
return `>=${major}.0.0 <${+major + 1}.0.0-0`;
|
||||
} else if (isXVersion(patch)) {
|
||||
return `>=${major}.${minor}.0 <${major}.${+minor + 1}.0-0`;
|
||||
} else if (preRelease2) {
|
||||
return `>=${major}.${minor}.${patch}-${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
return `>=${major}.${minor}.${patch} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseXRanges(range) {
|
||||
return range.split(/\s+/).map((rangeVersion) => {
|
||||
return rangeVersion.trim().replace(
|
||||
parseRegex(xRange),
|
||||
(ret, gtlt2, major, minor, patch, preRelease2) => {
|
||||
const isXMajor = isXVersion(major);
|
||||
const isXMinor = isXMajor || isXVersion(minor);
|
||||
const isXPatch = isXMinor || isXVersion(patch);
|
||||
if (gtlt2 === "=" && isXPatch) {
|
||||
gtlt2 = "";
|
||||
}
|
||||
preRelease2 = "";
|
||||
if (isXMajor) {
|
||||
if (gtlt2 === ">" || gtlt2 === "<") {
|
||||
return "<0.0.0-0";
|
||||
} else {
|
||||
return "*";
|
||||
}
|
||||
} else if (gtlt2 && isXPatch) {
|
||||
if (isXMinor) {
|
||||
minor = 0;
|
||||
}
|
||||
patch = 0;
|
||||
if (gtlt2 === ">") {
|
||||
gtlt2 = ">=";
|
||||
if (isXMinor) {
|
||||
major = +major + 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
} else {
|
||||
minor = +minor + 1;
|
||||
patch = 0;
|
||||
}
|
||||
} else if (gtlt2 === "<=") {
|
||||
gtlt2 = "<";
|
||||
if (isXMinor) {
|
||||
major = +major + 1;
|
||||
} else {
|
||||
minor = +minor + 1;
|
||||
}
|
||||
}
|
||||
if (gtlt2 === "<") {
|
||||
preRelease2 = "-0";
|
||||
}
|
||||
return `${gtlt2 + major}.${minor}.${patch}${preRelease2}`;
|
||||
} else if (isXMinor) {
|
||||
return `>=${major}.0.0${preRelease2} <${+major + 1}.0.0-0`;
|
||||
} else if (isXPatch) {
|
||||
return `>=${major}.${minor}.0${preRelease2} <${major}.${+minor + 1}.0-0`;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
);
|
||||
}).join(" ");
|
||||
}
|
||||
function parseStar(range) {
|
||||
return range.trim().replace(parseRegex(star), "");
|
||||
}
|
||||
function parseGTE0(comparatorString) {
|
||||
return comparatorString.trim().replace(parseRegex(gte0), "");
|
||||
}
|
||||
function compareAtom(rangeAtom, versionAtom) {
|
||||
rangeAtom = +rangeAtom || rangeAtom;
|
||||
versionAtom = +versionAtom || versionAtom;
|
||||
if (rangeAtom > versionAtom) {
|
||||
return 1;
|
||||
}
|
||||
if (rangeAtom === versionAtom) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function comparePreRelease(rangeAtom, versionAtom) {
|
||||
const { preRelease: rangePreRelease } = rangeAtom;
|
||||
const { preRelease: versionPreRelease } = versionAtom;
|
||||
if (rangePreRelease === void 0 && !!versionPreRelease) {
|
||||
return 1;
|
||||
}
|
||||
if (!!rangePreRelease && versionPreRelease === void 0) {
|
||||
return -1;
|
||||
}
|
||||
if (rangePreRelease === void 0 && versionPreRelease === void 0) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0, n = rangePreRelease.length; i <= n; i++) {
|
||||
const rangeElement = rangePreRelease[i];
|
||||
const versionElement = versionPreRelease[i];
|
||||
if (rangeElement === versionElement) {
|
||||
continue;
|
||||
}
|
||||
if (rangeElement === void 0 && versionElement === void 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!rangeElement) {
|
||||
return 1;
|
||||
}
|
||||
if (!versionElement) {
|
||||
return -1;
|
||||
}
|
||||
return compareAtom(rangeElement, versionElement);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function compareVersion(rangeAtom, versionAtom) {
|
||||
return compareAtom(rangeAtom.major, versionAtom.major) || compareAtom(rangeAtom.minor, versionAtom.minor) || compareAtom(rangeAtom.patch, versionAtom.patch) || comparePreRelease(rangeAtom, versionAtom);
|
||||
}
|
||||
function eq(rangeAtom, versionAtom) {
|
||||
return rangeAtom.version === versionAtom.version;
|
||||
}
|
||||
function compare(rangeAtom, versionAtom) {
|
||||
switch (rangeAtom.operator) {
|
||||
case "":
|
||||
case "=":
|
||||
return eq(rangeAtom, versionAtom);
|
||||
case ">":
|
||||
return compareVersion(rangeAtom, versionAtom) < 0;
|
||||
case ">=":
|
||||
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) < 0;
|
||||
case "<":
|
||||
return compareVersion(rangeAtom, versionAtom) > 0;
|
||||
case "<=":
|
||||
return eq(rangeAtom, versionAtom) || compareVersion(rangeAtom, versionAtom) > 0;
|
||||
case void 0: {
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function parseComparatorString(range) {
|
||||
return pipe(
|
||||
parseCarets,
|
||||
parseTildes,
|
||||
parseXRanges,
|
||||
parseStar
|
||||
)(range);
|
||||
}
|
||||
function parseRange(range) {
|
||||
return pipe(
|
||||
parseHyphen,
|
||||
parseComparatorTrim,
|
||||
parseTildeTrim,
|
||||
parseCaretTrim
|
||||
)(range.trim()).split(/\s+/).join(" ");
|
||||
}
|
||||
function satisfy(version, range) {
|
||||
if (!version) {
|
||||
return false;
|
||||
}
|
||||
const parsedRange = parseRange(range);
|
||||
const parsedComparator = parsedRange.split(" ").map((rangeVersion) => parseComparatorString(rangeVersion)).join(" ");
|
||||
const comparators = parsedComparator.split(/\s+/).map((comparator2) => parseGTE0(comparator2));
|
||||
const extractedVersion = extractComparator(version);
|
||||
if (!extractedVersion) {
|
||||
return false;
|
||||
}
|
||||
const [
|
||||
,
|
||||
versionOperator,
|
||||
,
|
||||
versionMajor,
|
||||
versionMinor,
|
||||
versionPatch,
|
||||
versionPreRelease
|
||||
] = extractedVersion;
|
||||
const versionAtom = {
|
||||
version: combineVersion(
|
||||
versionMajor,
|
||||
versionMinor,
|
||||
versionPatch,
|
||||
versionPreRelease
|
||||
),
|
||||
major: versionMajor,
|
||||
minor: versionMinor,
|
||||
patch: versionPatch,
|
||||
preRelease: versionPreRelease == null ? void 0 : versionPreRelease.split(".")
|
||||
};
|
||||
for (const comparator2 of comparators) {
|
||||
const extractedComparator = extractComparator(comparator2);
|
||||
if (!extractedComparator) {
|
||||
return false;
|
||||
}
|
||||
const [
|
||||
,
|
||||
rangeOperator,
|
||||
,
|
||||
rangeMajor,
|
||||
rangeMinor,
|
||||
rangePatch,
|
||||
rangePreRelease
|
||||
] = extractedComparator;
|
||||
const rangeAtom = {
|
||||
operator: rangeOperator,
|
||||
version: combineVersion(
|
||||
rangeMajor,
|
||||
rangeMinor,
|
||||
rangePatch,
|
||||
rangePreRelease
|
||||
),
|
||||
major: rangeMajor,
|
||||
minor: rangeMinor,
|
||||
patch: rangePatch,
|
||||
preRelease: rangePreRelease == null ? void 0 : rangePreRelease.split(".")
|
||||
};
|
||||
if (!compare(rangeAtom, versionAtom)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const moduleMap = {};
|
||||
const moduleCache = Object.create(null);
|
||||
async function importShared(name, shareScope = 'default') {
|
||||
return moduleCache[name]
|
||||
? new Promise((r) => r(moduleCache[name]))
|
||||
: (await getSharedFromRuntime(name, shareScope)) || getSharedFromLocal(name)
|
||||
}
|
||||
async function getSharedFromRuntime(name, shareScope) {
|
||||
let module = null;
|
||||
if (globalThis?.__federation_shared__?.[shareScope]?.[name]) {
|
||||
const versionObj = globalThis.__federation_shared__[shareScope][name];
|
||||
const requiredVersion = moduleMap[name]?.requiredVersion;
|
||||
const hasRequiredVersion = !!requiredVersion;
|
||||
if (hasRequiredVersion) {
|
||||
const versionKey = Object.keys(versionObj).find((version) =>
|
||||
satisfy(version, requiredVersion)
|
||||
);
|
||||
if (versionKey) {
|
||||
const versionValue = versionObj[versionKey];
|
||||
module = await (await versionValue.get())();
|
||||
} else {
|
||||
console.log(
|
||||
`provider support ${name}(${versionKey}) is not satisfied requiredVersion(\${moduleMap[name].requiredVersion})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const versionKey = Object.keys(versionObj)[0];
|
||||
const versionValue = versionObj[versionKey];
|
||||
module = await (await versionValue.get())();
|
||||
}
|
||||
}
|
||||
if (module) {
|
||||
return flattenModule(module, name)
|
||||
}
|
||||
}
|
||||
async function getSharedFromLocal(name) {
|
||||
if (moduleMap[name]?.import) {
|
||||
let module = await (await moduleMap[name].get())();
|
||||
return flattenModule(module, name)
|
||||
} else {
|
||||
console.error(
|
||||
`consumer config import=false,so cant use callback shared module`
|
||||
);
|
||||
}
|
||||
}
|
||||
function flattenModule(module, name) {
|
||||
// use a shared module which export default a function will getting error 'TypeError: xxx is not a function'
|
||||
if (typeof module.default === 'function') {
|
||||
Object.keys(module).forEach((key) => {
|
||||
if (key !== 'default') {
|
||||
module.default[key] = module[key];
|
||||
}
|
||||
});
|
||||
moduleCache[name] = module.default;
|
||||
return module.default
|
||||
}
|
||||
if (module.default) module = Object.assign({}, module.default, module);
|
||||
moduleCache[name] = module;
|
||||
return module
|
||||
}
|
||||
|
||||
export { importShared, getSharedFromLocal as importSharedLocal, getSharedFromRuntime as importSharedRuntime };
|
||||
17409
plugins.v2/agenttokens/dist/assets/__federation_shared_vuetify/styles-I7amCcZu.css
vendored
Normal file
44
plugins.v2/agenttokens/dist/assets/index-JEfKS0NU.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import AppPage from './__federation_expose_AppPage-CpDG627M.js';
|
||||
|
||||
true&&(function polyfill() {
|
||||
const relList = document.createElement("link").relList;
|
||||
if (relList && relList.supports && relList.supports("modulepreload")) {
|
||||
return;
|
||||
}
|
||||
for (const link of document.querySelectorAll('link[rel="modulepreload"]')) {
|
||||
processPreload(link);
|
||||
}
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type !== "childList") {
|
||||
continue;
|
||||
}
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.tagName === "LINK" && node.rel === "modulepreload")
|
||||
processPreload(node);
|
||||
}
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
function getFetchOpts(link) {
|
||||
const fetchOpts = {};
|
||||
if (link.integrity) fetchOpts.integrity = link.integrity;
|
||||
if (link.referrerPolicy) fetchOpts.referrerPolicy = link.referrerPolicy;
|
||||
if (link.crossOrigin === "use-credentials")
|
||||
fetchOpts.credentials = "include";
|
||||
else if (link.crossOrigin === "anonymous") fetchOpts.credentials = "omit";
|
||||
else fetchOpts.credentials = "same-origin";
|
||||
return fetchOpts;
|
||||
}
|
||||
function processPreload(link) {
|
||||
if (link.ep)
|
||||
return;
|
||||
link.ep = true;
|
||||
const fetchOpts = getFetchOpts(link);
|
||||
fetch(link.href, fetchOpts);
|
||||
}
|
||||
}());
|
||||
|
||||
const {createApp} = await importShared('vue');
|
||||
|
||||
createApp(AppPage).mount('#app');
|
||||
90
plugins.v2/agenttokens/dist/assets/remoteEntry.js
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
const currentImports = {};
|
||||
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
|
||||
let moduleMap = {
|
||||
"./Page":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_AppPage-BjNAU01R.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-4pktUk8J.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Config-BRhIQuJm.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-FSeNNeNu.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Dashboard":()=>{
|
||||
dynamicLoadingCss([], false, './Dashboard');
|
||||
return __federation_import('./__federation_expose_Dashboard-BPSul9jL.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./AppPage":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_AppPage-BjNAU01R.css"], false, './AppPage');
|
||||
return __federation_import('./__federation_expose_AppPage-CpDG627M.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
|
||||
const seen = {};
|
||||
const dynamicLoadingCss = (cssFilePaths, dontAppendStylesToHead, exposeItemName) => {
|
||||
const metaUrl = import.meta.url;
|
||||
if (typeof metaUrl === 'undefined') {
|
||||
console.warn('The remote style takes effect only when the build.target option in the vite.config.ts file is higher than that of "es2020".');
|
||||
return;
|
||||
}
|
||||
|
||||
const curUrl = metaUrl.substring(0, metaUrl.lastIndexOf('remoteEntry.js'));
|
||||
const base = '/';
|
||||
'assets';
|
||||
|
||||
cssFilePaths.forEach(cssPath => {
|
||||
let href = '';
|
||||
const baseUrl = base || curUrl;
|
||||
if (baseUrl) {
|
||||
const trimmer = {
|
||||
trailing: (path) => (path.endsWith('/') ? path.slice(0, -1) : path),
|
||||
leading: (path) => (path.startsWith('/') ? path.slice(1) : path)
|
||||
};
|
||||
const isAbsoluteUrl = (url) => url.startsWith('http') || url.startsWith('//');
|
||||
|
||||
const cleanBaseUrl = trimmer.trailing(baseUrl);
|
||||
const cleanCssPath = trimmer.leading(cssPath);
|
||||
const cleanCurUrl = trimmer.trailing(curUrl);
|
||||
|
||||
if (isAbsoluteUrl(baseUrl)) {
|
||||
href = [cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
} else {
|
||||
if (cleanCurUrl.includes(cleanBaseUrl)) {
|
||||
href = [cleanCurUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
} else {
|
||||
href = [cleanCurUrl + cleanBaseUrl, cleanCssPath].filter(Boolean).join('/');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
href = cssPath;
|
||||
}
|
||||
|
||||
if (dontAppendStylesToHead) {
|
||||
const key = 'css__AgentTokens__' + exposeItemName;
|
||||
window[key] = window[key] || [];
|
||||
window[key].push(href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (href in seen) return;
|
||||
seen[href] = true;
|
||||
|
||||
const element = document.createElement('link');
|
||||
element.rel = 'stylesheet';
|
||||
element.href = href;
|
||||
document.head.appendChild(element);
|
||||
});
|
||||
};
|
||||
async function __federation_import(name) {
|
||||
currentImports[name] ??= import(name);
|
||||
return currentImports[name]
|
||||
} const get =(module) => {
|
||||
if(!moduleMap[module]) throw new Error('Can not find remote module ' + module)
|
||||
return moduleMap[module]();
|
||||
};
|
||||
const init =(shareScope) => {
|
||||
globalThis.__federation_shared__= globalThis.__federation_shared__|| {};
|
||||
Object.entries(shareScope).forEach(([key, value]) => {
|
||||
for (const [versionKey, versionValue] of Object.entries(value)) {
|
||||
const scope = versionValue.scope || 'default';
|
||||
globalThis.__federation_shared__[scope] = globalThis.__federation_shared__[scope] || {};
|
||||
const shared= globalThis.__federation_shared__[scope];
|
||||
(shared[key] = shared[key]||{})[versionKey] = versionValue;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { dynamicLoadingCss, get, init };
|
||||
6
plugins.v2/agenttokens/dist/index.html
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<script type="module" crossorigin src="/assets/index-JEfKS0NU.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-pcqpp-6-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-CpDG627M.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/__federation_expose_AppPage-BjNAU01R.css">
|
||||
<div id="app"></div>
|
||||
2
plugins.v2/agenttokens/index.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
18
plugins.v2/agenttokens/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "moviepilot-agenttokens-plugin",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vuetify": "3.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@originjs/vite-plugin-federation": "^1.4.1",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
390
plugins.v2/agenttokens/src/components/AppPage.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'AgentTokens',
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 构造 API 基础路径。
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`)
|
||||
const config = computed(() => status.value.config || { enabled: false, providers: [] })
|
||||
const providerRows = computed(() => status.value.providers || [])
|
||||
const summary = computed(() => status.value.summary || {})
|
||||
|
||||
const providerTypeOptions = [
|
||||
{ title: 'OpenAI Compatible', value: 'openai' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
{ title: 'Google Gemini', value: 'google' },
|
||||
{ title: 'Anthropic Compatible', value: 'anthropic' },
|
||||
{ title: 'ChatGPT', value: 'chatgpt' },
|
||||
]
|
||||
|
||||
// 构建一个新的供应商默认配置。
|
||||
function createProvider() {
|
||||
return {
|
||||
id: '',
|
||||
enabled: true,
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
token_limit: 0,
|
||||
used_tokens: 0,
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 格式化 token 数字,保持表格紧凑可读。
|
||||
function formatTokens(value) {
|
||||
const numberValue = Number(value || 0)
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 根据供应商状态返回 Vuetify 颜色。
|
||||
function rowStatusColor(row) {
|
||||
if (!row.enabled) return 'default'
|
||||
if (row.usage?.exhausted) return 'error'
|
||||
if (!row.api_key || !row.base_url || !row.model) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 根据供应商状态返回短标签。
|
||||
function rowStatusText(row) {
|
||||
if (!row.enabled) return '停用'
|
||||
if (row.usage?.exhausted) return '耗尽'
|
||||
if (!row.api_key || !row.base_url || !row.model) return '缺配置'
|
||||
return '可用'
|
||||
}
|
||||
|
||||
// 从插件 API 拉取当前配置和用量状态。
|
||||
async function loadStatus() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/status`)
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} catch (err) {
|
||||
error.value = err?.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存完整插件配置并刷新服务端标准化后的状态。
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
enabled: Boolean(config.value.enabled),
|
||||
show_sidebar_nav: Boolean(config.value.show_sidebar_nav),
|
||||
providers: [...(config.value.providers || [])],
|
||||
}
|
||||
const response = await props.api.post(`${pluginBase.value}/config`, payload)
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} catch (err) {
|
||||
error.value = err?.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
const nextPriority = Math.max(0, ...(config.value.providers || []).map(item => Number(item.priority || 0))) + 1
|
||||
editedProvider.value = { ...createProvider(), priority: nextPriority }
|
||||
editorIndex.value = -1
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...config.value.providers[index] }
|
||||
editorIndex.value = index
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回配置列表。
|
||||
function commitProvider() {
|
||||
const providers = [...(config.value.providers || [])]
|
||||
const normalized = {
|
||||
...editedProvider.value,
|
||||
token_limit: Number(editedProvider.value.token_limit || 0),
|
||||
used_tokens: Number(editedProvider.value.used_tokens || 0),
|
||||
priority: Number(editedProvider.value.priority || providers.length + 1),
|
||||
}
|
||||
if (editorIndex.value >= 0) {
|
||||
providers.splice(editorIndex.value, 1, normalized)
|
||||
} else {
|
||||
providers.push(normalized)
|
||||
}
|
||||
status.value.config = { ...config.value, providers }
|
||||
showEditor.value = false
|
||||
}
|
||||
|
||||
// 从配置列表中移除一个供应商。
|
||||
function removeProvider(index) {
|
||||
const providers = [...(config.value.providers || [])]
|
||||
providers.splice(index, 1)
|
||||
status.value.config = { ...config.value, providers }
|
||||
}
|
||||
|
||||
// 重置指定供应商的运行记录。
|
||||
async function resetUsage(providerId) {
|
||||
if (!providerId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset`, { provider_id: providerId })
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置全部供应商的运行记录。
|
||||
async function resetAllUsage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await props.api.post(`${pluginBase.value}/usage/reset_all`, {})
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStatus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-page pa-4">
|
||||
<div class="d-flex align-center gap-2 mb-4 flex-wrap">
|
||||
<div v-if="!hideTitle" class="text-h5 font-weight-medium">Agent Tokens 管理</div>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-refresh" variant="text" :loading="loading" @click="loadStatus" />
|
||||
<VBtn prepend-icon="mdi-content-save" color="primary" :loading="saving" @click="saveConfig">保存</VBtn>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="error" type="error" variant="tonal" class="mb-4">{{ error }}</VAlert>
|
||||
|
||||
<VRow class="mb-2">
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSheet border rounded class="pa-4 h-100">
|
||||
<div class="text-caption text-medium-emphasis">可用供应商</div>
|
||||
<div class="text-h5">{{ summary.available_count || 0 }} / {{ summary.enabled_count || 0 }}</div>
|
||||
</VSheet>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSheet border rounded class="pa-4 h-100">
|
||||
<div class="text-caption text-medium-emphasis">累计使用</div>
|
||||
<div class="text-h5">{{ formatTokens(summary.total_used) }}</div>
|
||||
</VSheet>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSheet border rounded class="pa-4 h-100">
|
||||
<div class="text-caption text-medium-emphasis">总额度</div>
|
||||
<div class="text-h5">{{ formatTokens(summary.total_limit) }}</div>
|
||||
</VSheet>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSheet border rounded class="pa-4 h-100 d-flex align-center">
|
||||
<div class="d-flex flex-column">
|
||||
<VSwitch v-model="status.config.enabled" color="primary" hide-details inset label="启用插件" />
|
||||
<VSwitch
|
||||
v-model="status.config.show_sidebar_nav"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
density="compact"
|
||||
label="侧边栏入口"
|
||||
/>
|
||||
</div>
|
||||
</VSheet>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<VTabs v-model="activeTab" density="comfortable" class="mb-3">
|
||||
<VTab value="usage">用量</VTab>
|
||||
<VTab value="config">配置</VTab>
|
||||
</VTabs>
|
||||
|
||||
<VWindow v-model="activeTab">
|
||||
<VWindowItem value="usage">
|
||||
<VSheet border rounded>
|
||||
<VTable density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>优先级</th>
|
||||
<th>名称</th>
|
||||
<th>模型</th>
|
||||
<th>已用</th>
|
||||
<th>余量</th>
|
||||
<th>进度</th>
|
||||
<th>状态</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in providerRows" :key="row.id">
|
||||
<td>{{ row.priority }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.model }}</td>
|
||||
<td>{{ formatTokens(row.usage?.total_tokens) }}</td>
|
||||
<td>
|
||||
{{ row.usage?.remaining_tokens === null ? '不限' : formatTokens(row.usage?.remaining_tokens) }}
|
||||
</td>
|
||||
<td class="progress-cell">
|
||||
<VProgressLinear
|
||||
:model-value="row.usage?.usage_percent || 0"
|
||||
:color="rowStatusColor(row)"
|
||||
height="8"
|
||||
rounded
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<VChip size="small" :color="rowStatusColor(row)" variant="tonal">{{ rowStatusText(row) }}</VChip>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<VBtn icon="mdi-backup-restore" size="small" variant="text" @click="resetUsage(row.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!providerRows.length">
|
||||
<td colspan="8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VSheet>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="config">
|
||||
<div class="d-flex justify-end mb-3 gap-2">
|
||||
<VBtn prepend-icon="mdi-plus" color="primary" variant="tonal" @click="addProvider">新增</VBtn>
|
||||
<VBtn prepend-icon="mdi-backup-restore" color="warning" variant="tonal" @click="resetAllUsage">重置用量</VBtn>
|
||||
</div>
|
||||
<VSheet border rounded>
|
||||
<VTable density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>启用</th>
|
||||
<th>优先级</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>地址</th>
|
||||
<th>Key</th>
|
||||
<th>模型</th>
|
||||
<th>额度</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in config.providers" :key="row.id || index">
|
||||
<td>
|
||||
<VSwitch v-model="row.enabled" color="primary" hide-details density="compact" />
|
||||
</td>
|
||||
<td>{{ row.priority }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.provider }}</td>
|
||||
<td class="truncate-cell">{{ row.base_url }}</td>
|
||||
<td>{{ providerRows[index]?.masked_api_key || '****' }}</td>
|
||||
<td>{{ row.model }}</td>
|
||||
<td>{{ row.token_limit > 0 ? formatTokens(row.token_limit) : '不限' }}</td>
|
||||
<td class="text-right">
|
||||
<VBtn icon="mdi-pencil" size="small" variant="text" @click="editProvider(index)" />
|
||||
<VBtn icon="mdi-delete" size="small" variant="text" color="error" @click="removeProvider(index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!config.providers?.length">
|
||||
<td colspan="9" class="text-center text-medium-emphasis py-8">暂无供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VSheet>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
<VDialog v-model="showEditor" max-width="760">
|
||||
<VCard>
|
||||
<VCardTitle>{{ editorIndex >= 0 ? '编辑供应商' : '新增供应商' }}</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField v-model="editedProvider.name" label="名称" variant="outlined" density="comfortable" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField v-model.number="editedProvider.priority" label="优先级" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="editedProvider.provider"
|
||||
:items="providerTypeOptions"
|
||||
label="类型"
|
||||
variant="outlined"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="editedProvider.model" label="模型" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="editedProvider.base_url" label="API 地址" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="editedProvider.api_key" label="API Key" type="password" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="editedProvider.token_limit" label="Token 额度" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="editedProvider.used_tokens" label="初始已用" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="showEditor = false">取消</VBtn>
|
||||
<VBtn color="primary" @click="commitProvider">确定</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agenttokens-page {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-cell {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.truncate-cell {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
213
plugins.v2/agenttokens/src/components/Config.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'close', 'switch'])
|
||||
|
||||
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] })
|
||||
const showEditor = ref(false)
|
||||
const editorIndex = ref(-1)
|
||||
const editedProvider = ref(createProvider())
|
||||
|
||||
const providerTypeOptions = [
|
||||
{ title: 'OpenAI Compatible', value: 'openai' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
{ title: 'Google Gemini', value: 'google' },
|
||||
{ title: 'Anthropic Compatible', value: 'anthropic' },
|
||||
{ title: 'ChatGPT', value: 'chatgpt' },
|
||||
]
|
||||
|
||||
// 构建一个新的供应商默认配置。
|
||||
function createProvider() {
|
||||
return {
|
||||
id: '',
|
||||
enabled: true,
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
token_limit: 0,
|
||||
used_tokens: 0,
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 生成深拷贝配置,避免直接修改父组件传入对象。
|
||||
function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config || { enabled: false, show_sidebar_nav: true, providers: [] }))
|
||||
}
|
||||
|
||||
// 格式化 token 数字。
|
||||
function formatTokens(value) {
|
||||
const numberValue = Number(value || 0)
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
const nextPriority = Math.max(0, ...(localConfig.value.providers || []).map(item => Number(item.priority || 0))) + 1
|
||||
editedProvider.value = { ...createProvider(), priority: nextPriority }
|
||||
editorIndex.value = -1
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...localConfig.value.providers[index] }
|
||||
editorIndex.value = index
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回本地配置。
|
||||
function commitProvider() {
|
||||
const providers = [...(localConfig.value.providers || [])]
|
||||
const provider = {
|
||||
...editedProvider.value,
|
||||
token_limit: Number(editedProvider.value.token_limit || 0),
|
||||
used_tokens: Number(editedProvider.value.used_tokens || 0),
|
||||
priority: Number(editedProvider.value.priority || providers.length + 1),
|
||||
}
|
||||
if (editorIndex.value >= 0) {
|
||||
providers.splice(editorIndex.value, 1, provider)
|
||||
} else {
|
||||
providers.push(provider)
|
||||
}
|
||||
localConfig.value.providers = providers
|
||||
showEditor.value = false
|
||||
}
|
||||
|
||||
// 移除一个供应商配置。
|
||||
function removeProvider(index) {
|
||||
const providers = [...(localConfig.value.providers || [])]
|
||||
providers.splice(index, 1)
|
||||
localConfig.value.providers = providers
|
||||
}
|
||||
|
||||
// 通知宿主保存 Vue 配置。
|
||||
function saveConfig() {
|
||||
emit('save', cloneConfig(localConfig.value))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localConfig.value = cloneConfig(props.initialConfig)
|
||||
if (localConfig.value.show_sidebar_nav === undefined) {
|
||||
localConfig.value.show_sidebar_nav = true
|
||||
}
|
||||
if (!Array.isArray(localConfig.value.providers)) {
|
||||
localConfig.value.providers = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-config">
|
||||
<VToolbar density="comfortable" color="transparent">
|
||||
<VToolbarTitle>Agent Tokens 配置</VToolbarTitle>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
|
||||
</VToolbar>
|
||||
<VDivider />
|
||||
|
||||
<div class="pa-4">
|
||||
<div class="d-flex align-center mb-4 gap-2 flex-wrap">
|
||||
<VSwitch v-model="localConfig.enabled" color="primary" hide-details inset label="启用插件" />
|
||||
<VSwitch v-model="localConfig.show_sidebar_nav" color="primary" hide-details inset label="显示侧边栏入口" />
|
||||
<VSpacer />
|
||||
<VBtn prepend-icon="mdi-database-eye" variant="tonal" @click="emit('switch')">用量</VBtn>
|
||||
<VBtn prepend-icon="mdi-plus" color="primary" variant="tonal" @click="addProvider">新增</VBtn>
|
||||
</div>
|
||||
|
||||
<VSheet border rounded>
|
||||
<VTable density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>启用</th>
|
||||
<th>优先级</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>模型</th>
|
||||
<th>额度</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in localConfig.providers" :key="row.id || index">
|
||||
<td>
|
||||
<VSwitch v-model="row.enabled" color="primary" hide-details density="compact" />
|
||||
</td>
|
||||
<td>{{ row.priority }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.provider }}</td>
|
||||
<td>{{ row.model }}</td>
|
||||
<td>{{ row.token_limit > 0 ? formatTokens(row.token_limit) : '不限' }}</td>
|
||||
<td class="text-right">
|
||||
<VBtn icon="mdi-pencil" size="small" variant="text" @click="editProvider(index)" />
|
||||
<VBtn icon="mdi-delete" size="small" variant="text" color="error" @click="removeProvider(index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!localConfig.providers.length">
|
||||
<td colspan="7" class="text-center text-medium-emphasis py-8">暂无供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VSheet>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
<div class="pa-4 d-flex justify-end">
|
||||
<VBtn prepend-icon="mdi-content-save" color="primary" @click="saveConfig">保存</VBtn>
|
||||
</div>
|
||||
|
||||
<VDialog v-model="showEditor" max-width="760">
|
||||
<VCard>
|
||||
<VCardTitle>{{ editorIndex >= 0 ? '编辑供应商' : '新增供应商' }}</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField v-model="editedProvider.name" label="名称" variant="outlined" density="comfortable" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField v-model.number="editedProvider.priority" label="优先级" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect v-model="editedProvider.provider" :items="providerTypeOptions" label="类型" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="editedProvider.model" label="模型" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="editedProvider.base_url" label="API 地址" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="editedProvider.api_key" label="API Key" type="password" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="editedProvider.token_limit" label="Token 额度" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="editedProvider.used_tokens" label="初始已用" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="showEditor = false">取消</VBtn>
|
||||
<VBtn color="primary" @click="commitProvider">确定</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
96
plugins.v2/agenttokens/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const status = ref({ providers: [], summary: {} })
|
||||
let timer = null
|
||||
|
||||
const summary = computed(() => status.value.summary || {})
|
||||
const providers = computed(() => status.value.providers || [])
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 格式化 token 数字。
|
||||
function formatTokens(value) {
|
||||
const numberValue = Number(value || 0)
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 读取仪表板所需的精简状态。
|
||||
async function loadStatus() {
|
||||
if (!props.allowRefresh) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await props.api.get('plugin/AgentTokens/status')
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
timer = window.setInterval(loadStatus, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-dashboard">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<div>
|
||||
<div class="text-subtitle-2">Agent Tokens 管理</div>
|
||||
<div class="text-h5">{{ summary.available_count || 0 }} / {{ summary.enabled_count || 0 }}</div>
|
||||
</div>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-refresh" variant="text" size="small" :loading="loading" @click="loadStatus" />
|
||||
</div>
|
||||
|
||||
<VProgressLinear
|
||||
:model-value="summary.total_limit ? Math.min((summary.total_used || 0) * 100 / summary.total_limit, 100) : 0"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<div class="text-caption text-medium-emphasis mb-3">
|
||||
{{ formatTokens(summary.total_used) }} / {{ summary.total_limit ? formatTokens(summary.total_limit) : '不限' }}
|
||||
</div>
|
||||
|
||||
<VList density="compact" class="py-0">
|
||||
<VListItem v-for="row in providers.slice(0, 4)" :key="row.id" :title="row.name" :subtitle="row.model">
|
||||
<template #prepend>
|
||||
<VIcon :color="row.usage?.exhausted ? 'error' : 'success'" size="small">
|
||||
{{ row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle' }}
|
||||
</VIcon>
|
||||
</template>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ formatTokens(row.usage?.total_tokens) }}</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</div>
|
||||
</template>
|
||||
24
plugins.v2/agenttokens/src/components/Page.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import AppPage from './AppPage.vue'
|
||||
|
||||
defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-page-wrapper">
|
||||
<VToolbar density="comfortable" color="transparent">
|
||||
<VToolbarTitle>Agent Tokens 数据</VToolbarTitle>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
|
||||
</VToolbar>
|
||||
<VDivider />
|
||||
|
||||
<AppPage :api="api" plugin-id="AgentTokens" hide-title />
|
||||
</div>
|
||||
</template>
|
||||
4
plugins.v2/agenttokens/src/main.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import AppPage from './components/AppPage.vue'
|
||||
|
||||
createApp(AppPage).mount('#app')
|
||||
67
plugins.v2/agenttokens/vite.config.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import federation from '@originjs/vite-plugin-federation'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
federation({
|
||||
name: 'AgentTokens',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
requiredVersion: false,
|
||||
generate: false,
|
||||
},
|
||||
vuetify: {
|
||||
requiredVersion: false,
|
||||
generate: false,
|
||||
singleton: true,
|
||||
},
|
||||
'vuetify/styles': {
|
||||
requiredVersion: false,
|
||||
generate: false,
|
||||
singleton: true,
|
||||
},
|
||||
},
|
||||
format: 'esm',
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
{
|
||||
postcssPlugin: 'internal:charset-removal',
|
||||
AtRule: {
|
||||
charset: atRule => {
|
||||
if (atRule.name === 'charset') {
|
||||
atRule.remove()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
postcssPlugin: 'vuetify-filter',
|
||||
Root(root) {
|
||||
root.walkRules(rule => {
|
||||
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
|
||||
rule.remove()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
83
plugins.v2/airecognizerenhancer/ARCHITECTURE.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# AI识别增强架构草案
|
||||
|
||||
`AI识别增强` 用来承接 MoviePilot 原生识别失败后的本地 AI 兜底链路。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- 摆脱外部 AI Gateway 的强依赖
|
||||
- 直接使用 MoviePilot 已启用的 LLM 配置
|
||||
- 输出结构化识别结果,而不是只回传一段自由文本
|
||||
|
||||
## 模块分层
|
||||
|
||||
### 1. hooks
|
||||
|
||||
负责接住识别失败事件和后续整理事件。
|
||||
|
||||
### 2. llm
|
||||
|
||||
负责封装对 MP 当前 LLM 的调用:
|
||||
|
||||
- 标准提示词
|
||||
- 结构化返回约束
|
||||
- 超时与错误兜底
|
||||
|
||||
### 3. normalize
|
||||
|
||||
负责把 AI 输出转换成可继续进入 MP 整理链路的数据:
|
||||
|
||||
- 标题
|
||||
- 年份
|
||||
- 类型
|
||||
- 季
|
||||
- 集
|
||||
- 置信度
|
||||
|
||||
### 4. actions
|
||||
|
||||
负责根据结果执行后续动作:
|
||||
|
||||
- 二次识别
|
||||
- 二次整理
|
||||
- 记录失败样本
|
||||
|
||||
## 首期配置模型
|
||||
|
||||
- `enabled`
|
||||
- `notify`
|
||||
- `debug`
|
||||
- `confidence_threshold`
|
||||
- `request_timeout`
|
||||
- `max_retries`
|
||||
- `save_failed_samples`
|
||||
|
||||
## 二期规划
|
||||
|
||||
- 生成自定义识别词建议
|
||||
- 失败样本聚合分析
|
||||
- 提供给 MP Agent / Skill 直接调起
|
||||
|
||||
## 首个里程碑
|
||||
|
||||
第一个可用版本只追求:
|
||||
|
||||
1. 原生识别失败后自动触发本地 LLM 判断
|
||||
2. 拿到结构化结果后自动二次整理
|
||||
3. 能明确记录“成功 / 放弃 / 失败原因”
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
- 已接住 `ChainEventType.NameRecognize`
|
||||
- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出
|
||||
- 已提供手动调试接口用于验证标题识别结果
|
||||
- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议
|
||||
- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers`
|
||||
- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制
|
||||
- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本
|
||||
- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除
|
||||
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
|
||||
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
|
||||
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
|
||||
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
|
||||
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
|
||||
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升
|
||||
101
plugins.v2/airecognizerenhancer/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# AI识别增强
|
||||
|
||||
`AI识别增强` 用来补强 MoviePilot 原生整理链里的识别阶段。
|
||||
|
||||
它的核心思路很简单:
|
||||
|
||||
- 复用 MoviePilot 当前已经启用的 LLM 配置
|
||||
- 在原生识别失败或置信度不足时,做一次本地结构化识别兜底
|
||||
- 把结果回写给 MoviePilot,继续走原生二次识别和后续整理链
|
||||
|
||||
## 适合什么场景
|
||||
|
||||
- 文件名比较脏,混有压制组、分辨率、语言、站点标记
|
||||
- 同一部剧经常出现英文名、别名、原名、翻译名混用
|
||||
- 网盘挂载、手动整理、历史资源补录时,原生识别偶尔不稳定
|
||||
- 你想把失败样本沉淀下来,后面持续优化 `CustomIdentifiers`
|
||||
|
||||
## 和 MoviePilot 原版智能体的区别
|
||||
|
||||
MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次”的能力。
|
||||
|
||||
这和 `AI识别增强` 有重叠,但定位不同:
|
||||
|
||||
- **MP 原版智能体**
|
||||
- 更偏“一次性补救”
|
||||
- 适合偶发失败、想省事的场景
|
||||
|
||||
- **AI识别增强**
|
||||
- 更偏“识别失败治理层”
|
||||
- 除了补救当前这次,还能:
|
||||
- 保存失败样本
|
||||
- 汇总样本洞察
|
||||
- 生成 `CustomIdentifiers` 建议
|
||||
- 写入识别词
|
||||
- 重放 / 复查 / 批量出队
|
||||
|
||||
一句话区分:
|
||||
|
||||
- 原版智能体:自动接管一次
|
||||
- `AI识别增强`:把失败样本沉淀下来,长期减少同类失败
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 监听 `ChainEventType.NameRecognize`
|
||||
- 用当前 LLM 结构化判断标题、年份、类型、季集
|
||||
- 回写 `name / year / season / episode`
|
||||
- 交回 MoviePilot 原生链路继续二次识别
|
||||
- 保存低置信度失败样本
|
||||
- 提供失败样本工作清单、洞察、重放、删除和清空能力
|
||||
- 生成并应用 `CustomIdentifiers` 建议
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
|
||||
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
|
||||
- 对单个标题做一次本地结构化识别测试
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
|
||||
- 查看最近保存的失败样本
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
|
||||
- 返回适合继续处理的失败样本摘要列表
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
|
||||
- 汇总失败原因、重复问题和优先处理样本
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/replay_failed_sample`
|
||||
- 用当前识别词和当前识别器重放复查某条失败样本
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/suggest_identifiers_from_sample`
|
||||
- 直接基于失败样本生成识别词建议
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier`
|
||||
- 把建议规则写入系统 `CustomIdentifiers`
|
||||
|
||||
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
|
||||
|
||||
## 配置建议
|
||||
|
||||
- 先确认 MoviePilot 本身已经配置好可用的 LLM
|
||||
- 建议保持“保存失败样本”开启
|
||||
- 如果你经常处理历史资源或网盘资源,建议定期查看:
|
||||
- `failed_samples`
|
||||
- `sample_worklist`
|
||||
- `sample_insights`
|
||||
|
||||
## 已验证情况
|
||||
|
||||
当前版本:`0.1.12`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
|
||||
|
||||
这版已经验证过:
|
||||
|
||||
- 最新版 MoviePilot 下可以正常加载
|
||||
- 正常中文标题识别可用
|
||||
- 英文别名、韩文原名、中文别名可识别回标准媒体信息
|
||||
- 低置信度标题会落失败样本
|
||||
- `replay_failed_sample` 复查链可用
|
||||
|
||||
## 说明
|
||||
|
||||
- 这个插件不依赖外部 AI Gateway 回调链
|
||||
- 重点是增强识别,不负责替代 MoviePilot 全部整理流程
|
||||
- 如果你只是偶发整理失败,原版智能体可能已经够用
|
||||
- 如果你长期受命名混乱困扰,这个插件更有价值
|
||||
2043
plugins.v2/airecognizerenhancer/__init__.py
Normal file
537
plugins.v2/autoauction/__init__.py
Normal file
@@ -0,0 +1,537 @@
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import EventType, NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class AutoAuction(_PluginBase):
|
||||
plugin_name = "朱雀交易行自动上架"
|
||||
plugin_desc = "自动上架灵石或上传到交易行"
|
||||
plugin_icon = "auction.png"
|
||||
plugin_version = "1.0.1"
|
||||
plugin_author = "no_reply"
|
||||
author_url = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
plugin_config_prefix = "autoauction_"
|
||||
plugin_order = 50
|
||||
auth_level = 2
|
||||
|
||||
_enabled: bool = False
|
||||
_onlyonce: bool = False
|
||||
_tasks: List[Dict[str, Any]] = []
|
||||
_history: List[Dict[str, Any]] = []
|
||||
_global_cron: str = ""
|
||||
_csrf_token: str = ""
|
||||
_notify_enabled: bool = True
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_is_running: bool = False
|
||||
_running_lock: threading.Lock = threading.Lock()
|
||||
_last_run_time: float = 0
|
||||
_min_interval_seconds: int = 60
|
||||
|
||||
ZHUQUE_DOMAIN = "zhuque.in"
|
||||
LIST_API = "https://zhuque.in/api/transaction/list"
|
||||
CREATE_API = "https://zhuque.in/api/transaction/create"
|
||||
CREATE_SUCCESS_CODE = "CREATE_TRANSACTION_SUCCESS"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
config = config or {}
|
||||
|
||||
self._enabled = config.get("enabled", False)
|
||||
onlyonce = config.get("onlyonce", False)
|
||||
|
||||
tasks_json = config.get("tasks_json")
|
||||
|
||||
if tasks_json is None:
|
||||
self._tasks = []
|
||||
elif isinstance(tasks_json, str):
|
||||
try:
|
||||
parsed = json.loads(tasks_json) if tasks_json.strip() else []
|
||||
self._tasks = parsed if isinstance(parsed, list) else []
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"任务配置JSON解析失败: {tasks_json}")
|
||||
self._tasks = []
|
||||
elif isinstance(tasks_json, list):
|
||||
self._tasks = tasks_json
|
||||
else:
|
||||
self._tasks = []
|
||||
|
||||
self._global_cron = config.get("global_cron", "") or ""
|
||||
self._csrf_token = config.get("csrf_token", "") or ""
|
||||
self._notify_enabled = config.get("notify_enabled", True)
|
||||
|
||||
self._history = self.get_data("history") or []
|
||||
|
||||
if self._scheduler:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
|
||||
if onlyonce and self._tasks:
|
||||
logger.info("拍卖行上架立即执行一次")
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
self._scheduler.add_job(
|
||||
func=self.run_all_tasks,
|
||||
trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="拍卖行上架-立即执行"
|
||||
)
|
||||
self._scheduler.start()
|
||||
logger.info("调度器已启动,等待3秒后执行")
|
||||
|
||||
self._onlyonce = False
|
||||
self.update_config({"onlyonce": False})
|
||||
logger.info("已重置onlyonce状态")
|
||||
|
||||
self._save_config()
|
||||
|
||||
def _save_config(self):
|
||||
tasks_json = json.dumps(self._tasks, ensure_ascii=False)
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"onlyonce": self._onlyonce,
|
||||
"notify_enabled": self._notify_enabled,
|
||||
"global_cron": self._global_cron,
|
||||
"csrf_token": self._csrf_token,
|
||||
"tasks_json": tasks_json
|
||||
})
|
||||
logger.info(f"配置已保存: tasks_json={tasks_json[:100]}...")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": "/list",
|
||||
"endpoint": self.get_listings,
|
||||
"methods": ["GET"],
|
||||
"auth": "bear",
|
||||
"summary": "获取当前挂单列表",
|
||||
"description": "获取拍卖行当前挂单列表",
|
||||
},
|
||||
{
|
||||
"path": "/create",
|
||||
"endpoint": self.create_listing,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "手动上架商品",
|
||||
"description": "手动上架商品到拍卖行",
|
||||
},
|
||||
{
|
||||
"path": "/run",
|
||||
"endpoint": self.run_all_tasks,
|
||||
"methods": ["POST"],
|
||||
"auth": "bear",
|
||||
"summary": "执行所有配置",
|
||||
"description": "执行所有上架配置",
|
||||
}
|
||||
]
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enabled",
|
||||
"label": "启用插件",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlyonce",
|
||||
"label": "立即执行一次",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify_enabled",
|
||||
"label": "发送通知",
|
||||
"hide-details": True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCronField",
|
||||
"props": {
|
||||
"model": "global_cron",
|
||||
"label": "执行周期",
|
||||
"placeholder": "0 9 * * *"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "csrf_token",
|
||||
"label": "CSRF Token",
|
||||
"hint": "从浏览器开发者工具获取"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCardText",
|
||||
"props": {
|
||||
"class": "text-pre-wrap"
|
||||
},
|
||||
"text": "配置格式:[{\"bonus\": 146285, \"unit\": \"TiB\", \"upload\": 1, \"type\": 2}]\n\n说明:\n- bonus: 挂牌灵石数量\n- unit: 单位,可选值为 \"TiB\"、\"GiB\"\n- upload: 挂牌上传量\n- type: 1出售灵石/2出售上传"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "tasks_json",
|
||||
"label": "上架配置列表 (JSON)",
|
||||
"rows": 8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"notify_enabled": True,
|
||||
"global_cron": "",
|
||||
"csrf_token": "",
|
||||
"tasks_json": "[]"
|
||||
}
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if not self._enabled:
|
||||
return []
|
||||
|
||||
services = []
|
||||
|
||||
if self._global_cron and self._global_cron.strip():
|
||||
cron = self._global_cron.strip()
|
||||
if cron.count(" ") == 4:
|
||||
try:
|
||||
services.append({
|
||||
"id": "AutoAuction.Global",
|
||||
"name": "拍卖行上架-全局任务",
|
||||
"trigger": CronTrigger.from_crontab(cron),
|
||||
"func": self.run_all_tasks,
|
||||
"kwargs": {},
|
||||
"misfire_grace_time": self._min_interval_seconds * 2,
|
||||
"max_instances": 1,
|
||||
"coalesce": True
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"全局cron配置错误: {str(e)}")
|
||||
|
||||
return services
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
history_by_date = defaultdict(list)
|
||||
for record in self._history:
|
||||
date = record.get("time", "")[:10]
|
||||
history_by_date[date].append(record)
|
||||
|
||||
items = []
|
||||
for date in sorted(history_by_date.keys(), reverse=True):
|
||||
records = history_by_date[date]
|
||||
if records:
|
||||
type_text = "出售上传" if records[0].get("type") == 2 else "出售灵石"
|
||||
items.append({
|
||||
"component": "VListSubheader",
|
||||
"props": {"class": "text-grey"},
|
||||
"text": f"{date} {type_text}"
|
||||
})
|
||||
for record in records:
|
||||
time_str = record.get("time", "")[11:]
|
||||
items.append({
|
||||
"component": "VListItem",
|
||||
"props": {
|
||||
"title": f"上传 {record.get('upload')} {record.get('unit')} | 灵石 {record.get('bonus')} | 上架时间: {time_str}"
|
||||
}
|
||||
})
|
||||
|
||||
if not items:
|
||||
items.append({
|
||||
"component": "VListItem",
|
||||
"props": {
|
||||
"title": "暂无上架记录",
|
||||
"subtitle": "执行上架后将显示历史记录"
|
||||
}
|
||||
})
|
||||
|
||||
return [{"component": "VList", "props": {"nav": True}, "content": items}]
|
||||
|
||||
def stop_service(self):
|
||||
if self._scheduler:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
|
||||
def _get_zhuque_site(self) -> Optional[Dict[str, Any]]:
|
||||
for site in SitesHelper().get_indexers():
|
||||
site_url = site.get("url", "") or ""
|
||||
if site_url and self.ZHUQUE_DOMAIN in site_url:
|
||||
return site
|
||||
return None
|
||||
|
||||
def _get_zhuque_cookie(self) -> Optional[str]:
|
||||
site = self._get_zhuque_site()
|
||||
if site:
|
||||
return site.get("cookie")
|
||||
return None
|
||||
|
||||
def _get_csrf_token(self) -> Optional[str]:
|
||||
if self._csrf_token:
|
||||
return self._csrf_token
|
||||
|
||||
cookie = self._get_zhuque_cookie()
|
||||
if not cookie:
|
||||
return None
|
||||
|
||||
try:
|
||||
req = RequestUtils(cookies=cookie, headers={"User-Agent": "Mozilla/5.0"})
|
||||
res = req.get_res(url="https://zhuque.in/bonus/transaction/upload")
|
||||
|
||||
if res and res.status_code == 200:
|
||||
html = res.text
|
||||
|
||||
patterns = [
|
||||
r'x-csrf-token["\']?\s*[:=]\s*["\']([^"\']+)["\']',
|
||||
r'<meta[^>]*name=["\']csrf-token["\'][^>]*content=["\']([^"\']+)["\']',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, html, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取CSRF Token异常: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_listings(self, page: int = 1, size: int = 20, type: int = 2) -> Dict[str, Any]:
|
||||
cookie = self._get_zhuque_cookie()
|
||||
|
||||
if not cookie:
|
||||
return {"success": False, "message": "站点Cookie不存在"}
|
||||
|
||||
try:
|
||||
res = RequestUtils(
|
||||
cookies=cookie,
|
||||
headers={"User-Agent": "Mozilla/5.0"}
|
||||
).get_res(url=f"{self.LIST_API}?page={page}&size={size}&type={type}&onlyUnsold=true&onlyRelated=false")
|
||||
|
||||
if res and res.status_code == 200:
|
||||
data = res.json()
|
||||
return {"success": True, "data": data}
|
||||
else:
|
||||
return {"success": False, "message": f"获取列表失败: {res.status_code if res else '无响应'}"}
|
||||
except Exception as e:
|
||||
logger.error(f"获取挂单列表失败: {str(e)}")
|
||||
return {"success": False, "message": f"获取列表异常: {str(e)}"}
|
||||
|
||||
def create_listing(self, bonus: int = None, unit: str = None,
|
||||
upload: int = None, type: int = 2) -> Dict[str, Any]:
|
||||
cookie = self._get_zhuque_cookie()
|
||||
csrf_token = self._get_csrf_token()
|
||||
|
||||
if not cookie:
|
||||
logger.error("站点Cookie不存在")
|
||||
return {"success": False, "message": "站点Cookie不存在"}
|
||||
|
||||
payload = {
|
||||
"type": type,
|
||||
"unit": unit or "TiB",
|
||||
"bonus": bonus or 0,
|
||||
"upload": upload or 1
|
||||
}
|
||||
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Origin": "https://zhuque.in",
|
||||
"Referer": "https://zhuque.in/bonus/transaction/upload",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
}
|
||||
if csrf_token:
|
||||
headers["x-csrf-token"] = csrf_token
|
||||
|
||||
try:
|
||||
req = RequestUtils(cookies=cookie, headers=headers)
|
||||
res = req.post_res(url=self.CREATE_API, json=payload)
|
||||
|
||||
if not res:
|
||||
logger.error("上架请求无响应")
|
||||
return {"success": False, "message": "上架失败: 无响应"}
|
||||
|
||||
if res.status_code == 200:
|
||||
result = res.json()
|
||||
logger.info(f"上架响应内容: {result}")
|
||||
if result.get("status") == 200:
|
||||
code = result.get("data", {}).get("code")
|
||||
if code == self.CREATE_SUCCESS_CODE:
|
||||
transaction_id = result.get("data", {}).get("transactionId")
|
||||
logger.info(f"上架成功: transactionId={transaction_id}")
|
||||
return {"success": True, "data": result.get("data")}
|
||||
else:
|
||||
logger.error(f"上架失败: code={code}")
|
||||
return {"success": False, "message": f"上架失败: {code}"}
|
||||
else:
|
||||
logger.error(f"上架失败: status={result.get('status')}")
|
||||
return {"success": False, "message": f"上架失败: status={result.get('status')}"}
|
||||
else:
|
||||
logger.error(f"上架失败: {res.status_code if res else '无响应'}")
|
||||
return {"success": False, "message": f"上架失败: {res.status_code if res else '无响应'}"}
|
||||
except Exception as e:
|
||||
logger.error(f"上架异常: {str(e)}")
|
||||
return {"success": False, "message": f"上架异常: {str(e)}"}
|
||||
|
||||
def run_all_tasks(self) -> Dict[str, Any]:
|
||||
tz = pytz.timezone(settings.TZ)
|
||||
now = datetime.now(tz=tz)
|
||||
current_time = now.timestamp()
|
||||
|
||||
with self._running_lock:
|
||||
if self._is_running:
|
||||
logger.warn("上架任务正在执行中,跳过本次调用")
|
||||
return {"success": False, "message": "任务正在执行中"}
|
||||
if current_time - self._last_run_time < self._min_interval_seconds:
|
||||
logger.warn(f"上架任务执行间隔太短({self._min_interval_seconds}秒),跳过本次调用")
|
||||
return {"success": False, "message": f"执行间隔太短,请等待{self._min_interval_seconds}秒"}
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
last_run_date = self.get_data("last_run_date") or ""
|
||||
if last_run_date == today:
|
||||
logger.warn(f"今日({today})已执行过上架任务,跳过本次调用")
|
||||
return {"success": False, "message": f"今日({today})已执行过上架任务"}
|
||||
self._is_running = True
|
||||
self._last_run_time = current_time
|
||||
|
||||
try:
|
||||
logger.info(f"开始执行上架任务,共 {len(self._tasks)} 个配置")
|
||||
results = []
|
||||
success_records = []
|
||||
|
||||
for idx, task in enumerate(self._tasks):
|
||||
result = self.create_listing(
|
||||
bonus=task.get("bonus"),
|
||||
unit=task.get("unit"),
|
||||
upload=task.get("upload"),
|
||||
type=task.get("type", 2)
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
transaction_id = result.get("data", {}).get("transactionId")
|
||||
record_time = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._history.insert(0, {
|
||||
"upload": task.get("upload"),
|
||||
"bonus": task.get("bonus"),
|
||||
"unit": task.get("unit"),
|
||||
"type": task.get("type", 2),
|
||||
"time": record_time,
|
||||
"transactionId": transaction_id
|
||||
})
|
||||
if len(self._history) > 50:
|
||||
self._history = self._history[:50]
|
||||
success_records.append(f"上传 {task.get('upload')} {task.get('unit')} | 灵石 {task.get('bonus')} | 上架时间: {record_time[11:]}")
|
||||
results.append({"index": idx + 1, "success": True})
|
||||
logger.info(f"配置 {idx + 1} 上架成功")
|
||||
else:
|
||||
logger.error(f"配置 {idx + 1} 上架失败: {result.get('message')}")
|
||||
results.append({"index": idx + 1, "success": False, "error": result.get('message')})
|
||||
|
||||
if self._history:
|
||||
self.save_data("history", self._history)
|
||||
|
||||
self.save_data("last_run_date", today)
|
||||
|
||||
if self._notify_enabled and success_records:
|
||||
try:
|
||||
type_text = "出售上传" if self._tasks[0].get("type", 2) == 2 else "出售灵石" if self._tasks[0].get("type", 2) == 1 else ""
|
||||
text_lines = [f"{today} {type_text}"]
|
||||
for record in success_records:
|
||||
text_lines.append(record)
|
||||
text = "\n".join(text_lines)
|
||||
self.post_message(
|
||||
mtype=NotificationType.Plugin,
|
||||
title="拍卖行上架",
|
||||
text=text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发送通知异常: {str(e)}")
|
||||
|
||||
return {"success": True, "results": results}
|
||||
finally:
|
||||
self._is_running = False
|
||||
@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "signin.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.7"
|
||||
plugin_version = "2.8.2"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
|
||||
114
plugins.v2/autosignin/sites/rousipro.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from typing import Tuple
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.log import logger
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.plugins.autosignin.sites import _ISiteSigninHandler
|
||||
|
||||
|
||||
class RousiPro(_ISiteSigninHandler):
|
||||
"""
|
||||
rousi pro 签到
|
||||
"""
|
||||
# 匹配的站点Url,每一个实现类都需要设置为自己的站点Url
|
||||
site_url = "rousi.pro"
|
||||
|
||||
@classmethod
|
||||
def match(cls, url: str) -> bool:
|
||||
"""
|
||||
根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可
|
||||
:param url: 站点Url
|
||||
:return: 是否匹配,如匹配则会调用该类的signin方法
|
||||
"""
|
||||
return True if StringUtils.url_equal(url, cls.site_url) else False
|
||||
|
||||
def signin(self, site_info: CommentedMap) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行签到操作,固定签到
|
||||
:param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息
|
||||
:return: 签到结果信息
|
||||
"""
|
||||
site = site_info.get("name")
|
||||
ua = site_info.get("ua")
|
||||
token = site_info.get("token")
|
||||
timeout = site_info.get("timeout")
|
||||
if not token or token.strip() == "":
|
||||
logger.error(f"{site} 签到失败,缺少 Authorization 信息")
|
||||
return False, "签到失败,缺少 Authorization 信息"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ua,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
|
||||
}
|
||||
body = {
|
||||
"mode": "fixed"
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
).post_res(
|
||||
url="https://rousi.pro/api/points/attendance",
|
||||
json=body
|
||||
)
|
||||
|
||||
if res is not None and res.status_code == 200 and res.json().get("code", -1) == 0:
|
||||
logger.info(f"{site} 签到成功")
|
||||
return True, "签到成功"
|
||||
elif res is not None and res.status_code == 400 and res.json().get("code", -1) == 1:
|
||||
logger.info(f"{site} 今日已签到")
|
||||
return True, "今日已签到"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 签到失败,Authorization 已失效")
|
||||
return False, "签到失败,Authorization 已失效"
|
||||
elif res is not None:
|
||||
logger.error(f"{site} 签到失败,状态码:{res.status_code}")
|
||||
return False, f"签到失败,状态码:{res.status_code}"
|
||||
else:
|
||||
logger.error(f"{site} 签到失败,无法访问网站")
|
||||
return False, "签到失败,无法访问网站"
|
||||
|
||||
def login(self, site_info: CommentedMap) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行登录操作,访问签到统计接口更新站点最后活跃时间
|
||||
:param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息
|
||||
:return: 登录结果信息
|
||||
"""
|
||||
site = site_info.get("name")
|
||||
ua = site_info.get("ua")
|
||||
token = site_info.get("token")
|
||||
timeout = site_info.get("timeout")
|
||||
if not token or token.strip() == "":
|
||||
logger.error(f"{site} 模拟登录失败,缺少 Authorization 信息")
|
||||
return False, "模拟登录失败,缺少 Authorization 信息"
|
||||
|
||||
headers = {
|
||||
"User-Agent": ua,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": token if token.startswith("Bearer ") else f"Bearer {token}"
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
proxies=settings.PROXY if site_info.get("proxy") else None,
|
||||
).get_res(
|
||||
url="https://rousi.pro/api/points/attendance/stats"
|
||||
)
|
||||
|
||||
if res is not None and res.status_code == 200 and res.json().get("code", -1) == 0:
|
||||
logger.info(f"{site} 模拟登录成功")
|
||||
return True, "模拟登录成功"
|
||||
elif res is not None and res.status_code == 401:
|
||||
logger.error(f"{site} 模拟登录失败,Authorization 已失效")
|
||||
return False, "模拟登录失败,Authorization 已失效"
|
||||
elif res is not None:
|
||||
logger.error(f"{site} 模拟登录失败,状态码:{res.status_code}")
|
||||
return False, f"模拟登录失败,状态码:{res.status_code}"
|
||||
else:
|
||||
logger.error(f"{site} 模拟登录失败,无法访问网站")
|
||||
return False, "模拟登录失败,无法访问网站"
|
||||
@@ -79,6 +79,7 @@ class BrushConfig:
|
||||
self.qb_category = config.get("qb_category")
|
||||
self.site_hr_active = config.get("site_hr_active", False)
|
||||
self.site_skip_tips = config.get("site_skip_tips", False)
|
||||
self.rss_support = config.get("rss_support", False)
|
||||
|
||||
self.brush_tag = "刷流"
|
||||
# 站点独立配置
|
||||
@@ -123,7 +124,8 @@ class BrushConfig:
|
||||
"qb_category",
|
||||
"site_hr_active",
|
||||
"site_skip_tips",
|
||||
"del_no_free"
|
||||
"del_no_free",
|
||||
"rss_support"
|
||||
# 当新增支持字段时,仅在此处添加字段名
|
||||
}
|
||||
try:
|
||||
@@ -193,7 +195,8 @@ class BrushConfig:
|
||||
"del_no_free": false,
|
||||
"qb_category": "刷流",
|
||||
"site_hr_active": true,
|
||||
"site_skip_tips": true
|
||||
"site_skip_tips": true,
|
||||
"rss_support": true
|
||||
}]"""
|
||||
return desc + config
|
||||
|
||||
@@ -259,9 +262,9 @@ class BrushFlow(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "brush.jpg"
|
||||
# 插件版本
|
||||
plugin_version = "4.3.3"
|
||||
plugin_version = "4.3.5"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp,InfinityPacer"
|
||||
plugin_author = "jxxghp,InfinityPacer,Seed680"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/InfinityPacer"
|
||||
# 插件配置项ID前缀
|
||||
@@ -1638,6 +1641,22 @@ class BrushFlow(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'rss_support',
|
||||
'label': '启用RSS支持',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1817,7 +1836,8 @@ class BrushFlow(_PluginBase):
|
||||
"freeleech": "free",
|
||||
"hr": "yes",
|
||||
"enable_site_config": False,
|
||||
"site_config": BrushConfig.get_demo_site_config()
|
||||
"site_config": BrushConfig.get_demo_site_config(),
|
||||
"rss_support": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
@@ -2002,7 +2022,14 @@ class BrushFlow(_PluginBase):
|
||||
return True
|
||||
|
||||
logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...")
|
||||
torrents = TorrentsChain().browse(domain=siteinfo.domain)
|
||||
|
||||
# 根据rss_support配置决定使用browse还是rss方法获取种子
|
||||
brush_config = self.__get_brush_config(sitename=siteinfo.name)
|
||||
if brush_config.rss_support:
|
||||
torrents = TorrentsChain().rss(domain=siteinfo.domain)
|
||||
else:
|
||||
torrents = TorrentsChain().browse(domain=siteinfo.domain)
|
||||
|
||||
if not torrents:
|
||||
logger.info(f"站点 {siteinfo.name} 没有获取到种子")
|
||||
return True
|
||||
@@ -2219,16 +2246,34 @@ class BrushFlow(_PluginBase):
|
||||
return False, "存在H&R"
|
||||
|
||||
# 包含规则
|
||||
if brush_config.include and not (
|
||||
re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include,
|
||||
torrent.description, re.I)):
|
||||
return False, "不符合包含规则"
|
||||
if brush_config.include:
|
||||
try:
|
||||
include_match = False
|
||||
if torrent.title and re.search(brush_config.include, torrent.title, re.I):
|
||||
include_match = True
|
||||
elif torrent.description and re.search(brush_config.include, torrent.description, re.I):
|
||||
include_match = True
|
||||
|
||||
if not include_match:
|
||||
return False, "不符合包含规则"
|
||||
except re.error:
|
||||
logger.warning(f"包含规则正则表达式错误: {brush_config.include}")
|
||||
return False, "包含规则正则表达式错误"
|
||||
|
||||
# 排除规则
|
||||
if brush_config.exclude and (
|
||||
re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude,
|
||||
torrent.description, re.I)):
|
||||
return False, "符合排除规则"
|
||||
if brush_config.exclude:
|
||||
try:
|
||||
exclude_match = False
|
||||
if torrent.title and re.search(brush_config.exclude, torrent.title, re.I):
|
||||
exclude_match = True
|
||||
elif torrent.description and re.search(brush_config.exclude, torrent.description, re.I):
|
||||
exclude_match = True
|
||||
|
||||
if exclude_match:
|
||||
return False, "符合排除规则"
|
||||
except re.error:
|
||||
logger.warning(f"排除规则正则表达式错误: {brush_config.exclude}")
|
||||
return False, "排除规则正则表达式错误"
|
||||
|
||||
# 种子大小(GB)
|
||||
if brush_config.size:
|
||||
@@ -3048,6 +3093,7 @@ class BrushFlow(_PluginBase):
|
||||
"enable_site_config": brush_config.enable_site_config,
|
||||
"site_config": brush_config.site_config,
|
||||
"del_no_free": brush_config.del_no_free,
|
||||
"rss_support": brush_config.rss_support,
|
||||
"_tabs": self._tabs
|
||||
}
|
||||
|
||||
|
||||
296
plugins.v2/bugreporter/__init__.py
Normal file
@@ -0,0 +1,296 @@
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
from typing import List, Tuple
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
import sentry_sdk
|
||||
from app.plugins import _PluginBase
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SentrySanitizer:
|
||||
# 常见敏感字段名(可自行扩展)
|
||||
SENSITIVE_KEYS = {
|
||||
"password", "passwd", "pwd",
|
||||
"secret", "token", "access_token", "refresh_token",
|
||||
"authorization", "api_key", "apikey",
|
||||
"cookie", "set-cookie", "passkey",
|
||||
"key", "credential", "auth", "login", "user", "username",
|
||||
"email", "phone", "address", "ip", "host", "domain"
|
||||
}
|
||||
|
||||
# 匹配包含敏感关键词的正则
|
||||
SENSITIVE_PATTERN = re.compile(
|
||||
"|".join(re.escape(key) for key in SENSITIVE_KEYS), re.IGNORECASE
|
||||
)
|
||||
|
||||
# 网络连接错误类异常(不上报)
|
||||
NETWORK_ERRORS = {
|
||||
"ConnectionError", "ConnectionRefusedError", "ConnectionAbortedError",
|
||||
"ConnectionResetError", "TimeoutError", "socket.timeout", "socket.error",
|
||||
"ssl.SSLError", "ssl.SSLCertVerificationError", "ssl.SSLWantReadError",
|
||||
"ssl.SSLWantWriteError", "ssl.SSLZeroReturnError", "ssl.SSLSyscallError",
|
||||
"urllib.error.URLError", "urllib.error.HTTPError", "requests.exceptions.ConnectionError",
|
||||
"requests.exceptions.Timeout", "requests.exceptions.ConnectTimeout",
|
||||
"requests.exceptions.ReadTimeout", "requests.exceptions.SSLError",
|
||||
"aiohttp.ClientConnectionError", "aiohttp.ClientTimeout", "aiohttp.ServerTimeoutError",
|
||||
"aiohttp.ServerDisconnectedError", "aiohttp.ClientOSError"
|
||||
}
|
||||
|
||||
# 网络连接错误关键词
|
||||
NETWORK_ERROR_KEYWORDS = [
|
||||
"connection", "timeout", "network", "dns", "ssl", "certificate",
|
||||
"refused", "reset", "aborted", "unreachable", "no route to host",
|
||||
"name or service not known", "temporary failure", "network is unreachable",
|
||||
"SOCKSHTTPSConnectionPool", "ERR_HTTP_RESPONSE_CODE_FAILURE", "HTTPSConnectionPool",
|
||||
"网络连接", "无法连接", "请求失败", "下载失败", "请求返回空值", "图片失败", "未获取到返回数据",
|
||||
"请求返回空值", "返回空响应", "连接出错", "请求错误", "未获取到"
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def scrub_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
递归清洗字典中的敏感信息
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
sanitized = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
sanitized[key] = cls.scrub_dict(value)
|
||||
elif isinstance(value, list):
|
||||
sanitized[key] = [cls.scrub_dict(v) if isinstance(v, dict) else v for v in value]
|
||||
else:
|
||||
if cls.SENSITIVE_PATTERN.search(str(key)):
|
||||
sanitized[key] = "[Filtered]"
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
@classmethod
|
||||
def scrub_url(cls, url: str) -> str:
|
||||
"""
|
||||
清理 URL 中的敏感 query 参数
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
for key in query:
|
||||
if cls.SENSITIVE_PATTERN.search(key):
|
||||
query[key] = ["[Filtered]"]
|
||||
new_query = urlencode(query, doseq=True)
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def is_network_error(cls, event) -> bool:
|
||||
"""
|
||||
判断是否为网络连接错误类异常
|
||||
"""
|
||||
# 检查异常类型
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
if "type" in exc:
|
||||
exc_type = exc["type"]
|
||||
if exc_type in cls.NETWORK_ERRORS:
|
||||
return True
|
||||
|
||||
# 检查异常消息是否包含网络错误关键词
|
||||
if "value" in exc:
|
||||
exc_value = exc["value"].lower()
|
||||
for keyword in cls.NETWORK_ERROR_KEYWORDS:
|
||||
if keyword in exc_value:
|
||||
return True
|
||||
|
||||
# 检查日志消息
|
||||
if "message" in event:
|
||||
message = event["message"].lower()
|
||||
for keyword in cls.NETWORK_ERROR_KEYWORDS:
|
||||
if keyword in message:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
TRACEBACK_PATTERN = re.compile(
|
||||
r"Traceback \(most recent call last\):|"
|
||||
r"File \"[^\"]+\", line \d+|"
|
||||
r"^\s+raise \w+|"
|
||||
r"^\w+Error:|^\w+Exception:",
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def has_stacktrace(cls, event) -> bool:
|
||||
"""
|
||||
判断事件是否包含明确的异常堆栈信息(结构化异常或日志文本中的堆栈)
|
||||
"""
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
stacktrace = exc.get("stacktrace")
|
||||
if stacktrace and stacktrace.get("frames"):
|
||||
return True
|
||||
|
||||
if "message" in event and cls.TRACEBACK_PATTERN.search(event["message"]):
|
||||
return True
|
||||
|
||||
if "logentry" in event:
|
||||
msg = event["logentry"].get("message", "") or event["logentry"].get("formatted", "")
|
||||
if cls.TRACEBACK_PATTERN.search(msg):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def before_send(cls, event, hint):
|
||||
"""
|
||||
在发送到 Sentry 之前脱敏和过滤
|
||||
"""
|
||||
# 只上报包含明确异常堆栈的事件,普通 error 日志不上报
|
||||
if not cls.has_stacktrace(event):
|
||||
return None
|
||||
|
||||
# 如果是网络连接错误,直接返回 None 不上报
|
||||
if cls.is_network_error(event):
|
||||
return None
|
||||
|
||||
# 处理 request 数据
|
||||
request = event.get("request", {})
|
||||
if "url" in request:
|
||||
request["url"] = cls.scrub_url(request["url"])
|
||||
if "headers" in request:
|
||||
request["headers"] = cls.scrub_dict(request["headers"])
|
||||
if "data" in request:
|
||||
request["data"] = cls.scrub_dict(request["data"])
|
||||
if "cookies" in request:
|
||||
request["cookies"] = cls.scrub_dict(request["cookies"])
|
||||
|
||||
# 处理 user 数据
|
||||
if "user" in event:
|
||||
event["user"] = cls.scrub_dict(event["user"])
|
||||
|
||||
# 处理 extra 数据
|
||||
if "extra" in event:
|
||||
event["extra"] = cls.scrub_dict(event["extra"])
|
||||
|
||||
# 处理异常信息(避免敏感数据出现在 message 中)
|
||||
if "exception" in event:
|
||||
for exc in event["exception"].get("values", []):
|
||||
if "value" in exc and cls.SENSITIVE_PATTERN.search(exc["value"]):
|
||||
exc["value"] = "[Filtered Exception Message]"
|
||||
|
||||
# 清理异常堆栈中的敏感信息
|
||||
if "stacktrace" in exc and "frames" in exc["stacktrace"]:
|
||||
for frame in exc["stacktrace"]["frames"]:
|
||||
if "vars" in frame:
|
||||
frame["vars"] = cls.scrub_dict(frame["vars"])
|
||||
if "context_line" in frame and cls.SENSITIVE_PATTERN.search(frame["context_line"]):
|
||||
frame["context_line"] = "[Filtered]"
|
||||
|
||||
# 清理消息中的敏感信息
|
||||
if "message" in event and cls.SENSITIVE_PATTERN.search(event["message"]):
|
||||
event["message"] = "[Filtered Message]"
|
||||
|
||||
return event
|
||||
|
||||
|
||||
class BugReporter(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "Bug反馈"
|
||||
# 插件描述
|
||||
plugin_desc = "自动上报异常,协助开发者发现和解决问题。"
|
||||
# 插件图标
|
||||
plugin_icon = "Alist_encrypt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5.1"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "bugreporter_"
|
||||
# 加载顺序
|
||||
plugin_order = 99
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
_enable: bool = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._enable = config.get("enable")
|
||||
if self._enable:
|
||||
sentry_sdk.init("https://3999f6a035db46a588b03e5a92b9f592@glitchtip.movie-pilot.org/2",
|
||||
before_send=SentrySanitizer.before_send,
|
||||
release=APP_VERSION,
|
||||
send_default_pii=False)
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enable',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'warning',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:开启插件即代表你同意将部分异常信息自动发送给开发者,以帮助改进软件;如果你不希望自动发送任何数据,请关闭或卸载此插件;仅上报包含异常堆栈的系统错误,普通日志和网络连接错误不会上报;不会包含任何个人隐私信息或敏感数据;异常信息采集为使用开源项目解决方案:GlitchTip。',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enable": self._enable,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enable
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
1
plugins.v2/bugreporter/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
sentry_sdk
|
||||
@@ -17,7 +17,7 @@ class ChatGPT(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "Chatgpt_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1.7"
|
||||
plugin_version = "2.1.9"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
|
||||
@@ -14,22 +14,33 @@ class OpenAi:
|
||||
_api_url: str = None
|
||||
_model: str = "gpt-3.5-turbo"
|
||||
_prompt: str = '接下来我会给你一个电影或电视剧的文件名,你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息,并按以下JSON格式返回:{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null},特别注意返回结果需要严格附合JSON格式,不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况,请还原最有可能的结果。'
|
||||
_client: openai.OpenAI = None
|
||||
|
||||
def __init__(self, api_key: str = None, api_url: str = None, proxy: dict = None, model: str = None, compatible:
|
||||
bool = False, customize_prompt: str = None):
|
||||
def __init__(self, api_key: str = None, api_url: str = None,
|
||||
proxy: dict = None, model: str = None,
|
||||
compatible: bool = False, customize_prompt: str = None):
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
if compatible:
|
||||
openai.api_base = self._api_url
|
||||
else:
|
||||
openai.api_base = self._api_url + "/v1"
|
||||
openai.api_key = self._api_key
|
||||
if proxy and proxy.get("https"):
|
||||
openai.proxy = proxy.get("https")
|
||||
if model:
|
||||
self._model = model
|
||||
if customize_prompt:
|
||||
self._prompt = customize_prompt
|
||||
|
||||
# 初始化 OpenAI 客户端
|
||||
if self._api_key and self._api_url:
|
||||
base_url = self._api_url if compatible else self._api_url + "/v1"
|
||||
http_client = None
|
||||
if proxy and proxy.get("https"):
|
||||
import httpx
|
||||
proxy_url = proxy.get("https")
|
||||
# httpx 支持字符串格式的代理 URL
|
||||
http_client = httpx.Client(proxies=proxy_url, timeout=60.0)
|
||||
self._client = openai.OpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=base_url,
|
||||
http_client=http_client
|
||||
)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True if self._api_key else False
|
||||
|
||||
@@ -82,6 +93,8 @@ class OpenAi:
|
||||
"""
|
||||
获取模型
|
||||
"""
|
||||
if not self._client:
|
||||
raise ValueError("OpenAI client not initialized. Please check API key and API URL.")
|
||||
if not isinstance(message, list):
|
||||
if prompt:
|
||||
message = [
|
||||
@@ -101,9 +114,10 @@ class OpenAi:
|
||||
"content": message
|
||||
}
|
||||
]
|
||||
return openai.ChatCompletion.create(
|
||||
# 新版本 API 不支持 user 参数,需要从 kwargs 中移除
|
||||
kwargs.pop('user', None)
|
||||
return self._client.chat.completions.create(
|
||||
model=self._model,
|
||||
user=user,
|
||||
messages=message,
|
||||
**kwargs
|
||||
)
|
||||
@@ -170,11 +184,11 @@ class OpenAi:
|
||||
if result:
|
||||
self.__save_session(userid, text)
|
||||
return result
|
||||
except openai.error.RateLimitError as e:
|
||||
except openai.RateLimitError as e:
|
||||
return f"请求被ChatGPT拒绝了,{str(e)}"
|
||||
except openai.error.APIConnectionError as e:
|
||||
except openai.APIConnectionError as e:
|
||||
return f"ChatGPT网络连接失败:{str(e)}"
|
||||
except openai.error.Timeout as e:
|
||||
except openai.APITimeoutError as e:
|
||||
return f"没有接收到ChatGPT的返回消息:{str(e)}"
|
||||
except Exception as e:
|
||||
return f"请求ChatGPT出现错误:{str(e)}"
|
||||
|
||||
@@ -1 +1 @@
|
||||
openai~=0.27.2
|
||||
cacheout~=0.16.0
|
||||
@@ -1,10 +1,10 @@
|
||||
# Clash Rule Provider
|
||||
|
||||
**Clash Rule Provider** 生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则。
|
||||
**Clash Rule Provider** 是一个[MoviePilot](https://github.com/jxxghp/MoviePilot)插件,用于生成适用于 [Meta Kernel](https://github.com/MetaCubeX/mihomo/tree/Meta) 定制配置,便于增加、修改和删除规则,基于 Meta 内核丰富的代理组配置,提供灵活的路由功能。
|
||||
|
||||
- 即时通知 Clash 刷新规则集合
|
||||
- 基于 Meta 内核丰富的代理组配置,提供灵活的路由功能
|
||||
- 支持按大洲分组节点
|
||||
- 支持按大洲和国家分组节点
|
||||
- 支持覆写出站代理
|
||||
- GEO 规则输入提示
|
||||
- 支持 [ACL4SSR](https://github.com/ACL4SSR/ACL4SSR) 规则集合
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
### 规则集规则
|
||||
|
||||
用于添加能够在 Clash 中即时生效的规则,Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合** `📂<-` + `出站`。
|
||||
用于添加能够在 Clash 中即时生效的规则,Clash Rule Provider 会根据每条规则的**出站**生成相应的**规则集合**。
|
||||
|
||||
### 置顶规则
|
||||
|
||||
@@ -40,4 +40,28 @@
|
||||
|
||||
### Hosts
|
||||
|
||||
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
|
||||
如果需要自动更新此处使用的 Cloudflare IP, 可以通过其它[插件](https://github.com/wumode/MoviePilot-Addons)实现。
|
||||
|
||||
### 配置隐藏
|
||||
|
||||
如果希望某些代理组、规则或是代理节点仅在特定条件下可见,可以使用可见性限制功能。例如,可以设置某些规则集仅在特定网络环境下可见。
|
||||
自定义表达式是个返回`bool`值的Python表达式,可以使用以下变量:
|
||||
|
||||
```python
|
||||
# 请求 URL
|
||||
url: str
|
||||
# 客户端的IP地址
|
||||
client_host: str
|
||||
# 请求的标识符
|
||||
identifier: str | None = None
|
||||
# User-Agent
|
||||
user_agent : str | None = None
|
||||
```
|
||||
|
||||
表达式示例:
|
||||
- `client_host == '192.168.1.1'`
|
||||
- `identifier == 'office-laptop' and 'Mobile' in user_agent`
|
||||
|
||||
## 远程组件
|
||||
|
||||
[ClashRuleProvider-Remote](https://github.com/wumode/ClashRuleProvider-Remote)
|
||||
320
plugins.v2/clashruleprovider/api.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any, Dict, List, Callable, Optional, Literal
|
||||
|
||||
import websockets
|
||||
import yaml
|
||||
from fastapi import HTTPException, Request, status, Response, Body
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
from .config import PluginConfig
|
||||
from .models import ProxyGroup, Proxy, HostData, RuleData, RuleProvider, RuleProviderData
|
||||
from .models.api import Connectivity, SubscriptionSetting, ConfigRequest
|
||||
from .models.metadata import Metadata
|
||||
from .models.types import RuleSet, DataSource
|
||||
from .services import ClashRuleProviderService
|
||||
|
||||
|
||||
class ApiCollection:
|
||||
def __init__(self):
|
||||
self.route_definitions = []
|
||||
|
||||
def register(self, path: str,
|
||||
methods: List[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE']],
|
||||
allow_anonymous: Optional[bool] = None,
|
||||
auth: Optional[str] = None,
|
||||
summary: Optional[str] = '',
|
||||
**kwargs):
|
||||
|
||||
def decorator(func: Callable):
|
||||
route_meta: Dict[str, Any] = {
|
||||
'path': path,
|
||||
'methods': methods,
|
||||
'summary': summary,
|
||||
'endpoint': func,
|
||||
**kwargs
|
||||
}
|
||||
if allow_anonymous is not None:
|
||||
route_meta['allow_anonymous'] = allow_anonymous
|
||||
if auth is not None:
|
||||
route_meta['auth'] = auth
|
||||
self.route_definitions.append(route_meta)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def get_routes(self, instance: Any) -> List[Dict[str, Any]]:
|
||||
bound_routes = []
|
||||
for route in self.route_definitions:
|
||||
func_name = route['endpoint'].__name__
|
||||
bound_method = getattr(instance, func_name)
|
||||
bound_routes.append({**route, 'endpoint': bound_method})
|
||||
return bound_routes
|
||||
|
||||
|
||||
apis = ApiCollection()
|
||||
|
||||
|
||||
class ClashRuleProviderApi:
|
||||
|
||||
def __init__(self, services: ClashRuleProviderService, config: PluginConfig):
|
||||
self.services: ClashRuleProviderService = services
|
||||
self.config = config
|
||||
|
||||
@apis.register(path="/connectivity", methods=["POST"], auth="bear", summary="测试连接")
|
||||
async def test_connectivity(self, item: Connectivity) -> schemas.Response:
|
||||
success, message = await self.services.test_connectivity(item.clash_apis, item.sub_links)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/clash-outbound", methods=["GET"], auth="bear", summary="获取所有出站")
|
||||
def get_clash_outbound(self) -> schemas.Response:
|
||||
outbound = self.services.clash_outbound()
|
||||
return schemas.Response(success=True, data=outbound)
|
||||
|
||||
@apis.register(path="/status", methods=["GET"], auth="bear", summary="插件状态")
|
||||
def get_status(self) -> schemas.Response:
|
||||
data = self.services.get_status()
|
||||
return schemas.Response(success=True, data=data)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}", methods=["GET"], auth="bear", summary="获取指定集合中的规则")
|
||||
def get_rules(self, ruleset: RuleSet) -> schemas.Response:
|
||||
data = self.services.get_rules(ruleset)
|
||||
return schemas.Response(success=True, data=data)
|
||||
|
||||
@apis.register(path="/reorder-rules/{ruleset}/{target}", methods=["PUT"], auth="bear", summary="重新排序规则")
|
||||
def reorder_rules(self, ruleset: RuleSet, target: int,
|
||||
moved_priority: int = Body(..., embed=True)) -> schemas.Response:
|
||||
success, message = self.services.reorder_rules(ruleset, moved_priority, target)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}", methods=["PATCH"], auth="bear", summary="更新规则")
|
||||
def update_rule(self, ruleset: RuleSet, priority: int, rule_data: RuleData) -> schemas.Response:
|
||||
success, message = self.services.update_rule(ruleset, priority, rule_data)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}", methods=["POST"], auth="bear", summary="添加规则")
|
||||
def add_rule(self, ruleset: RuleSet, rule_data: RuleData = Body(...)) -> schemas.Response:
|
||||
success, message = self.services.add_rule(ruleset, rule_data)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}/meta", methods=["PATCH"], auth="bear", summary="更新规则元数据")
|
||||
def update_rule_meta(self, ruleset: RuleSet, priority: int, meta: Metadata = Body(...)) -> schemas.Response:
|
||||
success, message = self.services.update_rule_meta(ruleset, priority, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/metadata/disabled", methods=["POST"], auth="bear", summary="设置规则状态")
|
||||
def set_rules_status(self, ruleset: RuleSet, priorities: dict[int, bool] = Body(...)):
|
||||
self.services.set_rules_status(ruleset, priorities)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}/{priority}", methods=["DELETE"], auth="bear", summary="删除规则")
|
||||
def delete_rule(self, ruleset: RuleSet, priority: int) -> schemas.Response:
|
||||
self.services.delete_rule(ruleset, priority)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/rules/{ruleset}", methods=["DELETE"], auth="bear", summary="批量删除规则")
|
||||
def delete_rules(self, ruleset: RuleSet, priority: list[int] = Body(...)) -> schemas.Response:
|
||||
self.services.delete_rules(ruleset, priority)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/refresh", methods=["PUT"], auth="bear", summary="更新订阅")
|
||||
async def refresh_subscription(self, url: str = Body(..., embed=True)) -> schemas.Response:
|
||||
success, message = await self.services.refresh_subscription(url)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers", methods=["GET"], auth="bear", summary="获取规则集合",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_rule_providers(self) -> schemas.Response:
|
||||
return schemas.Response(success=True, data=self.services.state.all_rule_providers)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}", methods=["POST"], auth="bear", summary="添加规则集合")
|
||||
def add_rule_provider(self, name: str, item: RuleProvider):
|
||||
success, message = self.services.add_rule_provider(name, item)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}", methods=["PATCH"], auth="bear", summary="更新规则集合")
|
||||
def update_rule_provider(self, name: str, item: RuleProviderData):
|
||||
success, message = self.services.update_rule_provider(name, item)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}/meta", methods=["PATCH"], auth="bear", summary="更新规则集元数据")
|
||||
def update_rule_providers_meta(self, name: str, meta: Metadata):
|
||||
success, message = self.services.update_rule_providers_meta(name, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/rule-providers/{name}", methods=["DELETE"], auth="bear", summary="删除规则集合")
|
||||
def delete_rule_provider(self, name: str):
|
||||
self.services.delete_rule_provider(name)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/proxies", methods=["GET"], auth="bear", summary="获取代理",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_proxies(self):
|
||||
proxies = self.services.get_proxies()
|
||||
return schemas.Response(success=True, data=proxies)
|
||||
|
||||
@apis.register(path="/proxies/{name}", methods=["DELETE"], auth="bear", summary="删除出站代理")
|
||||
def delete_proxy(self, name: str):
|
||||
self.services.delete_proxy(name)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/proxies", methods=["PUT"], auth="bear", summary="添加出站代理")
|
||||
def import_proxies(self, vehicle: Literal["YAML", "LINK"] = Body(...), payload: str = Body(...)):
|
||||
success, message = self.services.import_proxies(vehicle, payload)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxies/{name}", methods=["PATCH"], auth="bear", summary="更新出站代理")
|
||||
def update_proxy(self, name: str, source: DataSource = Body(...), proxy: Proxy = Body(...)) -> schemas.Response:
|
||||
success, message = self.services.update_proxy(name, source, proxy)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxies/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
|
||||
def update_proxy_meta(self, name: str, meta: Metadata):
|
||||
success, message = self.services.update_proxy_meta(name, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxies/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理补丁")
|
||||
def delete_proxy_patch(self, name: str):
|
||||
success, message = self.services.delete_proxy_patch(name)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups", methods=["GET"], auth="bear", summary="获取代理组",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_proxy_groups(self):
|
||||
proxy_groups = self.services.get_proxy_groups()
|
||||
return schemas.Response(success=True, data=proxy_groups)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}", methods=["DELETE"], auth="bear", summary="删除代理组")
|
||||
def delete_proxy_group(self, name: str):
|
||||
success, message = self.services.delete_proxy_group(name)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}/meta", methods=["PATCH"], auth="bear", summary="更新代理组元数据")
|
||||
def update_proxy_group_meta(self, name: str, meta: Metadata):
|
||||
success, message = self.services.update_proxy_group_meta(name, meta)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}/patch", methods=["DELETE"], auth="bear", summary="删除代理组补丁")
|
||||
def delete_proxy_group_patch(self, name: str):
|
||||
success, message = self.services.delete_proxy_group_patch(name)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups", methods=["POST"], auth="bear", summary="添加代理组")
|
||||
def add_proxy_group(self, item: ProxyGroup):
|
||||
success, message = self.services.add_proxy_group(item)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-groups/{name}", methods=["PATCH"], auth="bear", summary="更新代理组")
|
||||
def update_proxy_group(self, name: str, source: DataSource = Body(...), proxy_group: ProxyGroup = Body(...)):
|
||||
success, message = self.services.update_proxy_group(name, source, proxy_group)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/proxy-providers", methods=["GET"], auth="bear", summary="获取代理集合",
|
||||
response_model=schemas.Response, response_model_exclude_none=True)
|
||||
def get_proxy_providers(self):
|
||||
proxy_providers = self.services.state.all_proxy_providers
|
||||
return schemas.Response(success=True, data=proxy_providers)
|
||||
|
||||
@apis.register(path="/ruleset", methods=["GET"], allow_anonymous=True, summary="获取规则集规则")
|
||||
def get_ruleset(self, name: str, apikey: str) -> PlainTextResponse:
|
||||
_apikey = self.config.apikey or settings.API_TOKEN
|
||||
if not secrets.compare_digest(_apikey, apikey):
|
||||
raise HTTPException(status_code=403, detail="Invalid API Key")
|
||||
res = self.services.get_ruleset(name)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail=f"Ruleset {name!r} not found")
|
||||
return PlainTextResponse(content=res, media_type="application/x-yaml")
|
||||
|
||||
@apis.register(path="/import", methods=["POST"], auth="bear", summary="导入规则")
|
||||
def import_rules(self, vehicle: Literal["YAML"] = Body(...), payload: str = Body(...)):
|
||||
self.services.import_rules(vehicle, payload)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/hosts", methods=["GET"], auth="bear", summary="获取 Hosts")
|
||||
def get_hosts(self):
|
||||
return schemas.Response(success=True, data=self.services.state.hosts.model_dump(mode='json'))
|
||||
|
||||
@apis.register(path="/hosts", methods=["POST"], auth="bear", summary="更新 Hosts")
|
||||
def update_hosts(self, domain: str = Body(..., embed=True), host: HostData = Body(...)):
|
||||
success, message = self.services.update_hosts(domain, host)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/hosts/{domain}", methods=["DELETE"], auth="bear", summary="删除 Hosts")
|
||||
def delete_host(self, domain: str):
|
||||
success, message = self.services.delete_host(domain)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@apis.register(path="/subscription-info", methods=["POST"], auth="bear", summary="更新订阅信息")
|
||||
def update_subscription_info(self, sub_info: SubscriptionSetting):
|
||||
self.services.update_subscription_info(sub_info)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@apis.register(path="/config", methods=["GET"], allow_anonymous=bool(True), summary="获取 Clash 配置")
|
||||
def get_clash_config(self, apikey: str, request: Request, identifier: str | None = None):
|
||||
_apikey = self.config.apikey or settings.API_TOKEN
|
||||
param = ConfigRequest(
|
||||
url=str(request.url),
|
||||
client_host=request.client.host,
|
||||
identifier=identifier,
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
if not secrets.compare_digest(apikey, _apikey):
|
||||
raise HTTPException(status_code=403, detail="Invalid API Key")
|
||||
logger.info(f"{request.client.host} 正在获取配置")
|
||||
config = self.services.build_clash_config(param=param)
|
||||
if not config:
|
||||
raise HTTPException(status_code=500, detail="配置不可用")
|
||||
|
||||
config_dict = config.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
res = yaml.dump(config_dict, allow_unicode=True, sort_keys=False)
|
||||
sub_info = self.services.get_subscription_user_info()
|
||||
headers = {'Subscription-Userinfo': sub_info.header}
|
||||
return Response(headers=headers, content=res, media_type="text/yaml")
|
||||
|
||||
@apis.register(path="/clash/proxy/{path:path}", methods=["GET"], auth="bear", summary="转发 Clash API 请求")
|
||||
async def clash_proxy(self, path: str):
|
||||
return await self.services.fetch_clash_data(path)
|
||||
|
||||
@apis.register(path="/clash/ws/{endpoint}", methods=["GET"], allow_anonymous=True,
|
||||
summary="转发 Clash API Websocket 请求")
|
||||
async def clash_websocket(self, request: Request, endpoint: str, secret: str):
|
||||
if not secrets.compare_digest(secret, self.config.dashboard_secret):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Secret 校验不通过")
|
||||
if endpoint not in ['traffic', 'connections', 'memory']:
|
||||
raise HTTPException(status_code=400, detail="Invalid endpoint")
|
||||
|
||||
# This logic is highly coupled with the web framework, so it stays here.
|
||||
queue = asyncio.Queue()
|
||||
ws_base = self.config.dashboard_url.replace(
|
||||
'http://', 'ws://').replace('https://', 'wss://')
|
||||
url = f"{ws_base}/{endpoint}?token={self.config.dashboard_secret}"
|
||||
|
||||
async def clash_ws_listener():
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=None) as ws:
|
||||
async for message in ws:
|
||||
await queue.put(json.loads(message))
|
||||
except Exception as e:
|
||||
await queue.put({"error": str(e)})
|
||||
|
||||
listener_task = asyncio.create_task(clash_ws_listener())
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
data = await queue.get()
|
||||
yield {'event': endpoint, 'data': json.dumps(data)}
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
finally:
|
||||
listener_task.cancel()
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
8
plugins.v2/clashruleprovider/base.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Constant:
|
||||
PATCH_LIFESPAN: Final[int] = 10
|
||||
ACL4SSR_API: Final[str] = "https://api.github.com/repos/ACL4SSR/ACL4SSR"
|
||||
METACUBEX_RULE_DAT_API: Final[str] = "https://api.github.com/repos/MetaCubeX/meta-rules-dat"
|
||||
MISFIRE_GRACE_TIME: Final[int] = 120
|
||||
90
plugins.v2/clashruleprovider/config.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .models.api import ClashApi
|
||||
|
||||
|
||||
class SubscriptionConfig(BaseModel):
|
||||
url: str
|
||||
rules: Optional[bool] = True
|
||||
rule_providers: Optional[bool] = Field(default=True, alias='rule-providers')
|
||||
proxies: Optional[bool] = True
|
||||
proxy_groups: Optional[bool] = Field(default=True, alias='proxy-groups')
|
||||
proxy_providers: Optional[bool] = Field(default=True, alias='proxy-providers')
|
||||
|
||||
@field_validator('url')
|
||||
@classmethod
|
||||
def validate_url(cls, v: str) -> str:
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PluginConfig(BaseModel):
|
||||
"""
|
||||
A dataclass to hold all the configuration of the ClashRuleProvider plugin.
|
||||
"""
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
enabled: bool = False
|
||||
proxy: bool = False
|
||||
notify: bool = False
|
||||
subscriptions_config: list[SubscriptionConfig] = Field(default_factory=list)
|
||||
movie_pilot_url: str = ''
|
||||
cron_string: str = '30 12 * * *'
|
||||
timeout: int = 10
|
||||
retry_times: int = 3
|
||||
filter_keywords: List[str] = Field(default_factory=list)
|
||||
auto_update_subscriptions: bool = True
|
||||
ruleset_prefix: str = '📂<='
|
||||
acl4ssr_prefix: str = '🗂️=>'
|
||||
group_by_region: bool = False
|
||||
group_by_country: bool = False
|
||||
refresh_delay: int = 5
|
||||
enable_acl4ssr: bool = False
|
||||
dashboard_components: List[str] = Field(default_factory=list)
|
||||
clash_template: str = ''
|
||||
hint_geo_dat: bool = False
|
||||
best_cf_ip: List[str] = Field(default_factory=list)
|
||||
apikey: Optional[str] = None
|
||||
clash_dashboards: List[ClashApi] = Field(default_factory=list)
|
||||
active_dashboard: Optional[int] = None
|
||||
identifiers: list[str] = Field(default_factory=list)
|
||||
cache_ttl: int = 3600
|
||||
|
||||
@field_validator('clash_dashboards')
|
||||
@classmethod
|
||||
def validate_clash_dashboards(cls, v: List[ClashApi]):
|
||||
for item in v:
|
||||
url = item.url.rstrip('/')
|
||||
if not (url.startswith('http://') or url.startswith('https://')):
|
||||
url = 'http://' + url
|
||||
item.url = url
|
||||
return v
|
||||
|
||||
@field_validator('movie_pilot_url')
|
||||
@classmethod
|
||||
def validate_movie_pilot_url(cls, v: str):
|
||||
return v.rstrip('/')
|
||||
|
||||
@property
|
||||
def sub_links(self) -> List[str]:
|
||||
return [sub.url for sub in self.subscriptions_config]
|
||||
|
||||
@property
|
||||
def dashboard_url(self) -> str:
|
||||
dashboard_url = ''
|
||||
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
|
||||
dashboard_url = self.clash_dashboards[self.active_dashboard].url
|
||||
return dashboard_url
|
||||
|
||||
@property
|
||||
def dashboard_secret(self) -> str:
|
||||
dashboard_secret = ''
|
||||
if self.active_dashboard is not None and self.active_dashboard in range(len(self.clash_dashboards)):
|
||||
dashboard_secret = self.clash_dashboards[self.active_dashboard].secret
|
||||
return dashboard_secret
|
||||
|
||||
def get_sub_conf(self, url: str) -> SubscriptionConfig:
|
||||
return next((conf for conf in self.subscriptions_config if conf.url == url), SubscriptionConfig(url=url))
|
||||
5136
plugins.v2/clashruleprovider/countries.json
Executable file → Normal file
3
plugins.v2/clashruleprovider/dist/assets/Meta-1zu2nKV2.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const MetaLogo = "/assets/Meta-uqWbsmWL.png";
|
||||
|
||||
export { MetaLogo as M };
|
||||
BIN
plugins.v2/clashruleprovider/dist/assets/Meta-uqWbsmWL.png
vendored
Normal file
|
After Width: | Height: | Size: 79 KiB |
@@ -1,4 +0,0 @@
|
||||
|
||||
.plugin-config[data-v-929102b8] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
1479
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CY46uj5g.js
vendored
Normal file
4
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Config-CwbjkOP2.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.plugin-config[data-v-3fef8398] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
.dashboard-widget[data-v-de7a088e] {
|
||||
.dashboard-widget[data-v-318a5020] {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
|
||||
.plugin-page[data-v-d6db167c] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 使卡片等宽并适应移动端 */
|
||||
.d-flex.flex-wrap[data-v-d6db167c] {
|
||||
gap: 16px;
|
||||
}
|
||||
.url-display[data-v-d6db167c] {
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 移动端堆叠布局 */
|
||||
@media (max-width: 768px) {
|
||||
.d-flex.flex-wrap[data-v-d6db167c] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add visual distinction between sections */
|
||||
.ruleset-section[data-v-d6db167c] {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.top-section[data-v-d6db167c] {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
/* Optional: Add different border colors to further distinguish */
|
||||
.ruleset-section[data-v-d6db167c] {
|
||||
border-left: 4px solid #2196F3; /* Blue accent */
|
||||
}
|
||||
.top-section[data-v-d6db167c] {
|
||||
border-left: 4px solid #4CAF50; /* Green accent */
|
||||
}
|
||||
.drag-handle[data-v-d6db167c] {
|
||||
cursor: move;
|
||||
}
|
||||
.gap-2[data-v-d6db167c] {
|
||||
gap: 8px;
|
||||
}
|
||||
84
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-BVPPK5SA.css
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
.rule-card[data-v-5bf9d562]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.proxy-group-card[data-v-88bfc397]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.proxy-card[data-v-e80a10d3]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.subscription-card[data-v-b5b6e9bb] {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.subscription-card[data-v-b5b6e9bb]:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
.card-header[data-v-b5b6e9bb] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.05);
|
||||
}
|
||||
.bg-surface-variant-lighten[data-v-b5b6e9bb] {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.02);
|
||||
}
|
||||
.stats-grid[data-v-b5b6e9bb] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bounce[data-v-6a1d5a83] {
|
||||
animation: bounce-6a1d5a83 2s infinite;
|
||||
}
|
||||
@keyframes bounce-6a1d5a83 {
|
||||
0%,
|
||||
20%,
|
||||
50%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.rule-provider-card[data-v-01e2e8ef]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.host-card[data-v-a5d6e0e6]:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.search-field {
|
||||
max-width: 25rem;
|
||||
}
|
||||
|
||||
.clash-data-table {
|
||||
max-height: 40rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.dragging-item {
|
||||
opacity: 0.5;
|
||||
background-color: rgb(var(--v-theme-grey-200));
|
||||
}
|
||||
|
||||
.drop-over {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
}
|
||||
|
||||
.plugin-page[data-v-ab912b83] {
|
||||
margin: 0 auto;
|
||||
}
|
||||
14255
plugins.v2/clashruleprovider/dist/assets/__federation_expose_Page-DfFWx370.js
vendored
Normal file
208
plugins.v2/clashruleprovider/dist/assets/_plugin-vue_export-helper-D32QZFxh.js
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
const isValidUrl = (urlString) => {
|
||||
if (!urlString) return false;
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
function isValidIP(ip) {
|
||||
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){1,7}|:):([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})$/;
|
||||
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
||||
}
|
||||
function validateIPs(ips) {
|
||||
if (ips.length === 0) {
|
||||
return `至少需要一个 IP 地址`;
|
||||
}
|
||||
for (const ip of ips) {
|
||||
if (!isValidIP(ip)) {
|
||||
return `无效的 IP 地址: ${ip}`;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function getUsageColor(percentage) {
|
||||
return percentage > 90 ? "error" : percentage > 70 ? "warning" : "success";
|
||||
}
|
||||
function getBehaviorColor(action) {
|
||||
const colors = {
|
||||
classical: "success",
|
||||
domain: "error",
|
||||
ipcidr: "error"
|
||||
};
|
||||
return colors[action] || "primary";
|
||||
}
|
||||
function getFormatColor(action) {
|
||||
const colors = {
|
||||
yaml: "success",
|
||||
text: "warning",
|
||||
mrs: "info"
|
||||
};
|
||||
return colors[action] || "secondary";
|
||||
}
|
||||
function getRuleTypeColor(type) {
|
||||
const colors = {
|
||||
DOMAIN: "primary",
|
||||
"DOMAIN-SUFFIX": "primary",
|
||||
"DOMAIN-KEYWORD": "primary",
|
||||
"DOMAIN-REGEX": "primary",
|
||||
"DOMAIN-WILDCARD": "primary",
|
||||
GEOSITE: "info",
|
||||
GEOIP: "info",
|
||||
"IP-CIDR": "warning",
|
||||
"IP-CIDR6": "warning",
|
||||
"IP-SUFFIX": "warning",
|
||||
"IP-ASN": "warning",
|
||||
"SRC-GEOIP": "info",
|
||||
"SRC-IP-ASN": "warning",
|
||||
"SRC-IP-CIDR": "warning",
|
||||
"SRC-IP-SUFFIX": "warning",
|
||||
"DST-PORT": "success",
|
||||
"SRC-PORT": "success",
|
||||
"IN-PORT": "success",
|
||||
"IN-TYPE": "success",
|
||||
"IN-USER": "success",
|
||||
"IN-NAME": "success",
|
||||
"PROCESS-PATH": "error",
|
||||
"PROCESS-PATH-REGEX": "error",
|
||||
"PROCESS-NAME": "error",
|
||||
"PROCESS-NAME-REGEX": "error",
|
||||
UID: "secondary",
|
||||
NETWORK: "secondary",
|
||||
DSCP: "secondary",
|
||||
"RULE-SET": "deep-purple",
|
||||
AND: "deep-orange",
|
||||
OR: "deep-orange",
|
||||
NOT: "deep-orange",
|
||||
"SUB-RULE": "deep-orange",
|
||||
MATCH: "teal"
|
||||
};
|
||||
return colors[type] || "grey";
|
||||
}
|
||||
function getSourceColor(source) {
|
||||
const colors = {
|
||||
Auto: "success",
|
||||
Manual: "info"
|
||||
};
|
||||
return colors[source] || "primary";
|
||||
}
|
||||
function getActionColor(action) {
|
||||
const colors = {
|
||||
DIRECT: "success",
|
||||
REJECT: "error",
|
||||
"REJECT-DROP": "error",
|
||||
PASS: "warning",
|
||||
COMPATIBLE: "info"
|
||||
};
|
||||
return colors[action] || "primary";
|
||||
}
|
||||
function getProxyGroupTypeColor(action) {
|
||||
const colors = {
|
||||
"url-test": "success",
|
||||
fallback: "error",
|
||||
"load-balance": "primary",
|
||||
select: "info"
|
||||
};
|
||||
return colors[action] || "warning";
|
||||
}
|
||||
function getProxyColor(action) {
|
||||
const colors = {
|
||||
ss: "success",
|
||||
ssr: "success",
|
||||
trojan: "error",
|
||||
vmess: "primary",
|
||||
vless: "primary",
|
||||
hysteria: "info",
|
||||
hysteria2: "info",
|
||||
anytls: "warning"
|
||||
};
|
||||
return colors[action] || "secondary";
|
||||
}
|
||||
function getBoolColor(value) {
|
||||
if (value) {
|
||||
return "primary";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
function isSystemRule(rule) {
|
||||
return rule.meta.source?.startsWith("Auto");
|
||||
}
|
||||
function isManual(source) {
|
||||
return source === "Manual";
|
||||
}
|
||||
function isInvalid(source) {
|
||||
return source === "Invalid";
|
||||
}
|
||||
function isRegion(source) {
|
||||
return source === "Auto";
|
||||
}
|
||||
function pageTitle(itemPerPageValue) {
|
||||
if (itemPerPageValue < 0) {
|
||||
return "♾️";
|
||||
}
|
||||
return `${itemPerPageValue}`;
|
||||
}
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return "N/A";
|
||||
const date = new Date(timestamp * 1e3);
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
}
|
||||
function timestampToDate(timestamp) {
|
||||
if (!timestamp) return "N/A";
|
||||
const date = new Date(timestamp * 1e3);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
// 'en-GB' 表示使用英国格式(YYYY-MM-DD HH:mm:ss)
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
// 24小时制
|
||||
});
|
||||
}
|
||||
function getExpireColor(timestamp) {
|
||||
if (!timestamp) return "grey";
|
||||
const secondsLeft = timestamp - Math.floor(Date.now() / 1e3);
|
||||
const daysLeft = secondsLeft / 86400;
|
||||
return daysLeft < 7 ? "error" : daysLeft < 30 ? "warning" : "success";
|
||||
}
|
||||
function extractDomain(url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
const parts = hostname.split(".");
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(":")) {
|
||||
return hostname;
|
||||
}
|
||||
if (parts.length <= 2) {
|
||||
return hostname;
|
||||
}
|
||||
return parts.slice(-2).join(".");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
function getUsedPercentageFloor(data) {
|
||||
const used = data.upload + data.download;
|
||||
return data.total > 0 ? Math.floor(used / data.total * 100) : 0;
|
||||
}
|
||||
|
||||
const _export_sfc = (sfc, props) => {
|
||||
const target = sfc.__vccOpts || sfc;
|
||||
for (const [key, val] of props) {
|
||||
target[key] = val;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export { _export_sfc as _, getActionColor as a, isManual as b, isRegion as c, getSourceColor as d, getProxyGroupTypeColor as e, isValidUrl as f, getRuleTypeColor as g, isInvalid as h, isSystemRule as i, getProxyColor as j, extractDomain as k, formatTimestamp as l, getExpireColor as m, formatBytes as n, getUsageColor as o, pageTitle as p, getUsedPercentageFloor as q, getFormatColor as r, getBehaviorColor as s, timestampToDate as t, getBoolColor as u, validateIPs as v };
|
||||