Compare commits
383 Commits
AutoSignIn
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e591f872a1 | ||
|
|
8d36e6865b | ||
|
|
4c73b74f59 | ||
|
|
4af7cf1583 | ||
|
|
a7d37e2f8f | ||
|
|
8dd0783b4a | ||
|
|
afc28446f5 | ||
|
|
e89047a95f | ||
|
|
ad8137a65a | ||
|
|
1ececcb410 | ||
|
|
8490668f5d | ||
|
|
1fb6b4845e | ||
|
|
b63e74b680 | ||
|
|
2b575d9e8d | ||
|
|
85ea5cba61 | ||
|
|
0665cfb71b | ||
|
|
692205095c | ||
|
|
465ce39f6f | ||
|
|
949fced655 | ||
|
|
2e352a1845 | ||
|
|
295e49311f | ||
|
|
613b1f2604 | ||
|
|
088b9e6d98 | ||
|
|
43e080839b | ||
|
|
9105a95bb9 | ||
|
|
6203fa69c2 | ||
|
|
34895bc520 | ||
|
|
3e6ad20d1b | ||
|
|
96ab3c73a7 | ||
|
|
b564a71203 | ||
|
|
75925415a3 | ||
|
|
60451c9b7f | ||
|
|
c1270840e4 | ||
|
|
c2dbaf5c5e | ||
|
|
45829c05e5 | ||
|
|
c5eac77128 | ||
|
|
1b948cab0c | ||
|
|
fa2be65bc6 | ||
|
|
08a32a009d | ||
|
|
aa502fc5fc | ||
|
|
7722ce3406 | ||
|
|
3bd0964209 | ||
|
|
157a1053b1 | ||
|
|
154f996dbe | ||
|
|
5ed9ee9793 | ||
|
|
bb3c392e62 | ||
|
|
0398af971b | ||
|
|
a5237c6a5b | ||
|
|
b048106d2e | ||
|
|
ec0c8cc521 | ||
|
|
52cd5b96e1 | ||
|
|
0d7be2b58c | ||
|
|
e29a710a33 | ||
|
|
de83b88ad1 | ||
|
|
96ac52041a | ||
|
|
287ccf50b2 | ||
|
|
08faed6ff0 | ||
|
|
944867f96e | ||
|
|
5f7c342b78 | ||
|
|
7aa0f188c7 | ||
|
|
3a7afe5047 | ||
|
|
9c87f5e51b | ||
|
|
35b010c03d | ||
|
|
74926ca150 | ||
|
|
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 |
67
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: 问题反馈
|
||||
description: File a bug report
|
||||
title: "[错误报告]: 请在此处简单描述你的问题"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请确认以下信息:
|
||||
1. 请按此模板提交 issues,不按模板提交的问题将直接关闭。
|
||||
2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到,那么你的 issue 将会被直接关闭。
|
||||
3. **$\color{red}{提交问题务必描述清楚、附上日志}$**,描述不清导致无法理解和分析的问题会被直接关闭。
|
||||
4. 此仓库为**官方插件仓库**,如果是**主程序问题**请在 [MoviePilot 主仓库](https://github.com/jxxghp/MoviePilot/issues) 提 issue,如果是**前端 WebUI 问题**请在 [前端仓库](https://github.com/jxxghp/MoviePilot-Frontend) 提 issue。
|
||||
5. **$\color{red}{不要通过 issues 来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**,否则直接关闭并加入用户 $\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。
|
||||
- type: checkboxes
|
||||
id: ensure
|
||||
attributes:
|
||||
label: 确认
|
||||
description: 在提交 issue 之前,请确认你已经阅读并确认以下内容
|
||||
options:
|
||||
- label: 我的版本是最新版本,我的版本号与 [version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。
|
||||
required: true
|
||||
- label: 我已经 [issue](https://github.com/jxxghp/MoviePilot-Plugins/issues) 中搜索过,确认我的问题没有被提出过。
|
||||
required: true
|
||||
- label: 我已经 [Telegram频道](https://t.me/moviepilot_channel) 中搜索过,确认我的问题没有被提出过。
|
||||
required: true
|
||||
- label: 我已经修改标题,将标题中的 描述 替换为我遇到的问题。
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 当前程序版本
|
||||
description: 遇到问题时 MoviePilot 主程序所在的版本号
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: plugin
|
||||
attributes:
|
||||
label: 涉及插件名称及版本
|
||||
description: 如果是插件问题,请填写插件名称及版本号(如不清楚可留空)
|
||||
placeholder: "例如:ChatGPT v3.0.4"
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 当前程序运行环境
|
||||
options:
|
||||
- Docker
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请详细描述你碰到的问题
|
||||
placeholder: "问题描述"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 发生问题时系统日志和配置文件
|
||||
description: 问题出现时,程序运行日志请复制到这里。
|
||||
render: bash
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 项目讨论
|
||||
url: https://github.com/jxxghp/MoviePilot/discussions/new/choose
|
||||
about: discussion
|
||||
- name: Telegram 频道
|
||||
url: https://t.me/moviepilot_channel
|
||||
about: 更新日志
|
||||
- name: Telegram 交流群
|
||||
url: https://t.me/moviepilot_official
|
||||
about: 交流互助
|
||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: 功能改进
|
||||
description: Feature Request
|
||||
title: "[Feature Request]: "
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请说明你希望添加的功能。
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 当前程序版本
|
||||
description: 目前使用的 MoviePilot 程序版本
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 当前程序运行环境
|
||||
options:
|
||||
- Docker
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-request
|
||||
attributes:
|
||||
label: 功能改进
|
||||
description: 请详细描述需要改进或者添加的功能。
|
||||
placeholder: "功能改进"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: 参考资料
|
||||
description: 可以列举一些参考资料,但是不要引用同类但商业化软件的任何内容。
|
||||
placeholder: "参考资料"
|
||||
144
.github/workflows/issues.yml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
|
||||
jobs:
|
||||
label-opened-issue:
|
||||
if: github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title || '';
|
||||
const body = issue.body || '';
|
||||
const currentLabels = (issue.labels || []).map((label) => label.name);
|
||||
|
||||
// 网页 Issue Form 已经会自动带模板 labels;这里只兜底处理
|
||||
// API 创建或异常路径产生的无 label issue,避免重复补标。
|
||||
if (currentLabels.length > 0) {
|
||||
core.info(`Issue #${issue.number} already has labels: ${currentLabels.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAllMarkers = (markers) => markers.every((marker) => body.includes(marker));
|
||||
const labelRules = [
|
||||
{
|
||||
label: 'bug',
|
||||
titlePrefix: '[错误报告]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 问题描述'],
|
||||
},
|
||||
{
|
||||
label: 'feature request',
|
||||
titlePrefix: '[Feature Request]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进'],
|
||||
},
|
||||
];
|
||||
|
||||
const matched = labelRules.find((rule) => (
|
||||
title.startsWith(rule.titlePrefix) || hasAllMarkers(rule.markers)
|
||||
));
|
||||
|
||||
if (!matched) {
|
||||
core.info(`Issue #${issue.number} does not match known issue templates.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [matched.label],
|
||||
});
|
||||
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
|
||||
|
||||
label-unlabeled-issues:
|
||||
if: github.event_name != 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const labelRules = [
|
||||
{
|
||||
label: 'bug',
|
||||
titlePrefix: '[错误报告]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 问题描述'],
|
||||
},
|
||||
{
|
||||
label: 'feature request',
|
||||
titlePrefix: '[Feature Request]:',
|
||||
markers: ['### 当前程序版本', '### 运行环境', '### 功能改进'],
|
||||
},
|
||||
];
|
||||
|
||||
const hasAllMarkers = (body, markers) => markers.every((marker) => body.includes(marker));
|
||||
const getMatchedRule = (issue) => {
|
||||
const title = issue.title || '';
|
||||
const body = issue.body || '';
|
||||
return labelRules.find((rule) => (
|
||||
title.startsWith(rule.titlePrefix) || hasAllMarkers(body, rule.markers)
|
||||
));
|
||||
};
|
||||
|
||||
// Search API 支持 no:label 查询;issues.listForRepo 的 labels=none
|
||||
// 会被当作名为 none 的标签,不能用于扫描无 label issue。
|
||||
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open no:label`;
|
||||
for await (const response of github.paginate.iterator(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
per_page: 100,
|
||||
})) {
|
||||
for (const issue of response.data) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = getMatchedRule(issue);
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [matched.label],
|
||||
});
|
||||
core.info(`Added label "${matched.label}" to issue #${issue.number}.`);
|
||||
}
|
||||
}
|
||||
|
||||
close-issues:
|
||||
if: github.event_name != 'issues'
|
||||
needs: label-unlabeled-issues
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
# 标记 stale 标签时间
|
||||
days-before-issue-stale: 30
|
||||
# 关闭 issues 标签时间
|
||||
days-before-issue-close: 14
|
||||
# 自定义标签名
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "此问题已过时,因为它已打开 30 天且没有任何活动。"
|
||||
close-issue-message: "此问题已关闭,因为它在标记为 stale 后,已处于无更新状态 14 天。"
|
||||
# 忽略所有的 Pull Request,只处理 Issue
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.gitignore
vendored
@@ -10,7 +10,6 @@ __pycache__/
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
@@ -70,6 +69,7 @@ instance/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
docs/superpowers/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
@@ -160,4 +160,4 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
.vscode/
|
||||
|
||||
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/Oidcauth_A.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
icons/Oidcauth_B.png
Normal file
|
After Width: | Height: | Size: 5.4 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/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 |
137
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安全性"
|
||||
@@ -245,11 +253,12 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.3",
|
||||
"version": "1.4",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.4": "兼容Jellyfin ItemAdded入库Webhook事件",
|
||||
"v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景",
|
||||
"v1.2": "播放通知增加超链接跳转(需要v1.9.4+)"
|
||||
}
|
||||
@@ -319,11 +328,14 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "1.9.11",
|
||||
"version": "1.9.14",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.9.14": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
|
||||
"v1.9.13": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v1.9.12": "修复海豹不能辅种的问题",
|
||||
"v1.9.11": "修复馒头不能辅种的问题",
|
||||
"v1.9.10": "Revert 辅种结束后,一起开始所有辅种后暂停的种子(排除了出错的种子)",
|
||||
"v1.9.9": "修复qb辅种结束后自动开始暂停的种子",
|
||||
@@ -348,11 +360,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 +375,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 +478,17 @@
|
||||
"name": "药丸签到",
|
||||
"description": "药丸论坛签到。",
|
||||
"labels": "站点",
|
||||
"version": "1.4.1",
|
||||
"version": "2.0.4",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.0.4": "切换药丸真实签到接口并校验站点实时签到状态,修复失败误报成功问题",
|
||||
"v2.0.3": "增加启用浏览器仿真功能发送请求",
|
||||
"v2.0.2": "增加签到检测机制防止重复签到,增强代码健壮性。",
|
||||
"v2.0.1": "尝试修复签到失败问题,新增使用代理、Cookie自动更新功能",
|
||||
"v2.0.0": "修复签到失败问题,新增账户登录签到功能、新增签到失败重试机制,美化界面UI",
|
||||
"v1.4.1": "更新签到域名前缀",
|
||||
"v1.4": "自定义保留消息天数"
|
||||
}
|
||||
@@ -477,11 +497,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 +511,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 +583,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 +646,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 +667,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 +695,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 +800,7 @@
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
},
|
||||
"v2": true
|
||||
}
|
||||
},
|
||||
"FeiShuMsg": {
|
||||
"name": "飞书机器人消息通知",
|
||||
@@ -801,13 +830,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 +869,6 @@
|
||||
"icon": "Macos_Sierra.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4.1": "修复Bing壁纸命名问题",
|
||||
"v1.3": "适配MoviePilot v2.5.3+版本",
|
||||
@@ -850,12 +880,14 @@
|
||||
"name": "MoviePilot服务器监控",
|
||||
"description": "在仪表板中实时显示MoviePilot公共服务器状态。",
|
||||
"labels": "仪表板",
|
||||
"version": "1.2",
|
||||
"version": "1.4",
|
||||
"icon": "Duplicati_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4": "重新设计仪表板,仅展示响应延迟、连接状态和速率等关键卡片",
|
||||
"v1.3": "增加HTTP/DNS/TLS探测、请求速率、连接占比和异常兜底展示",
|
||||
"v1.2": "优化数量示",
|
||||
"v1.1": "增加详情界面显示"
|
||||
}
|
||||
@@ -943,11 +975,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 +992,6 @@
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.7.3": "修复检测登录的元素",
|
||||
"v1.7.2": "||wan参数细分,修复使用||wan时立即检测一次实际不生效,修复v1第三方备用通知可能无效,调整验证码获取",
|
||||
@@ -1040,5 +1074,54 @@
|
||||
"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.13",
|
||||
"icon": "airecognizerenhancer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.1.13": "同步运行态能力,兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,并修复失败样本持久化、清空和自动移除后的配置保存一致性。",
|
||||
"0.1.10": "完善失败样本批量工作流,支持复查、批量建议、批量写入、低 token 摘要,并在模型异常时提供精确规则兜底。",
|
||||
"0.1.4": "建立失败样本治理闭环,支持失败样本查看、摘要洞察、清理去重、保留上限,以及生成并写入 CustomIdentifiers。",
|
||||
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
|
||||
}
|
||||
},
|
||||
"AgentResourceOfficer": {
|
||||
"name": "Agent影视助手",
|
||||
"description": "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
|
||||
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
|
||||
"version": "0.3.0",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"0.3.0": "精简 Agent/MCP 暴露工具集,仅保留核心业务能力工具,移除自建计划、会话和自描述脚手架噪声;保留飞书与 HTTP 端点。",
|
||||
"0.2.99": "重做 Vue 配置页与 115 扫码体验,修复扫码登录、配置保存、依赖误判、影巢网页登录 Cookie、自动刷新和 Playwright 兜底等问题。",
|
||||
"0.2.74": "收口云盘搜索、转存、影巢签到和恢复链路,适配影巢 OpenAPI 新鉴权,补齐 115/夸克目录清理、Cookie 修复、远端接入和兼容检查。",
|
||||
"0.2.67": "沉淀外部智能体执行契约和低 token 模板,统一 followup、error、score、command 摘要,并完善执行后追踪、恢复、维护与模板编排。",
|
||||
"0.2.43": "形成 MP 原生 PT 主线与智能评分推荐,新增媒体识别、热门推荐、搜索详情、下载/订阅/控制计划、生命周期追踪、入库诊断、偏好画像和安全确认链路。",
|
||||
"0.1.119": "完善请求模板协议、启动包、自检、维护和飞书入口迁移,并内置影巢签到日志与 Cookie 自动刷新,降低外部智能体接入成本。",
|
||||
"0.1.79": "建立 assistant 协议层,新增 action、workflow、plans、history、readiness、recover、startup、toolbox、selfcheck 等能力,支持 compact 回执、会话恢复和安全 dry-run 计划执行。",
|
||||
"0.1.34": "打通统一智能入口、原生 Agent Tool、会话状态/清理、115 扫码登录、待处理任务恢复与 115 独立直转执行层。",
|
||||
"0.1.9": "首版接入夸克/115 转存、影巢搜索/解锁、盘搜/直链路由、候选分页详情、账号配额、签到兜底和健康检查。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
429
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,17 @@
|
||||
"name": "站点自动签到",
|
||||
"description": "自动模拟登录、签到站点。",
|
||||
"labels": "站点",
|
||||
"version": "2.7",
|
||||
"version": "2.9.1",
|
||||
"icon": "signin.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.9.1": "修复详情页历史记录部分站点只显示站点ID的问题",
|
||||
"v2.9.0": "优化插件详情页,改为紧凑状态矩阵展示签到和登录情况",
|
||||
"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 +68,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 +99,18 @@
|
||||
"name": "媒体库服务器通知",
|
||||
"description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。",
|
||||
"labels": "消息通知,媒体库",
|
||||
"version": "1.6",
|
||||
"version": "1.8.2.3",
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.8.2.3": "兼容Jellyfin ItemAdded入库Webhook事件",
|
||||
"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 版本媒体库服务器通知插件"
|
||||
@@ -100,14 +118,23 @@
|
||||
},
|
||||
"ChatGPT": {
|
||||
"name": "ChatGPT",
|
||||
"description": "消息交互支持与ChatGPT对话。",
|
||||
"labels": "消息通知,识别",
|
||||
"version": "2.1.7",
|
||||
"description": "使用系统智能助手或 Agent Tokens 管理插件的 LLM 配置增强媒体名称识别。",
|
||||
"labels": "AI,识别,LLM,媒体识别,Agent Tokens",
|
||||
"version": "3.0.5",
|
||||
"icon": "Chatgpt_A.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">=2.13.2",
|
||||
"history": {
|
||||
"v2.1.7":"独立安装OpenAi SDK依赖",
|
||||
"v3.0.5": "新增识别调用 Token 用量统计,详情页按千分位展示并支持清除统计,同时优化缓存说明文案。",
|
||||
"v3.0.4": "重构缓存管理 UI,缓存数量改从数据库读取,新增插件详情页管理缓存。",
|
||||
"v3.0.3": "添加识别结果持久化缓存,避免相同标题重复调用 LLM API,支持手动清除缓存并显示缓存数量。",
|
||||
"v3.0.2": "修复插件入口误打开空详情页,并统一配置表单行列布局。",
|
||||
"v3.0.1": "兼容系统智能助手和 Agent Tokens 供应商的 LLM 代理开关配置。",
|
||||
"v3.0": "移除聊天功能,仅保留媒体识别增强;模型来源改为系统智能助手设置或 Agent Tokens 管理插件;重写英文识别提示词并优化配置界面。",
|
||||
"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 +150,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)",
|
||||
@@ -167,11 +195,13 @@
|
||||
"name": "媒体库刮削",
|
||||
"description": "定时对媒体库进行刮削,补齐缺失元数据和图片。",
|
||||
"labels": "刮削",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.3",
|
||||
"icon": "scraper.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v2.1.3": "修复分类路径下按文件标记识别及强制类型跨库扫描问题",
|
||||
"v2.1.2": "修复分类目录被误识别导致下级媒体未刮削的问题",
|
||||
"v2.1.1": "调整目录计算方法,以支持更多重命名格式",
|
||||
"v2.1": "优化执行周期输入,需要MoviePilot v2.2.1+",
|
||||
"v2.0": "兼容MoviePilot V2 版本",
|
||||
@@ -183,11 +213,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 +276,14 @@
|
||||
"name": "IYUU自动辅种",
|
||||
"description": "基于IYUU官方Api实现自动辅种。",
|
||||
"labels": "做种,IYUU",
|
||||
"version": "2.14",
|
||||
"version": "2.17",
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp,CKun",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v2.17": "修复由于站点哈希值过期导致辅种失败的问题,并优化代码逻辑",
|
||||
"v2.16": "限制辅种缓存大小并重置运行期校验队列,避免长期运行缓存无限增长",
|
||||
"v2.15": "修复海豹不能辅种的问题",
|
||||
"v2.14": "修复馒头不能辅种的问题",
|
||||
"v2.13": "开启跳过校验后需手动开启自动开始",
|
||||
"v2.12": "增加qb下载器分类复用配置",
|
||||
@@ -267,11 +305,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 +391,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 +474,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 +497,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 +525,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 +539,33 @@
|
||||
"name": "Clash Rule Provider",
|
||||
"description": "随时为Clash添加一些额外的规则。",
|
||||
"labels": "工具",
|
||||
"version": "1.3.2",
|
||||
"version": "2.1.7",
|
||||
"icon": "Mihomo_Meta_A.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"release": true,
|
||||
"history": {
|
||||
"v2.1.7": "移除全局 TLS 指纹配置,请在代理节点中直接设置 client-fingerprint",
|
||||
"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 +588,22 @@
|
||||
"name": "美剧生词标注",
|
||||
"description": "根据CEFR等级,为英语影视剧标注高级词汇。",
|
||||
"labels": "英语",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.6",
|
||||
"icon": "LexiAnnot.png",
|
||||
"author": "wumode",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.2.6": "适配 MoviePilot 新版 LLM 助手",
|
||||
"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 +620,291 @@
|
||||
"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.1.2",
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "RamenRa",
|
||||
"level": 2,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v2.1.2": "修复本地扫码获取不到验证码的问题",
|
||||
"v2.1.1": "优化MP/Nas关闭期间IP变动检测不到的现象。支持IYUU通知移除AnPush v2支持在微信通知失效时用第三方发送通知 支持||Q修改IP时不发送通知 使用全局AI助手需使用/wxcode 510010的格式发送验证码",
|
||||
"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.1",
|
||||
"icon": "invites.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"release": true,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v3.0.1": "切换药丸真实签到接口并校验站点实时签到状态,修复失败误报成功问题",
|
||||
"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.13",
|
||||
"icon": "airecognizerenhancer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.1.13": "同步运行态能力,兼容 MoviePilot 最新 LLM 路径与异步 get_llm 接口,并修复失败样本持久化、清空和自动移除后的配置保存一致性。",
|
||||
"0.1.10": "完善失败样本批量工作流,支持复查、批量建议、批量写入、低 token 摘要,并在模型异常时提供精确规则兜底。",
|
||||
"0.1.4": "建立失败样本治理闭环,支持失败样本查看、摘要洞察、清理去重、保留上限,以及生成并写入 CustomIdentifiers。",
|
||||
"0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。"
|
||||
}
|
||||
},
|
||||
"AgentResourceOfficer": {
|
||||
"name": "Agent影视助手",
|
||||
"description": "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。",
|
||||
"labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁",
|
||||
"version": "0.3.0",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "liuyuexi1987",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"0.3.0": "精简 Agent/MCP 暴露工具集,仅保留核心业务能力工具,移除自建计划、会话和自描述脚手架噪声;保留飞书与 HTTP 端点。",
|
||||
"0.2.99": "重做 Vue 配置页与 115 扫码体验,修复扫码登录、配置保存、依赖误判、影巢网页登录 Cookie、自动刷新和 Playwright 兜底等问题。",
|
||||
"0.2.74": "收口云盘搜索、转存、影巢签到和恢复链路,适配影巢 OpenAPI 新鉴权,补齐 115/夸克目录清理、Cookie 修复、远端接入和兼容检查。",
|
||||
"0.2.67": "沉淀外部智能体执行契约和低 token 模板,统一 followup、error、score、command 摘要,并完善执行后追踪、恢复、维护与模板编排。",
|
||||
"0.2.43": "形成 MP 原生 PT 主线与智能评分推荐,新增媒体识别、热门推荐、搜索详情、下载/订阅/控制计划、生命周期追踪、入库诊断、偏好画像和安全确认链路。",
|
||||
"0.1.119": "完善请求模板协议、启动包、自检、维护和飞书入口迁移,并内置影巢签到日志与 Cookie 自动刷新,降低外部智能体接入成本。",
|
||||
"0.1.79": "建立 assistant 协议层,新增 action、workflow、plans、history、readiness、recover、startup、toolbox、selfcheck 等能力,支持 compact 回执、会话恢复和安全 dry-run 计划执行。",
|
||||
"0.1.34": "打通统一智能入口、原生 Agent Tool、会话状态/清理、115 扫码登录、待处理任务恢复与 115 独立直转执行层。",
|
||||
"0.1.9": "首版接入夸克/115 转存、影巢搜索/解锁、盘搜/直链路由、候选分页详情、账号配额、签到兜底和健康检查。"
|
||||
}
|
||||
},
|
||||
"AutoAuction": {
|
||||
"name": "朱雀交易行自动上架",
|
||||
"description": "自动上架上传或灵石到交易行,支持定时任务和历史记录",
|
||||
"labels": "交易行自动化",
|
||||
"version": "1.0.1",
|
||||
"icon": "auction.png",
|
||||
"author": "no_reply",
|
||||
"level": 2,
|
||||
"history": {
|
||||
"v1.0.0": "初始版本,支持交易行自动上架和定时任务",
|
||||
"v1.0.1": "修复定时任务重复触发问题"
|
||||
}
|
||||
},
|
||||
"OidcAuth": {
|
||||
"name": "OIDC 认证",
|
||||
"description": "通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。",
|
||||
"labels": "认证,OIDC,SSO",
|
||||
"version": "0.3.1",
|
||||
"icon": "Oidcauth_A.png",
|
||||
"author": "ui-beam-9,jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">=2.13.5",
|
||||
"release": true,
|
||||
"history": {
|
||||
"v0.1.0": "新增插件化 OIDC 登录、账号绑定、Provider 配置与联邦认证界面。",
|
||||
"v0.2.0": "AuthPage 自动跳转 OIDC 授权,新增加载动画与错误重试;修复弹窗拦截提示及 PROXY_HOST 空值崩溃,补充配置表单指南。",
|
||||
"v0.3.0": "重构双栏布局与动态背景,支持深浅主题自适应;新增绑定可视化、详情卡片及解绑确认;升级通信机制,新增特性介绍与底部信息栏,统一图标风格。",
|
||||
"v0.3.1": "修复回调事件类型不匹配导致前端错误提示不准确;移除解绑方法多余检查,允许 OIDC 关闭状态下正常解绑。"
|
||||
}
|
||||
},
|
||||
"AgentTokens": {
|
||||
"name": "Agent Tokens 管理",
|
||||
"description": "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。",
|
||||
"labels": "Agent,AI,系统",
|
||||
"version": "1.0.12",
|
||||
"icon": "agentresourceofficer.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"system_version": ">=2.13.2",
|
||||
"release": true,
|
||||
"history": {
|
||||
"v1.0.12": "优化仪表板组件主题 token 适配和手动调整大小后的紧凑自适应布局",
|
||||
"v1.0.11": "重设计仪表板组件,改为紧凑卡片式配额概览并对齐主仪表板视觉风格",
|
||||
"v1.0.10": "新增供应商使用代理服务器配置,分配 Agent LLM 供应商时按配置传递代理开关",
|
||||
"v1.0.9": "统一配置页和管理页内容,新增总使用进度图表卡片并优化大小屏布局",
|
||||
"v1.0.8": "支持为 Agent LLM 供应商配置并传递 User-Agent",
|
||||
"v1.0.7": "禁用VWindow触摸滑动,修复表格内滑动触发tab切换问题",
|
||||
"v1.0.6": "优化标题样式并对齐站点管理页面风格,修复弹窗标题截断问题",
|
||||
"v1.0.5": "优化UI布局,修复页面标题和按钮滚动问题",
|
||||
"v1.0.4": "补充分配模型信息及更新用量的运行日志",
|
||||
"v1.0.3": "修复因组件导出导致的界面空白问题、统一图标样式并去除全局样式污染",
|
||||
"v1.0.2": "修复UI界面显示不全及前端路由报错问题",
|
||||
"v1.0.1": "新增 Agent Tokens 配额管理、供应商优先级切换和用量展示"
|
||||
}
|
||||
},
|
||||
"TraktCleaner": {
|
||||
"name": "Trakt 观看清理",
|
||||
"description": "根据 Trakt 播放记录,自动清理下载器中已观看的种子。",
|
||||
"labels": "Trakt,清理",
|
||||
"version": "1.0",
|
||||
"icon": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/trakt.png",
|
||||
"author": "Guoyin-Wen",
|
||||
"level": 1,
|
||||
"history": {
|
||||
"v1.0": "初始版本:根据 Trakt 播放记录自动清理下载器中已观看的种子"
|
||||
}
|
||||
},
|
||||
"UpdateWeChatIp": {
|
||||
"name": "动态企微可信IP",
|
||||
"description": "修改企微应用可信IP,可本地扫码刷新Cookie,直接调用接口更稳定",
|
||||
"labels": "消息通知",
|
||||
"version": "1.0.8",
|
||||
"icon": "Wecom_A.png",
|
||||
"author": "书小白",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"1.0.8": "完善日志输出",
|
||||
"1.0.7": "插件初始化时调用一下check确定登录状态",
|
||||
"1.0.6": "修复未登录时_party_cache_data为空导致UI崩溃的BUG\n图片地址优先使用MP_DOMAIN获取,如果未配置使用127.0.0.1地址\n回调解析qrcode_key时判断是否存在,不存在发送错误\n优化请求企微接口的参数",
|
||||
"1.0.5": "根据Code Review结果优化代码",
|
||||
"1.0.4": "增加IP更新记录查询",
|
||||
"1.0.3": "cookie保活输出返回值",
|
||||
"1.0.2": "支持多个应用ID",
|
||||
"1.0.1": "IP更新时发送通知,增加API接口,指定更新的IP",
|
||||
"1.0.0": "初始化"
|
||||
}
|
||||
},
|
||||
"MaoyanRank": {
|
||||
"name": "猫眼榜单订阅",
|
||||
"description": "监控猫眼数据,自动添加订阅。",
|
||||
"version": "3.1",
|
||||
"icon": "https://raw.githubusercontent.com/baozaodetudou/MoviePilot-Plugins/main/icons/maoyan.jpg",
|
||||
"color": "#fefefe",
|
||||
"author": "逗猫",
|
||||
"level": 1,
|
||||
"system_version": ">=2.12.0",
|
||||
"history": {
|
||||
"v3.1": "优化详情页UI,增加分页卡片与空值兜底,避免历史记录显示None",
|
||||
"v3.0": "适配MoviePilot浏览器助手,修复Playwright浏览器可执行文件路径失配问题",
|
||||
"v2.7": "增加优酷平台获取",
|
||||
"v1.7": "更改榜单的排列组合支持多个平台同时订阅的功能",
|
||||
"v1.6": "适配新的url获取",
|
||||
"v1.5": "增加了条数配置选项支持1,2,3,5,7,10选择",
|
||||
"v1.4": "增加支持了网络电影的订阅功能",
|
||||
"v1.3": "取消猫眼榜单删除的平台",
|
||||
"v1.2": "修改获取榜单的方法增加成功率",
|
||||
"v1.1": "优化执行周期录入",
|
||||
"v1.0": "修复使用缓存进行订阅搜索集数不全的问题",
|
||||
"v0.7": "界面点击名称跳转TMDB",
|
||||
"v0.6": "更改图标",
|
||||
"v0.5": "更改爬取方式,提高成功率",
|
||||
"v0.4": "电视剧订阅添加去重",
|
||||
"v0.3": "增加平台分类",
|
||||
"v0.2": "电视剧电影以及综艺的分类",
|
||||
"v0.1": "初始化版本猫眼订阅功能"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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帮助` 统一补充下一步建议,减少人工猜测下一条命令
|
||||
218
plugins.v2/agentresourceofficer/README.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Agent影视助手
|
||||
|
||||
`Agent影视助手` 是这个仓库的主线插件,重点解决一件事:
|
||||
|
||||
把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。
|
||||
|
||||
当前版本:`0.2.73`
|
||||
|
||||
当前 helper 版本:`0.1.51`
|
||||
|
||||
当前已验证上游 MoviePilot:`v2.11.4`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
|
||||
|
||||
如果你是第一次用这个仓库,先把这个插件跑通就够了。
|
||||
|
||||
---
|
||||
|
||||
## 适合谁
|
||||
|
||||
- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。
|
||||
- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。
|
||||
- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。
|
||||
- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅` 放进同一套命令入口。
|
||||
- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。
|
||||
|
||||
---
|
||||
|
||||
## 两种主要用法
|
||||
|
||||
### 1. 不使用外部智能体,只用飞书命令入口
|
||||
|
||||
如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。
|
||||
|
||||
配好后,直接在飞书里发:
|
||||
|
||||
```text
|
||||
盘搜搜索 片名
|
||||
影巢搜索 片名
|
||||
搜索 片名
|
||||
选择 1
|
||||
下载 片名
|
||||
订阅 片名
|
||||
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 搜索,不再回退到云盘。
|
||||
- `更新检查 <片名>`、`查更新 <片名>`、`检查 <片名>` 这些旧写法已并回搜索语义;现在直接用 `搜索 / 盘搜搜索 / 影巢搜索 / MP搜索 / PT搜索` 即可。
|
||||
- `检查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`
|
||||
27852
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)
|
||||
34
plugins.v2/agentresourceofficer/dist/assets/__federation_expose_Config-DenBkx3K.css
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
.aro-config[data-v-eb2e8235] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 82vh;
|
||||
}
|
||||
.aro-toolbar[data-v-eb2e8235] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.aro-body[data-v-eb2e8235] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.aro-inner[data-v-eb2e8235] {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.aro-intro[data-v-eb2e8235] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-primary), 0.06);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.aro-card-head[data-v-eb2e8235] {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
1838
plugins.v2/agentresourceofficer/dist/assets/__federation_expose_Config-SJKIC-xp.js
vendored
Normal file
38
plugins.v2/agentresourceofficer/dist/assets/__federation_expose_Page-DAJ1MzFo.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import Config from './__federation_expose_Config-SJKIC-xp.js';
|
||||
|
||||
const {openBlock:_openBlock,createBlock:_createBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Page',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['save', 'close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
return (_openBlock(), _createBlock(Config, {
|
||||
api: __props.api,
|
||||
"initial-config": __props.initialConfig,
|
||||
onSave: _cache[0] || (_cache[0] = payload => emit('save', payload)),
|
||||
onClose: _cache[1] || (_cache[1] = $event => (emit('close')))
|
||||
}, null, 8, ["api", "initial-config"]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
418
plugins.v2/agentresourceofficer/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 };
|
||||
44
plugins.v2/agentresourceofficer/dist/assets/index-WG_aDWmR.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import _sfc_main from './__federation_expose_Page-DAJ1MzFo.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(_sfc_main).mount('#app');
|
||||
84
plugins.v2/agentresourceofficer/dist/assets/remoteEntry.js
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
const currentImports = {};
|
||||
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
|
||||
let moduleMap = {
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Config-DenBkx3K.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-SJKIC-xp.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Page":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Config-DenBkx3K.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-DAJ1MzFo.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__AgentResourceOfficer__' + 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/agentresourceofficer/dist/index.html
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<script type="module" crossorigin src="/assets/index-WG_aDWmR.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_Config-SJKIC-xp.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_Page-DAJ1MzFo.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/__federation_expose_Config-DenBkx3K.css">
|
||||
<div id="app"></div>
|
||||
1941
plugins.v2/agentresourceofficer/feishu_channel.py
Normal file
2
plugins.v2/agentresourceofficer/index.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
1283
plugins.v2/agentresourceofficer/package-lock.json
generated
Normal file
18
plugins.v2/agentresourceofficer/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "moviepilot-agent-resource-officer-plugin",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
4
plugins.v2/agentresourceofficer/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests
|
||||
cloudscraper
|
||||
lark-oapi>=1.4.0
|
||||
p115client==0.0.8.6.4
|
||||
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影视助手."""
|
||||
396
plugins.v2/agentresourceofficer/services/hdhive_browser.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
影巢(HDHive)网页方式资源搜索/解锁服务。
|
||||
|
||||
通过 MoviePilot 官方 app.helper.browser.PlaywrightHelper(cloakbrowser 后端,
|
||||
内置反检测与 FlareSolverr),用账号 cookie 在影巢网页上搜索资源、解锁拿 115 链接,
|
||||
不依赖影巢 OpenAPI。仅在 MoviePilot docker 容器内 headless 运行。
|
||||
|
||||
本模块的页面抓取/解锁 JavaScript 与流程改编自 GPL v3 项目
|
||||
DDSRem-Dev/MoviePilot-Plugins (plugins.v2/p115strmhelper/helper/hdhive/browser.py)。
|
||||
原仓库: https://github.com/DDSRem-Dev/MoviePilot-Plugins
|
||||
本仓库同为 GPL v3。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.log import logger
|
||||
|
||||
# 改编自 DDSRem-Dev p115strmhelper browser.py::_scrape_resource_cards_js (GPL v3)
|
||||
_SCRAPE_CARDS_JS = r"""
|
||||
() => {
|
||||
const sizeRe = /(\d+\.?\d*)\s*(TB|GB|MB|G(?!B)|M(?!B))\b/i;
|
||||
const dateRe = /发布于\s*([\d/\-]+)/;
|
||||
const resRe = /\b(4K|8K|2K|1080[piP]?|720[piP]?|480[piP]?)\b/;
|
||||
const pointsRe = /(\d+)\s*积分/;
|
||||
const candidates = [];
|
||||
for (const el of document.querySelectorAll('a,div,article,li,section')) {
|
||||
const t = el.innerText || '';
|
||||
if (!t.includes('发布于') || !sizeRe.test(t)) continue;
|
||||
if ((t.match(/发布于/g) || []).length !== 1) continue;
|
||||
if (t.length < 30 || t.length > 5000) continue;
|
||||
candidates.push(el);
|
||||
}
|
||||
const minimal = candidates.filter(
|
||||
el => !candidates.some(other => other !== el && el.contains(other))
|
||||
);
|
||||
const metaTerms = new Set([
|
||||
'4K','8K','2K','免费','官组','管理员','WEB-DL','WEBRip','BDRip','REMUX','HDTV',
|
||||
'简中','繁中','简英','繁英','内封','外挂','内嵌','简日','繁日','简韩','繁韩',
|
||||
'1080P','1080p','720P','720p','480P','480p','蓝光原盘','ISO'
|
||||
]);
|
||||
return minimal.map(card => {
|
||||
const text = card.innerText || '';
|
||||
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
const dateMatch = text.match(dateRe);
|
||||
const sizeMatch = text.match(sizeRe);
|
||||
const resMatch = text.match(resRe);
|
||||
const pointsMatch = text.match(pointsRe);
|
||||
const isFree = text.includes('免费');
|
||||
const tags = [];
|
||||
if (text.includes('官组') || text.includes('管理员')) tags.push('官组');
|
||||
if (isFree) tags.push('免费');
|
||||
if (pointsMatch) tags.push(pointsMatch[0].trim());
|
||||
const dateLineIdx = lines.findIndex(l => /发布于/.test(l));
|
||||
const user = dateLineIdx > 0 ? lines[dateLineIdx - 1] : (lines[0] || '');
|
||||
const titleLines = lines.filter(l => {
|
||||
if (l.length < 3) return false;
|
||||
if (metaTerms.has(l)) return false;
|
||||
if (/^发布于/.test(l)) return false;
|
||||
if (/^\d+\s*积分$/.test(l)) return false;
|
||||
if (/^\d+\.?\d*\s*(T?B|G[Bi]?|M[Bi]?)$/i.test(l)) return false;
|
||||
if (l === user) return false;
|
||||
return true;
|
||||
});
|
||||
let title = titleLines
|
||||
.map(l => l.replace(/^\d+\s*积分\s*/, '').trim())
|
||||
.filter(Boolean).join(' ').trim();
|
||||
let hrefEl = card;
|
||||
while (hrefEl && hrefEl.tagName !== 'A') { hrefEl = hrefEl.parentElement; }
|
||||
const href = hrefEl ? (hrefEl.getAttribute('href') || '') : '';
|
||||
return {
|
||||
user, posted_at: dateMatch ? dateMatch[1] : '', tags, title,
|
||||
resolution: resMatch ? resMatch[1] : '',
|
||||
size: sizeMatch ? (sizeMatch[1] + ' ' + sizeMatch[2].toUpperCase()) : '',
|
||||
is_free: isFree,
|
||||
unlock_points: isFree ? 0 : (pointsMatch ? parseInt(pointsMatch[1]) : null),
|
||||
href,
|
||||
};
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
# 改编自 DDSRem-Dev p115strmhelper browser.py::unlock_resource 内 _EXTRACT_URL_JS (GPL v3)
|
||||
_EXTRACT_115_URL_JS = r"""
|
||||
() => {
|
||||
const urlPrefixRe = /^https?:\/\/(115cdn|115)\.com\//;
|
||||
for (const el of document.querySelectorAll('input')) {
|
||||
const v = (el.value || '').trim();
|
||||
if (urlPrefixRe.test(v)) return v;
|
||||
}
|
||||
for (const el of document.querySelectorAll('div, span, p, a, code')) {
|
||||
if (el.children.length > 0) continue;
|
||||
const t = (el.textContent || '').trim();
|
||||
if (urlPrefixRe.test(t)) return t;
|
||||
}
|
||||
const m = (document.body?.innerText || '').match(/https?:\/\/(115cdn|115)\.com\/\S+/);
|
||||
return m ? m[0].replace(/\s+$/, '') : null;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class HDHiveBrowserService:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://hdhive.com",
|
||||
cookie: str = "",
|
||||
timeout: int = 30,
|
||||
cookie_refresh_callback: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
self.base_url = (base_url or "https://hdhive.com").rstrip("/")
|
||||
self.cookie = (cookie or "").strip()
|
||||
self.timeout = int(timeout or 30)
|
||||
self.cookie_refresh_callback = cookie_refresh_callback
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
return bool(self.cookie)
|
||||
|
||||
@staticmethod
|
||||
def _cookie_expired_result() -> Dict[str, str]:
|
||||
return {"__hdhive_browser_error__": "cookie_expired"}
|
||||
|
||||
@staticmethod
|
||||
def _is_cookie_expired_result(value: Any) -> bool:
|
||||
return isinstance(value, dict) and value.get("__hdhive_browser_error__") == "cookie_expired"
|
||||
|
||||
def _refresh_cookie(self) -> str:
|
||||
if not self.cookie_refresh_callback:
|
||||
return ""
|
||||
try:
|
||||
cookie = self.cookie_refresh_callback()
|
||||
except Exception as exc:
|
||||
logger.warning(f"[HDHiveBrowser] 自动刷新 Cookie 失败: {exc}")
|
||||
return ""
|
||||
cookie = str(cookie or "").strip()
|
||||
if cookie:
|
||||
self.cookie = cookie
|
||||
return cookie
|
||||
|
||||
def _context_cookies(self) -> List[Dict[str, str]]:
|
||||
items: List[Dict[str, str]] = []
|
||||
for part in str(self.cookie or "").split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
name, value = part.strip().split("=", 1)
|
||||
name = name.strip()
|
||||
value = value.strip()
|
||||
if name and value:
|
||||
items.append({"name": name, "value": value, "url": f"{self.base_url}/"})
|
||||
return items
|
||||
|
||||
def _detail_url(self, media_type: Any, tmdb_id: Any) -> str:
|
||||
mt = "movie" if str(media_type).lower() in ("movie", "电影") else "tv"
|
||||
return f"{self.base_url}/tmdb/{mt}/{tmdb_id}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize(card: Dict[str, Any]) -> Dict[str, Any]:
|
||||
href = (card.get("href") or "").strip()
|
||||
slug = href.rstrip("/").split("/")[-1] if href else ""
|
||||
return {
|
||||
"slug": slug,
|
||||
"href": href,
|
||||
"title": card.get("title", ""),
|
||||
"resolution": card.get("resolution", ""),
|
||||
"size": card.get("size", ""),
|
||||
"is_free": bool(card.get("is_free")),
|
||||
"unlock_points": card.get("unlock_points"),
|
||||
"user": card.get("user", ""),
|
||||
"posted_at": card.get("posted_at", ""),
|
||||
"tags": card.get("tags", []),
|
||||
}
|
||||
|
||||
def _run_browser_action(self, url: str, callback: Any) -> Any:
|
||||
"""Run MoviePilot's sync Playwright helper outside the active async request loop."""
|
||||
helper_timeout = max(60, self.timeout)
|
||||
|
||||
def _callback_with_context_cookies(page: Any) -> Any:
|
||||
context_cookies = self._context_cookies()
|
||||
if context_cookies:
|
||||
page.context.add_cookies(context_cookies)
|
||||
page.goto(url)
|
||||
page.wait_for_load_state("networkidle", timeout=helper_timeout * 1000)
|
||||
return callback(page)
|
||||
|
||||
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="hdhive-browser")
|
||||
future = executor.submit(
|
||||
lambda: PlaywrightHelper().action(
|
||||
url,
|
||||
callback=_callback_with_context_cookies,
|
||||
timeout=helper_timeout,
|
||||
)
|
||||
)
|
||||
try:
|
||||
return future.result(timeout=helper_timeout + 30)
|
||||
except concurrent.futures.TimeoutError as exc:
|
||||
future.cancel()
|
||||
raise RuntimeError(f"影巢网页操作超时({helper_timeout} 秒)") from exc
|
||||
finally:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def search(self, media_type: Any, tmdb_id: Any) -> List[Dict[str, Any]]:
|
||||
"""打开影巢详情页抓资源卡片。失败返回 []。"""
|
||||
url = self._detail_url(media_type, tmdb_id)
|
||||
|
||||
def _callback(page: Any) -> List[Dict[str, Any]]:
|
||||
cards: List[Dict[str, Any]] = []
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if "/login" in (page.url or ""):
|
||||
return self._cookie_expired_result()
|
||||
cards = page.evaluate(_SCRAPE_CARDS_JS) or []
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
cards = []
|
||||
if cards:
|
||||
break
|
||||
page.wait_for_timeout(500)
|
||||
return cards
|
||||
|
||||
try:
|
||||
cards = self._run_browser_action(url, _callback)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[HDHiveBrowser] 搜索失败({url}): {exc}")
|
||||
return []
|
||||
if self._is_cookie_expired_result(cards):
|
||||
raise RuntimeError("cookie 失效,被重定向到登录页")
|
||||
return [self._normalize(c) for c in (cards or []) if c.get("href")]
|
||||
|
||||
def unlock(self, slug: str) -> Dict[str, Any]:
|
||||
"""解锁资源,返回 {'url','already_owned'}。失败抛 RuntimeError。"""
|
||||
if not slug:
|
||||
raise RuntimeError("缺少资源 slug")
|
||||
url = f"{self.base_url}/resource/115/{slug}"
|
||||
|
||||
def _callback(page: Any) -> Dict[str, Any]:
|
||||
captured: Dict[str, Optional[str]] = {"url": None}
|
||||
|
||||
def _on_response(response: Any) -> None:
|
||||
try:
|
||||
if response.status != 200:
|
||||
return
|
||||
if "json" not in response.headers.get("content-type", ""):
|
||||
return
|
||||
body = response.json()
|
||||
if not isinstance(body, dict):
|
||||
return
|
||||
data = body.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
for key in ("full_url", "url", "link", "resource_url"):
|
||||
val = data.get(key)
|
||||
if val and re.search(r"(115cdn|115)\.com", str(val)):
|
||||
captured["url"] = str(val).strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", _on_response)
|
||||
|
||||
if "/login" in (page.url or ""):
|
||||
return self._cookie_expired_result()
|
||||
|
||||
confirm = page.get_by_text("确定解锁", exact=True)
|
||||
existing: Optional[str] = None
|
||||
has_confirm = False
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
existing = page.evaluate(_EXTRACT_115_URL_JS)
|
||||
except Exception:
|
||||
existing = None
|
||||
if existing:
|
||||
break
|
||||
try:
|
||||
if confirm.first.is_visible():
|
||||
has_confirm = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
if existing:
|
||||
return {"url": existing, "already_owned": True}
|
||||
if not has_confirm:
|
||||
raise RuntimeError(f"未找到「确定解锁」按钮或链接(URL: {page.url})")
|
||||
|
||||
confirm.first.click()
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
if captured["url"]:
|
||||
return {"url": captured["url"], "already_owned": False}
|
||||
if re.search(r"(115cdn|115)\.com", page.url or ""):
|
||||
return {"url": page.url, "already_owned": False}
|
||||
try:
|
||||
extracted = page.evaluate(_EXTRACT_115_URL_JS)
|
||||
except Exception:
|
||||
extracted = None
|
||||
if extracted:
|
||||
return {"url": extracted, "already_owned": False}
|
||||
page.wait_for_timeout(500)
|
||||
raise RuntimeError(f"解锁后未获取 115 链接(URL: {page.url})")
|
||||
|
||||
result = self._run_browser_action(url, _callback)
|
||||
if self._is_cookie_expired_result(result):
|
||||
raise RuntimeError("cookie 失效,被重定向到登录页")
|
||||
return result
|
||||
|
||||
# ----- 与 HDHiveOpenApiService 对齐的兼容接口(返回 (ok, result, message) 三元组) -----
|
||||
|
||||
@staticmethod
|
||||
def _norm_media_type(media_type: Any) -> str:
|
||||
mt = str(media_type or "").strip().lower()
|
||||
if mt in ("movie", "电影"):
|
||||
return "movie"
|
||||
if mt in ("tv", "电视剧"):
|
||||
return "tv"
|
||||
return mt
|
||||
|
||||
def search_resources(self, media_type: Any, tmdb_id: Any) -> tuple:
|
||||
"""与 HDHiveOpenApiService.search_resources 同签名/同返回结构(网页方式)。"""
|
||||
mt = self._norm_media_type(media_type)
|
||||
tid = str(tmdb_id or "").strip()
|
||||
query = {"media_type": mt, "tmdb_id": tid}
|
||||
if mt not in ("movie", "tv"):
|
||||
return False, {"ok": False, "message": "媒体类型必须是 movie 或 tv", "query": query, "data": []}, "媒体类型必须是 movie 或 tv"
|
||||
if not tid:
|
||||
return False, {"ok": False, "message": "TMDB ID 不能为空", "query": query, "data": []}, "TMDB ID 不能为空"
|
||||
if not self.is_ready() and not self._refresh_cookie():
|
||||
return False, {"ok": False, "message": "影巢网页 Cookie 未配置", "query": query, "data": []}, "影巢网页 Cookie 未配置"
|
||||
try:
|
||||
items = self.search(mt, tid)
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if "cookie 失效" in message and self._refresh_cookie():
|
||||
try:
|
||||
items = self.search(mt, tid)
|
||||
except Exception as retry_exc:
|
||||
message = str(retry_exc)
|
||||
return False, {"ok": False, "message": message, "query": query, "data": []}, f"影巢网页搜索失败: {message}"
|
||||
else:
|
||||
return False, {"ok": False, "message": message, "query": query, "data": []}, f"影巢网页搜索失败: {message}"
|
||||
data = [
|
||||
{
|
||||
"slug": it.get("slug", ""),
|
||||
"title": it.get("title", ""),
|
||||
"name": it.get("title", ""),
|
||||
"unlock_points": it.get("unlock_points"),
|
||||
"size": it.get("size", ""),
|
||||
"resolution": it.get("resolution", ""),
|
||||
"is_free": it.get("is_free", False),
|
||||
"user": it.get("user", ""),
|
||||
"posted_at": it.get("posted_at", ""),
|
||||
"tags": it.get("tags", []),
|
||||
"source": "hdhive_browser",
|
||||
}
|
||||
for it in items
|
||||
]
|
||||
msg = "success" if data else "影巢网页方式未找到资源"
|
||||
result = {
|
||||
"ok": bool(data),
|
||||
"message": msg,
|
||||
"query": query,
|
||||
"data": data,
|
||||
"meta": {"total": len(data)},
|
||||
"source": "hdhive_browser",
|
||||
}
|
||||
return bool(data), result, msg
|
||||
|
||||
def unlock_resource(self, slug: str) -> tuple:
|
||||
"""与 HDHiveOpenApiService.unlock_resource 同签名/同返回结构(网页方式)。"""
|
||||
slug = (slug or "").strip()
|
||||
if not slug:
|
||||
return False, {"ok": False, "message": "slug 不能为空", "slug": "", "data": {}}, "slug 不能为空"
|
||||
if not self.is_ready() and not self._refresh_cookie():
|
||||
return False, {"ok": False, "message": "影巢网页 Cookie 未配置", "slug": slug, "data": {}}, "影巢网页 Cookie 未配置"
|
||||
try:
|
||||
res = self.unlock(slug)
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if "cookie 失效" in message and self._refresh_cookie():
|
||||
try:
|
||||
res = self.unlock(slug)
|
||||
except Exception as retry_exc:
|
||||
message = str(retry_exc)
|
||||
return False, {"ok": False, "message": message, "slug": slug, "data": {}}, f"影巢网页解锁失败: {message}"
|
||||
else:
|
||||
return False, {"ok": False, "message": message, "slug": slug, "data": {}}, f"影巢网页解锁失败: {message}"
|
||||
link = (res.get("url") or "").strip()
|
||||
data = {"full_url": link, "url": link, "pan_type": "115"}
|
||||
msg = "success" if link else "影巢网页方式解锁失败"
|
||||
return bool(link), {"ok": bool(link), "message": msg, "slug": slug, "data": data, "source": "hdhive_browser"}, msg
|
||||
1227
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
|
||||
735
plugins.v2/agentresourceofficer/src/components/Config.vue
Normal file
@@ -0,0 +1,735 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { CLIENT_TYPES, cloneConfig, maskSecret, unwrapResponse } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'AgentResourceOfficer',
|
||||
},
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
const config = ref({})
|
||||
const message = reactive({ text: '', type: 'info' })
|
||||
const showCookie = ref(false)
|
||||
const showFeishuSecret = ref(false)
|
||||
const showHdhiveApiKey = ref(false)
|
||||
const showHdhiveAccessToken = ref(false)
|
||||
const showHdhiveRefreshToken = ref(false)
|
||||
const showHdhiveCookie = ref(false)
|
||||
const showHdhivePassword = ref(false)
|
||||
const saving = ref(false)
|
||||
const healthLoading = ref(false)
|
||||
const health = ref(null)
|
||||
|
||||
const qr = reactive({
|
||||
show: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
qrcode: '',
|
||||
uid: '',
|
||||
time: '',
|
||||
sign: '',
|
||||
tips: '请使用 115 客户端扫描二维码登录',
|
||||
status: '等待扫码',
|
||||
clientType: 'alipaymini',
|
||||
timer: null,
|
||||
requestId: 0,
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentResourceOfficer'}`)
|
||||
const p115ReadyText = computed(() => {
|
||||
if (!health.value) return config.value.p115_cookie ? '已配置 Cookie' : '未检测'
|
||||
if (health.value.p115_ready) return '115 可用'
|
||||
return health.value.message || '115 未就绪'
|
||||
})
|
||||
|
||||
function enableChip(value) {
|
||||
return value
|
||||
? { text: '已启用', color: 'success' }
|
||||
: { text: '未启用', color: 'grey' }
|
||||
}
|
||||
|
||||
function showMessage(text, type = 'info') {
|
||||
message.text = text
|
||||
message.type = type
|
||||
if (text) {
|
||||
setTimeout(() => {
|
||||
if (message.text === text) message.text = ''
|
||||
}, 3500)
|
||||
}
|
||||
}
|
||||
|
||||
async function persistConfig({ silent = false } = {}) {
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
props.api.post(`${pluginBase.value}/config/save`, cloneConfig(config.value)),
|
||||
12000,
|
||||
'保存配置超时,请稍后重试'
|
||||
)
|
||||
const result = unwrapResponse(response)
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || '保存配置失败')
|
||||
}
|
||||
if (result.data) {
|
||||
config.value = cloneConfig(result.data)
|
||||
}
|
||||
emit('save', cloneConfig(config.value))
|
||||
if (!silent) showMessage(result.message || '配置已保存', 'success')
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!silent) showMessage(err?.message || '保存配置失败', 'error')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
persistConfig()
|
||||
}
|
||||
|
||||
async function copyText(value, label) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(value || ''))
|
||||
showMessage(`${label} 已复制`, 'success')
|
||||
} catch (err) {
|
||||
showMessage('复制失败,请手动复制', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function clearQrTimer() {
|
||||
if (qr.timer) {
|
||||
clearInterval(qr.timer)
|
||||
qr.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function applyQrData(data) {
|
||||
qr.qrcode = data?.qrcode || ''
|
||||
qr.uid = data?.uid || ''
|
||||
qr.time = data?.time || ''
|
||||
qr.sign = data?.sign || ''
|
||||
qr.tips = data?.tips || '请使用 115 客户端扫描二维码登录'
|
||||
qr.status = '等待扫码'
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, message) {
|
||||
let timeoutId
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(message)), ms)
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId))
|
||||
}
|
||||
|
||||
async function requestQrCode() {
|
||||
const requestId = qr.requestId + 1
|
||||
qr.requestId = requestId
|
||||
qr.loading = true
|
||||
qr.error = ''
|
||||
qr.qrcode = ''
|
||||
qr.uid = ''
|
||||
qr.time = ''
|
||||
qr.sign = ''
|
||||
clearQrTimer()
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
props.api.get(`${pluginBase.value}/p115/ui/qrcode?client_type=${encodeURIComponent(qr.clientType)}`),
|
||||
12000,
|
||||
'获取二维码超时,请稍后重试'
|
||||
)
|
||||
if (requestId !== qr.requestId || !qr.show) return
|
||||
const result = unwrapResponse(response)
|
||||
if (!result?.success || !result?.data) {
|
||||
throw new Error(result?.message || '获取二维码失败')
|
||||
}
|
||||
applyQrData(result.data)
|
||||
qr.timer = setInterval(() => checkQrCode(requestId), 3000)
|
||||
} catch (err) {
|
||||
if (requestId !== qr.requestId) return
|
||||
qr.error = err?.message || '获取二维码失败'
|
||||
qr.status = '二维码获取失败'
|
||||
} finally {
|
||||
if (requestId === qr.requestId) {
|
||||
qr.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkQrCode(requestId = qr.requestId) {
|
||||
if (!qr.show || !qr.uid || !qr.time || !qr.sign) return
|
||||
if (requestId !== qr.requestId || qr.checking) return
|
||||
qr.checking = true
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
uid: qr.uid,
|
||||
time: qr.time,
|
||||
sign: qr.sign,
|
||||
client_type: qr.clientType,
|
||||
})
|
||||
const response = await withTimeout(
|
||||
props.api.get(`${pluginBase.value}/p115/ui/qrcode/check?${query.toString()}`),
|
||||
10000,
|
||||
'检查二维码状态超时'
|
||||
)
|
||||
if (requestId !== qr.requestId || !qr.show) return
|
||||
const result = unwrapResponse(response)
|
||||
const data = result?.data || {}
|
||||
if (!result?.success) {
|
||||
if (data.status === 'expired') {
|
||||
clearQrTimer()
|
||||
qr.status = '二维码已失效'
|
||||
qr.error = result?.message || '二维码已失效,请刷新'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.status === 'waiting') qr.status = '等待扫码'
|
||||
if (data.status === 'scanned') qr.status = '已扫码,请在设备上确认'
|
||||
if (data.status === 'expired') {
|
||||
clearQrTimer()
|
||||
qr.status = '二维码已失效'
|
||||
qr.error = '二维码已失效,请刷新'
|
||||
}
|
||||
if (data.status === 'success') {
|
||||
clearQrTimer()
|
||||
qr.status = '登录成功'
|
||||
if (data.cookie_saved) {
|
||||
config.value.p115_client_type = qr.clientType
|
||||
if (data.cookie) config.value.p115_cookie = data.cookie
|
||||
await persistConfig({ silent: true })
|
||||
}
|
||||
showMessage('115 登录成功,Cookie 已自动保存。', 'success')
|
||||
setTimeout(() => {
|
||||
qr.show = false
|
||||
}, 1800)
|
||||
await loadP115Health()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('检查 115 二维码状态失败:', err)
|
||||
} finally {
|
||||
if (requestId === qr.requestId) {
|
||||
qr.checking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openQrDialog() {
|
||||
qr.show = true
|
||||
qr.error = ''
|
||||
qr.status = '等待扫码'
|
||||
qr.clientType = config.value.p115_client_type || 'alipaymini'
|
||||
requestQrCode()
|
||||
}
|
||||
|
||||
function closeQrDialog() {
|
||||
clearQrTimer()
|
||||
qr.requestId += 1
|
||||
qr.loading = false
|
||||
qr.checking = false
|
||||
qr.show = false
|
||||
}
|
||||
|
||||
async function refreshQrCode() {
|
||||
qr.error = ''
|
||||
await requestQrCode()
|
||||
}
|
||||
|
||||
async function changeQrClientType(value) {
|
||||
if (!value || value === qr.clientType) return
|
||||
qr.clientType = value
|
||||
qr.error = ''
|
||||
await requestQrCode()
|
||||
}
|
||||
|
||||
async function loadP115Health() {
|
||||
if (!props.api?.get) return
|
||||
healthLoading.value = true
|
||||
try {
|
||||
const response = await props.api.get(`${pluginBase.value}/p115/ui/health`)
|
||||
const result = unwrapResponse(response)
|
||||
if (result?.success) {
|
||||
health.value = result.data || null
|
||||
}
|
||||
} catch (err) {
|
||||
health.value = { p115_ready: false, message: err?.message || '检测失败' }
|
||||
} finally {
|
||||
healthLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestConfig() {
|
||||
if (!props.api?.get) return false
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
props.api.get(`${pluginBase.value}/config/get`),
|
||||
12000,
|
||||
'加载配置超时'
|
||||
)
|
||||
const result = unwrapResponse(response)
|
||||
if (result?.success && result.data) {
|
||||
config.value = cloneConfig(result.data)
|
||||
if (!config.value.p115_client_type) config.value.p115_client_type = 'alipaymini'
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载 Agent影视助手 配置失败:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
config.value = cloneConfig(props.initialConfig)
|
||||
if (!config.value.p115_client_type) config.value.p115_client_type = 'alipaymini'
|
||||
await loadLatestConfig()
|
||||
loadP115Health()
|
||||
})
|
||||
|
||||
onBeforeUnmount(clearQrTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aro-config">
|
||||
<VToolbar density="comfortable" color="transparent" class="aro-toolbar">
|
||||
<VIcon icon="mdi-robot-outline" color="primary" class="ms-3 me-2" />
|
||||
<div class="text-h6">Agent影视助手配置</div>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-refresh" variant="text" :loading="healthLoading" title="刷新 115 状态" @click="loadP115Health" />
|
||||
<VBtn icon="mdi-content-save" variant="text" color="success" :loading="saving" title="保存配置" @click="saveConfig" />
|
||||
<VBtn icon="mdi-close" variant="text" title="关闭" @click="emit('close')" />
|
||||
</VToolbar>
|
||||
<VDivider />
|
||||
|
||||
<div class="aro-body">
|
||||
<div class="aro-inner">
|
||||
<VAlert v-if="message.text" :type="message.type" variant="tonal" density="compact" closable class="mb-3">
|
||||
{{ message.text }}
|
||||
</VAlert>
|
||||
|
||||
<div class="aro-intro text-body-2 mb-3">
|
||||
<VIcon icon="mdi-rocket-launch-outline" size="small" color="primary" class="me-1" />
|
||||
<span>快速开始:先启用插件并配置 MP/PT,再按需开启影巢、盘搜与飞书入口;完整说明见</span>
|
||||
<a href="https://github.com/liuyuexi1987/MoviePilot-Plugins" target="_blank" rel="noopener" class="text-primary text-decoration-none font-weight-medium">主页文档</a>。
|
||||
</div>
|
||||
|
||||
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
|
||||
<VCardItem class="aro-card-head">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-toggle-switch" color="primary" />
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1">基础设置</VCardTitle>
|
||||
<VCardSubtitle class="text-caption">启用插件、通知与调试开关</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip :color="enableChip(config.enabled).color" size="small" variant="tonal">{{ enableChip(config.enabled).text }}</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow dense>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch v-model="config.enabled" label="启用插件" color="success" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch v-model="config.notify" label="发送通知" color="success" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch v-model="config.debug" label="调试日志" color="warning" density="compact" hide-details />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
|
||||
<VCardItem class="aro-card-head">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-movie-search-outline" color="primary" />
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1">MP/PT 策略</VCardTitle>
|
||||
<VCardSubtitle class="text-caption">首选主线:原生搜索/订阅/下载;评分仅影响未保存偏好的新会话</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip :color="enableChip(config.mp_pt_enabled).color" size="small" variant="tonal">{{ enableChip(config.mp_pt_enabled).text }}</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow dense>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSwitch v-model="config.mp_pt_enabled" label="启用 MP/PT" color="success" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3" md="3">
|
||||
<VTextField v-model="config.assistant_default_pt_min_seeders" label="最低做种数" type="number" placeholder="3" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3" md="3">
|
||||
<VTextField v-model="config.assistant_default_confirm_score_threshold" label="建议确认分" type="number" placeholder="70" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3" md="3">
|
||||
<VTextField v-model="config.assistant_default_auto_ingest_score_threshold" label="自动入库分" type="number" placeholder="90" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSwitch v-model="config.assistant_default_auto_ingest_enabled" label="高分自动入库" color="primary" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="9">
|
||||
<VTextField v-model="config.mp_download_save_path" label="PT 下载保存路径(可选)" placeholder="默认留空;需要时填 local:/downloads 等" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
|
||||
<VCardItem class="aro-card-head">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cloud-lock-outline" color="primary" />
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1">115 扫码登录</VCardTitle>
|
||||
<VCardSubtitle class="text-caption">扫码写入 Cookie,手填仅作兜底</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip :color="health?.p115_ready ? 'success' : 'warning'" size="small" variant="tonal">{{ p115ReadyText }}</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow dense align="center">
|
||||
<VCol cols="12" sm="6" md="4">
|
||||
<VTextField v-model="config.p115_default_path" label="115 默认目录" placeholder="/待整理" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="4">
|
||||
<VSelect v-model="config.p115_client_type" :items="CLIENT_TYPES" item-title="title" item-value="value" label="智能体扫码默认客户端" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch v-model="config.p115_prefer_direct" label="优先 115 直转" color="primary" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
:model-value="maskSecret(config.p115_cookie, showCookie)"
|
||||
label="115 Cookie"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
readonly
|
||||
hint="点击右侧二维码图标扫码,成功后自动保存 Cookie。"
|
||||
persistent-hint
|
||||
>
|
||||
<template #append-inner>
|
||||
<VIcon :icon="showCookie ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showCookie = !showCookie" />
|
||||
<VIcon icon="mdi-content-copy" class="me-2" size="small" :disabled="!config.p115_cookie" @click="copyText(config.p115_cookie, '115 Cookie')" />
|
||||
</template>
|
||||
<template #append>
|
||||
<VIcon icon="mdi-qrcode-scan" :color="config.p115_cookie ? 'success' : 'primary'" title="扫码获取或更新 115 Cookie" @click="openQrDialog" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
|
||||
<VCardItem class="aro-card-head">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-honeycomb-outline" color="primary" />
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1">影巢资源</VCardTitle>
|
||||
<VCardSubtitle class="text-caption">资源搜索 / 解锁 / 转存;积分上限填 0 不限制</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip :color="enableChip(config.hdhive_resource_enabled).color" size="small" variant="tonal">{{ enableChip(config.hdhive_resource_enabled).text }}</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow dense>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSwitch v-model="config.hdhive_resource_enabled" label="启用搜索/解锁" color="success" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VSelect
|
||||
v-model="config.hdhive_resource_mode"
|
||||
:items="[
|
||||
{ title: '网页方式', value: 'browser' },
|
||||
{ title: 'OpenAPI', value: 'openapi' },
|
||||
{ title: '自动(网页优先)', value: 'auto' },
|
||||
]"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="资源方式"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3" md="3">
|
||||
<VTextField v-model="config.hdhive_max_unlock_points" label="积分上限" type="number" placeholder="20" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3" md="3">
|
||||
<VTextField v-model="config.hdhive_candidate_page_size" label="候选页大小" type="number" placeholder="10" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="6" sm="3" md="3">
|
||||
<VTextField v-model="config.hdhive_timeout" label="超时(秒)" type="number" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="config.hdhive_base_url" label="影巢地址" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="config.hdhive_default_path" label="影巢默认转存目录" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="config.hdhive_api_key" :type="showHdhiveApiKey ? 'text' : 'password'" label="影巢 API Key" variant="outlined" density="compact" hide-details="auto">
|
||||
<template #append-inner>
|
||||
<VIcon :icon="showHdhiveApiKey ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveApiKey = !showHdhiveApiKey" />
|
||||
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_api_key" @click="copyText(config.hdhive_api_key, '影巢 API Key')" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="config.hdhive_openapi_user_token" :type="showHdhiveAccessToken ? 'text' : 'password'" label="OpenAPI Access Token" variant="outlined" density="compact" hide-details="auto">
|
||||
<template #append-inner>
|
||||
<VIcon :icon="showHdhiveAccessToken ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveAccessToken = !showHdhiveAccessToken" />
|
||||
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_openapi_user_token" @click="copyText(config.hdhive_openapi_user_token, '影巢 Access Token')" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="config.hdhive_openapi_refresh_token" :type="showHdhiveRefreshToken ? 'text' : 'password'" label="OpenAPI Refresh Token(可选)" variant="outlined" density="compact" hide-details="auto">
|
||||
<template #append-inner>
|
||||
<VIcon :icon="showHdhiveRefreshToken ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveRefreshToken = !showHdhiveRefreshToken" />
|
||||
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_openapi_refresh_token" @click="copyText(config.hdhive_openapi_refresh_token, '影巢 Refresh Token')" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
|
||||
<VCardItem class="aro-card-head">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-calendar-check-outline" color="primary" />
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1">影巢签到</VCardTitle>
|
||||
<VCardSubtitle class="text-caption">OpenAPI 优先,网页 Cookie 兜底,按 Cron 自动签到</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip :color="enableChip(config.hdhive_checkin_enabled).color" size="small" variant="tonal">{{ enableChip(config.hdhive_checkin_enabled).text }}</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow dense>
|
||||
<VCol cols="6" md="3">
|
||||
<VSwitch v-model="config.hdhive_checkin_enabled" label="启用签到" color="success" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSwitch v-model="config.hdhive_checkin_gambler_mode" label="默认赌狗签到" color="warning" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSwitch v-model="config.hdhive_checkin_once" label="保存后立即运行" color="primary" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VSwitch v-model="config.hdhive_checkin_auto_login" label="自动刷新 Cookie" color="primary" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="12" sm="4" md="4">
|
||||
<VTextField v-model="config.hdhive_checkin_cron" label="签到 Cron" placeholder="0 8 * * *" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" sm="4" md="4">
|
||||
<VTextField v-model="config.hdhive_checkin_username" label="影巢用户名/邮箱" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" sm="4" md="4">
|
||||
<VTextField v-model="config.hdhive_checkin_password" :type="showHdhivePassword ? 'text' : 'password'" label="影巢密码" variant="outlined" density="compact" hide-details="auto">
|
||||
<template #append-inner>
|
||||
<VIcon :icon="showHdhivePassword ? 'mdi-eye-off' : 'mdi-eye'" size="small" @click="showHdhivePassword = !showHdhivePassword" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="config.hdhive_checkin_cookie"
|
||||
:type="showHdhiveCookie ? 'text' : 'password'"
|
||||
label="影巢网页 Cookie(非 Premium 兜底)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
>
|
||||
<template #append-inner>
|
||||
<VIcon :icon="showHdhiveCookie ? 'mdi-eye-off' : 'mdi-eye'" class="me-2" size="small" @click="showHdhiveCookie = !showHdhiveCookie" />
|
||||
<VIcon icon="mdi-content-copy" size="small" :disabled="!config.hdhive_checkin_cookie" @click="copyText(config.hdhive_checkin_cookie, '影巢 Cookie')" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
|
||||
<VCardItem class="aro-card-head">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-magnify-scan" color="primary" />
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1">盘搜</VCardTitle>
|
||||
<VCardSubtitle class="text-caption">聚合公开网盘分享,地址需容器视角可访问</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip :color="enableChip(config.pansou_enabled).color" size="small" variant="tonal">{{ enableChip(config.pansou_enabled).text }}</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow dense>
|
||||
<VCol cols="12" sm="3" md="3">
|
||||
<VSwitch v-model="config.pansou_enabled" label="启用盘搜" color="success" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="8" sm="6" md="6">
|
||||
<VTextField v-model="config.pansou_base_url" label="盘搜 API 地址" placeholder="http://host.docker.internal:805" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="4" sm="3" md="3">
|
||||
<VTextField v-model="config.pansou_timeout" label="超时(秒)" type="number" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="outlined" class="aro-card mb-3 rounded-lg">
|
||||
<VCardItem class="aro-card-head">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-message-badge-outline" color="primary" />
|
||||
</template>
|
||||
<VCardTitle class="text-subtitle-1">飞书入口</VCardTitle>
|
||||
<VCardSubtitle class="text-caption">内置飞书机器人入口与会话白名单</VCardSubtitle>
|
||||
<template #append>
|
||||
<VChip :color="enableChip(config.feishu_enabled).color" size="small" variant="tonal">{{ enableChip(config.feishu_enabled).text }}</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="pt-2">
|
||||
<VRow dense>
|
||||
<VCol cols="12" sm="4" md="4">
|
||||
<VSwitch v-model="config.feishu_enabled" label="启用飞书入口" color="success" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="6" sm="4" md="4">
|
||||
<VSwitch v-model="config.feishu_allow_all" label="允许所有会话" color="primary" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="6" sm="4" md="4">
|
||||
<VSwitch v-model="config.feishu_reply_enabled" label="发送飞书回复" color="primary" density="compact" hide-details />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="config.feishu_app_id" label="飞书 App ID" placeholder="cli_xxxxxxxxx" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField :type="showFeishuSecret ? 'text' : 'password'" v-model="config.feishu_app_secret" label="飞书 App Secret" variant="outlined" density="compact" hide-details="auto">
|
||||
<template #append-inner>
|
||||
<VIcon :icon="showFeishuSecret ? 'mdi-eye-off' : 'mdi-eye'" size="small" @click="showFeishuSecret = !showFeishuSecret" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
<VCol v-if="!config.feishu_allow_all" cols="12" class="py-0">
|
||||
<div class="text-caption text-medium-emphasis">未允许所有会话时,仅下列白名单中的群聊或用户可触发飞书命令。</div>
|
||||
</VCol>
|
||||
<VCol v-if="!config.feishu_allow_all" cols="12" md="6">
|
||||
<VTextarea v-model="config.feishu_allowed_chat_ids" label="允许的群聊 Chat ID" rows="2" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
<VCol v-if="!config.feishu_allow_all" cols="12" md="6">
|
||||
<VTextarea v-model="config.feishu_allowed_user_ids" label="允许的用户 Open ID" rows="2" variant="outlined" density="compact" hide-details="auto" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDialog v-model="qr.show" max-width="450" @update:model-value="value => !value && closeQrDialog()">
|
||||
<VCard>
|
||||
<VCardTitle class="text-subtitle-1 d-flex align-center px-3 py-2 bg-primary-lighten-5">
|
||||
<VIcon icon="mdi-qrcode" color="primary" size="small" class="me-2" />
|
||||
115网盘扫码登录
|
||||
</VCardTitle>
|
||||
<VCardText class="text-center py-4">
|
||||
<VAlert v-if="qr.error" type="error" density="compact" variant="tonal" closable class="mb-3 mx-3">
|
||||
{{ qr.error }}
|
||||
</VAlert>
|
||||
<div v-if="qr.loading" class="d-flex flex-column align-center py-3">
|
||||
<VProgressCircular indeterminate color="primary" class="mb-3" />
|
||||
<div>正在获取二维码...</div>
|
||||
</div>
|
||||
<div v-else-if="qr.qrcode" class="d-flex flex-column align-center">
|
||||
<div class="mb-2 font-weight-medium">请选择扫码方式</div>
|
||||
<VChipGroup :model-value="qr.clientType" class="mb-3" mandatory selected-class="primary" @update:model-value="changeQrClientType">
|
||||
<VChip v-for="item in CLIENT_TYPES" :key="item.value" :value="item.value" variant="outlined" color="primary" size="small">
|
||||
{{ item.label }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
<div class="d-flex flex-column align-center mb-3">
|
||||
<VCard flat class="border pa-2 mb-2">
|
||||
<img :src="qr.qrcode" width="220" height="220" alt="115 登录二维码" />
|
||||
</VCard>
|
||||
<div class="text-body-2 text-grey mb-1">{{ qr.tips }}</div>
|
||||
<div class="text-subtitle-2 font-weight-medium text-primary">{{ qr.status }}</div>
|
||||
</div>
|
||||
<VBtn color="primary" variant="tonal" size="small" class="mb-2" prepend-icon="mdi-refresh" :disabled="qr.loading" @click="refreshQrCode">
|
||||
刷新二维码
|
||||
</VBtn>
|
||||
</div>
|
||||
<div v-else class="d-flex flex-column align-center py-3">
|
||||
<VIcon icon="mdi-qrcode-off" size="64" color="grey" class="mb-3" />
|
||||
<div class="text-subtitle-1">二维码获取失败</div>
|
||||
<div class="text-body-2 text-grey">请点击刷新按钮重试</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardActions class="px-3 py-2">
|
||||
<VBtn color="grey" variant="text" size="small" prepend-icon="mdi-close" @click="closeQrDialog">关闭</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="text" size="small" prepend-icon="mdi-refresh" :disabled="qr.loading" @click="refreshQrCode">刷新二维码</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aro-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 82vh;
|
||||
}
|
||||
|
||||
.aro-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.aro-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.aro-inner {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.aro-intro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-primary), 0.06);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.aro-card-head {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.aro-card :deep(.v-card-item__append) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.aro-card :deep(.v-card-subtitle) {
|
||||
opacity: 0.7;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
20
plugins.v2/agentresourceofficer/src/components/Page.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import Config from './Config.vue'
|
||||
|
||||
defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Config :api="api" :initial-config="initialConfig" @save="payload => emit('save', payload)" @close="emit('close')" />
|
||||
</template>
|
||||
4
plugins.v2/agentresourceofficer/src/main.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import Page from './components/Page.vue'
|
||||
|
||||
createApp(Page).mount('#app')
|
||||
26
plugins.v2/agentresourceofficer/src/provider.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export const CLIENT_TYPES = [
|
||||
{ title: '支付宝', label: '支付宝', value: 'alipaymini' },
|
||||
{ title: '微信', label: '微信', value: 'wechatmini' },
|
||||
{ title: '安卓', label: '安卓', value: '115android' },
|
||||
{ title: 'iOS', label: 'iOS', value: '115ios' },
|
||||
{ title: '网页', label: '网页', value: 'web' },
|
||||
{ title: 'PAD', label: 'PAD', value: '115ipad' },
|
||||
{ title: 'TV', label: 'TV', value: 'tv' },
|
||||
]
|
||||
|
||||
export function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config || {}))
|
||||
}
|
||||
|
||||
export function unwrapResponse(response) {
|
||||
if (!response) return response
|
||||
if (Object.prototype.hasOwnProperty.call(response, 'success')) return response
|
||||
if (Object.prototype.hasOwnProperty.call(response, 'data')) return response.data
|
||||
return response
|
||||
}
|
||||
|
||||
export function maskSecret(value, visible) {
|
||||
const text = String(value || '')
|
||||
if (visible || !text) return text
|
||||
return '•'.repeat(Math.min(Math.max(text.length, 8), 24))
|
||||
}
|
||||
62
plugins.v2/agentresourceofficer/tests/test_hdhive_browser.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""HDHiveBrowserService 纯函数测试(无需 pytest,直接 python3 运行)。
|
||||
|
||||
绕开 AgentResourceOfficer 包(其 __init__.py 依赖 MoviePilot app.*),
|
||||
桩掉 app.helper.browser / app.log 后直接加载 services/hdhive_browser.py。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
for _name in ("app", "app.helper", "app.helper.browser", "app.log"):
|
||||
sys.modules.setdefault(_name, types.ModuleType(_name))
|
||||
sys.modules["app.helper.browser"].PlaywrightHelper = object # type: ignore[attr-defined]
|
||||
sys.modules["app.log"].logger = types.SimpleNamespace( # type: ignore[attr-defined]
|
||||
info=lambda *a, **k: None,
|
||||
warning=lambda *a, **k: None,
|
||||
error=lambda *a, **k: None,
|
||||
debug=lambda *a, **k: None,
|
||||
)
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "services"))
|
||||
import hdhive_browser # noqa: E402
|
||||
|
||||
HDHiveBrowserService = hdhive_browser.HDHiveBrowserService
|
||||
|
||||
|
||||
def test_detail_url_movie_and_tv():
|
||||
svc = HDHiveBrowserService(base_url="https://hdhive.com/", cookie="token=abc")
|
||||
assert svc._detail_url("movie", 123) == "https://hdhive.com/tmdb/movie/123"
|
||||
assert svc._detail_url("电影", 123) == "https://hdhive.com/tmdb/movie/123"
|
||||
assert svc._detail_url("tv", 9) == "https://hdhive.com/tmdb/tv/9"
|
||||
assert svc._detail_url("电视剧", 9) == "https://hdhive.com/tmdb/tv/9"
|
||||
|
||||
|
||||
def test_is_ready_requires_cookie():
|
||||
assert HDHiveBrowserService(cookie="token=abc").is_ready() is True
|
||||
assert HDHiveBrowserService(cookie="").is_ready() is False
|
||||
|
||||
|
||||
def test_normalize_extracts_slug_from_href():
|
||||
svc = HDHiveBrowserService(cookie="token=abc")
|
||||
raw = {
|
||||
"href": "/resource/115/abc-uuid-123/",
|
||||
"title": "电影标题",
|
||||
"resolution": "1080P",
|
||||
"size": "10 GB",
|
||||
"is_free": False,
|
||||
"unlock_points": 20,
|
||||
"user": "u",
|
||||
"posted_at": "2026/01/01",
|
||||
"tags": ["官组"],
|
||||
}
|
||||
out = svc._normalize(raw)
|
||||
assert out["slug"] == "abc-uuid-123"
|
||||
assert out["unlock_points"] == 20
|
||||
assert out["title"] == "电影标题"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_detail_url_movie_and_tv()
|
||||
test_is_ready_requires_cookie()
|
||||
test_normalize_extracts_slug_from_href()
|
||||
print("ALL PASS")
|
||||
55
plugins.v2/agentresourceofficer/vite.config.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import federation from '@originjs/vite-plugin-federation'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
federation({
|
||||
name: 'AgentResourceOfficer',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Page': './src/components/Page.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
requiredVersion: false,
|
||||
generate: false,
|
||||
},
|
||||
},
|
||||
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()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
2
plugins.v2/agenttokens/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
package-lock.json
|
||||
node_modules/
|
||||
493
plugins.v2/agenttokens/__init__.py
Normal file
@@ -0,0 +1,493 @@
|
||||
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.12"
|
||||
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], Optional[List[dict]]]]:
|
||||
"""
|
||||
返回 Vue 仪表板组件的布局与标题配置。
|
||||
"""
|
||||
if not self.get_state():
|
||||
return None
|
||||
return (
|
||||
{"cols": 12, "sm": 6, "md": 4},
|
||||
{
|
||||
"title": "Agent Tokens 管理",
|
||||
"subtitle": "LLM 配额使用情况",
|
||||
"refresh": 30,
|
||||
"border": True,
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
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")),
|
||||
"user_agent": cls._clean_text(provider.get("user_agent")),
|
||||
"use_proxy": bool(provider.get("use_proxy", True)),
|
||||
"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
|
||||
|
||||
provider_name = provider.get("name")
|
||||
model = provider.get("model")
|
||||
logger.info(f"Agent Tokens 分配 LLM 供应商:[{provider_name}] 模型:[{model}]")
|
||||
|
||||
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, "user_agent", provider.get("user_agent"))
|
||||
self._event_set(event.event_data, "use_proxy", bool(provider.get("use_proxy", True)))
|
||||
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
|
||||
|
||||
provider_name = self._clean_text(self._event_get(event.event_data, "selected_provider_name")) or provider_id
|
||||
logger.info(f"Agent Tokens 更新用量记录:供应商 [{provider_name}] 本次消耗了 {total_tokens} Tokens")
|
||||
|
||||
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__)
|
||||
148
plugins.v2/agenttokens/dist/assets/AgentTokensManager-9miSzH4d.css
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
|
||||
.provider-table-shell[data-v-cd4337d8] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.provider-table-shell[data-v-cd4337d8] table {
|
||||
min-width: 960px;
|
||||
}
|
||||
.truncate-cell[data-v-cd4337d8] {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-table-shell[data-v-a305c97e] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.provider-table-shell[data-v-a305c97e] table {
|
||||
min-width: 760px;
|
||||
}
|
||||
.progress-cell[data-v-a305c97e] {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.usage-overview-card[data-v-f9b76345] {
|
||||
block-size: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
.usage-overview-card__content[data-v-f9b76345] {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.usage-overview-card__chart[data-v-f9b76345] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.usage-overview-card__percent[data-v-f9b76345] {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.usage-overview-card__headline[data-v-f9b76345] {
|
||||
margin-block-start: 4px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.usage-overview-card__meta[data-v-f9b76345] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.usage-overview-card[data-v-f9b76345] {
|
||||
padding: 16px;
|
||||
}
|
||||
.usage-overview-card__content[data-v-f9b76345] {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
.usage-overview-card__meta[data-v-f9b76345] {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.agenttokens-page[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.agenttokens-header[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.agenttokens-control-panel[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.agenttokens-control-panel__switches[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
.agenttokens-overview-grid[data-v-a6c1ea54] {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) repeat(3, minmax(10rem, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.agenttokens-overview-card[data-v-a6c1ea54] {
|
||||
min-block-size: 172px;
|
||||
}
|
||||
.agenttokens-stat-card[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-block-size: 104px;
|
||||
padding: 16px;
|
||||
}
|
||||
.agenttokens-stat-card__value[data-v-a6c1ea54] {
|
||||
margin-block-start: 2px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.agenttokens-content-panel[data-v-a6c1ea54] {
|
||||
overflow: hidden;
|
||||
}
|
||||
.agenttokens-tabs-row[data-v-a6c1ea54] {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.agenttokens-window[data-v-a6c1ea54] {
|
||||
padding: 12px;
|
||||
}
|
||||
.agenttokens-table-actions[data-v-a6c1ea54] {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.agenttokens-overview-grid[data-v-a6c1ea54] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.agenttokens-overview-card[data-v-a6c1ea54] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.agenttokens-page[data-v-a6c1ea54] {
|
||||
padding: 12px;
|
||||
}
|
||||
.agenttokens-overview-grid[data-v-a6c1ea54] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.agenttokens-stat-card[data-v-a6c1ea54] {
|
||||
min-block-size: 88px;
|
||||
}
|
||||
}
|
||||
983
plugins.v2/agenttokens/dist/assets/AgentTokensManager-BTcJgtTd.js
vendored
Normal file
@@ -0,0 +1,983 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { _ as _export_sfc, f as formatTokens, P as PROVIDER_TYPE_OPTIONS, d as createProvider, b as buildProviderRows, a as buildProviderSummary, g as getNextProviderPriority, n as normalizeProvider } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode$3,openBlock:_openBlock$4,createElementBlock:_createElementBlock$2,createCommentVNode:_createCommentVNode$2,renderList:_renderList$1,Fragment:_Fragment$1,resolveComponent:_resolveComponent$4,createVNode:_createVNode$4,toDisplayString:_toDisplayString$4,createTextVNode:_createTextVNode$4,withCtx:_withCtx$4,unref:_unref$4,createBlock:_createBlock$4} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1$3 = { key: 0 };
|
||||
const _hoisted_2$3 = { key: 1 };
|
||||
const _hoisted_3$3 = {
|
||||
key: 0,
|
||||
class: "truncate-cell"
|
||||
};
|
||||
const _hoisted_4$2 = { key: 1 };
|
||||
const _hoisted_5$2 = { class: "text-right" };
|
||||
const _hoisted_6$2 = { key: 0 };
|
||||
const _hoisted_7$2 = ["colspan"];
|
||||
|
||||
|
||||
const _sfc_main$4 = {
|
||||
__name: 'ProviderConfigTable',
|
||||
props: {
|
||||
providers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showCredentials: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['edit', 'remove'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
// 获取管理页服务端返回的脱敏 Key。
|
||||
function getMaskedApiKey(index) {
|
||||
return props.providerRows[index]?.masked_api_key || '****'
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSwitch = _resolveComponent$4("VSwitch");
|
||||
const _component_VChip = _resolveComponent$4("VChip");
|
||||
const _component_VBtn = _resolveComponent$4("VBtn");
|
||||
const _component_VTable = _resolveComponent$4("VTable");
|
||||
const _component_VSheet = _resolveComponent$4("VSheet");
|
||||
|
||||
return (_openBlock$4(), _createBlock$4(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "provider-table-shell"
|
||||
}, {
|
||||
default: _withCtx$4(() => [
|
||||
_createVNode$4(_component_VTable, { density: "comfortable" }, {
|
||||
default: _withCtx$4(() => [
|
||||
_createElementVNode$3("thead", null, [
|
||||
_createElementVNode$3("tr", null, [
|
||||
_cache[0] || (_cache[0] = _createElementVNode$3("th", null, "启用", -1)),
|
||||
_cache[1] || (_cache[1] = _createElementVNode$3("th", null, "优先级", -1)),
|
||||
_cache[2] || (_cache[2] = _createElementVNode$3("th", null, "名称", -1)),
|
||||
_cache[3] || (_cache[3] = _createElementVNode$3("th", null, "类型", -1)),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("th", _hoisted_1$3, "地址"))
|
||||
: _createCommentVNode$2("", true),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("th", _hoisted_2$3, "Key"))
|
||||
: _createCommentVNode$2("", true),
|
||||
_cache[4] || (_cache[4] = _createElementVNode$3("th", null, "代理", -1)),
|
||||
_cache[5] || (_cache[5] = _createElementVNode$3("th", null, "模型", -1)),
|
||||
_cache[6] || (_cache[6] = _createElementVNode$3("th", null, "额度", -1)),
|
||||
_cache[7] || (_cache[7] = _createElementVNode$3("th", { class: "text-right" }, "操作", -1))
|
||||
])
|
||||
]),
|
||||
_createElementVNode$3("tbody", null, [
|
||||
(_openBlock$4(true), _createElementBlock$2(_Fragment$1, null, _renderList$1(__props.providers, (row, index) => {
|
||||
return (_openBlock$4(), _createElementBlock$2("tr", {
|
||||
key: row.id || index
|
||||
}, [
|
||||
_createElementVNode$3("td", null, [
|
||||
_createVNode$4(_component_VSwitch, {
|
||||
modelValue: row.enabled,
|
||||
"onUpdate:modelValue": $event => ((row.enabled) = $event),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
density: "compact"
|
||||
}, null, 8, ["modelValue", "onUpdate:modelValue"])
|
||||
]),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.priority), 1),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.name), 1),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.provider), 1),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("td", _hoisted_3$3, _toDisplayString$4(row.base_url), 1))
|
||||
: _createCommentVNode$2("", true),
|
||||
(__props.showCredentials)
|
||||
? (_openBlock$4(), _createElementBlock$2("td", _hoisted_4$2, _toDisplayString$4(getMaskedApiKey(index)), 1))
|
||||
: _createCommentVNode$2("", true),
|
||||
_createElementVNode$3("td", null, [
|
||||
_createVNode$4(_component_VChip, {
|
||||
size: "small",
|
||||
color: row.use_proxy === false ? 'default' : 'primary',
|
||||
variant: "tonal"
|
||||
}, {
|
||||
default: _withCtx$4(() => [
|
||||
_createTextVNode$4(_toDisplayString$4(row.use_proxy === false ? '直连' : '代理'), 1)
|
||||
]),
|
||||
_: 2
|
||||
}, 1032, ["color"])
|
||||
]),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.model), 1),
|
||||
_createElementVNode$3("td", null, _toDisplayString$4(row.token_limit > 0 ? _unref$4(formatTokens)(row.token_limit) : '不限'), 1),
|
||||
_createElementVNode$3("td", _hoisted_5$2, [
|
||||
_createVNode$4(_component_VBtn, {
|
||||
icon: "mdi-pencil",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
onClick: $event => (emit('edit', index))
|
||||
}, null, 8, ["onClick"]),
|
||||
_createVNode$4(_component_VBtn, {
|
||||
icon: "mdi-delete",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
color: "error",
|
||||
onClick: $event => (emit('remove', index))
|
||||
}, null, 8, ["onClick"])
|
||||
])
|
||||
]))
|
||||
}), 128)),
|
||||
(!__props.providers.length)
|
||||
? (_openBlock$4(), _createElementBlock$2("tr", _hoisted_6$2, [
|
||||
_createElementVNode$3("td", {
|
||||
colspan: __props.showCredentials ? 10 : 8,
|
||||
class: "text-center text-medium-emphasis py-8"
|
||||
}, "暂无供应商", 8, _hoisted_7$2)
|
||||
]))
|
||||
: _createCommentVNode$2("", true)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const ProviderConfigTable = /*#__PURE__*/_export_sfc(_sfc_main$4, [['__scopeId',"data-v-cd4337d8"]]);
|
||||
|
||||
const {toDisplayString:_toDisplayString$3,createTextVNode:_createTextVNode$3,resolveComponent:_resolveComponent$3,withCtx:_withCtx$3,createVNode:_createVNode$3,unref:_unref$3,openBlock:_openBlock$3,createBlock:_createBlock$3} = await importShared('vue');
|
||||
|
||||
|
||||
const {computed: computed$2} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main$3 = {
|
||||
__name: 'ProviderEditorDialog',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
provider: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
editorIndex: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'commit'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const dialogVisible = computed$2({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
// 提交当前弹窗编辑的供应商配置。
|
||||
function commitProvider() {
|
||||
emit('commit');
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VCardTitle = _resolveComponent$3("VCardTitle");
|
||||
const _component_VTextField = _resolveComponent$3("VTextField");
|
||||
const _component_VCol = _resolveComponent$3("VCol");
|
||||
const _component_VSelect = _resolveComponent$3("VSelect");
|
||||
const _component_VSwitch = _resolveComponent$3("VSwitch");
|
||||
const _component_VRow = _resolveComponent$3("VRow");
|
||||
const _component_VCardText = _resolveComponent$3("VCardText");
|
||||
const _component_VSpacer = _resolveComponent$3("VSpacer");
|
||||
const _component_VBtn = _resolveComponent$3("VBtn");
|
||||
const _component_VCardActions = _resolveComponent$3("VCardActions");
|
||||
const _component_VCard = _resolveComponent$3("VCard");
|
||||
const _component_VDialog = _resolveComponent$3("VDialog");
|
||||
|
||||
return (_openBlock$3(), _createBlock$3(_component_VDialog, {
|
||||
modelValue: dialogVisible.value,
|
||||
"onUpdate:modelValue": _cache[11] || (_cache[11] = $event => ((dialogVisible).value = $event)),
|
||||
"max-width": "760",
|
||||
"max-height": "85vh",
|
||||
scrollable: ""
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VCard, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VCardTitle, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createTextVNode$3(_toDisplayString$3(__props.editorIndex >= 0 ? '编辑供应商' : '新增供应商'), 1)
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCardText, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VRow, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "8"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.name,
|
||||
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((__props.provider.name) = $event)),
|
||||
label: "名称",
|
||||
variant: "outlined",
|
||||
density: "comfortable"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "4"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.priority,
|
||||
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((__props.provider.priority) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "优先级",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VSelect, {
|
||||
modelValue: __props.provider.provider,
|
||||
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((__props.provider.provider) = $event)),
|
||||
items: _unref$3(PROVIDER_TYPE_OPTIONS),
|
||||
label: "类型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue", "items"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.model,
|
||||
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((__props.provider.model) = $event)),
|
||||
label: "模型",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.base_url,
|
||||
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((__props.provider.base_url) = $event)),
|
||||
label: "API 地址",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.api_key,
|
||||
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((__props.provider.api_key) = $event)),
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.user_agent,
|
||||
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((__props.provider.user_agent) = $event)),
|
||||
label: "User-Agent",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, { cols: "12" }, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VSwitch, {
|
||||
modelValue: __props.provider.use_proxy,
|
||||
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((__props.provider.use_proxy) = $event)),
|
||||
color: "primary",
|
||||
label: "使用代理服务器",
|
||||
hint: "启用后,Agent 连接该供应商时会使用系统代理服务器",
|
||||
"persistent-hint": ""
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.token_limit,
|
||||
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((__props.provider.token_limit) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "Token 额度",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCol, {
|
||||
cols: "12",
|
||||
md: "6"
|
||||
}, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VTextField, {
|
||||
modelValue: __props.provider.used_tokens,
|
||||
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((__props.provider.used_tokens) = $event)),
|
||||
modelModifiers: { number: true },
|
||||
label: "初始已用",
|
||||
type: "number",
|
||||
variant: "outlined"
|
||||
}, null, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VCardActions, null, {
|
||||
default: _withCtx$3(() => [
|
||||
_createVNode$3(_component_VSpacer),
|
||||
_createVNode$3(_component_VBtn, {
|
||||
variant: "text",
|
||||
onClick: _cache[10] || (_cache[10] = $event => (dialogVisible.value = false))
|
||||
}, {
|
||||
default: _withCtx$3(() => [...(_cache[12] || (_cache[12] = [
|
||||
_createTextVNode$3("取消", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode$3(_component_VBtn, {
|
||||
color: "primary",
|
||||
onClick: commitProvider
|
||||
}, {
|
||||
default: _withCtx$3(() => [...(_cache[13] || (_cache[13] = [
|
||||
_createTextVNode$3("确定", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const {createElementVNode:_createElementVNode$2,renderList:_renderList,Fragment:_Fragment,openBlock:_openBlock$2,createElementBlock:_createElementBlock$1,toDisplayString:_toDisplayString$2,unref:_unref$2,resolveComponent:_resolveComponent$2,createVNode:_createVNode$2,createTextVNode:_createTextVNode$2,withCtx:_withCtx$2,createCommentVNode:_createCommentVNode$1,createBlock:_createBlock$2} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1$2 = { class: "progress-cell" };
|
||||
const _hoisted_2$2 = { class: "text-right" };
|
||||
const _hoisted_3$2 = { key: 0 };
|
||||
|
||||
|
||||
const _sfc_main$2 = {
|
||||
__name: 'ProviderUsageTable',
|
||||
props: {
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['reset'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
// 根据供应商状态返回 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 '可用'
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VProgressLinear = _resolveComponent$2("VProgressLinear");
|
||||
const _component_VChip = _resolveComponent$2("VChip");
|
||||
const _component_VBtn = _resolveComponent$2("VBtn");
|
||||
const _component_VTable = _resolveComponent$2("VTable");
|
||||
const _component_VSheet = _resolveComponent$2("VSheet");
|
||||
|
||||
return (_openBlock$2(), _createBlock$2(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "provider-table-shell"
|
||||
}, {
|
||||
default: _withCtx$2(() => [
|
||||
_createVNode$2(_component_VTable, { density: "comfortable" }, {
|
||||
default: _withCtx$2(() => [
|
||||
_cache[1] || (_cache[1] = _createElementVNode$2("thead", null, [
|
||||
_createElementVNode$2("tr", null, [
|
||||
_createElementVNode$2("th", null, "优先级"),
|
||||
_createElementVNode$2("th", null, "名称"),
|
||||
_createElementVNode$2("th", null, "模型"),
|
||||
_createElementVNode$2("th", null, "已用"),
|
||||
_createElementVNode$2("th", null, "余量"),
|
||||
_createElementVNode$2("th", null, "进度"),
|
||||
_createElementVNode$2("th", null, "状态"),
|
||||
_createElementVNode$2("th", { class: "text-right" }, "操作")
|
||||
])
|
||||
], -1)),
|
||||
_createElementVNode$2("tbody", null, [
|
||||
(_openBlock$2(true), _createElementBlock$1(_Fragment, null, _renderList(__props.providerRows, (row, index) => {
|
||||
return (_openBlock$2(), _createElementBlock$1("tr", {
|
||||
key: row.id || index
|
||||
}, [
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.priority), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.name), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.model), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(_unref$2(formatTokens)(row.usage?.total_tokens)), 1),
|
||||
_createElementVNode$2("td", null, _toDisplayString$2(row.usage?.remaining_tokens === null ? '不限' : _unref$2(formatTokens)(row.usage?.remaining_tokens)), 1),
|
||||
_createElementVNode$2("td", _hoisted_1$2, [
|
||||
_createVNode$2(_component_VProgressLinear, {
|
||||
"model-value": row.usage?.usage_percent || 0,
|
||||
color: rowStatusColor(row),
|
||||
height: "8",
|
||||
rounded: ""
|
||||
}, null, 8, ["model-value", "color"])
|
||||
]),
|
||||
_createElementVNode$2("td", null, [
|
||||
_createVNode$2(_component_VChip, {
|
||||
size: "small",
|
||||
color: rowStatusColor(row),
|
||||
variant: "tonal"
|
||||
}, {
|
||||
default: _withCtx$2(() => [
|
||||
_createTextVNode$2(_toDisplayString$2(rowStatusText(row)), 1)
|
||||
]),
|
||||
_: 2
|
||||
}, 1032, ["color"])
|
||||
]),
|
||||
_createElementVNode$2("td", _hoisted_2$2, [
|
||||
_createVNode$2(_component_VBtn, {
|
||||
icon: "mdi-backup-restore",
|
||||
size: "small",
|
||||
variant: "text",
|
||||
onClick: $event => (emit('reset', row.id, index))
|
||||
}, null, 8, ["onClick"])
|
||||
])
|
||||
]))
|
||||
}), 128)),
|
||||
(!__props.providerRows.length)
|
||||
? (_openBlock$2(), _createElementBlock$1("tr", _hoisted_3$2, [...(_cache[0] || (_cache[0] = [
|
||||
_createElementVNode$2("td", {
|
||||
colspan: "8",
|
||||
class: "text-center text-medium-emphasis py-8"
|
||||
}, "暂无供应商", -1)
|
||||
]))]))
|
||||
: _createCommentVNode$1("", true)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const ProviderUsageTable = /*#__PURE__*/_export_sfc(_sfc_main$2, [['__scopeId',"data-v-a305c97e"]]);
|
||||
|
||||
const {toDisplayString:_toDisplayString$1,createElementVNode:_createElementVNode$1,resolveComponent:_resolveComponent$1,withCtx:_withCtx$1,createVNode:_createVNode$1,unref:_unref$1,createTextVNode:_createTextVNode$1,openBlock:_openBlock$1,createBlock:_createBlock$1} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1$1 = { class: "usage-overview-card__content" };
|
||||
const _hoisted_2$1 = { class: "usage-overview-card__chart" };
|
||||
const _hoisted_3$1 = { class: "usage-overview-card__percent" };
|
||||
const _hoisted_4$1 = { class: "usage-overview-card__body" };
|
||||
const _hoisted_5$1 = { class: "usage-overview-card__headline" };
|
||||
const _hoisted_6$1 = { class: "text-medium-emphasis" };
|
||||
const _hoisted_7$1 = { class: "usage-overview-card__meta" };
|
||||
|
||||
const {computed: computed$1} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main$1 = {
|
||||
__name: 'UsageOverviewCard',
|
||||
props: {
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(__props) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const totalUsed = computed$1(() => Number(props.summary.total_used || 0));
|
||||
const totalLimit = computed$1(() => Number(props.summary.total_limit || 0));
|
||||
const usagePercent = computed$1(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
});
|
||||
const usagePercentText = computed$1(() => `${Math.round(usagePercent.value)}%`);
|
||||
const remainingTokens = computed$1(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
});
|
||||
const progressColor = computed$1(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VProgressCircular = _resolveComponent$1("VProgressCircular");
|
||||
const _component_VProgressLinear = _resolveComponent$1("VProgressLinear");
|
||||
const _component_VSheet = _resolveComponent$1("VSheet");
|
||||
|
||||
return (_openBlock$1(), _createBlock$1(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "usage-overview-card"
|
||||
}, {
|
||||
default: _withCtx$1(() => [
|
||||
_createElementVNode$1("div", _hoisted_1$1, [
|
||||
_createElementVNode$1("div", _hoisted_2$1, [
|
||||
_createVNode$1(_component_VProgressCircular, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
"bg-color": "surface-variant",
|
||||
size: 132,
|
||||
width: 12
|
||||
}, {
|
||||
default: _withCtx$1(() => [
|
||||
_createElementVNode$1("div", _hoisted_3$1, _toDisplayString$1(totalLimit.value > 0 ? usagePercentText.value : '不限'), 1)
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["model-value", "color"])
|
||||
]),
|
||||
_createElementVNode$1("div", _hoisted_4$1, [
|
||||
_cache[0] || (_cache[0] = _createElementVNode$1("div", { class: "text-caption text-medium-emphasis" }, "总使用进度", -1)),
|
||||
_createElementVNode$1("div", _hoisted_5$1, [
|
||||
_createTextVNode$1(_toDisplayString$1(_unref$1(formatTokens)(totalUsed.value)) + " ", 1),
|
||||
_createElementVNode$1("span", _hoisted_6$1, "/ " + _toDisplayString$1(totalLimit.value > 0 ? _unref$1(formatTokens)(totalLimit.value) : '不限'), 1)
|
||||
]),
|
||||
_createVNode$1(_component_VProgressLinear, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
height: "8",
|
||||
rounded: "",
|
||||
class: "my-4"
|
||||
}, null, 8, ["model-value", "color"]),
|
||||
_createElementVNode$1("div", _hoisted_7$1, [
|
||||
_createElementVNode$1("span", null, "剩余 " + _toDisplayString$1(remainingTokens.value === null ? '不限' : _unref$1(formatTokens)(remainingTokens.value)), 1),
|
||||
_createElementVNode$1("span", null, "可用 " + _toDisplayString$1(__props.summary.available_count || 0) + " / " + _toDisplayString$1(__props.summary.enabled_count || 0), 1)
|
||||
])
|
||||
])
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const UsageOverviewCard = /*#__PURE__*/_export_sfc(_sfc_main$1, [['__scopeId',"data-v-f9b76345"]]);
|
||||
|
||||
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,createCommentVNode:_createCommentVNode,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,withCtx:_withCtx,createBlock:_createBlock,unref:_unref} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-page" };
|
||||
const _hoisted_2 = {
|
||||
key: 0,
|
||||
class: "agenttokens-header"
|
||||
};
|
||||
const _hoisted_3 = { class: "agenttokens-control-panel__switches" };
|
||||
const _hoisted_4 = { class: "agenttokens-overview-grid" };
|
||||
const _hoisted_5 = { class: "agenttokens-stat-card__value" };
|
||||
const _hoisted_6 = { class: "agenttokens-stat-card__value" };
|
||||
const _hoisted_7 = { class: "agenttokens-stat-card__value" };
|
||||
const _hoisted_8 = { class: "agenttokens-tabs-row" };
|
||||
const _hoisted_9 = { class: "agenttokens-table-actions" };
|
||||
|
||||
const {computed,ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'AgentTokensManager',
|
||||
props: {
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ enabled: false, show_sidebar_nav: true, providers: [] }),
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['refresh', 'save', 'reset-usage', 'reset-all-usage'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const activeTab = ref('usage');
|
||||
const showEditor = ref(false);
|
||||
const editorIndex = ref(-1);
|
||||
const editedProvider = ref(createProvider());
|
||||
|
||||
const configValue = computed(() => props.config || { enabled: false, show_sidebar_nav: true, providers: [] });
|
||||
const providers = computed(() => (Array.isArray(configValue.value.providers) ? configValue.value.providers : []));
|
||||
const displayProviderRows = computed(() => (
|
||||
props.providerRows.length ? props.providerRows : buildProviderRows(providers.value)
|
||||
));
|
||||
const displaySummary = computed(() => (
|
||||
Object.keys(props.summary || {}).length ? props.summary : buildProviderSummary(displayProviderRows.value)
|
||||
));
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
editedProvider.value = { ...createProvider(), priority: getNextProviderPriority(providers.value) };
|
||||
editorIndex.value = -1;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...providers.value[index] };
|
||||
editorIndex.value = index;
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回配置列表。
|
||||
function commitProvider() {
|
||||
const nextProviders = [...providers.value];
|
||||
const normalized = normalizeProvider(editedProvider.value, nextProviders.length + 1);
|
||||
if (editorIndex.value >= 0) {
|
||||
nextProviders.splice(editorIndex.value, 1, normalized);
|
||||
} else {
|
||||
nextProviders.push(normalized);
|
||||
}
|
||||
configValue.value.providers = nextProviders;
|
||||
showEditor.value = false;
|
||||
}
|
||||
|
||||
// 从配置列表中移除一个供应商。
|
||||
function removeProvider(index) {
|
||||
const nextProviders = [...providers.value];
|
||||
nextProviders.splice(index, 1);
|
||||
configValue.value.providers = nextProviders;
|
||||
}
|
||||
|
||||
// 请求重置单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
emit('reset-usage', providerId, index);
|
||||
}
|
||||
|
||||
// 请求重置全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
emit('reset-all-usage');
|
||||
}
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VSwitch = _resolveComponent("VSwitch");
|
||||
const _component_VSheet = _resolveComponent("VSheet");
|
||||
const _component_VIcon = _resolveComponent("VIcon");
|
||||
const _component_VTab = _resolveComponent("VTab");
|
||||
const _component_VTabs = _resolveComponent("VTabs");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
const _component_VWindowItem = _resolveComponent("VWindowItem");
|
||||
const _component_VWindow = _resolveComponent("VWindow");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
(!__props.hideTitle)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_2, [
|
||||
_cache[7] || (_cache[7] = _createElementVNode("h2", { class: "text-2xl font-bold leading-7 text-gray-100 truncate sm:text-3xl sm:leading-9" }, [
|
||||
_createElementVNode("span", { class: "text-moviepilot" }, "Agent Tokens 管理")
|
||||
], -1)),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-refresh",
|
||||
variant: "text",
|
||||
loading: __props.loading,
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('refresh')))
|
||||
}, null, 8, ["loading"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-content-save",
|
||||
variant: "text",
|
||||
color: "primary",
|
||||
loading: __props.saving,
|
||||
onClick: _cache[1] || (_cache[1] = $event => (emit('save')))
|
||||
}, null, 8, ["loading"])
|
||||
]))
|
||||
: _createCommentVNode("", true),
|
||||
(__props.error)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 1,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
class: "mb-4"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(__props.error), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-control-panel"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_3, [
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: configValue.value.enabled,
|
||||
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((configValue.value.enabled) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
label: "启用插件"
|
||||
}, null, 8, ["modelValue"]),
|
||||
_createVNode(_component_VSwitch, {
|
||||
modelValue: configValue.value.show_sidebar_nav,
|
||||
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((configValue.value.show_sidebar_nav) = $event)),
|
||||
color: "primary",
|
||||
"hide-details": "",
|
||||
inset: "",
|
||||
label: "侧边栏入口"
|
||||
}, null, 8, ["modelValue"])
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createElementVNode("div", _hoisted_4, [
|
||||
_createVNode(UsageOverviewCard, {
|
||||
class: "agenttokens-overview-card",
|
||||
summary: displaySummary.value
|
||||
}, null, 8, ["summary"]),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-stat-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-check-decagram-outline",
|
||||
color: "success"
|
||||
}),
|
||||
_createElementVNode("div", null, [
|
||||
_cache[8] || (_cache[8] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
|
||||
_createElementVNode("div", _hoisted_5, _toDisplayString(displaySummary.value.available_count || 0) + " / " + _toDisplayString(displaySummary.value.enabled_count || 0), 1)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-stat-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-chart-timeline-variant",
|
||||
color: "primary"
|
||||
}),
|
||||
_createElementVNode("div", null, [
|
||||
_cache[9] || (_cache[9] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "累计使用", -1)),
|
||||
_createElementVNode("div", _hoisted_6, _toDisplayString(_unref(formatTokens)(displaySummary.value.total_used)), 1)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-stat-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-database-outline",
|
||||
color: "info"
|
||||
}),
|
||||
_createElementVNode("div", null, [
|
||||
_cache[10] || (_cache[10] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "总额度", -1)),
|
||||
_createElementVNode("div", _hoisted_7, _toDisplayString(displaySummary.value.total_limit ? _unref(formatTokens)(displaySummary.value.total_limit) : '不限'), 1)
|
||||
])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(_component_VSheet, {
|
||||
border: "",
|
||||
rounded: "",
|
||||
class: "agenttokens-content-panel"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_8, [
|
||||
_createVNode(_component_VTabs, {
|
||||
modelValue: activeTab.value,
|
||||
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((activeTab).value = $event)),
|
||||
density: "comfortable"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VTab, { value: "usage" }, {
|
||||
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
|
||||
_createTextVNode("用量", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VTab, { value: "config" }, {
|
||||
default: _withCtx(() => [...(_cache[12] || (_cache[12] = [
|
||||
_createTextVNode("配置", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"])
|
||||
]),
|
||||
_createVNode(_component_VDivider),
|
||||
_createVNode(_component_VWindow, {
|
||||
modelValue: activeTab.value,
|
||||
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((activeTab).value = $event)),
|
||||
touch: false,
|
||||
class: "agenttokens-window"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VWindowItem, { value: "usage" }, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(ProviderUsageTable, {
|
||||
"provider-rows": displayProviderRows.value,
|
||||
onReset: resetUsage
|
||||
}, null, 8, ["provider-rows"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VWindowItem, { value: "config" }, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("div", _hoisted_9, [
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-plus",
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
onClick: addProvider
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[13] || (_cache[13] = [
|
||||
_createTextVNode("新增", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
"prepend-icon": "mdi-backup-restore",
|
||||
color: "warning",
|
||||
variant: "tonal",
|
||||
onClick: resetAllUsage
|
||||
}, {
|
||||
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
|
||||
_createTextVNode(" 重置用量 ", -1)
|
||||
]))]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_createVNode(ProviderConfigTable, {
|
||||
providers: providers.value,
|
||||
"provider-rows": displayProviderRows.value,
|
||||
"show-credentials": "",
|
||||
onEdit: editProvider,
|
||||
onRemove: removeProvider
|
||||
}, null, 8, ["providers", "provider-rows"])
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["modelValue"])
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_sfc_main$3, {
|
||||
modelValue: showEditor.value,
|
||||
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((showEditor).value = $event)),
|
||||
provider: editedProvider.value,
|
||||
"editor-index": editorIndex.value,
|
||||
onCommit: commitProvider
|
||||
}, null, 8, ["modelValue", "provider", "editor-index"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const AgentTokensManager = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-a6c1ea54"]]);
|
||||
|
||||
export { AgentTokensManager as A };
|
||||
130
plugins.v2/agenttokens/dist/assets/__federation_expose_AppPage-EV4Kchio.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { A as AgentTokensManager } from './AgentTokensManager-BTcJgtTd.js';
|
||||
import { u as unwrapResponse } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {openBlock:_openBlock,createBlock:_createBlock} = await importShared('vue');
|
||||
|
||||
|
||||
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, { expose: __expose }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const status = ref({
|
||||
config: { enabled: false, show_sidebar_nav: true, providers: [] },
|
||||
providers: [],
|
||||
summary: {},
|
||||
});
|
||||
|
||||
// 构造 API 基础路径。
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`);
|
||||
const config = computed(() => status.value.config || { enabled: false, show_sidebar_nav: true, providers: [] });
|
||||
const providerRows = computed(() => status.value.providers || []);
|
||||
const summary = computed(() => status.value.summary || {});
|
||||
|
||||
// 从插件 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置指定供应商的运行记录。
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
__expose({
|
||||
loadStatus,
|
||||
saveConfig,
|
||||
loading,
|
||||
saving,
|
||||
});
|
||||
|
||||
onMounted(loadStatus);
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
return (_openBlock(), _createBlock(AgentTokensManager, {
|
||||
config: config.value,
|
||||
"provider-rows": providerRows.value,
|
||||
summary: summary.value,
|
||||
error: error.value,
|
||||
loading: loading.value,
|
||||
saving: saving.value,
|
||||
"hide-title": __props.hideTitle,
|
||||
onRefresh: loadStatus,
|
||||
onSave: saveConfig,
|
||||
onResetUsage: resetUsage,
|
||||
onResetAllUsage: resetAllUsage
|
||||
}, null, 8, ["config", "provider-rows", "summary", "error", "loading", "saving", "hide-title"]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
103
plugins.v2/agenttokens/dist/assets/__federation_expose_Config-CpvEDTaR.js
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { A as AgentTokensManager } from './AgentTokensManager-BTcJgtTd.js';
|
||||
import { c as cloneConfig } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,openBlock:_openBlock,createElementBlock:_createElementBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-config" };
|
||||
|
||||
const {onMounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Config',
|
||||
props: {
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['save', 'close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] });
|
||||
|
||||
// 重置本地配置中的单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
const providers = localConfig.value.providers || [];
|
||||
const providerIndex = providers.findIndex(provider => provider.id && provider.id === providerId);
|
||||
const targetIndex = providerIndex >= 0 ? providerIndex : index;
|
||||
if (!providers[targetIndex]) return
|
||||
providers[targetIndex].used_tokens = 0;
|
||||
}
|
||||
|
||||
// 重置本地配置中的全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
(localConfig.value.providers || []).forEach(provider => {
|
||||
provider.used_tokens = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 通知宿主保存 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_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(() => [
|
||||
_cache[1] || (_cache[1] = _createElementVNode("div", { class: "text-h6 ms-3" }, "Agent Tokens 配置", -1)),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-content-save",
|
||||
variant: "text",
|
||||
color: "primary",
|
||||
onClick: saveConfig
|
||||
}),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-close",
|
||||
variant: "text",
|
||||
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VDivider),
|
||||
_createVNode(AgentTokensManager, {
|
||||
config: localConfig.value,
|
||||
"hide-title": "",
|
||||
onSave: saveConfig,
|
||||
onResetUsage: resetUsage,
|
||||
onResetAllUsage: resetAllUsage
|
||||
}, null, 8, ["config"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export { _sfc_main as default };
|
||||
198
plugins.v2/agenttokens/dist/assets/__federation_expose_Dashboard-CMoy7CAI.css
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
|
||||
.agenttokens-dashboard-widget[data-v-cd87a760] {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 100%;
|
||||
|
||||
--agenttokens-divider-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
--agenttokens-muted-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
--agenttokens-soft-surface: rgba(var(--v-theme-on-surface), 0.035);
|
||||
--agenttokens-soft-surface-hover: rgba(var(--v-theme-on-surface), 0.055);
|
||||
}
|
||||
.agenttokens-dashboard-card[data-v-cd87a760] {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.agenttokens-dashboard-card__header[data-v-cd87a760] {
|
||||
flex: 0 0 auto;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-card__title[data-v-cd87a760] {
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agenttokens-dashboard-card__body[data-v-cd87a760] {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block-start: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-card__actions[data-v-cd87a760] {
|
||||
flex: 0 0 auto;
|
||||
min-block-size: 40px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.agenttokens-dashboard-state[data-v-cd87a760] {
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.agenttokens-dashboard-content[data-v-cd87a760] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-summary[data-v-cd87a760] {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.agenttokens-dashboard-summary__percent[data-v-cd87a760] {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.agenttokens-dashboard-summary__body[data-v-cd87a760] {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-summary__count[data-v-cd87a760] {
|
||||
margin-block: 2px 8px;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.agenttokens-dashboard-summary__count span[data-v-cd87a760] {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.agenttokens-dashboard-metrics[data-v-cd87a760] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-metric[data-v-cd87a760] {
|
||||
min-block-size: 54px;
|
||||
border: 1px solid var(--agenttokens-divider-color);
|
||||
border-radius: 6px;
|
||||
background: var(--agenttokens-soft-surface);
|
||||
padding: 8px 10px;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.agenttokens-dashboard-metric[data-v-cd87a760]:hover {
|
||||
background: var(--agenttokens-soft-surface-hover);
|
||||
}
|
||||
.agenttokens-dashboard-metric span[data-v-cd87a760] {
|
||||
display: block;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.agenttokens-dashboard-metric strong[data-v-cd87a760] {
|
||||
display: block;
|
||||
margin-block-start: 4px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.agenttokens-dashboard-list[data-v-cd87a760] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-provider[data-v-cd87a760] {
|
||||
min-block-size: 34px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
.agenttokens-dashboard-provider[data-v-cd87a760]:hover {
|
||||
background: var(--agenttokens-soft-surface);
|
||||
}
|
||||
.agenttokens-dashboard-provider__main[data-v-cd87a760] {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.agenttokens-dashboard-provider__name[data-v-cd87a760],
|
||||
.agenttokens-dashboard-provider__model[data-v-cd87a760] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agenttokens-dashboard-provider__name[data-v-cd87a760] {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.agenttokens-dashboard-provider__model[data-v-cd87a760] {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.agenttokens-dashboard-provider__tokens[data-v-cd87a760] {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.agenttokens-dashboard-empty[data-v-cd87a760] {
|
||||
min-block-size: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-card__body[data-v-cd87a760] {
|
||||
padding-block: 6px 10px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-content[data-v-cd87a760] {
|
||||
gap: 8px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary[data-v-cd87a760] {
|
||||
gap: 10px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary__count[data-v-cd87a760] {
|
||||
margin-block-end: 6px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-metric[data-v-cd87a760] {
|
||||
min-block-size: 46px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-provider[data-v-cd87a760] {
|
||||
min-block-size: 30px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header[data-v-cd87a760] {
|
||||
padding-block: 10px 4px;
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary[data-v-cd87a760] {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary__count[data-v-cd87a760] {
|
||||
margin-block: 0 4px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__actions[data-v-cd87a760] {
|
||||
justify-content: flex-end;
|
||||
min-block-size: 34px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.agenttokens-dashboard-metrics[data-v-cd87a760] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
429
plugins.v2/agenttokens/dist/assets/__federation_expose_Dashboard-DdqUAuX4.js
vendored
Normal file
@@ -0,0 +1,429 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import { _ as _export_sfc, f as formatTokens, u as unwrapResponse } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,openBlock:_openBlock,createElementBlock:_createElementBlock,createCommentVNode:_createCommentVNode,createBlock:_createBlock,createElementVNode:_createElementVNode,unref:_unref,renderList:_renderList,Fragment:_Fragment,normalizeClass:_normalizeClass} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = {
|
||||
key: 0,
|
||||
class: "agenttokens-dashboard-state"
|
||||
};
|
||||
const _hoisted_2 = {
|
||||
key: 2,
|
||||
class: "agenttokens-dashboard-content"
|
||||
};
|
||||
const _hoisted_3 = { class: "agenttokens-dashboard-summary" };
|
||||
const _hoisted_4 = { class: "agenttokens-dashboard-summary__percent" };
|
||||
const _hoisted_5 = { class: "agenttokens-dashboard-summary__body" };
|
||||
const _hoisted_6 = { class: "agenttokens-dashboard-summary__count" };
|
||||
const _hoisted_7 = {
|
||||
key: 0,
|
||||
class: "agenttokens-dashboard-metrics"
|
||||
};
|
||||
const _hoisted_8 = { class: "agenttokens-dashboard-metric" };
|
||||
const _hoisted_9 = { class: "agenttokens-dashboard-metric" };
|
||||
const _hoisted_10 = { class: "agenttokens-dashboard-metric" };
|
||||
const _hoisted_11 = {
|
||||
key: 1,
|
||||
class: "agenttokens-dashboard-list"
|
||||
};
|
||||
const _hoisted_12 = { class: "agenttokens-dashboard-provider__main" };
|
||||
const _hoisted_13 = { class: "agenttokens-dashboard-provider__name" };
|
||||
const _hoisted_14 = { class: "agenttokens-dashboard-provider__model" };
|
||||
const _hoisted_15 = { class: "agenttokens-dashboard-provider__tokens" };
|
||||
const _hoisted_16 = {
|
||||
key: 2,
|
||||
class: "agenttokens-dashboard-empty"
|
||||
};
|
||||
const _hoisted_17 = {
|
||||
key: 3,
|
||||
class: "agenttokens-dashboard-state text-caption text-disabled"
|
||||
};
|
||||
const _hoisted_18 = {
|
||||
key: 0,
|
||||
class: "text-caption text-disabled"
|
||||
};
|
||||
|
||||
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Dashboard',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ attrs: {} }),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
setup(__props) {
|
||||
|
||||
const props = __props;
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const initialDataLoaded = ref(false);
|
||||
const lastRefreshedAt = ref(null);
|
||||
const widgetRef = ref(null);
|
||||
const widgetSize = ref({ inline: 0, block: 0 });
|
||||
const status = ref({ providers: [], summary: {} });
|
||||
let timer = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
const attrs = computed(() => props.config?.attrs || {});
|
||||
const summary = computed(() => status.value.summary || {});
|
||||
const providers = computed(() => status.value.providers || []);
|
||||
const totalUsed = computed(() => Number(summary.value.total_used || 0));
|
||||
const totalLimit = computed(() => Number(summary.value.total_limit || 0));
|
||||
const remainingTokens = computed(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
});
|
||||
const usagePercent = computed(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
});
|
||||
const usagePercentText = computed(() => (totalLimit.value > 0 ? `${Math.round(usagePercent.value)}%` : '不限'));
|
||||
const progressColor = computed(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
});
|
||||
const isCompact = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 340) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 300)
|
||||
));
|
||||
const isMini = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 260) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 230)
|
||||
));
|
||||
const gaugeSize = computed(() => {
|
||||
if (isMini.value) return 52
|
||||
if (isCompact.value) return 68
|
||||
return 84
|
||||
});
|
||||
const gaugeWidth = computed(() => {
|
||||
if (isMini.value) return 5
|
||||
if (isCompact.value) return 6
|
||||
return 8
|
||||
});
|
||||
const showMetrics = computed(() => !isMini.value);
|
||||
const visibleProviderLimit = computed(() => {
|
||||
if (isMini.value) return 0
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 320) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 310)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 380) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 360)
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
});
|
||||
const visibleProviders = computed(() => providers.value.slice(0, visibleProviderLimit.value));
|
||||
// 兼容宿主传入的数字或字符串刷新间隔。
|
||||
const refreshSeconds = computed(() => {
|
||||
const seconds = Number(props.refreshInterval || attrs.value.refresh || 0);
|
||||
return Number.isFinite(seconds) ? seconds : 0
|
||||
});
|
||||
const cardTitle = computed(() => attrs.value.title || 'Agent Tokens 管理');
|
||||
const cardSubtitle = computed(() => attrs.value.subtitle || 'LLM 配额使用情况');
|
||||
const cardFlat = computed(() => attrs.value.border === false);
|
||||
const widgetClasses = computed(() => ({
|
||||
'agenttokens-dashboard-widget--compact': isCompact.value,
|
||||
'agenttokens-dashboard-widget--mini': isMini.value,
|
||||
}));
|
||||
const lastRefreshedTime = computed(() => {
|
||||
if (!lastRefreshedAt.value) return ''
|
||||
return new Date(lastRefreshedAt.value).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
});
|
||||
|
||||
// 读取 Agent Tokens 仪表板状态。
|
||||
async function loadStatus() {
|
||||
if (!props.api?.get) {
|
||||
error.value = 'API 未就绪';
|
||||
return
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await props.api.get('plugin/AgentTokens/status');
|
||||
status.value = unwrapResponse(response) || status.value;
|
||||
initialDataLoaded.value = true;
|
||||
lastRefreshedAt.value = Date.now();
|
||||
} catch (err) {
|
||||
error.value = err?.message || '获取数据失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动宿主传入或插件配置中的自动刷新。
|
||||
function startRefreshTimer() {
|
||||
if (refreshSeconds.value <= 0) return
|
||||
timer = window.setInterval(loadStatus, refreshSeconds.value * 1000);
|
||||
}
|
||||
|
||||
// 清理仪表板自动刷新计时器。
|
||||
function stopRefreshTimer() {
|
||||
if (!timer) return
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
// 记录宿主 GridStack 分配给组件的实际尺寸,用于切换紧凑布局。
|
||||
function observeWidgetSize() {
|
||||
if (!widgetRef.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return
|
||||
widgetSize.value = {
|
||||
inline: entry.contentRect.width,
|
||||
block: entry.contentRect.height,
|
||||
};
|
||||
});
|
||||
resizeObserver.observe(widgetRef.value);
|
||||
}
|
||||
|
||||
// 停止监听组件尺寸,避免仪表板卸载后继续触发布局计算。
|
||||
function stopWidgetSizeObserver() {
|
||||
if (!resizeObserver) return
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observeWidgetSize();
|
||||
loadStatus();
|
||||
startRefreshTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWidgetSizeObserver();
|
||||
stopRefreshTimer();
|
||||
});
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
const _component_VIcon = _resolveComponent("VIcon");
|
||||
const _component_VAvatar = _resolveComponent("VAvatar");
|
||||
const _component_VCardTitle = _resolveComponent("VCardTitle");
|
||||
const _component_VCardSubtitle = _resolveComponent("VCardSubtitle");
|
||||
const _component_VCardItem = _resolveComponent("VCardItem");
|
||||
const _component_VProgressCircular = _resolveComponent("VProgressCircular");
|
||||
const _component_VAlert = _resolveComponent("VAlert");
|
||||
const _component_VProgressLinear = _resolveComponent("VProgressLinear");
|
||||
const _component_VCardText = _resolveComponent("VCardText");
|
||||
const _component_VDivider = _resolveComponent("VDivider");
|
||||
const _component_VSpacer = _resolveComponent("VSpacer");
|
||||
const _component_VBtn = _resolveComponent("VBtn");
|
||||
const _component_VCardActions = _resolveComponent("VCardActions");
|
||||
const _component_VCard = _resolveComponent("VCard");
|
||||
|
||||
return (_openBlock(), _createElementBlock("div", {
|
||||
ref_key: "widgetRef",
|
||||
ref: widgetRef,
|
||||
class: _normalizeClass(["agenttokens-dashboard-widget", widgetClasses.value])
|
||||
}, [
|
||||
_createVNode(_component_VCard, {
|
||||
flat: cardFlat.value,
|
||||
loading: loading.value,
|
||||
class: "agenttokens-dashboard-card"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardItem, { class: "agenttokens-dashboard-card__header" }, {
|
||||
prepend: _withCtx(() => [
|
||||
_createVNode(_component_VAvatar, {
|
||||
color: "primary",
|
||||
variant: "tonal",
|
||||
size: "36"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-key-chain",
|
||||
size: "20"
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VCardTitle, { class: "agenttokens-dashboard-card__title" }, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(cardTitle.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardSubtitle, null, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(cardSubtitle.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VCardText, { class: "agenttokens-dashboard-card__body" }, {
|
||||
default: _withCtx(() => [
|
||||
(loading.value && !initialDataLoaded.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_1, [
|
||||
_createVNode(_component_VProgressCircular, {
|
||||
indeterminate: "",
|
||||
color: "primary",
|
||||
size: "28"
|
||||
})
|
||||
]))
|
||||
: (error.value)
|
||||
? (_openBlock(), _createBlock(_component_VAlert, {
|
||||
key: 1,
|
||||
type: "error",
|
||||
variant: "tonal",
|
||||
density: "compact",
|
||||
class: "text-caption"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createTextVNode(_toDisplayString(error.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: (initialDataLoaded.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_2, [
|
||||
_createElementVNode("div", _hoisted_3, [
|
||||
_createVNode(_component_VProgressCircular, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
"bg-color": "surface",
|
||||
size: gaugeSize.value,
|
||||
width: gaugeWidth.value
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createElementVNode("span", _hoisted_4, _toDisplayString(usagePercentText.value), 1)
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["model-value", "color", "size", "width"]),
|
||||
_createElementVNode("div", _hoisted_5, [
|
||||
_cache[0] || (_cache[0] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
|
||||
_createElementVNode("div", _hoisted_6, [
|
||||
_createTextVNode(_toDisplayString(summary.value.available_count || 0) + " ", 1),
|
||||
_createElementVNode("span", null, "/ " + _toDisplayString(summary.value.enabled_count || 0), 1)
|
||||
]),
|
||||
_createVNode(_component_VProgressLinear, {
|
||||
"model-value": usagePercent.value,
|
||||
color: progressColor.value,
|
||||
height: "6",
|
||||
rounded: ""
|
||||
}, null, 8, ["model-value", "color"])
|
||||
])
|
||||
]),
|
||||
(showMetrics.value)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_7, [
|
||||
_createElementVNode("div", _hoisted_8, [
|
||||
_cache[1] || (_cache[1] = _createElementVNode("span", null, "累计", -1)),
|
||||
_createElementVNode("strong", null, _toDisplayString(_unref(formatTokens)(totalUsed.value)), 1)
|
||||
]),
|
||||
_createElementVNode("div", _hoisted_9, [
|
||||
_cache[2] || (_cache[2] = _createElementVNode("span", null, "额度", -1)),
|
||||
_createElementVNode("strong", null, _toDisplayString(totalLimit.value > 0 ? _unref(formatTokens)(totalLimit.value) : '不限'), 1)
|
||||
]),
|
||||
_createElementVNode("div", _hoisted_10, [
|
||||
_cache[3] || (_cache[3] = _createElementVNode("span", null, "剩余", -1)),
|
||||
_createElementVNode("strong", null, _toDisplayString(remainingTokens.value === null ? '不限' : _unref(formatTokens)(remainingTokens.value)), 1)
|
||||
])
|
||||
]))
|
||||
: _createCommentVNode("", true),
|
||||
(visibleProviders.value.length)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_11, [
|
||||
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(visibleProviders.value, (row) => {
|
||||
return (_openBlock(), _createElementBlock("div", {
|
||||
key: row.id,
|
||||
class: "agenttokens-dashboard-provider"
|
||||
}, [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle',
|
||||
color: row.usage?.exhausted ? 'error' : 'success',
|
||||
size: "16"
|
||||
}, null, 8, ["icon", "color"]),
|
||||
_createElementVNode("div", _hoisted_12, [
|
||||
_createElementVNode("div", _hoisted_13, _toDisplayString(row.name || '未命名供应商'), 1),
|
||||
_createElementVNode("div", _hoisted_14, _toDisplayString(row.model || '未配置模型'), 1)
|
||||
]),
|
||||
_createElementVNode("div", _hoisted_15, _toDisplayString(_unref(formatTokens)(row.usage?.total_tokens)), 1)
|
||||
]))
|
||||
}), 128))
|
||||
]))
|
||||
: (!providers.value.length)
|
||||
? (_openBlock(), _createElementBlock("div", _hoisted_16, [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-database-off-outline",
|
||||
size: "18"
|
||||
}),
|
||||
_cache[4] || (_cache[4] = _createElementVNode("span", null, "暂无供应商", -1))
|
||||
]))
|
||||
: _createCommentVNode("", true)
|
||||
]))
|
||||
: (_openBlock(), _createElementBlock("div", _hoisted_17, " 暂无数据 "))
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
(__props.allowRefresh)
|
||||
? (_openBlock(), _createBlock(_component_VDivider, { key: 0 }))
|
||||
: _createCommentVNode("", true),
|
||||
(__props.allowRefresh)
|
||||
? (_openBlock(), _createBlock(_component_VCardActions, {
|
||||
key: 1,
|
||||
class: "agenttokens-dashboard-card__actions"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
(!isMini.value)
|
||||
? (_openBlock(), _createElementBlock("span", _hoisted_18, _toDisplayString(lastRefreshedTime.value ? `更新于 ${lastRefreshedTime.value}` : '等待更新'), 1))
|
||||
: _createCommentVNode("", true),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "",
|
||||
variant: "text",
|
||||
size: "small",
|
||||
loading: loading.value,
|
||||
onClick: loadStatus
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_createVNode(_component_VIcon, {
|
||||
icon: "mdi-refresh",
|
||||
size: "18"
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["loading"])
|
||||
]),
|
||||
_: 1
|
||||
}))
|
||||
: _createCommentVNode("", true)
|
||||
]),
|
||||
_: 1
|
||||
}, 8, ["flat", "loading"])
|
||||
], 2))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const Dashboard = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-cd87a760"]]);
|
||||
|
||||
export { Dashboard as default };
|
||||
79
plugins.v2/agenttokens/dist/assets/__federation_expose_Page-BikS33tm.js
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import _sfc_main$1 from './__federation_expose_AppPage-EV4Kchio.js';
|
||||
import { _ as _export_sfc } from './_plugin-vue_export-helper-B_eZRIX_.js';
|
||||
|
||||
const {createElementVNode:_createElementVNode,resolveComponent:_resolveComponent,createVNode:_createVNode,withCtx:_withCtx,openBlock:_openBlock,createElementBlock:_createElementBlock} = await importShared('vue');
|
||||
|
||||
|
||||
const _hoisted_1 = { class: "agenttokens-page-wrapper" };
|
||||
|
||||
const {ref} = await importShared('vue');
|
||||
|
||||
|
||||
const _sfc_main = {
|
||||
__name: 'Page',
|
||||
props: {
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(__props, { emit: __emit }) {
|
||||
|
||||
|
||||
const emit = __emit;
|
||||
|
||||
const pageRef = ref(null);
|
||||
|
||||
return (_ctx, _cache) => {
|
||||
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",
|
||||
class: "sticky-toolbar"
|
||||
}, {
|
||||
default: _withCtx(() => [
|
||||
_cache[3] || (_cache[3] = _createElementVNode("div", { class: "text-h6 ms-3" }, "Agent Tokens 管理", -1)),
|
||||
_createVNode(_component_VSpacer),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-refresh",
|
||||
variant: "text",
|
||||
loading: pageRef.value?.loading,
|
||||
onClick: _cache[0] || (_cache[0] = $event => (pageRef.value?.loadStatus()))
|
||||
}, null, 8, ["loading"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-content-save",
|
||||
variant: "text",
|
||||
color: "primary",
|
||||
loading: pageRef.value?.saving,
|
||||
onClick: _cache[1] || (_cache[1] = $event => (pageRef.value?.saveConfig()))
|
||||
}, null, 8, ["loading"]),
|
||||
_createVNode(_component_VBtn, {
|
||||
icon: "mdi-close",
|
||||
variant: "text",
|
||||
onClick: _cache[2] || (_cache[2] = $event => (emit('close')))
|
||||
})
|
||||
]),
|
||||
_: 1
|
||||
}),
|
||||
_createVNode(_component_VDivider),
|
||||
_createVNode(_sfc_main$1, {
|
||||
ref_key: "pageRef",
|
||||
ref: pageRef,
|
||||
api: __props.api,
|
||||
"plugin-id": "AgentTokens",
|
||||
"hide-title": ""
|
||||
}, null, 8, ["api"])
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
const Page = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-2f12fb0f"]]);
|
||||
|
||||
export { Page as default };
|
||||
7
plugins.v2/agenttokens/dist/assets/__federation_expose_Page-vwwFlnk-.css
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
.sticky-toolbar[data-v-2f12fb0f] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
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 };
|
||||
112
plugins.v2/agenttokens/dist/assets/_plugin-vue_export-helper-B_eZRIX_.js
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
const PROVIDER_TYPE_OPTIONS = [
|
||||
{ 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: '',
|
||||
user_agent: '',
|
||||
use_proxy: true,
|
||||
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'
|
||||
}
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 计算新增供应商的下一个优先级。
|
||||
function getNextProviderPriority(providers) {
|
||||
return Math.max(0, ...(providers || []).map(item => Number(item.priority || 0))) + 1
|
||||
}
|
||||
|
||||
// 标准化弹窗中写回的供应商数值字段。
|
||||
function normalizeProvider(provider, fallbackPriority) {
|
||||
return {
|
||||
...provider,
|
||||
use_proxy: provider.use_proxy !== false,
|
||||
token_limit: Number(provider.token_limit || 0),
|
||||
used_tokens: Number(provider.used_tokens || 0),
|
||||
priority: Number(provider.priority || fallbackPriority),
|
||||
}
|
||||
}
|
||||
|
||||
// 按配置生成本地用量行,供配置弹窗复用管理页展示结构。
|
||||
function buildProviderRow(provider) {
|
||||
const tokenLimit = Number(provider.token_limit || 0);
|
||||
const totalTokens = Number(provider.used_tokens || 0);
|
||||
const remainingTokens = tokenLimit <= 0 ? null : Math.max(tokenLimit - totalTokens, 0);
|
||||
const usagePercent = tokenLimit <= 0 ? 0 : Math.min((totalTokens * 100) / tokenLimit, 100);
|
||||
|
||||
return {
|
||||
...provider,
|
||||
masked_api_key: provider.api_key ? '****' : '',
|
||||
usage: {
|
||||
total_tokens: totalTokens,
|
||||
remaining_tokens: remainingTokens,
|
||||
usage_percent: usagePercent,
|
||||
exhausted: tokenLimit > 0 && remainingTokens === 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 批量生成本地供应商用量行。
|
||||
function buildProviderRows(providers) {
|
||||
return (providers || []).map(provider => buildProviderRow(provider))
|
||||
}
|
||||
|
||||
// 根据供应商行汇总用量统计。
|
||||
function buildProviderSummary(rows) {
|
||||
const providers = rows || [];
|
||||
const enabledRows = providers.filter(row => row.enabled);
|
||||
const totalUsed = providers.reduce((sum, row) => sum + Number(row.usage?.total_tokens || row.used_tokens || 0), 0);
|
||||
const totalLimit = providers.reduce((sum, row) => {
|
||||
const tokenLimit = Number(row.token_limit || 0);
|
||||
return tokenLimit > 0 ? sum + tokenLimit : sum
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
available_count: enabledRows.filter(row => !row.usage?.exhausted && row.api_key && row.base_url && row.model).length,
|
||||
enabled_count: enabledRows.length,
|
||||
total_used: totalUsed,
|
||||
total_limit: totalLimit,
|
||||
}
|
||||
}
|
||||
|
||||
const _export_sfc = (sfc, props) => {
|
||||
const target = sfc.__vccOpts || sfc;
|
||||
for (const [key, val] of props) {
|
||||
target[key] = val;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export { PROVIDER_TYPE_OPTIONS as P, _export_sfc as _, buildProviderSummary as a, buildProviderRows as b, cloneConfig as c, createProvider as d, formatTokens as f, getNextProviderPriority as g, normalizeProvider as n, unwrapResponse as u };
|
||||
44
plugins.v2/agenttokens/dist/assets/index-Cqxebwzg.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
|
||||
import _sfc_main from './__federation_expose_AppPage-EV4Kchio.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(_sfc_main).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_Page-vwwFlnk-.css","AgentTokensManager-9miSzH4d.css"], false, './Page');
|
||||
return __federation_import('./__federation_expose_Page-BikS33tm.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Config":()=>{
|
||||
dynamicLoadingCss(["AgentTokensManager-9miSzH4d.css"], false, './Config');
|
||||
return __federation_import('./__federation_expose_Config-CpvEDTaR.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./Dashboard":()=>{
|
||||
dynamicLoadingCss(["__federation_expose_Dashboard-CMoy7CAI.css"], false, './Dashboard');
|
||||
return __federation_import('./__federation_expose_Dashboard-DdqUAuX4.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
|
||||
"./AppPage":()=>{
|
||||
dynamicLoadingCss(["AgentTokensManager-9miSzH4d.css"], false, './AppPage');
|
||||
return __federation_import('./__federation_expose_AppPage-EV4Kchio.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 };
|
||||
7
plugins.v2/agenttokens/dist/index.html
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<script type="module" crossorigin src="/assets/index-Cqxebwzg.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-B_eZRIX_.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/AgentTokensManager-BTcJgtTd.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-EV4Kchio.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/AgentTokensManager-9miSzH4d.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.12",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
299
plugins.v2/agenttokens/src/components/AgentTokensManager.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import ProviderConfigTable from './ProviderConfigTable.vue'
|
||||
import ProviderEditorDialog from './ProviderEditorDialog.vue'
|
||||
import ProviderUsageTable from './ProviderUsageTable.vue'
|
||||
import UsageOverviewCard from './UsageOverviewCard.vue'
|
||||
import {
|
||||
buildProviderRows,
|
||||
buildProviderSummary,
|
||||
createProvider,
|
||||
formatTokens,
|
||||
getNextProviderPriority,
|
||||
normalizeProvider,
|
||||
} from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ enabled: false, show_sidebar_nav: true, providers: [] }),
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['refresh', 'save', 'reset-usage', 'reset-all-usage'])
|
||||
|
||||
const activeTab = ref('usage')
|
||||
const showEditor = ref(false)
|
||||
const editorIndex = ref(-1)
|
||||
const editedProvider = ref(createProvider())
|
||||
|
||||
const configValue = computed(() => props.config || { enabled: false, show_sidebar_nav: true, providers: [] })
|
||||
const providers = computed(() => (Array.isArray(configValue.value.providers) ? configValue.value.providers : []))
|
||||
const displayProviderRows = computed(() => (
|
||||
props.providerRows.length ? props.providerRows : buildProviderRows(providers.value)
|
||||
))
|
||||
const displaySummary = computed(() => (
|
||||
Object.keys(props.summary || {}).length ? props.summary : buildProviderSummary(displayProviderRows.value)
|
||||
))
|
||||
|
||||
// 打开新增供应商弹窗。
|
||||
function addProvider() {
|
||||
editedProvider.value = { ...createProvider(), priority: getNextProviderPriority(providers.value) }
|
||||
editorIndex.value = -1
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 打开编辑供应商弹窗。
|
||||
function editProvider(index) {
|
||||
editedProvider.value = { ...providers.value[index] }
|
||||
editorIndex.value = index
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
// 将弹窗中的供应商写回配置列表。
|
||||
function commitProvider() {
|
||||
const nextProviders = [...providers.value]
|
||||
const normalized = normalizeProvider(editedProvider.value, nextProviders.length + 1)
|
||||
if (editorIndex.value >= 0) {
|
||||
nextProviders.splice(editorIndex.value, 1, normalized)
|
||||
} else {
|
||||
nextProviders.push(normalized)
|
||||
}
|
||||
configValue.value.providers = nextProviders
|
||||
showEditor.value = false
|
||||
}
|
||||
|
||||
// 从配置列表中移除一个供应商。
|
||||
function removeProvider(index) {
|
||||
const nextProviders = [...providers.value]
|
||||
nextProviders.splice(index, 1)
|
||||
configValue.value.providers = nextProviders
|
||||
}
|
||||
|
||||
// 请求重置单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
emit('reset-usage', providerId, index)
|
||||
}
|
||||
|
||||
// 请求重置全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
emit('reset-all-usage')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-page">
|
||||
<div v-if="!hideTitle" class="agenttokens-header">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-100 truncate sm:text-3xl sm:leading-9">
|
||||
<span class="text-moviepilot">Agent Tokens 管理</span>
|
||||
</h2>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-refresh" variant="text" :loading="loading" @click="emit('refresh')" />
|
||||
<VBtn icon="mdi-content-save" variant="text" color="primary" :loading="saving" @click="emit('save')" />
|
||||
</div>
|
||||
|
||||
<VAlert v-if="error" type="error" variant="tonal" class="mb-4">{{ error }}</VAlert>
|
||||
|
||||
<VSheet border rounded class="agenttokens-control-panel">
|
||||
<div class="agenttokens-control-panel__switches">
|
||||
<VSwitch v-model="configValue.enabled" color="primary" hide-details inset label="启用插件" />
|
||||
<VSwitch v-model="configValue.show_sidebar_nav" color="primary" hide-details inset label="侧边栏入口" />
|
||||
</div>
|
||||
</VSheet>
|
||||
|
||||
<div class="agenttokens-overview-grid">
|
||||
<UsageOverviewCard class="agenttokens-overview-card" :summary="displaySummary" />
|
||||
<VSheet border rounded class="agenttokens-stat-card">
|
||||
<VIcon icon="mdi-check-decagram-outline" color="success" />
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">可用供应商</div>
|
||||
<div class="agenttokens-stat-card__value">
|
||||
{{ displaySummary.available_count || 0 }} / {{ displaySummary.enabled_count || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
<VSheet border rounded class="agenttokens-stat-card">
|
||||
<VIcon icon="mdi-chart-timeline-variant" color="primary" />
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">累计使用</div>
|
||||
<div class="agenttokens-stat-card__value">{{ formatTokens(displaySummary.total_used) }}</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
<VSheet border rounded class="agenttokens-stat-card">
|
||||
<VIcon icon="mdi-database-outline" color="info" />
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">总额度</div>
|
||||
<div class="agenttokens-stat-card__value">
|
||||
{{ displaySummary.total_limit ? formatTokens(displaySummary.total_limit) : '不限' }}
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</div>
|
||||
|
||||
<VSheet border rounded class="agenttokens-content-panel">
|
||||
<div class="agenttokens-tabs-row">
|
||||
<VTabs v-model="activeTab" density="comfortable">
|
||||
<VTab value="usage">用量</VTab>
|
||||
<VTab value="config">配置</VTab>
|
||||
</VTabs>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VWindow v-model="activeTab" :touch="false" class="agenttokens-window">
|
||||
<VWindowItem value="usage">
|
||||
<ProviderUsageTable :provider-rows="displayProviderRows" @reset="resetUsage" />
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="config">
|
||||
<div class="agenttokens-table-actions">
|
||||
<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>
|
||||
<ProviderConfigTable
|
||||
:providers="providers"
|
||||
:provider-rows="displayProviderRows"
|
||||
show-credentials
|
||||
@edit="editProvider"
|
||||
@remove="removeProvider"
|
||||
/>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VSheet>
|
||||
|
||||
<ProviderEditorDialog
|
||||
v-model="showEditor"
|
||||
:provider="editedProvider"
|
||||
:editor-index="editorIndex"
|
||||
@commit="commitProvider"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agenttokens-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.agenttokens-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.agenttokens-control-panel__switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
|
||||
.agenttokens-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) repeat(3, minmax(10rem, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agenttokens-overview-card {
|
||||
min-block-size: 172px;
|
||||
}
|
||||
|
||||
.agenttokens-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-block-size: 104px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.agenttokens-stat-card__value {
|
||||
margin-block-start: 2px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agenttokens-content-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agenttokens-tabs-row {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-window {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.agenttokens-table-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-block-end: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.agenttokens-overview-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.agenttokens-overview-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.agenttokens-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.agenttokens-table-actions > :deep(.v-btn) {
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
|
||||
.agenttokens-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agenttokens-stat-card {
|
||||
min-block-size: 88px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
116
plugins.v2/agenttokens/src/components/AppPage.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AgentTokensManager from './AgentTokensManager.vue'
|
||||
import { unwrapResponse } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: 'AgentTokens',
|
||||
},
|
||||
hideTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const status = ref({
|
||||
config: { enabled: false, show_sidebar_nav: true, providers: [] },
|
||||
providers: [],
|
||||
summary: {},
|
||||
})
|
||||
|
||||
// 构造 API 基础路径。
|
||||
const pluginBase = computed(() => `plugin/${props.pluginId || 'AgentTokens'}`)
|
||||
const config = computed(() => status.value.config || { enabled: false, show_sidebar_nav: true, providers: [] })
|
||||
const providerRows = computed(() => status.value.providers || [])
|
||||
const summary = computed(() => status.value.summary || {})
|
||||
|
||||
// 从插件 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
|
||||
}
|
||||
}
|
||||
|
||||
// 重置指定供应商的运行记录。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
loadStatus,
|
||||
saveConfig,
|
||||
loading,
|
||||
saving,
|
||||
})
|
||||
|
||||
onMounted(loadStatus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentTokensManager
|
||||
:config="config"
|
||||
:provider-rows="providerRows"
|
||||
:summary="summary"
|
||||
:error="error"
|
||||
:loading="loading"
|
||||
:saving="saving"
|
||||
:hide-title="hideTitle"
|
||||
@refresh="loadStatus"
|
||||
@save="saveConfig"
|
||||
@reset-usage="resetUsage"
|
||||
@reset-all-usage="resetAllUsage"
|
||||
/>
|
||||
</template>
|
||||
67
plugins.v2/agenttokens/src/components/Config.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import AgentTokensManager from './AgentTokensManager.vue'
|
||||
import { cloneConfig } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
const localConfig = ref({ enabled: false, show_sidebar_nav: true, providers: [] })
|
||||
|
||||
// 重置本地配置中的单个供应商用量。
|
||||
function resetUsage(providerId, index) {
|
||||
const providers = localConfig.value.providers || []
|
||||
const providerIndex = providers.findIndex(provider => provider.id && provider.id === providerId)
|
||||
const targetIndex = providerIndex >= 0 ? providerIndex : index
|
||||
if (!providers[targetIndex]) return
|
||||
providers[targetIndex].used_tokens = 0
|
||||
}
|
||||
|
||||
// 重置本地配置中的全部供应商用量。
|
||||
function resetAllUsage() {
|
||||
;(localConfig.value.providers || []).forEach(provider => {
|
||||
provider.used_tokens = 0
|
||||
})
|
||||
}
|
||||
|
||||
// 通知宿主保存 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">
|
||||
<div class="text-h6 ms-3">Agent Tokens 配置</div>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-content-save" variant="text" color="primary" @click="saveConfig" />
|
||||
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
|
||||
</VToolbar>
|
||||
<VDivider />
|
||||
|
||||
<AgentTokensManager
|
||||
:config="localConfig"
|
||||
hide-title
|
||||
@save="saveConfig"
|
||||
@reset-usage="resetUsage"
|
||||
@reset-all-usage="resetAllUsage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
530
plugins.v2/agenttokens/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { formatTokens, unwrapResponse } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({ attrs: {} }),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const initialDataLoaded = ref(false)
|
||||
const lastRefreshedAt = ref(null)
|
||||
const widgetRef = ref(null)
|
||||
const widgetSize = ref({ inline: 0, block: 0 })
|
||||
const status = ref({ providers: [], summary: {} })
|
||||
let timer = null
|
||||
let resizeObserver = null
|
||||
|
||||
const attrs = computed(() => props.config?.attrs || {})
|
||||
const summary = computed(() => status.value.summary || {})
|
||||
const providers = computed(() => status.value.providers || [])
|
||||
const totalUsed = computed(() => Number(summary.value.total_used || 0))
|
||||
const totalLimit = computed(() => Number(summary.value.total_limit || 0))
|
||||
const remainingTokens = computed(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
})
|
||||
const usagePercent = computed(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
})
|
||||
const usagePercentText = computed(() => (totalLimit.value > 0 ? `${Math.round(usagePercent.value)}%` : '不限'))
|
||||
const progressColor = computed(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
const isCompact = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 340) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 300)
|
||||
))
|
||||
const isMini = computed(() => (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 260) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 230)
|
||||
))
|
||||
const gaugeSize = computed(() => {
|
||||
if (isMini.value) return 52
|
||||
if (isCompact.value) return 68
|
||||
return 84
|
||||
})
|
||||
const gaugeWidth = computed(() => {
|
||||
if (isMini.value) return 5
|
||||
if (isCompact.value) return 6
|
||||
return 8
|
||||
})
|
||||
const showMetrics = computed(() => !isMini.value)
|
||||
const visibleProviderLimit = computed(() => {
|
||||
if (isMini.value) return 0
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 320) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 310)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
if (
|
||||
(widgetSize.value.inline > 0 && widgetSize.value.inline < 380) ||
|
||||
(widgetSize.value.block > 0 && widgetSize.value.block < 360)
|
||||
) {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
})
|
||||
const visibleProviders = computed(() => providers.value.slice(0, visibleProviderLimit.value))
|
||||
// 兼容宿主传入的数字或字符串刷新间隔。
|
||||
const refreshSeconds = computed(() => {
|
||||
const seconds = Number(props.refreshInterval || attrs.value.refresh || 0)
|
||||
return Number.isFinite(seconds) ? seconds : 0
|
||||
})
|
||||
const cardTitle = computed(() => attrs.value.title || 'Agent Tokens 管理')
|
||||
const cardSubtitle = computed(() => attrs.value.subtitle || 'LLM 配额使用情况')
|
||||
const cardFlat = computed(() => attrs.value.border === false)
|
||||
const widgetClasses = computed(() => ({
|
||||
'agenttokens-dashboard-widget--compact': isCompact.value,
|
||||
'agenttokens-dashboard-widget--mini': isMini.value,
|
||||
}))
|
||||
const lastRefreshedTime = computed(() => {
|
||||
if (!lastRefreshedAt.value) return ''
|
||||
return new Date(lastRefreshedAt.value).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
})
|
||||
|
||||
// 读取 Agent Tokens 仪表板状态。
|
||||
async function loadStatus() {
|
||||
if (!props.api?.get) {
|
||||
error.value = 'API 未就绪'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await props.api.get('plugin/AgentTokens/status')
|
||||
status.value = unwrapResponse(response) || status.value
|
||||
initialDataLoaded.value = true
|
||||
lastRefreshedAt.value = Date.now()
|
||||
} catch (err) {
|
||||
error.value = err?.message || '获取数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 启动宿主传入或插件配置中的自动刷新。
|
||||
function startRefreshTimer() {
|
||||
if (refreshSeconds.value <= 0) return
|
||||
timer = window.setInterval(loadStatus, refreshSeconds.value * 1000)
|
||||
}
|
||||
|
||||
// 清理仪表板自动刷新计时器。
|
||||
function stopRefreshTimer() {
|
||||
if (!timer) return
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
// 记录宿主 GridStack 分配给组件的实际尺寸,用于切换紧凑布局。
|
||||
function observeWidgetSize() {
|
||||
if (!widgetRef.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
widgetSize.value = {
|
||||
inline: entry.contentRect.width,
|
||||
block: entry.contentRect.height,
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(widgetRef.value)
|
||||
}
|
||||
|
||||
// 停止监听组件尺寸,避免仪表板卸载后继续触发布局计算。
|
||||
function stopWidgetSizeObserver() {
|
||||
if (!resizeObserver) return
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observeWidgetSize()
|
||||
loadStatus()
|
||||
startRefreshTimer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWidgetSizeObserver()
|
||||
stopRefreshTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="widgetRef" class="agenttokens-dashboard-widget" :class="widgetClasses">
|
||||
<VCard :flat="cardFlat" :loading="loading" class="agenttokens-dashboard-card">
|
||||
<VCardItem class="agenttokens-dashboard-card__header">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" size="36">
|
||||
<VIcon icon="mdi-key-chain" size="20" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="agenttokens-dashboard-card__title">{{ cardTitle }}</VCardTitle>
|
||||
<VCardSubtitle>{{ cardSubtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="agenttokens-dashboard-card__body">
|
||||
<div v-if="loading && !initialDataLoaded" class="agenttokens-dashboard-state">
|
||||
<VProgressCircular indeterminate color="primary" size="28" />
|
||||
</div>
|
||||
|
||||
<VAlert v-else-if="error" type="error" variant="tonal" density="compact" class="text-caption">
|
||||
{{ error }}
|
||||
</VAlert>
|
||||
|
||||
<div v-else-if="initialDataLoaded" class="agenttokens-dashboard-content">
|
||||
<div class="agenttokens-dashboard-summary">
|
||||
<VProgressCircular
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
bg-color="surface"
|
||||
:size="gaugeSize"
|
||||
:width="gaugeWidth"
|
||||
>
|
||||
<span class="agenttokens-dashboard-summary__percent">{{ usagePercentText }}</span>
|
||||
</VProgressCircular>
|
||||
|
||||
<div class="agenttokens-dashboard-summary__body">
|
||||
<div class="text-caption text-medium-emphasis">可用供应商</div>
|
||||
<div class="agenttokens-dashboard-summary__count">
|
||||
{{ summary.available_count || 0 }}
|
||||
<span>/ {{ summary.enabled_count || 0 }}</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMetrics" class="agenttokens-dashboard-metrics">
|
||||
<div class="agenttokens-dashboard-metric">
|
||||
<span>累计</span>
|
||||
<strong>{{ formatTokens(totalUsed) }}</strong>
|
||||
</div>
|
||||
<div class="agenttokens-dashboard-metric">
|
||||
<span>额度</span>
|
||||
<strong>{{ totalLimit > 0 ? formatTokens(totalLimit) : '不限' }}</strong>
|
||||
</div>
|
||||
<div class="agenttokens-dashboard-metric">
|
||||
<span>剩余</span>
|
||||
<strong>{{ remainingTokens === null ? '不限' : formatTokens(remainingTokens) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleProviders.length" class="agenttokens-dashboard-list">
|
||||
<div v-for="row in visibleProviders" :key="row.id" class="agenttokens-dashboard-provider">
|
||||
<VIcon
|
||||
:icon="row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle'"
|
||||
:color="row.usage?.exhausted ? 'error' : 'success'"
|
||||
size="16"
|
||||
/>
|
||||
<div class="agenttokens-dashboard-provider__main">
|
||||
<div class="agenttokens-dashboard-provider__name">{{ row.name || '未命名供应商' }}</div>
|
||||
<div class="agenttokens-dashboard-provider__model">{{ row.model || '未配置模型' }}</div>
|
||||
</div>
|
||||
<div class="agenttokens-dashboard-provider__tokens">
|
||||
{{ formatTokens(row.usage?.total_tokens) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!providers.length" class="agenttokens-dashboard-empty">
|
||||
<VIcon icon="mdi-database-off-outline" size="18" />
|
||||
<span>暂无供应商</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="agenttokens-dashboard-state text-caption text-disabled">
|
||||
暂无数据
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VDivider v-if="allowRefresh" />
|
||||
<VCardActions v-if="allowRefresh" class="agenttokens-dashboard-card__actions">
|
||||
<span v-if="!isMini" class="text-caption text-disabled">
|
||||
{{ lastRefreshedTime ? `更新于 ${lastRefreshedTime}` : '等待更新' }}
|
||||
</span>
|
||||
<VSpacer />
|
||||
<VBtn icon variant="text" size="small" :loading="loading" @click="loadStatus">
|
||||
<VIcon icon="mdi-refresh" size="18" />
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agenttokens-dashboard-widget {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 100%;
|
||||
|
||||
--agenttokens-divider-color: rgba(var(--v-theme-on-surface), 0.08);
|
||||
--agenttokens-muted-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
--agenttokens-soft-surface: rgba(var(--v-theme-on-surface), 0.035);
|
||||
--agenttokens-soft-surface-hover: rgba(var(--v-theme-on-surface), 0.055);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card {
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__header {
|
||||
flex: 0 0 auto;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__header :deep(.v-card-item__content) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__title {
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block-start: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-card__actions {
|
||||
flex: 0 0 auto;
|
||||
min-block-size: 40px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-state {
|
||||
block-size: 100%;
|
||||
min-block-size: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__percent {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__body {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__count {
|
||||
margin-block: 2px 8px;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-summary__count span {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric {
|
||||
min-block-size: 54px;
|
||||
border: 1px solid var(--agenttokens-divider-color);
|
||||
border-radius: 6px;
|
||||
background: var(--agenttokens-soft-surface);
|
||||
padding: 8px 10px;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric:hover {
|
||||
background: var(--agenttokens-soft-surface-hover);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric span {
|
||||
display: block;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-metric strong {
|
||||
display: block;
|
||||
margin-block-start: 4px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider {
|
||||
min-block-size: 34px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider:hover {
|
||||
background: var(--agenttokens-soft-surface);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__main {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__name,
|
||||
.agenttokens-dashboard-provider__model {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__model {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-provider__tokens {
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-empty {
|
||||
min-block-size: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--agenttokens-muted-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget :deep(.v-progress-circular__underlay) {
|
||||
stroke: rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-card__body {
|
||||
padding-block: 6px 10px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-content {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-summary__count {
|
||||
margin-block-end: 6px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-metric {
|
||||
min-block-size: 46px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--compact .agenttokens-dashboard-provider {
|
||||
min-block-size: 30px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header {
|
||||
padding-block: 10px 4px;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__header :deep(.v-card-subtitle) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-summary__count {
|
||||
margin-block: 0 4px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.agenttokens-dashboard-widget--mini .agenttokens-dashboard-card__actions {
|
||||
justify-content: flex-end;
|
||||
min-block-size: 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agenttokens-dashboard-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
plugins.v2/agenttokens/src/components/Page.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AppPage from './AppPage.vue'
|
||||
|
||||
defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const pageRef = ref(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenttokens-page-wrapper">
|
||||
<VToolbar density="comfortable" class="sticky-toolbar">
|
||||
<div class="text-h6 ms-3">Agent Tokens 管理</div>
|
||||
<VSpacer />
|
||||
<VBtn icon="mdi-refresh" variant="text" :loading="pageRef?.loading" @click="pageRef?.loadStatus()" />
|
||||
<VBtn icon="mdi-content-save" variant="text" color="primary" :loading="pageRef?.saving" @click="pageRef?.saveConfig()" />
|
||||
<VBtn icon="mdi-close" variant="text" @click="emit('close')" />
|
||||
</VToolbar>
|
||||
<VDivider />
|
||||
|
||||
<AppPage ref="pageRef" :api="api" plugin-id="AgentTokens" hide-title />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sticky-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { formatTokens } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
providers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showCredentials: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['edit', 'remove'])
|
||||
|
||||
// 获取管理页服务端返回的脱敏 Key。
|
||||
function getMaskedApiKey(index) {
|
||||
return props.providerRows[index]?.masked_api_key || '****'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VSheet border rounded class="provider-table-shell">
|
||||
<VTable density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>启用</th>
|
||||
<th>优先级</th>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th v-if="showCredentials">地址</th>
|
||||
<th v-if="showCredentials">Key</th>
|
||||
<th>代理</th>
|
||||
<th>模型</th>
|
||||
<th>额度</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in 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 v-if="showCredentials" class="truncate-cell">{{ row.base_url }}</td>
|
||||
<td v-if="showCredentials">{{ getMaskedApiKey(index) }}</td>
|
||||
<td>
|
||||
<VChip size="small" :color="row.use_proxy === false ? 'default' : 'primary'" variant="tonal">
|
||||
{{ row.use_proxy === false ? '直连' : '代理' }}
|
||||
</VChip>
|
||||
</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="emit('edit', index)" />
|
||||
<VBtn icon="mdi-delete" size="small" variant="text" color="error" @click="emit('remove', index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!providers.length">
|
||||
<td :colspan="showCredentials ? 10 : 8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-table-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.provider-table-shell :deep(table) {
|
||||
min-width: 960px;
|
||||
}
|
||||
|
||||
.truncate-cell {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { PROVIDER_TYPE_OPTIONS } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
provider: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
editorIndex: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'commit'])
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// 提交当前弹窗编辑的供应商配置。
|
||||
function commitProvider() {
|
||||
emit('commit')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="760" max-height="85vh" scrollable>
|
||||
<VCard>
|
||||
<VCardTitle>{{ editorIndex >= 0 ? '编辑供应商' : '新增供应商' }}</VCardTitle>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="8">
|
||||
<VTextField v-model="provider.name" label="名称" variant="outlined" density="comfortable" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField v-model.number="provider.priority" label="优先级" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect v-model="provider.provider" :items="PROVIDER_TYPE_OPTIONS" label="类型" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model="provider.model" label="模型" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="provider.base_url" label="API 地址" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="provider.api_key" label="API Key" type="password" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField v-model="provider.user_agent" label="User-Agent" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="provider.use_proxy"
|
||||
color="primary"
|
||||
label="使用代理服务器"
|
||||
hint="启用后,Agent 连接该供应商时会使用系统代理服务器"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="provider.token_limit" label="Token 额度" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField v-model.number="provider.used_tokens" label="初始已用" type="number" variant="outlined" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="dialogVisible = false">取消</VBtn>
|
||||
<VBtn color="primary" @click="commitProvider">确定</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
89
plugins.v2/agenttokens/src/components/ProviderUsageTable.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { formatTokens } from '../provider'
|
||||
|
||||
defineProps({
|
||||
providerRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reset'])
|
||||
|
||||
// 根据供应商状态返回 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 '可用'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VSheet border rounded class="provider-table-shell">
|
||||
<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, index) in providerRows" :key="row.id || index">
|
||||
<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="emit('reset', row.id, index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!providerRows.length">
|
||||
<td colspan="8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-table-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.provider-table-shell :deep(table) {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.progress-cell {
|
||||
min-width: 140px;
|
||||
}
|
||||
</style>
|
||||
121
plugins.v2/agenttokens/src/components/UsageOverviewCard.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { formatTokens } from '../provider'
|
||||
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const totalUsed = computed(() => Number(props.summary.total_used || 0))
|
||||
const totalLimit = computed(() => Number(props.summary.total_limit || 0))
|
||||
const usagePercent = computed(() => {
|
||||
if (totalLimit.value <= 0) return 0
|
||||
return Math.min((totalUsed.value * 100) / totalLimit.value, 100)
|
||||
})
|
||||
const usagePercentText = computed(() => `${Math.round(usagePercent.value)}%`)
|
||||
const remainingTokens = computed(() => {
|
||||
if (totalLimit.value <= 0) return null
|
||||
return Math.max(totalLimit.value - totalUsed.value, 0)
|
||||
})
|
||||
const progressColor = computed(() => {
|
||||
if (totalLimit.value <= 0) return 'primary'
|
||||
if (usagePercent.value >= 90) return 'error'
|
||||
if (usagePercent.value >= 70) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VSheet border rounded class="usage-overview-card">
|
||||
<div class="usage-overview-card__content">
|
||||
<div class="usage-overview-card__chart">
|
||||
<VProgressCircular
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
bg-color="surface-variant"
|
||||
:size="132"
|
||||
:width="12"
|
||||
>
|
||||
<div class="usage-overview-card__percent">{{ totalLimit > 0 ? usagePercentText : '不限' }}</div>
|
||||
</VProgressCircular>
|
||||
</div>
|
||||
|
||||
<div class="usage-overview-card__body">
|
||||
<div class="text-caption text-medium-emphasis">总使用进度</div>
|
||||
<div class="usage-overview-card__headline">
|
||||
{{ formatTokens(totalUsed) }}
|
||||
<span class="text-medium-emphasis">/ {{ totalLimit > 0 ? formatTokens(totalLimit) : '不限' }}</span>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:model-value="usagePercent"
|
||||
:color="progressColor"
|
||||
height="8"
|
||||
rounded
|
||||
class="my-4"
|
||||
/>
|
||||
<div class="usage-overview-card__meta">
|
||||
<span>剩余 {{ remainingTokens === null ? '不限' : formatTokens(remainingTokens) }}</span>
|
||||
<span>可用 {{ summary.available_count || 0 }} / {{ summary.enabled_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-overview-card {
|
||||
block-size: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.usage-overview-card__content {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.usage-overview-card__chart {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.usage-overview-card__percent {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.usage-overview-card__headline {
|
||||
margin-block-start: 4px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.usage-overview-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.usage-overview-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.usage-overview-card__content {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.usage-overview-card__meta {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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')
|
||||
102
plugins.v2/agenttokens/src/provider.js
Normal file
@@ -0,0 +1,102 @@
|
||||
export const PROVIDER_TYPE_OPTIONS = [
|
||||
{ title: 'OpenAI Compatible', value: 'openai' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
{ title: 'Google Gemini', value: 'google' },
|
||||
{ title: 'Anthropic Compatible', value: 'anthropic' },
|
||||
{ title: 'ChatGPT', value: 'chatgpt' },
|
||||
]
|
||||
|
||||
// 构建一个新的供应商默认配置。
|
||||
export function createProvider() {
|
||||
return {
|
||||
id: '',
|
||||
enabled: true,
|
||||
name: '',
|
||||
provider: 'openai',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
user_agent: '',
|
||||
use_proxy: true,
|
||||
model: '',
|
||||
token_limit: 0,
|
||||
used_tokens: 0,
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// 生成深拷贝配置,避免直接修改父组件传入对象。
|
||||
export function cloneConfig(config) {
|
||||
return JSON.parse(JSON.stringify(config || { enabled: false, show_sidebar_nav: true, providers: [] }))
|
||||
}
|
||||
|
||||
// 格式化 token 数字,保持表格和统计展示可读。
|
||||
export function formatTokens(value) {
|
||||
const numberValue = Number(value || 0)
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString() : '0'
|
||||
}
|
||||
|
||||
// 兼容 MoviePilot API 包装器和原始响应两种返回形态。
|
||||
export function unwrapResponse(response) {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && response.success !== undefined) {
|
||||
return response.data
|
||||
}
|
||||
return response?.data ?? response
|
||||
}
|
||||
|
||||
// 计算新增供应商的下一个优先级。
|
||||
export function getNextProviderPriority(providers) {
|
||||
return Math.max(0, ...(providers || []).map(item => Number(item.priority || 0))) + 1
|
||||
}
|
||||
|
||||
// 标准化弹窗中写回的供应商数值字段。
|
||||
export function normalizeProvider(provider, fallbackPriority) {
|
||||
return {
|
||||
...provider,
|
||||
use_proxy: provider.use_proxy !== false,
|
||||
token_limit: Number(provider.token_limit || 0),
|
||||
used_tokens: Number(provider.used_tokens || 0),
|
||||
priority: Number(provider.priority || fallbackPriority),
|
||||
}
|
||||
}
|
||||
|
||||
// 按配置生成本地用量行,供配置弹窗复用管理页展示结构。
|
||||
export function buildProviderRow(provider) {
|
||||
const tokenLimit = Number(provider.token_limit || 0)
|
||||
const totalTokens = Number(provider.used_tokens || 0)
|
||||
const remainingTokens = tokenLimit <= 0 ? null : Math.max(tokenLimit - totalTokens, 0)
|
||||
const usagePercent = tokenLimit <= 0 ? 0 : Math.min((totalTokens * 100) / tokenLimit, 100)
|
||||
|
||||
return {
|
||||
...provider,
|
||||
masked_api_key: provider.api_key ? '****' : '',
|
||||
usage: {
|
||||
total_tokens: totalTokens,
|
||||
remaining_tokens: remainingTokens,
|
||||
usage_percent: usagePercent,
|
||||
exhausted: tokenLimit > 0 && remainingTokens === 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 批量生成本地供应商用量行。
|
||||
export function buildProviderRows(providers) {
|
||||
return (providers || []).map(provider => buildProviderRow(provider))
|
||||
}
|
||||
|
||||
// 根据供应商行汇总用量统计。
|
||||
export function buildProviderSummary(rows) {
|
||||
const providers = rows || []
|
||||
const enabledRows = providers.filter(row => row.enabled)
|
||||
const totalUsed = providers.reduce((sum, row) => sum + Number(row.usage?.total_tokens || row.used_tokens || 0), 0)
|
||||
const totalLimit = providers.reduce((sum, row) => {
|
||||
const tokenLimit = Number(row.token_limit || 0)
|
||||
return tokenLimit > 0 ? sum + tokenLimit : sum
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
available_count: enabledRows.filter(row => !row.usage?.exhausted && row.api_key && row.base_url && row.model).length,
|
||||
enabled_count: enabledRows.length,
|
||||
total_used: totalUsed,
|
||||
total_limit: totalLimit,
|
||||
}
|
||||
}
|
||||
57
plugins.v2/agenttokens/vite.config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
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()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
115
plugins.v2/airecognizerenhancer/ARCHITECTURE.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 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`
|
||||
- `save_title_only_samples`
|
||||
- `max_failed_samples`
|
||||
- `auto_remove_applied_sample`
|
||||
- `clear_failed_samples_once`
|
||||
|
||||
## 新增数据面
|
||||
|
||||
### 可处理失败样本
|
||||
|
||||
用于承载低置信度、可继续分析和出队的样本数据,支持:
|
||||
|
||||
- 摘要列表
|
||||
- 洞察汇总
|
||||
- 重放复查
|
||||
- 批量复查
|
||||
- 批量建议
|
||||
- 批量写入
|
||||
- 清空与按索引出队
|
||||
|
||||
### LLM 错误诊断记录
|
||||
|
||||
用于承载超时、网络错误、模型不可用等 LLM 调用失败信息,和可处理失败样本分开存储,避免噪音污染主样本池。
|
||||
|
||||
### 样本来源标注
|
||||
|
||||
失败样本与诊断记录都会保留轻量 provenance 标记,便于区分:
|
||||
|
||||
- 路径样本
|
||||
- 仅标题样本
|
||||
- 来自哪个 source plugin
|
||||
|
||||
## 二期规划
|
||||
|
||||
- 生成自定义识别词建议
|
||||
- 失败样本聚合分析
|
||||
- 提供给 MP Agent / Skill 直接调起
|
||||
|
||||
## 首个里程碑
|
||||
|
||||
第一个可用版本只追求:
|
||||
|
||||
1. 原生识别失败后自动触发本地 LLM 判断
|
||||
2. 拿到结构化结果后自动二次整理
|
||||
3. 能明确记录“成功 / 放弃 / 失败原因”
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
- 已接住 `ChainEventType.NameRecognize`
|
||||
- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出
|
||||
- 已提供手动调试接口用于验证标题识别结果
|
||||
- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议
|
||||
- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers`
|
||||
- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制
|
||||
- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本
|
||||
- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除
|
||||
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
|
||||
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
|
||||
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
|
||||
- 已支持 LLM 错误诊断记录独立存储,避免污染可处理样本池
|
||||
- 已支持样本来源标注,便于区分路径样本与仅标题样本
|
||||
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
|
||||
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
|
||||
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升
|
||||
116
plugins.v2/airecognizerenhancer/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# AI识别增强
|
||||
|
||||
`AI识别增强` 用来补强 MoviePilot 原生整理链里的识别阶段。
|
||||
|
||||
它的核心思路很简单:
|
||||
|
||||
- 复用 MoviePilot 当前已经启用的 LLM 配置
|
||||
- 在原生识别失败或置信度不足时,做一次本地结构化识别兜底
|
||||
- 把结果回写给 MoviePilot,继续走原生二次识别和后续整理链
|
||||
|
||||
## 适合什么场景
|
||||
|
||||
- 文件名比较脏,混有压制组、分辨率、语言、站点标记
|
||||
- 同一部剧经常出现英文名、别名、原名、翻译名混用
|
||||
- 网盘挂载、手动整理、历史资源补录时,原生识别偶尔不稳定
|
||||
- 你想把失败样本沉淀下来,后面持续优化 `CustomIdentifiers`
|
||||
|
||||
## 和 MoviePilot 原版智能体的区别
|
||||
|
||||
MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次”的能力。
|
||||
|
||||
这和 `AI识别增强` 有重叠,但定位不同:
|
||||
|
||||
- **MP 原版智能体**
|
||||
- 更偏“一次性补救”
|
||||
- 适合偶发失败、想省事的场景
|
||||
|
||||
- **AI识别增强**
|
||||
- 更偏“识别失败治理层”
|
||||
- 除了补救当前这次,还能:
|
||||
- 保存失败样本
|
||||
- 汇总样本洞察
|
||||
- 生成 `CustomIdentifiers` 建议
|
||||
- 写入识别词
|
||||
- 重放 / 复查 / 批量出队
|
||||
|
||||
一句话区分:
|
||||
|
||||
- 原版智能体:自动接管一次
|
||||
- `AI识别增强`:把失败样本沉淀下来,长期减少同类失败
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 监听 `ChainEventType.NameRecognize`
|
||||
- 用当前 LLM 结构化判断标题、年份、类型、季集
|
||||
- 回写 `name / year / season / episode`
|
||||
- 交回 MoviePilot 原生链路继续二次识别
|
||||
- 保存低置信度失败样本(可处理)
|
||||
- 保存 LLM 调用错误诊断记录(独立存储,不污染可处理样本池)
|
||||
- 失败样本和 LLM 诊断记录附带来源标注(`sample_source_kind` / `sample_source_plugin`)
|
||||
- 可配置是否保存仅标题样本(无真实文件路径),默认关闭以减少噪音
|
||||
- 提供失败样本工作清单、洞察、重放、删除和清空能力
|
||||
- 生成并应用 `CustomIdentifiers` 建议
|
||||
- 设置页提供“保存时清空失败样本(一次性)”开关,可在保存配置时顺手重置失败样本池
|
||||
|
||||
## 主要接口
|
||||
|
||||
### 可处理失败样本接口
|
||||
|
||||
这些接口只返回因置信度不足或名称为空而落盘的识别失败记录,可用于生成识别词建议、复查和出队。
|
||||
|
||||
- `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`
|
||||
|
||||
### LLM 诊断错误接口
|
||||
|
||||
这些接口返回因 LLM 调用异常(如超时、网络错误、模型不可用)而产生的诊断记录。它们不参与识别词生成流程,仅供排查 LLM 问题使用。
|
||||
|
||||
- `GET /api/v1/plugin/AIRecognizerEnhancer/llm_errors`
|
||||
- 查看 LLM 调用失败的诊断记录
|
||||
- `POST /api/v1/plugin/AIRecognizerEnhancer/clear_llm_errors`
|
||||
- 清空 LLM 错误诊断记录
|
||||
|
||||
其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。
|
||||
|
||||
## 配置建议
|
||||
|
||||
- 先确认 MoviePilot 本身已经配置好可用的 LLM
|
||||
- 建议保持”保存失败样本”开启
|
||||
- 默认情况下”保存仅标题样本”是关闭的,这可以减少没有真实文件路径的低价值噪音;如果你的使用场景以纯标题匹配为主,可以在设置中手动开启
|
||||
- 如果失败样本池已经积累了大量历史噪音,可在设置页勾选“一次性清空”后保存
|
||||
- 如果你经常处理历史资源或网盘资源,建议定期查看:
|
||||
- `failed_samples`
|
||||
- `sample_worklist`
|
||||
- `sample_insights`
|
||||
|
||||
## 已验证情况
|
||||
|
||||
当前版本:`0.1.13`
|
||||
|
||||
当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
|
||||
|
||||
这版已经验证过:
|
||||
|
||||
- 最新版 MoviePilot 下可以正常加载
|
||||
- 正常中文标题识别可用
|
||||
- 英文别名、韩文原名、中文别名可识别回标准媒体信息
|
||||
- 低置信度标题会落失败样本
|
||||
- `replay_failed_sample` 复查链可用
|
||||
|
||||
## 说明
|
||||
|
||||
- 这个插件不依赖外部 AI Gateway 回调链
|
||||
- 重点是增强识别,不负责替代 MoviePilot 全部整理流程
|
||||
- 如果你只是偶发整理失败,原版智能体可能已经够用
|
||||
- 如果你长期受命名混乱困扰,这个插件更有价值
|
||||
2254
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
|
||||