Compare commits

...

28 Commits

Author SHA1 Message Date
jxxghp
613b1f2604 chore: add remaining workspace changes 2026-06-06 20:18:01 +08:00
jxxghp
088b9e6d98 feat: update agenttokens、moviepilotserver status plugin 2026-06-06 20:16:01 +08:00
wumode
43e080839b feat(clashruleprovider): 移除全局 TLS 指纹配置并更新版本号至 2.1.7 (#1040) 2026-06-05 13:15:57 +08:00
InfinityPacer
9105a95bb9 test: organize plugin tests by plugin id (#1039) 2026-06-04 20:26:08 +08:00
RamenRa
6203fa69c2 fix:目前存在的一些问题 (#1037) 2026-06-04 11:36:04 +08:00
DDSRem
34895bc520 feat: add issue templates for bug reports, feature requests (#1036)
* feat: add issue templates for bug reports, feature requests and RFC

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: add issue management workflow for auto-labeling and stale cleanup

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: adapt issue templates and workflow for plugin repo context

- Remove RFC template (not needed for plugin repo)

- Remove 问题类型/功能改进类型 dropdowns

- Update workflow label rules to match simplified templates

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-04 09:02:48 +08:00
jxxghp
3e6ad20d1b feat: update OIDC plugin author information and icon 2026-06-04 08:21:13 +08:00
jxxghp
96ab3c73a7 feat: initialize OIDC authentication plugin with Vue components and configuration 2026-06-04 08:15:47 +08:00
InfinityPacer
b564a71203 test: align plugin shared harness shim (#1034) 2026-06-03 19:27:17 +08:00
InfinityPacer
75925415a3 test: add pytest scaffold and agenttokens plugin test (#1033) 2026-06-03 10:51:53 +08:00
jxxghp
60451c9b7f feat: add ChatGPT token usage stats 2026-05-31 07:57:52 +08:00
jxxghp
c1270840e4 chore: add .gitignore for node dependencies and lock file 2026-05-31 07:52:47 +08:00
jxxghp
c2dbaf5c5e chore(ChatGPT): v3.0.4 2026-05-30 22:07:14 +08:00
jxxghp
45829c05e5 fix(ChatGPT): 重构缓存管理UI
- get_form 仅显示缓存数量和提示,移除无效的 onClick 按钮
- get_page 实现缓存管理详情页,使用正确的 events.click 模式
- get_cache_stats 改为从数据库读取缓存数量
- 新增 _get_cache_count 方法统一获取缓存数量
2026-05-30 21:59:10 +08:00
liuyuexi1987
c5eac77128 feat: add one-time clear switch for failed samples in settings page (v0.1.13) (#1032) 2026-05-30 21:49:56 +08:00
jxxghp
1b948cab0c fix(ChatGPT): 修复缓存数量显示问题,将 _recognize_cache 改为实例变量 2026-05-30 21:48:13 +08:00
jxxghp
fa2be65bc6 fix(ChatGPT): 修复 API 注册格式 2026-05-30 21:41:57 +08:00
jxxghp
08a32a009d fix(ChatGPT): 添加缓存数量刷新按钮 2026-05-30 21:37:58 +08:00
jxxghp
aa502fc5fc fix(ChatGPT): 删除重复的缓存按钮 2026-05-30 21:35:32 +08:00
jxxghp
7722ce3406 fix(ChatGPT): 修复语法错误 2026-05-30 21:33:29 +08:00
jxxghp
3bd0964209 fix(ChatGPT): 调整缓存区布局
- 缓存区移到识别提示词上方
- 缓存数量在左,清除按钮在右
- 清除缓存后自动刷新页面显示最新数量
2026-05-30 21:27:32 +08:00
jxxghp
157a1053b1 feat(ChatGPT): v3.0.3 添加识别结果持久化缓存
- 添加识别结果持久化缓存,避免相同标题重复调用 LLM API
- 初始化时从数据库加载缓存到内存,新增缓存时同时写入内存和数据库
- 支持手动清除缓存并显示当前缓存数量
- 添加 /clear_cache 和 /cache_stats API
2026-05-30 21:17:51 +08:00
jxxghp
154f996dbe fix: open ChatGPT plugin config directly 2026-05-27 18:34:29 +08:00
jxxghp
5ed9ee9793 fix: hide unknown autosignin sites 2026-05-27 13:22:46 +08:00
jxxghp
bb3c392e62 fix: resolve autosignin site name display 2026-05-27 13:16:50 +08:00
jxxghp
0398af971b feat: support v2 ChatGPT LLM proxy config 2026-05-27 07:09:45 +08:00
jxxghp
a5237c6a5b feat: add Agent Tokens proxy toggle 2026-05-27 07:05:45 +08:00
jxxghp
b048106d2e feat: optimize v2 ChatGPT recognition plugin 2026-05-27 06:59:47 +08:00
65 changed files with 6111 additions and 1108 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

67
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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
View 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: 交流互助

View 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
View 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
View File

@@ -10,9 +10,6 @@ __pycache__/
.Python
build/
develop-eggs/
dist/
!plugins.v2/agenttokens/dist/
!plugins.v2/agenttokens/dist/**
downloads/
eggs/
.eggs/
@@ -72,6 +69,7 @@ instance/
# Sphinx documentation
docs/_build/
docs/superpowers/
# PyBuilder
.pybuilder/

View File

@@ -877,12 +877,13 @@
"name": "MoviePilot服务器监控",
"description": "在仪表板中实时显示MoviePilot公共服务器状态。",
"labels": "仪表板",
"version": "1.3",
"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": "增加详情界面显示"

View File

@@ -44,12 +44,13 @@
"name": "站点自动签到",
"description": "自动模拟登录、签到站点。",
"labels": "站点",
"version": "2.9.0",
"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 签到接口",
@@ -116,13 +117,20 @@
},
"ChatGPT": {
"name": "ChatGPT",
"description": "消息交互支持与ChatGPT对话。",
"labels": "消息通知,识别",
"version": "2.1.9",
"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": {
"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依赖",
@@ -527,12 +535,13 @@
"name": "Clash Rule Provider",
"description": "随时为Clash添加一些额外的规则。",
"labels": "工具",
"version": "2.1.6",
"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 协议",
@@ -652,12 +661,13 @@
"name": "动态企微可信IP",
"description": "修改企微应用可信IP支持Srever酱等第三方通知。验证码以结尾发送到企业微信应用",
"labels": "消息通知",
"version": "2.0.1",
"version": "2.1.1",
"icon": "Wecom_A.png",
"author": "RamenRa",
"level": 2,
"system_version": ">=2.12.0",
"history": {
"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": "修复检测登录的元素",
@@ -1045,17 +1055,33 @@
"v1.0.1": "修复定时任务重复触发问题"
}
},
"OidcAuth": {
"name": "OIDC 认证",
"description": "通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。",
"labels": "认证,OIDC,SSO",
"version": "0.1.0",
"icon": "Authelia_A.png",
"author": "ui-beam-9,jxxghp",
"level": 1,
"system_version": ">=2.13.5",
"release": true,
"history": {
"v0.1.0": "新增插件化 OIDC 登录、账号绑定、Provider 配置与联邦认证界面。"
}
},
"AgentTokens": {
"name": "Agent Tokens 管理",
"description": "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。",
"labels": "Agent,AI,系统",
"version": "1.0.9",
"version": "1.0.11",
"icon": "agentresourceofficer.png",
"author": "jxxghp",
"level": 1,
"system_version": ">=2.13.0",
"system_version": ">=2.13.2",
"release": true,
"history": {
"v1.0.11": "重设计仪表板组件,改为紧凑卡片式配额概览并对齐主仪表板视觉风格",
"v1.0.10": "新增供应商使用代理服务器配置,分配 Agent LLM 供应商时按配置传递代理开关",
"v1.0.9": "统一配置页和管理页内容,新增总使用进度图表卡片并优化大小屏布局",
"v1.0.8": "支持为 Agent LLM 供应商配置并传递 User-Agent",
"v1.0.7": "禁用VWindow触摸滑动修复表格内滑动触发tab切换问题",

2
plugins.v2/agenttokens/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
package-lock.json
node_modules/

View File

@@ -24,7 +24,7 @@ class AgentTokens(_PluginBase):
plugin_name = "Agent Tokens 管理"
plugin_desc = "管理多平台免费 Token 配额,按优先级自动切换 Agent LLM 供应商。"
plugin_icon = "agentresourceofficer.png"
plugin_version = "1.0.9"
plugin_version = "1.0.11"
plugin_author = "jxxghp"
author_url = "https://github.com/jxxghp"
plugin_config_prefix = "agenttokens_"
@@ -117,21 +117,21 @@ class AgentTokens(_PluginBase):
"""
return [{"key": "usage", "name": "Agent Tokens 管理"}] if self.get_state() else []
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
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, "md": 6},
{"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]]:
@@ -213,6 +213,7 @@ class AgentTokens(_PluginBase):
"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,
@@ -428,6 +429,7 @@ class AgentTokens(_PluginBase):
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"))

View File

@@ -1,11 +1,11 @@
.provider-table-shell[data-v-74897f54] {
.provider-table-shell[data-v-cd4337d8] {
overflow-x: auto;
}
.provider-table-shell[data-v-74897f54] table {
min-width: 880px;
.provider-table-shell[data-v-cd4337d8] table {
min-width: 960px;
}
.truncate-cell[data-v-74897f54] {
.truncate-cell[data-v-cd4337d8] {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,15 +1,7 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { f as formatTokens, P as PROVIDER_TYPE_OPTIONS, d as createProvider, b as buildProviderRows, a as buildProviderSummary, g as getNextProviderPriority, n as normalizeProvider } from './provider-BURm2Fqi.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 _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
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,unref:_unref$4,withCtx:_withCtx$4,createBlock:_createBlock$4} = await importShared('vue');
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 };
@@ -54,6 +46,7 @@ function getMaskedApiKey(index) {
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");
@@ -78,9 +71,10 @@ return (_ctx, _cache) => {
(__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", { class: "text-right" }, "操作", -1))
_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, [
@@ -106,6 +100,18 @@ return (_ctx, _cache) => {
(__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, [
@@ -128,7 +134,7 @@ return (_ctx, _cache) => {
(!__props.providers.length)
? (_openBlock$4(), _createElementBlock$2("tr", _hoisted_6$2, [
_createElementVNode$3("td", {
colspan: __props.showCredentials ? 9 : 7,
colspan: __props.showCredentials ? 10 : 8,
class: "text-center text-medium-emphasis py-8"
}, "暂无供应商", 8, _hoisted_7$2)
]))
@@ -144,7 +150,7 @@ return (_ctx, _cache) => {
}
};
const ProviderConfigTable = /*#__PURE__*/_export_sfc(_sfc_main$4, [['__scopeId',"data-v-74897f54"]]);
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');
@@ -190,6 +196,7 @@ return (_ctx, _cache) => {
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");
@@ -200,7 +207,7 @@ return (_ctx, _cache) => {
return (_openBlock$3(), _createBlock$3(_component_VDialog, {
modelValue: dialogVisible.value,
"onUpdate:modelValue": _cache[10] || (_cache[10] = $event => ((dialogVisible).value = $event)),
"onUpdate:modelValue": _cache[11] || (_cache[11] = $event => ((dialogVisible).value = $event)),
"max-width": "760",
"max-height": "85vh",
scrollable: ""
@@ -312,6 +319,19 @@ return (_ctx, _cache) => {
]),
_: 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"
@@ -319,7 +339,7 @@ return (_ctx, _cache) => {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.token_limit,
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((__props.provider.token_limit) = $event)),
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((__props.provider.token_limit) = $event)),
modelModifiers: { number: true },
label: "Token 额度",
type: "number",
@@ -335,7 +355,7 @@ return (_ctx, _cache) => {
default: _withCtx$3(() => [
_createVNode$3(_component_VTextField, {
modelValue: __props.provider.used_tokens,
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((__props.provider.used_tokens) = $event)),
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((__props.provider.used_tokens) = $event)),
modelModifiers: { number: true },
label: "初始已用",
type: "number",
@@ -355,9 +375,9 @@ return (_ctx, _cache) => {
_createVNode$3(_component_VSpacer),
_createVNode$3(_component_VBtn, {
variant: "text",
onClick: _cache[9] || (_cache[9] = $event => (dialogVisible.value = false))
onClick: _cache[10] || (_cache[10] = $event => (dialogVisible.value = false))
}, {
default: _withCtx$3(() => [...(_cache[11] || (_cache[11] = [
default: _withCtx$3(() => [...(_cache[12] || (_cache[12] = [
_createTextVNode$3("取消", -1)
]))]),
_: 1
@@ -366,7 +386,7 @@ return (_ctx, _cache) => {
color: "primary",
onClick: commitProvider
}, {
default: _withCtx$3(() => [...(_cache[12] || (_cache[12] = [
default: _withCtx$3(() => [...(_cache[13] || (_cache[13] = [
_createTextVNode$3("确定", -1)
]))]),
_: 1
@@ -960,4 +980,4 @@ return (_ctx, _cache) => {
};
const AgentTokensManager = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-a6c1ea54"]]);
export { AgentTokensManager as A, _export_sfc as _ };
export { AgentTokensManager as A };

View File

@@ -1,6 +1,6 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { A as AgentTokensManager } from './AgentTokensManager-DnY91SQC.js';
import { u as unwrapResponse } from './provider-BURm2Fqi.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');

View File

@@ -1,6 +1,6 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { A as AgentTokensManager } from './AgentTokensManager-DnY91SQC.js';
import { c as cloneConfig } from './provider-BURm2Fqi.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');

View File

@@ -1,130 +0,0 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import { f as formatTokens, u as unwrapResponse } from './provider-BURm2Fqi.js';
const {createElementVNode:_createElementVNode,toDisplayString:_toDisplayString,resolveComponent:_resolveComponent,createVNode:_createVNode,unref:_unref,renderList:_renderList,Fragment:_Fragment,openBlock:_openBlock,createElementBlock:_createElementBlock,createTextVNode:_createTextVNode,withCtx:_withCtx,createBlock:_createBlock} = await importShared('vue');
const _hoisted_1 = { class: "agenttokens-dashboard" };
const _hoisted_2 = { class: "d-flex align-center mb-3" };
const _hoisted_3 = { class: "text-h5" };
const _hoisted_4 = { class: "text-caption text-medium-emphasis mb-3" };
const _hoisted_5 = { class: "text-caption" };
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'Dashboard',
props: {
api: {
type: Object,
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true,
},
},
setup(__props) {
const props = __props;
const loading = ref(false);
const status = ref({ providers: [], summary: {} });
let timer = null;
const summary = computed(() => status.value.summary || {});
const providers = computed(() => status.value.providers || []);
// 读取仪表板所需的精简状态。
async function loadStatus() {
if (!props.allowRefresh) return
loading.value = true;
try {
const response = await props.api.get('plugin/AgentTokens/status');
status.value = unwrapResponse(response) || status.value;
} finally {
loading.value = false;
}
}
onMounted(() => {
loadStatus();
timer = window.setInterval(loadStatus, 30000);
});
onUnmounted(() => {
if (timer) {
window.clearInterval(timer);
}
});
return (_ctx, _cache) => {
const _component_VSpacer = _resolveComponent("VSpacer");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VProgressLinear = _resolveComponent("VProgressLinear");
const _component_VIcon = _resolveComponent("VIcon");
const _component_VListItem = _resolveComponent("VListItem");
const _component_VList = _resolveComponent("VList");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("div", _hoisted_2, [
_createElementVNode("div", null, [
_cache[0] || (_cache[0] = _createElementVNode("div", { class: "text-subtitle-2" }, "Agent Tokens 管理", -1)),
_createElementVNode("div", _hoisted_3, _toDisplayString(summary.value.available_count || 0) + " / " + _toDisplayString(summary.value.enabled_count || 0), 1)
]),
_createVNode(_component_VSpacer),
_createVNode(_component_VBtn, {
icon: "mdi-refresh",
variant: "text",
size: "small",
loading: loading.value,
onClick: loadStatus
}, null, 8, ["loading"])
]),
_createVNode(_component_VProgressLinear, {
"model-value": summary.value.total_limit ? Math.min((summary.value.total_used || 0) * 100 / summary.value.total_limit, 100) : 0,
color: "primary",
height: "8",
rounded: "",
class: "mb-3"
}, null, 8, ["model-value"]),
_createElementVNode("div", _hoisted_4, _toDisplayString(_unref(formatTokens)(summary.value.total_used)) + " / " + _toDisplayString(summary.value.total_limit ? _unref(formatTokens)(summary.value.total_limit) : '不限'), 1),
_createVNode(_component_VList, {
density: "compact",
class: "py-0"
}, {
default: _withCtx(() => [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(providers.value.slice(0, 4), (row) => {
return (_openBlock(), _createBlock(_component_VListItem, {
key: row.id,
title: row.name,
subtitle: row.model
}, {
prepend: _withCtx(() => [
_createVNode(_component_VIcon, {
color: row.usage?.exhausted ? 'error' : 'success',
size: "small"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle'), 1)
]),
_: 2
}, 1032, ["color"])
]),
append: _withCtx(() => [
_createElementVNode("span", _hoisted_5, _toDisplayString(_unref(formatTokens)(row.usage?.total_tokens)), 1)
]),
_: 2
}, 1032, ["title", "subtitle"]))
}), 128))
]),
_: 1
})
]))
}
}
};
export { _sfc_main as default };

View File

@@ -0,0 +1,349 @@
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} = await importShared('vue');
const _hoisted_1 = { class: "agenttokens-dashboard-widget" };
const _hoisted_2 = {
key: 0,
class: "agenttokens-dashboard-state"
};
const _hoisted_3 = {
key: 2,
class: "agenttokens-dashboard-content"
};
const _hoisted_4 = { class: "agenttokens-dashboard-summary" };
const _hoisted_5 = { class: "agenttokens-dashboard-summary__percent" };
const _hoisted_6 = { class: "agenttokens-dashboard-summary__body" };
const _hoisted_7 = { class: "agenttokens-dashboard-summary__count" };
const _hoisted_8 = { class: "agenttokens-dashboard-metrics" };
const _hoisted_9 = { class: "agenttokens-dashboard-metric" };
const _hoisted_10 = { class: "agenttokens-dashboard-metric" };
const _hoisted_11 = { class: "agenttokens-dashboard-metric" };
const _hoisted_12 = {
key: 0,
class: "agenttokens-dashboard-list"
};
const _hoisted_13 = { class: "agenttokens-dashboard-provider__main" };
const _hoisted_14 = { class: "agenttokens-dashboard-provider__name" };
const _hoisted_15 = { class: "agenttokens-dashboard-provider__model" };
const _hoisted_16 = { class: "agenttokens-dashboard-provider__tokens" };
const _hoisted_17 = {
key: 1,
class: "agenttokens-dashboard-empty"
};
const _hoisted_18 = {
key: 3,
class: "agenttokens-dashboard-state text-caption text-disabled"
};
const _hoisted_19 = { 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 status = ref({ providers: [], summary: {} });
let timer = null;
const attrs = computed(() => props.config?.attrs || {});
const summary = computed(() => status.value.summary || {});
const providers = computed(() => status.value.providers || []);
const visibleProviders = computed(() => providers.value.slice(0, 3));
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 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 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;
}
onMounted(() => {
loadStatus();
startRefreshTimer();
});
onUnmounted(() => {
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", _hoisted_1, [
_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_2, [
_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_3, [
_createElementVNode("div", _hoisted_4, [
_createVNode(_component_VProgressCircular, {
"model-value": usagePercent.value,
color: progressColor.value,
"bg-color": "surface-variant",
size: 84,
width: 8
}, {
default: _withCtx(() => [
_createElementVNode("span", _hoisted_5, _toDisplayString(usagePercentText.value), 1)
]),
_: 1
}, 8, ["model-value", "color"]),
_createElementVNode("div", _hoisted_6, [
_cache[0] || (_cache[0] = _createElementVNode("div", { class: "text-caption text-medium-emphasis" }, "可用供应商", -1)),
_createElementVNode("div", _hoisted_7, [
_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"])
])
]),
_createElementVNode("div", _hoisted_8, [
_createElementVNode("div", _hoisted_9, [
_cache[1] || (_cache[1] = _createElementVNode("span", null, "累计", -1)),
_createElementVNode("strong", null, _toDisplayString(_unref(formatTokens)(totalUsed.value)), 1)
]),
_createElementVNode("div", _hoisted_10, [
_cache[2] || (_cache[2] = _createElementVNode("span", null, "额度", -1)),
_createElementVNode("strong", null, _toDisplayString(totalLimit.value > 0 ? _unref(formatTokens)(totalLimit.value) : '不限'), 1)
]),
_createElementVNode("div", _hoisted_11, [
_cache[3] || (_cache[3] = _createElementVNode("span", null, "剩余", -1)),
_createElementVNode("strong", null, _toDisplayString(remainingTokens.value === null ? '不限' : _unref(formatTokens)(remainingTokens.value)), 1)
])
]),
(visibleProviders.value.length)
? (_openBlock(), _createElementBlock("div", _hoisted_12, [
(_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_13, [
_createElementVNode("div", _hoisted_14, _toDisplayString(row.name || '未命名供应商'), 1),
_createElementVNode("div", _hoisted_15, _toDisplayString(row.model || '未配置模型'), 1)
]),
_createElementVNode("div", _hoisted_16, _toDisplayString(_unref(formatTokens)(row.usage?.total_tokens)), 1)
]))
}), 128))
]))
: (_openBlock(), _createElementBlock("div", _hoisted_17, [
_createVNode(_component_VIcon, {
icon: "mdi-database-off-outline",
size: "18"
}),
_cache[4] || (_cache[4] = _createElementVNode("span", null, "暂无供应商", -1))
]))
]))
: (_openBlock(), _createElementBlock("div", _hoisted_18, " 暂无数据 "))
]),
_: 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(() => [
_createElementVNode("span", _hoisted_19, _toDisplayString(lastRefreshedTime.value ? `更新于 ${lastRefreshedTime.value}` : '等待更新'), 1),
_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"])
]))
}
}
};
const Dashboard = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-7844b861"]]);
export { Dashboard as default };

View File

@@ -0,0 +1,139 @@
.agenttokens-dashboard-widget[data-v-7844b861] {
block-size: 100%;
inline-size: 100%;
}
.agenttokens-dashboard-card[data-v-7844b861] {
block-size: 100%;
min-block-size: 280px;
display: flex;
flex-direction: column;
}
.agenttokens-dashboard-card__header[data-v-7844b861] {
padding-block-end: 8px;
}
.agenttokens-dashboard-card__title[data-v-7844b861] {
font-size: 1rem;
line-height: 1.35;
}
.agenttokens-dashboard-card__body[data-v-7844b861] {
flex: 1 1 auto;
padding-block-start: 8px;
}
.agenttokens-dashboard-card__actions[data-v-7844b861] {
min-block-size: 40px;
padding: 4px 12px;
}
.agenttokens-dashboard-state[data-v-7844b861] {
min-block-size: 144px;
display: flex;
align-items: center;
justify-content: center;
}
.agenttokens-dashboard-content[data-v-7844b861] {
display: flex;
flex-direction: column;
gap: 12px;
}
.agenttokens-dashboard-summary[data-v-7844b861] {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 14px;
}
.agenttokens-dashboard-summary__percent[data-v-7844b861] {
font-size: 0.95rem;
font-weight: 700;
}
.agenttokens-dashboard-summary__body[data-v-7844b861] {
min-inline-size: 0;
}
.agenttokens-dashboard-summary__count[data-v-7844b861] {
margin-block: 2px 8px;
font-size: 1.7rem;
font-weight: 700;
line-height: 1.1;
}
.agenttokens-dashboard-summary__count span[data-v-7844b861] {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 1rem;
font-weight: 600;
}
.agenttokens-dashboard-metrics[data-v-7844b861] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.agenttokens-dashboard-metric[data-v-7844b861] {
min-block-size: 54px;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
padding: 8px 10px;
background: rgba(var(--v-theme-surface-variant), 0.22);
}
.agenttokens-dashboard-metric span[data-v-7844b861] {
display: block;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.2;
}
.agenttokens-dashboard-metric strong[data-v-7844b861] {
display: block;
margin-block-start: 4px;
font-size: 0.95rem;
line-height: 1.2;
overflow-wrap: anywhere;
}
.agenttokens-dashboard-list[data-v-7844b861] {
display: flex;
flex-direction: column;
gap: 2px;
}
.agenttokens-dashboard-provider[data-v-7844b861] {
min-block-size: 34px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.agenttokens-dashboard-provider__main[data-v-7844b861] {
min-inline-size: 0;
}
.agenttokens-dashboard-provider__name[data-v-7844b861],
.agenttokens-dashboard-provider__model[data-v-7844b861] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agenttokens-dashboard-provider__name[data-v-7844b861] {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
}
.agenttokens-dashboard-provider__model[data-v-7844b861] {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.2;
}
.agenttokens-dashboard-provider__tokens[data-v-7844b861] {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.agenttokens-dashboard-empty[data-v-7844b861] {
min-block-size: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.82rem;
}
@media (max-width: 480px) {
.agenttokens-dashboard-card[data-v-7844b861] {
min-block-size: 300px;
}
.agenttokens-dashboard-metrics[data-v-7844b861] {
grid-template-columns: 1fr;
}
}

View File

@@ -1,6 +1,6 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main$1 from './__federation_expose_AppPage-DVPoxkMN.js';
import { _ as _export_sfc } from './AgentTokensManager-DnY91SQC.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');

View File

@@ -16,6 +16,7 @@ function createProvider() {
base_url: '',
api_key: '',
user_agent: '',
use_proxy: true,
model: '',
token_limit: 0,
used_tokens: 0,
@@ -51,6 +52,7 @@ function getNextProviderPriority(providers) {
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),
@@ -99,4 +101,12 @@ function buildProviderSummary(rows) {
}
}
export { PROVIDER_TYPE_OPTIONS as P, buildProviderSummary as a, buildProviderRows as b, cloneConfig as c, createProvider as d, formatTokens as f, getNextProviderPriority as g, normalizeProvider as n, unwrapResponse as u };
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 };

View File

@@ -1,5 +1,5 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main from './__federation_expose_AppPage-DVPoxkMN.js';
import _sfc_main from './__federation_expose_AppPage-EV4Kchio.js';
true&&(function polyfill() {
const relList = document.createElement("link").relList;

View File

@@ -2,17 +2,17 @@ const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./Page":()=>{
dynamicLoadingCss(["__federation_expose_Page-vwwFlnk-.css","AgentTokensManager-BJe0fhEr.css"], false, './Page');
return __federation_import('./__federation_expose_Page-MLoSpL20.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
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-BJe0fhEr.css"], false, './Config');
return __federation_import('./__federation_expose_Config-C6p4hYpa.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
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([], false, './Dashboard');
return __federation_import('./__federation_expose_Dashboard-Ch2BuVKu.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
dynamicLoadingCss(["__federation_expose_Dashboard-P4ydnnXH.css"], false, './Dashboard');
return __federation_import('./__federation_expose_Dashboard-HhEWi8U6.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./AppPage":()=>{
dynamicLoadingCss(["AgentTokensManager-BJe0fhEr.css"], false, './AppPage');
return __federation_import('./__federation_expose_AppPage-DVPoxkMN.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},};
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;

View File

@@ -1,7 +1,7 @@
<script type="module" crossorigin src="/assets/index-D6dGOibj.js"></script>
<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/provider-BURm2Fqi.js">
<link rel="modulepreload" crossorigin href="/assets/AgentTokensManager-DnY91SQC.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-DVPoxkMN.js">
<link rel="stylesheet" crossorigin href="/assets/AgentTokensManager-BJe0fhEr.css">
<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>

View File

@@ -1,7 +1,7 @@
{
"name": "moviepilot-agenttokens-plugin",
"private": true,
"version": "1.0.9",
"version": "1.0.11",
"type": "module",
"scripts": {
"build": "vite build"

View File

@@ -7,77 +7,377 @@ const props = defineProps({
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 status = ref({ providers: [], summary: {} })
let timer = null
const attrs = computed(() => props.config?.attrs || {})
const summary = computed(() => status.value.summary || {})
const providers = computed(() => status.value.providers || [])
const visibleProviders = computed(() => providers.value.slice(0, 3))
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 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 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.allowRefresh) return
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
}
onMounted(() => {
loadStatus()
timer = window.setInterval(loadStatus, 30000)
startRefreshTimer()
})
onUnmounted(() => {
if (timer) {
window.clearInterval(timer)
}
stopRefreshTimer()
})
</script>
<template>
<div class="agenttokens-dashboard">
<div class="d-flex align-center mb-3">
<div>
<div class="text-subtitle-2">Agent Tokens 管理</div>
<div class="text-h5">{{ summary.available_count || 0 }} / {{ summary.enabled_count || 0 }}</div>
</div>
<VSpacer />
<VBtn icon="mdi-refresh" variant="text" size="small" :loading="loading" @click="loadStatus" />
</div>
<VProgressLinear
:model-value="summary.total_limit ? Math.min((summary.total_used || 0) * 100 / summary.total_limit, 100) : 0"
color="primary"
height="8"
rounded
class="mb-3"
/>
<div class="text-caption text-medium-emphasis mb-3">
{{ formatTokens(summary.total_used) }} / {{ summary.total_limit ? formatTokens(summary.total_limit) : '不限' }}
</div>
<VList density="compact" class="py-0">
<VListItem v-for="row in providers.slice(0, 4)" :key="row.id" :title="row.name" :subtitle="row.model">
<div class="agenttokens-dashboard-widget">
<VCard :flat="cardFlat" :loading="loading" class="agenttokens-dashboard-card">
<VCardItem class="agenttokens-dashboard-card__header">
<template #prepend>
<VIcon :color="row.usage?.exhausted ? 'error' : 'success'" size="small">
{{ row.usage?.exhausted ? 'mdi-alert-circle' : 'mdi-check-circle' }}
</VIcon>
<VAvatar color="primary" variant="tonal" size="36">
<VIcon icon="mdi-key-chain" size="20" />
</VAvatar>
</template>
<template #append>
<span class="text-caption">{{ formatTokens(row.usage?.total_tokens) }}</span>
</template>
</VListItem>
</VList>
<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-variant"
:size="84"
:width="8"
>
<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 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 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 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%;
inline-size: 100%;
}
.agenttokens-dashboard-card {
block-size: 100%;
min-block-size: 280px;
display: flex;
flex-direction: column;
}
.agenttokens-dashboard-card__header {
padding-block-end: 8px;
}
.agenttokens-dashboard-card__title {
font-size: 1rem;
line-height: 1.35;
}
.agenttokens-dashboard-card__body {
flex: 1 1 auto;
padding-block-start: 8px;
}
.agenttokens-dashboard-card__actions {
min-block-size: 40px;
padding: 4px 12px;
}
.agenttokens-dashboard-state {
min-block-size: 144px;
display: flex;
align-items: center;
justify-content: center;
}
.agenttokens-dashboard-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.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: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
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 rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
padding: 8px 10px;
background: rgba(var(--v-theme-surface-variant), 0.22);
}
.agenttokens-dashboard-metric span {
display: block;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
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;
}
.agenttokens-dashboard-provider {
min-block-size: 34px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.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: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.2;
}
.agenttokens-dashboard-provider__tokens {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
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: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.82rem;
}
@media (max-width: 480px) {
.agenttokens-dashboard-card {
min-block-size: 300px;
}
.agenttokens-dashboard-metrics {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -35,6 +35,7 @@ function getMaskedApiKey(index) {
<th>类型</th>
<th v-if="showCredentials">地址</th>
<th v-if="showCredentials">Key</th>
<th>代理</th>
<th>模型</th>
<th>额度</th>
<th class="text-right">操作</th>
@@ -50,6 +51,11 @@ function getMaskedApiKey(index) {
<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">
@@ -58,7 +64,7 @@ function getMaskedApiKey(index) {
</td>
</tr>
<tr v-if="!providers.length">
<td :colspan="showCredentials ? 9 : 7" class="text-center text-medium-emphasis py-8">暂无供应商</td>
<td :colspan="showCredentials ? 10 : 8" class="text-center text-medium-emphasis py-8">暂无供应商</td>
</tr>
</tbody>
</VTable>
@@ -71,7 +77,7 @@ function getMaskedApiKey(index) {
}
.provider-table-shell :deep(table) {
min-width: 880px;
min-width: 960px;
}
.truncate-cell {

View File

@@ -57,6 +57,15 @@ function commitProvider() {
<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>

View File

@@ -16,6 +16,7 @@ export function createProvider() {
base_url: '',
api_key: '',
user_agent: '',
use_proxy: true,
model: '',
token_limit: 0,
used_tokens: 0,
@@ -51,6 +52,7 @@ export function getNextProviderPriority(providers) {
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),

View File

@@ -50,6 +50,36 @@
- `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
## 二期规划
@@ -78,6 +108,8 @@
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
- 已支持 LLM 错误诊断记录独立存储,避免污染可处理样本池
- 已支持样本来源标注,便于区分路径样本与仅标题样本
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升

View File

@@ -45,18 +45,22 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- 用当前 LLM 结构化判断标题、年份、类型、季集
- 回写 `name / year / season / episode`
- 交回 MoviePilot 原生链路继续二次识别
- 保存低置信度失败样本
- 保存低置信度失败样本(可处理)
- 保存 LLM 调用错误诊断记录(独立存储,不污染可处理样本池)
- 失败样本和 LLM 诊断记录附带来源标注(`sample_source_kind` / `sample_source_plugin`
- 可配置是否保存仅标题样本(无真实文件路径),默认关闭以减少噪音
- 提供失败样本工作清单、洞察、重放、删除和清空能力
- 生成并应用 `CustomIdentifiers` 建议
- 设置页提供“保存时清空失败样本(一次性)”开关,可在保存配置时顺手重置失败样本池
## 主要接口
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
- 对单个标题做一次本地结构化识别测试
### 可处理失败样本接口
这些接口只返回因置信度不足或名称为空而落盘的识别失败记录,可用于生成识别词建议、复查和出队。
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
- 查看最近保存的失败样本
- 查看最近保存的可处理失败样本
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
- 返回适合继续处理的失败样本摘要列表
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
@@ -68,12 +72,23 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- `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`
@@ -81,9 +96,9 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
## 已验证情况
当前版本:`0.1.12`
当前版本:`0.1.13`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
这版已经验证过:

View File

@@ -53,7 +53,7 @@ class AIRecognizerEnhancer(_PluginBase):
plugin_name = "AI识别增强"
plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。"
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png"
plugin_version = "0.1.12"
plugin_version = "0.1.13"
plugin_author = "liuyuexi1987"
plugin_level = 1
author_url = "https://github.com/liuyuexi1987"
@@ -67,8 +67,10 @@ class AIRecognizerEnhancer(_PluginBase):
_request_timeout = 25
_max_retries = 2
_save_failed_samples = True
_save_title_only_samples = False
_max_failed_samples = 200
_auto_remove_applied_sample = True
_clear_failed_samples_once = False
_systemconfig: Optional[SystemConfigOper] = None
def init_plugin(self, config: Optional[Dict[str, Any]] = None):
@@ -79,10 +81,17 @@ class AIRecognizerEnhancer(_PluginBase):
self._request_timeout = self._safe_int(config.get("request_timeout"), 25)
self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2)))
self._save_failed_samples = bool(config.get("save_failed_samples", True))
self._save_title_only_samples = bool(config.get("save_title_only_samples", False))
self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200)))
self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True))
self._clear_failed_samples_once = bool(config.get("clear_failed_samples_once", False))
self._systemconfig = SystemConfigOper()
self._register_events()
if self._clear_failed_samples_once:
cleared = self._clear_failed_samples()
self._clear_failed_samples_once = False
self.update_config(self._build_config({"clear_failed_samples_once": False}))
logger.info(f"[AI识别增强] 已按配置清空失败样本 {cleared}")
def get_state(self) -> bool:
return self._enabled
@@ -117,11 +126,28 @@ class AIRecognizerEnhancer(_PluginBase):
if header.lower().startswith("bearer "):
return header.split(" ", 1)[1].strip()
if body:
for key in ("apikey", "api_key"):
for key in ("apikey", "api_key", "token"):
token = str(body.get(key) or "").strip()
if token:
return token
return str(request.query_params.get("apikey") or "").strip()
return str(request.query_params.get("apikey") or request.query_params.get("token") or "").strip()
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = {
"enabled": self._enabled,
"debug": self._debug,
"confidence_threshold": self._confidence_threshold,
"request_timeout": self._request_timeout,
"max_retries": self._max_retries,
"save_failed_samples": self._save_failed_samples,
"save_title_only_samples": self._save_title_only_samples,
"max_failed_samples": self._max_failed_samples,
"auto_remove_applied_sample": self._auto_remove_applied_sample,
"clear_failed_samples_once": self._clear_failed_samples_once,
}
if overrides:
config.update(overrides)
return config
def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]:
expected = str(getattr(settings, "API_TOKEN", "") or "").strip()
@@ -174,6 +200,30 @@ class AIRecognizerEnhancer(_PluginBase):
)
return str(title or "").strip(), str(path or "").strip()
@staticmethod
def _extract_provenance(event_data: Any) -> Dict[str, str]:
"""Extract lightweight provenance metadata from event data for sample recording."""
source_plugin = ""
if isinstance(event_data, dict):
source_plugin = str(event_data.get("source_plugin") or "").strip()
else:
source_plugin = str(getattr(event_data, "source_plugin", "") or "").strip()
title = ""
path = ""
if isinstance(event_data, dict):
title = str(event_data.get("title") or event_data.get("name") or event_data.get("org_string") or "").strip()
path = str(event_data.get("path") or event_data.get("file_path") or event_data.get("org_string") or "").strip()
else:
title = str(getattr(event_data, "title", "") or getattr(event_data, "name", "") or getattr(event_data, "org_string", "") or "").strip()
path = str(getattr(event_data, "path", "") or getattr(event_data, "file_path", "") or getattr(event_data, "org_string", "") or "").strip()
is_path_backed = bool(path) and path != title and ("/" in path or "\\" in path)
return {
"sample_source_kind": "path_backed" if is_path_backed else "title_only",
"sample_source_plugin": source_plugin,
}
def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]:
try:
meta = MetaInfo(raw_text)
@@ -221,6 +271,12 @@ class AIRecognizerEnhancer(_PluginBase):
def _sample_path(self) -> Path:
return self.get_data_path() / "failed_samples.jsonl"
def _llm_errors_path(self) -> Path:
return self.get_data_path() / "llm_errors.jsonl"
def _failed_sample_cap(self) -> int:
return max(20, min(1000, self._safe_int(self._max_failed_samples, 200)))
@staticmethod
def _sample_identity(payload: Dict[str, Any]) -> str:
return json.dumps(
@@ -236,7 +292,8 @@ class AIRecognizerEnhancer(_PluginBase):
def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None:
sample_path = self._sample_path()
sample_path.parent.mkdir(parents=True, exist_ok=True)
trimmed = rows[-self._max_failed_samples:]
filtered = [row for row in rows if not str(row.get("reason") or "").startswith("llm_error:")]
trimmed = filtered[-self._failed_sample_cap():]
with sample_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
@@ -254,6 +311,69 @@ class AIRecognizerEnhancer(_PluginBase):
except Exception as exc:
logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}")
def _record_llm_error(self, title: str, path: str, meta_hint: Dict[str, Any], error: Any, provenance: Optional[Dict[str, str]] = None) -> None:
try:
error_path = self._llm_errors_path()
error_path.parent.mkdir(parents=True, exist_ok=True)
provenance = provenance or {}
entry = {
"title": title,
"path": path,
"meta_hint": meta_hint,
"reason": f"llm_error:{error}",
"timestamp": __import__("datetime").datetime.now().isoformat(),
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
existing = self._read_llm_errors(limit=1000)
existing.reverse()
new_identity = {"title": title, "path": path, "reason": entry["reason"]}
existing = [
row for row in existing
if {
"title": row.get("title"),
"path": row.get("path"),
"reason": row.get("reason"),
} != new_identity
]
existing.append(entry)
trimmed = existing[-self._failed_sample_cap():]
with error_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
except Exception as exc:
logger.warning(f"[AI识别增强] 写入 LLM 错误诊断记录失败: {exc}")
def _read_llm_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
error_path = self._llm_errors_path()
if not error_path.exists():
return []
rows: List[Dict[str, Any]] = []
try:
with error_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except Exception:
continue
except Exception as exc:
logger.warning(f"[AI识别增强] 读取 LLM 错误诊断记录失败: {exc}")
return []
if limit > 0:
rows = rows[-limit:]
rows.reverse()
return rows
def _clear_llm_errors(self) -> int:
rows = self._read_llm_errors(limit=10000)
error_path = self._llm_errors_path()
if error_path.exists():
error_path.unlink()
return len(rows)
def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]:
sample_path = self._sample_path()
if not sample_path.exists():
@@ -353,7 +473,7 @@ class AIRecognizerEnhancer(_PluginBase):
sample_index: Optional[Any] = None,
limit: int = 100,
) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]:
samples = self._read_failed_samples(limit=max(1, min(limit, 200)))
samples = self._read_failed_samples(limit=max(1, min(limit, self._failed_sample_cap())))
if not samples:
return None, None, "暂无失败样本"
index = self._safe_int(sample_index, 0)
@@ -369,9 +489,13 @@ class AIRecognizerEnhancer(_PluginBase):
self,
sample_indexes: Optional[List[Any]] = None,
limit: int = 10,
pool_limit: int = 200,
pool_limit: int = 0,
) -> Tuple[List[int], List[Dict[str, Any]], str]:
current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000))))
if pool_limit <= 0:
pool_limit = self._failed_sample_cap()
current_samples = self._inject_sample_indices(
self._read_failed_samples(limit=max(1, min(pool_limit, self._failed_sample_cap())))
)
if not current_samples:
return [], [], "暂无失败样本"
if isinstance(sample_indexes, list) and sample_indexes:
@@ -414,6 +538,8 @@ class AIRecognizerEnhancer(_PluginBase):
"title": sample.get("title"),
"path": sample.get("path"),
"reason": sample.get("reason"),
"sample_source_kind": sample.get("sample_source_kind", ""),
"sample_source_plugin": sample.get("sample_source_plugin", ""),
"guess_name": guess.get("name"),
"guess_confidence": self._safe_float(guess.get("confidence"), 0.0),
"verified_title": verified.get("title"),
@@ -551,7 +677,10 @@ class AIRecognizerEnhancer(_PluginBase):
label = self._sample_display_name(summary)
confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2)
can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工"
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}")
source_tag = "有路径" if summary.get("sample_source_kind") == "path_backed" else "仅标题"
source_plugin = summary.get("sample_source_plugin") or ""
source_info = f" | {source_tag}" + (f" ({source_plugin})" if source_plugin else "")
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}{source_info}")
lines.append("下一步:可直接调用批量建议或批量复查接口。")
return "\n".join(lines)
@@ -937,7 +1066,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1006,7 +1135,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1102,7 +1231,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1356,40 +1485,49 @@ AI 识别增强结果:
logger.warning(f"[AI识别增强] 二次校验失败: {exc}")
return None
def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]:
def _recognize(
self, title: str, path: str = "", record_failed_sample: bool = True,
provenance: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
title = str(title or "").strip()
path = str(path or "").strip()
if not title and path:
title = Path(path).name
if not title:
return {"success": False, "message": "标题为空"}
provenance = provenance or {}
sample_source_kind = provenance.get("sample_source_kind")
is_title_only = sample_source_kind == "title_only" if sample_source_kind else not path
try:
guess = self._invoke_llm(title, path)
except Exception as exc:
if record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"reason": f"llm_error:{exc}",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题 LLM 错误: {title} (save_title_only_samples=False)")
else:
self._record_llm_error(title, path, self._build_meta_hint(path or title), exc, provenance=provenance)
return {"success": False, "message": f"LLM 调用失败: {exc}"}
verified = self._verify_guess(title, path, guess)
passed = bool(guess.name and guess.confidence >= self._confidence_threshold)
if not passed and record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题样本: {title} (save_title_only_samples=False)")
else:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
)
return {
"success": passed,
"message": "success" if passed else "识别结果置信度不足,已放弃注入",
@@ -1404,7 +1542,8 @@ AI 识别增强结果:
title, path = self._extract_title_path(event_data)
if not title and not path:
return
result = self._recognize(title=title, path=path)
provenance = self._extract_provenance(event_data)
result = self._recognize(title=title, path=path, provenance=provenance)
if not result.get("success"):
if self._debug:
logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}")
@@ -1496,7 +1635,7 @@ AI 识别增强结果:
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 50)
limit = max(1, min(limit, 200))
limit = max(1, min(limit, self._failed_sample_cap()))
top = self._safe_int(request.query_params.get("top"), 10)
top = max(1, min(top, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=limit))
@@ -1512,7 +1651,7 @@ AI 识别增强结果:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 5)
limit = max(1, min(limit, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=100))
samples = self._inject_sample_indices(self._read_failed_samples(limit=self._failed_sample_cap()))
return {
"success": True,
"data": {
@@ -1558,6 +1697,34 @@ AI 识别增强结果:
},
}
async def api_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 20)
limit = max(1, min(limit, 100))
errors = self._read_llm_errors(limit=limit)
return {
"success": True,
"data": {
"count": len(errors),
"errors": errors,
},
}
async def api_clear_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
cleared = self._clear_llm_errors()
return {
"success": True,
"message": "success",
"data": {
"cleared_count": cleared,
},
}
async def api_remove_failed_sample(self, request: Request):
body = await request.json()
ok, message = self._check_api_access(request, body)
@@ -1697,6 +1864,18 @@ AI 识别增强结果:
"methods": ["POST"],
"summary": "清空失败样本文件",
},
{
"path": "/llm_errors",
"endpoint": self.api_llm_errors,
"methods": ["GET"],
"summary": "查看 LLM 调用失败的诊断记录",
},
{
"path": "/clear_llm_errors",
"endpoint": self.api_clear_llm_errors,
"methods": ["POST"],
"summary": "清空 LLM 错误诊断记录",
},
{
"path": "/remove_failed_sample",
"endpoint": self.api_remove_failed_sample,
@@ -1731,7 +1910,8 @@ AI 识别增强结果:
def get_page(self) -> List[dict]:
llm_ready = bool(getattr(settings, "LLM_API_KEY", None))
failed_samples_count = len(self._read_failed_samples(limit=200))
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
llm_errors_count = len(self._read_llm_errors(limit=self._max_failed_samples))
custom_identifiers_count = len(self._get_custom_identifiers())
llm_provider = getattr(settings, "LLM_PROVIDER", "")
llm_model = getattr(settings, "LLM_MODEL", "")
@@ -1784,22 +1964,27 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 可用", "" if llm_ready else "", f"{llm_provider} / {llm_model}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"content": [stat_card("失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("可处理失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 错误", f"{llm_errors_count}", "诊断记录")],
},
{
"component": "VCol",
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("自定义识别词", f"{custom_identifiers_count}", "系统 CustomIdentifiers")],
},
],
@@ -1810,34 +1995,7 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VCard",
"props": {"variant": "outlined", "class": "pa-4 h-100"},
"content": [
{
"component": "div",
"props": {"class": "text-subtitle-1 font-weight-bold mb-2"},
"text": "识别兜底",
},
{
"component": "div",
"props": {"class": "text-body-2 text-medium-emphasis"},
"text": "在 Chain NameRecognize 阶段回写 name / year / season / episode供 MoviePilot 继续原生二次识别。",
},
{
"component": "div",
"props": {"class": "text-caption text-medium-emphasis mt-3"},
"text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout}",
},
],
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"props": {"cols": 12, "md": 12},
"content": [
{
"component": "VCard",
@@ -1873,6 +2031,7 @@ AI 识别增强结果:
return "vuetify", None
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
form = [
{
"component": "VForm",
@@ -1896,6 +2055,25 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VAlert",
"props": {
"type": "warning",
"variant": "tonal",
"text": f"当前累计 {failed_samples_count} 条失败样本。如需重置噪音数据,请勾选下方“一次性清空”开关后点击保存。该操作只清空失败样本,不会删除已写入的 CustomIdentifiers。",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -1929,6 +2107,19 @@ AI 识别增强结果:
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "save_title_only_samples",
"label": "保存仅标题样本",
},
}
],
},
],
},
{
@@ -2010,6 +2201,24 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VSwitch",
"props": {
"model": "clear_failed_samples_once",
"label": "保存时清空失败样本(一次性)",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -2038,6 +2247,8 @@ AI 识别增强结果:
"request_timeout": 25,
"max_retries": 2,
"save_failed_samples": True,
"save_title_only_samples": False,
"max_failed_samples": 200,
"auto_remove_applied_sample": True,
"clear_failed_samples_once": False,
}

View File

@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
# 插件图标
plugin_icon = "signin.png"
# 插件版本
plugin_version = "2.9.0"
plugin_version = "2.9.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -549,18 +549,7 @@ class AutoSignIn(_PluginBase):
"signin": [], # 签到数据
"login": [] # 登录数据
}
sites_info = {} # 记录站点信息
# 获取站点信息
site_indexers = SitesHelper().get_indexers()
for site in site_indexers:
if not site.get("public"):
sites_info[site.get("id")] = site.get("name")
# 自定义站点
custom_sites = self.__custom_sites()
for site in custom_sites:
sites_info[site.get("id")] = site.get("name")
sites_info = self._build_sites_info()
# 获取常规日期格式数据
for day in date_list:
@@ -592,6 +581,8 @@ class AutoSignIn(_PluginBase):
# 为所有已完成签到的站点创建记录
for site_id in done_sites:
site_name = self._get_site_display_name(site_id=site_id, sites_info=sites_info)
if not site_name:
continue
# 跳过需要重试的站点
if site_id in retry_sites:
@@ -616,6 +607,8 @@ class AutoSignIn(_PluginBase):
# 为所有已完成登录的站点创建记录
for site_id in done_sites:
site_name = self._get_site_display_name(site_id=site_id, sites_info=sites_info)
if not site_name:
continue
# 跳过需要重试的站点
if site_id in retry_sites:
@@ -684,9 +677,13 @@ class AutoSignIn(_PluginBase):
# 补齐已配置但暂无历史记录的站点,详情页能直接看出未记录项。
for site_id in self._sign_sites:
site_name = self._get_site_display_name(site_id=site_id, sites_info=sites_info)
if not site_name:
continue
signin_site_data.setdefault(site_name, [])
for site_id in self._login_sites:
site_name = self._get_site_display_name(site_id=site_id, sites_info=sites_info)
if not site_name:
continue
login_site_data.setdefault(site_name, [])
display_dates = date_list[:7]
@@ -909,12 +906,48 @@ class AutoSignIn(_PluginBase):
]
@staticmethod
def _get_site_display_name(site_id, sites_info: dict) -> str:
def _add_site_info(sites_info: dict, site_id: Any, site_name: Any) -> None:
"""
根据站点ID获取详情页中展示的站点名称
记录站点ID到名称的映射兼容历史记录中ID类型不一致的情况
"""
if site_id is None or not site_name:
return
sites_info[site_id] = site_name
sites_info[str(site_id)] = site_name
def _build_sites_info(self) -> dict:
"""
汇总系统站点、索引器站点和自定义站点名称,供详情页历史记录反查。
"""
sites_info = {}
for site in SitesHelper().get_indexers():
if not site.get("public"):
self._add_site_info(
sites_info=sites_info,
site_id=site.get("id"),
site_name=site.get("name")
)
for site in SiteOper().list_order_by_pri():
self._add_site_info(
sites_info=sites_info,
site_id=getattr(site, "id", None),
site_name=getattr(site, "name", None)
)
for site in self.__custom_sites():
self._add_site_info(
sites_info=sites_info,
site_id=site.get("id"),
site_name=site.get("name")
)
return sites_info
@staticmethod
def _get_site_display_name(site_id, sites_info: dict) -> Optional[str]:
"""
根据站点ID获取详情页中展示的站点名称查不到时返回空值便于跳过。
"""
site_id_str = str(site_id)
return sites_info.get(site_id_str) or sites_info.get(site_id) or f"站点ID: {site_id}"
return sites_info.get(site_id_str) or sites_info.get(site_id)
@staticmethod
def _status_meta(status_text: str) -> dict:

File diff suppressed because it is too large Load Diff

View File

@@ -1,235 +1,216 @@
import asyncio
import inspect
import json
import re
import time
from typing import List, Union
import threading
from typing import Any, Dict, Optional
import openai
from cacheout import Cache
OpenAISessionCache = Cache(maxsize=100, ttl=3600, timer=time.time, default=None)
from app.agent.llm import LLMHelper
from langchain_core.messages import HumanMessage, SystemMessage
class OpenAi:
_api_key: str = None
_api_url: str = None
_model: str = "gpt-3.5-turbo"
_prompt: str = '接下来我会给你一个电影或电视剧的文件名你需要识别文件名中的名称、版本、分段、年份、分瓣率、季集等信息并按以下JSON格式返回{"name":string,"version":string,"part":string,"year":string,"resolution":string,"season":number|null,"episode":number|null}特别注意返回结果需要严格附合JSON格式不需要有任何其它的字符。如果中文电影或电视剧的文件名中存在谐音字或字母替代的情况请还原最有可能的结果。'
_client: openai.OpenAI = None
"""
Lightweight LLM recognition client kept under the original module name for plugin compatibility.
"""
def __init__(self, api_key: str = None, api_url: str = None,
proxy: dict = None, model: str = None,
compatible: bool = False, customize_prompt: str = None):
_JSON_FENCE_PATTERN = re.compile(r"^```(?:json)?\s*([\s\S]*?)\s*```$", re.IGNORECASE)
def __init__(
self,
api_key: str = None,
api_url: str = None,
provider: str = None,
model: str = None,
base_url_preset: str = None,
user_agent: str = None,
use_proxy: bool = None,
thinking_level: str = None,
customize_prompt: str = None,
**kwargs,
):
"""
初始化用于媒体识别的 LLM 客户端运行参数。
"""
self._api_key = api_key
self._api_url = api_url
if model:
self._model = model
if customize_prompt:
self._prompt = customize_prompt
# 初始化 OpenAI 客户端
if self._api_key and self._api_url:
base_url = self._api_url if compatible else self._api_url + "/v1"
http_client = None
if proxy and proxy.get("https"):
import httpx
proxy_url = proxy.get("https")
# httpx 支持字符串格式的代理 URL
http_client = httpx.Client(proxies=proxy_url, timeout=60.0)
self._client = openai.OpenAI(
api_key=self._api_key,
base_url=base_url,
http_client=http_client
)
self._provider = provider or "openai"
self._model = model
self._base_url_preset = base_url_preset
self._user_agent = user_agent
self._use_proxy = use_proxy
self._thinking_level = thinking_level
self._prompt = customize_prompt or ""
self._last_usage: Dict[str, int] = {}
def get_state(self) -> bool:
return True if self._api_key else False
"""
返回当前客户端是否具备发起识别调用的必要模型配置。
"""
return bool(self._api_key and self._model)
def get_last_usage(self) -> Dict[str, int]:
"""
返回最近一次模型调用提取到的 token 用量。
"""
return dict(self._last_usage or {})
@staticmethod
def __save_session(session_id: str, message: str):
def _run_async_compatible(value: Any) -> Any:
"""
保存会话
:param session_id: 会话ID
:param message: 消息
:return:
在同步插件回调中兼容执行新版 MoviePilot 的异步 LLM 初始化。
"""
seasion = OpenAISessionCache.get(session_id)
if seasion:
seasion.append({
"role": "assistant",
"content": message
})
OpenAISessionCache.set(session_id, seasion)
if not inspect.isawaitable(value):
return value
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(value)
result: Dict[str, Any] = {}
error: Dict[str, BaseException] = {}
def _worker() -> None:
"""
在独立线程中运行协程,避免嵌套事件循环。
"""
try:
result["value"] = asyncio.run(value)
except BaseException as exc: # noqa: BLE001
error["exc"] = exc
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join()
if "exc" in error:
raise error["exc"]
return result.get("value")
@staticmethod
def __get_session(session_id: str, message: str) -> List[dict]:
def _lookup_int(data: Any, key: str) -> Optional[int]:
"""
获取会话
:param session_id: 会话ID
:return: 会话上下文
从字典或对象字段中安全读取整数 token 统计。
"""
seasion = OpenAISessionCache.get(session_id)
if seasion:
seasion.append({
"role": "user",
"content": message
})
else:
seasion = [
{
"role": "system",
"content": "请在接下来的对话中请使用中文回复,并且内容尽可能详细。"
},
{
"role": "user",
"content": message
}]
OpenAISessionCache.set(session_id, seasion)
return seasion
if not data:
return None
value = data.get(key) if isinstance(data, dict) else getattr(data, key, None)
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def __get_model(self, message: Union[str, List[dict]],
prompt: str = None,
user: str = "MoviePilot",
**kwargs):
@classmethod
def _extract_usage(cls, response: Any) -> Dict[str, int]:
"""
获取模型
从 LangChain AIMessage 中提取 token 用量。
"""
if not self._client:
raise ValueError("OpenAI client not initialized. Please check API key and API URL.")
if not isinstance(message, list):
if prompt:
message = [
{
"role": "system",
"content": prompt
},
{
"role": "user",
"content": message
}
]
else:
message = [
{
"role": "user",
"content": message
}
]
# 新版本 API 不支持 user 参数,需要从 kwargs 中移除
kwargs.pop('user', None)
return self._client.chat.completions.create(
model=self._model,
messages=message,
**kwargs
usage_metadata = getattr(response, "usage_metadata", None)
response_metadata = getattr(response, "response_metadata", None) or {}
token_usage = (
response_metadata.get("token_usage")
or response_metadata.get("usage")
or response_metadata.get("usage_metadata")
or {}
)
@staticmethod
def __clear_session(session_id: str):
"""
清除会话
:param session_id: 会话ID
:return:
"""
if OpenAISessionCache.get(session_id):
OpenAISessionCache.delete(session_id)
input_tokens = (
cls._lookup_int(usage_metadata, "input_tokens")
or cls._lookup_int(token_usage, "input_tokens")
or cls._lookup_int(token_usage, "prompt_tokens")
or 0
)
output_tokens = (
cls._lookup_int(usage_metadata, "output_tokens")
or cls._lookup_int(token_usage, "output_tokens")
or cls._lookup_int(token_usage, "completion_tokens")
or 0
)
total_tokens = (
cls._lookup_int(usage_metadata, "total_tokens")
or cls._lookup_int(token_usage, "total_tokens")
or input_tokens + output_tokens
)
return {
"input_tokens": max(input_tokens, 0),
"output_tokens": max(output_tokens, 0),
"total_tokens": max(total_tokens, 0),
}
def get_media_name(self, filename: str):
def _get_llm(self) -> Any:
"""
从文件名中提取媒体名称等要素
:param filename: 文件名
:return: Json
按当前运行参数创建 MoviePilot LLM 实例。
"""
llm = LLMHelper.get_llm(
streaming=False,
provider=self._provider,
model=self._model,
thinking_level=self._thinking_level,
api_key=self._api_key,
base_url=self._api_url,
base_url_preset=self._base_url_preset,
user_agent=self._user_agent,
use_proxy=self._use_proxy,
)
return self._run_async_compatible(llm)
@staticmethod
def _extract_response_text(response: Any) -> str:
"""
从模型响应对象中提取文本内容。
"""
content = getattr(response, "content", response)
return LLMHelper._extract_text_content(content).strip()
@classmethod
def _strip_json_fence(cls, text: str) -> str:
"""
移除模型可能附加的 Markdown JSON 代码块包裹。
"""
text = str(text or "").strip()
match = cls._JSON_FENCE_PATTERN.match(text)
return match.group(1).strip() if match else text
@classmethod
def _extract_json_text(cls, text: str) -> str:
"""
从模型回复中提取第一个 JSON 对象文本。
"""
text = cls._strip_json_fence(text)
if text.startswith("{") and text.endswith("}"):
return text
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return text[start:end + 1]
return text
def get_media_name(self, filename: str) -> Dict[str, Any]:
"""
从媒体文件名中提取结构化识别信息。
"""
self._last_usage = {}
if not self.get_state():
return None
return {"errorMsg": "LLM API Key or model is not configured"}
result = ""
try:
_filename_prompt = self._prompt
completion = self.__get_model(prompt=_filename_prompt, message=filename)
result = completion.choices[0].message.content
# 有些模型返回json数据时会使用 ```json ``` 包裹json对象 所以需要进行提取
# 定义正则表达式模式,匹配```json开头和```结尾的内容
pattern = r'^```json\s*([\s\S]*?)\s*```$'
# 使用正则表达式进行匹配
match = re.match(pattern, result.strip())
if match:
# 提取中间的JSON部分
result = match.group(1)
return json.loads(result)
except Exception as e:
llm = self._get_llm()
completion = llm.invoke(
[
SystemMessage(content=self._prompt),
HumanMessage(content=str(filename or "")),
]
)
self._last_usage = self._extract_usage(completion)
result = self._extract_response_text(completion)
json_text = self._extract_json_text(result)
data = json.loads(json_text)
if not isinstance(data, dict):
raise ValueError("LLM response is not a JSON object")
return data
except Exception as exc:
return {
"content": result,
"errorMsg": str(e)
"errorMsg": str(exc),
}
def get_response(self, text: str, userid: str):
"""
聊天对话,获取答案
:param text: 输入文本
:param userid: 用户ID
:return:
"""
if not self.get_state():
return ""
try:
if not userid:
return "用户信息错误"
else:
userid = str(userid)
if text == "#清除":
self.__clear_session(userid)
return "会话已清除"
# 获取历史上下文
messages = self.__get_session(userid, text)
completion = self.__get_model(message=messages, user=userid)
result = completion.choices[0].message.content
if result:
self.__save_session(userid, text)
return result
except openai.RateLimitError as e:
return f"请求被ChatGPT拒绝了{str(e)}"
except openai.APIConnectionError as e:
return f"ChatGPT网络连接失败{str(e)}"
except openai.APITimeoutError as e:
return f"没有接收到ChatGPT的返回消息{str(e)}"
except Exception as e:
return f"请求ChatGPT出现错误{str(e)}"
def translate_to_zh(self, text: str):
"""
翻译为中文
:param text: 输入文本
"""
if not self.get_state():
return False, None
system_prompt = "You are a translation engine that can only translate text and cannot interpret it."
user_prompt = f"translate to zh-CN:\n\n{text}"
result = ""
try:
completion = self.__get_model(prompt=system_prompt,
message=user_prompt,
temperature=0,
top_p=1,
frequency_penalty=0,
presence_penalty=0)
result = completion.choices[0].message.content.strip()
return True, result
except Exception as e:
print(f"{str(e)}{result}")
return False, str(e)
def get_question_answer(self, question: str):
"""
从给定问题和选项中获取正确答案
:param question: 问题及选项
:return: Json
"""
if not self.get_state():
return None
result = ""
try:
_question_prompt = "下面我们来玩一个游戏,你是老师,我是学生,你需要回答我的问题,我会给你一个题目和几个选项,你的回复必须是给定选项中正确答案对应的序号,请直接回复数字"
completion = self.__get_model(prompt=_question_prompt, message=question)
result = completion.choices[0].message.content
return result
except Exception as e:
print(f"{str(e)}{result}")
return {}

View File

@@ -1 +0,0 @@
cacheout~=0.16.0

View File

@@ -36,7 +36,7 @@ class ClashRuleProvider(_PluginBase):
# 插件图标
plugin_icon = "Mihomo_Meta_A.png"
# 插件版本
plugin_version = "2.1.6"
plugin_version = "2.1.7"
# 插件作者
plugin_author = "wumode"
# 作者主页

View File

@@ -7,7 +7,6 @@ from app.log import logger
from .proxy import Proxy
from .proxygroups import ProxyGroup
from .proxyproviders import ProxyProvider
from .proxy.tlsmixin import ClientFingerprint
from .ruleproviders import RuleProvider
from .rule import RuleType, Action, RoutingRuleType
from ..helper.clashruleparser import ClashRuleParser
@@ -70,8 +69,7 @@ class ClashConfig(BaseModel):
interface_name: str | None = Field(default=None, alias="interface-name")
routing_mark: int | None = Field(default=None, alias="routing-mark")
tls: dict[str, Any] | None = Field(default=None, alias="tls")
global_client_fingerprint: ClientFingerprint | None = Field(default=ClientFingerprint.chrome,
alias="global-client-fingerprint")
geodata_mode: bool | None = Field(default=None, alias="geodata-mode")
geodata_loader: Literal["memconservative", "standard"] = Field(default="memconservative", alias="geodata-loader")
geo_auto_update: bool = Field(default=False, alias="geo-auto-update")

View File

@@ -20,7 +20,7 @@ from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType
from .helper import PyCookieCloud, MySender, IpLocationParser
from .helper import PyCookieCloud, MySender, IpLocationParser, JsonFieldManager
class DynamicWeChat(_PluginBase):
@@ -31,7 +31,7 @@ class DynamicWeChat(_PluginBase):
# 插件图标
plugin_icon = "Wecom_A.png"
# 插件版本
plugin_version = "2.0.1"
plugin_version = "2.1.1"
# 插件作者
plugin_author = "RamenRa"
# 作者主页
@@ -73,13 +73,13 @@ class DynamicWeChat(_PluginBase):
_notification_token = ''
# 标记企业微信通知可用
_wechat_available = True
# 标记IP变动后 是否发送通知
# 标记IP变动后 通知发送过了没有
_send_notification = False
# 匹配ip地址的正则
_ip_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
# 获取ip地址的网址列表
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://4.ipw.cn"]
_ip_urls = ["https://myip.ipip.net", "https://ddns.oray.com/checkip", "https://ip.3322.net", "https://r.inews.qq.com/api/ip2city", "https://uapis.cn/api/v1/network/myip"]
# 当前ip地址
_current_ip_address = '0.0.0.0'
# 企业微信登录
@@ -131,6 +131,7 @@ class DynamicWeChat(_PluginBase):
def init_plugin(self, config: dict = None):
# 清空配置
self._last_code = ""
self._notification_token = ''
self._cron = '*/10 * * * *'
self._ip_changed = True
@@ -140,13 +141,14 @@ class DynamicWeChat(_PluginBase):
self._input_id_list = ''
self._cookie_header = ""
self._settings_file_path = self.get_data_path() / "settings.json"
self.cfg = JsonFieldManager(self._settings_file_path)
self._qr_running = False
if config:
self._enabled = config.get("enabled")
self._notification_token = config.get("notification_token")
self._cron = config.get("cron")
self._onlyonce = config.get("onlyonce")
self._input_id_list = config.get("input_id_list")
# self._current_ip_address = config.get("current_ip_address")
self._forced_update = config.get("forced_update")
self._local_scan = config.get("local_scan")
self._use_cookiecloud = config.get("use_cookiecloud")
@@ -170,7 +172,9 @@ class DynamicWeChat(_PluginBase):
self._current_ip_address = self.wan2.read_ips("ips") # 从文件中读取
else:
self.wan2 = None
_, self._current_ip_address = self.get_ip_from_url() # 直接从网页获取
# _, self._current_ip_address = self.get_ip_from_url() # 直接从网页获取 返回URL和IP
self._current_ip_address = self.cfg.get("WECHAT_NOW_IP") # 应对MP/NAS长时间关闭后公网IP和可信IP不一致
# 停止现有任务
self.stop_service()
if (self._enabled or self._onlyonce) and self._input_id_list:
@@ -240,14 +244,33 @@ class DynamicWeChat(_PluginBase):
def _send_cookie_false(self):
self._cookie_valid = False
if self._my_send and not self._await_ip: # 不启用“IP变动后通知
if self._my_send and not self._await_ip and self._wechat_available: # 配置了通知 且 不启用“IP变动后通知 且 微信通知有效
error = self._my_send.send(
title="cookie已失效,请及时更新",
content="请在企业微信应用发送/push_qr, 如有验证码以''结束发送到企业微信应用。 如果使用微信通知请确保公网IP还没有变动",
content="请在企业微信应用发送/push_qr, 验证码以''结束发送到企业微信应用。 如果使用微信通知请确保公网IP还没有变动",
image=None, force_send=False
)
if error:
logger.info(f"cookie失效通知发送失败,原因:{error}")
return None
elif self._my_send and not self._wechat_available and self._my_send.other_channel: # self._my_send 防止空对象
'''
# 微信通知无效IP已不一致 且 配置了第三方通知
'''
for channel, token in self._my_send.other_channel:
# logger.info(f"正常尝试:{channel} {token}")
error = self._my_send.send(
title="cookie已失效,且微信通知失效",
content="请在企业微信应用发送/push_qr, 验证码以''结束发送到企业微信应用。",
image=None, force_send=False, diy_channel=channel, diy_token=token
)
if error:
logger.error(f"通道 {channel} 发送失败,原因:{error}")
else:
return None
else:
# logger.error(f"通道 {self._my_send} 发送失败,原因:{error}")
return None
@eventmanager.register(EventType.PluginAction)
def forced_change(self, event: Event = None):
@@ -302,7 +325,8 @@ class DynamicWeChat(_PluginBase):
page = context.new_page()
page.goto(self._wechatUrl)
time.sleep(3) # 页面加载等待时间
if self.find_qrc(page):
img, _ = self.find_qrc(page)
if img:
current_time = datetime.now()
future_time = current_time + timedelta(seconds=110)
self._future_timestamp = int(future_time.timestamp())
@@ -370,36 +394,26 @@ class DynamicWeChat(_PluginBase):
if not event_data or event_data.get("action") != "dynamicwechat":
return
# 情况1cookie有效
if self._cookie_valid:
logger.info("开始检测公网IP")
if self.CheckIP():
self.ChangeIP()
self.__update_config()
logger.info("----------------------本次任务结束----------------------")
elif self._await_ip and not self._send_notification:
# logger.info("cookie已失效。但配置了第三方通知继续检测公网IP。当IP变动企业微信通知彻底无法使用时通知用户")
return
# 情况2cookie失效 + 启用IP变动后通知有第三方通知
if self._await_ip:
logger.info("开始检测公网IP,等待IP变动后发送通知")
if self.CheckIP(func="public"):
# logger.info(f"配置的第三方通知{self._my_send.other_channel}")
for channel, token in self._my_send.other_channel:
# logger.info(f"正常尝试:{channel} {token}")
error = self._my_send.send(
title="公网IP与企业微信IP不一致",
content="请在企业微信应用发送/push_qr, 如有验证码以''结束发送到企业微信应用。",
image=None, force_send=False, diy_channel=channel, diy_token=token
)
if error:
logger.error(f"通道 {channel} 发送失败,原因:{error}")
else:
self._send_notification = True
break # 发送成功后退出循环
self._wechat_available = False # 标记不可用
self._send_cookie_false()
logger.info("----------------------本次任务结束----------------------")
else:
if self._send_notification:
logger.info("企业微信可信IP和公网IP不一致微信通知可能已经无法使用。第三方通知已经发送。")
else:
logger.info("cookie已失效请及时更新,本次不检查公网IP")
return
# 情况3cookie失效 + 不等待IP变化
logger.info("Cookie已失效本次不检查IP")
self._send_cookie_false()
def CheckIP(self, func=None):
if self.wan2:
@@ -408,7 +422,7 @@ class DynamicWeChat(_PluginBase):
else:
url, ip_address = self.get_ip_from_url()
if ip_address == "获取IP失败" or not url:
if not ip_address or ip_address == "获取IP失败" or not url:
logger.error("获取IP失败 不操作可信IP")
return False
@@ -438,6 +452,7 @@ class DynamicWeChat(_PluginBase):
# 检查 IP 是否变化
if ip_address != self._current_ip_address:
logger.info("检测到IP变化")
self._wechat_available = False
return True
return False
@@ -470,7 +485,6 @@ class DynamicWeChat(_PluginBase):
urls = self._input_id_list
else:
urls = self._ip_urls
# 随机化 URL 列表
random.shuffle(urls)
if not self.wan2:
@@ -546,7 +560,7 @@ class DynamicWeChat(_PluginBase):
time.sleep(3)
img_src, refuse_time = self.find_qrc(page)
if img_src:
if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发二维码
if self._my_send: # 统一逻辑,只有用户发送'/push_qr'才会发二维码
self._ip_changed = False
self._send_cookie_false()
logger.info("已尝试发送cookie失效通知")
@@ -599,7 +613,7 @@ class DynamicWeChat(_PluginBase):
formatted_cookies[domain] = []
formatted_cookies[domain].append(cookie)
if self._cc_server.update_cookie(formatted_cookies):
logger.info("更新 CookieCloud 成功")
logger.info("更新 CookieCloud 成功如没有CC服务器同步cookie请不要在其他地方登录企业微信")
self._cookie_valid = True
self._is_special_upload = True
else:
@@ -621,7 +635,7 @@ class DynamicWeChat(_PluginBase):
self._is_special_upload = False
return
else:
logger.info("更新本地 Cookie成功")
logger.info("更新本地 Cookie成功,请不要在其他地方登录企业微信")
self._is_special_upload = True
self._saved_cookie = current_cookies # 保存
self._cookie_valid = True
@@ -630,16 +644,24 @@ class DynamicWeChat(_PluginBase):
logger.error(f"更新本地 cookie 发生错误: {e}")
def get_cookie(self):
"""
获取企业微信 Cookie。
获取优先级:
1. 本地内存缓存_saved_cookie 且标记有效)
2. CookieCloud 中 .work.weixin.qq.com 域名的 cookie
Returns:
Playwright 格式的 Cookie 字典列表;获取失败或未启用时返回 None。
"""
if self._saved_cookie and self._cookie_valid:
return self._saved_cookie
try:
cookie_header = ''
if not self._use_cookiecloud:
return
return None
cookies, msg = self._cookiecloud.download()
if not cookies: # CookieCloud获取cookie失败
logger.error(f"CookieCloud获取cookie失败,失败原因:{msg}")
return
return None
for domain, cookie in cookies.items():
if domain == ".work.weixin.qq.com":
cookie_header = cookie
@@ -650,7 +672,7 @@ class DynamicWeChat(_PluginBase):
return cookie
except Exception as e:
logger.error(f"从CookieCloud获取cookie错误,错误原因:{e}")
return
return None
# @staticmethod
def parse_cookie_header(self, cookie_header):
@@ -675,17 +697,17 @@ class DynamicWeChat(_PluginBase):
context = self._launch_browser_context(headless=True)
cookie_used = False
if self._saved_cookie:
# logger.info("尝试使用本地保存的 cookie")
# logger.info("尝试使用内存保存的 cookie")
context.add_cookies(self._saved_cookie)
page = context.new_page()
page.goto(self._wechatUrl)
time.sleep(3)
if self.check_login_status(page, task='refresh_cookie'):
# logger.info("本地保存的 cookie 有效")
# logger.info("本地内存保存的 cookie 有效")
self._cookie_valid = True
cookie_used = True
else:
# logger.warning("本地保存的 cookie 无效")
# logger.warning("本地内存保存的 cookie 无效")
self._cookie_valid = False
self._saved_cookie = None # 清空无效的 cookie
@@ -780,7 +802,7 @@ class DynamicWeChat(_PluginBase):
except:
continue
else:
logger.error("未收到短信验证码")
logger.error("未收到短信验证码请以问号结尾发送到企业微信应用。如510010? 使用全局AI助手需使用/wxcode 510010的格式发送验证码")
return False
except Exception as e:
# logger.debug(str(e)) # 基于bug运行,请不要将错误输出到日志
@@ -845,10 +867,16 @@ class DynamicWeChat(_PluginBase):
if self._ip_changed:
self._wechat_available = True # 标记微信通知重新有效
self._send_notification = False # 重置第三方通知已发送标记
self.cfg.update("WECHAT_NOW_IP", self._current_ip_address)
'''
将填入企业微信的IP写入settings.json
应对MP/NAS长时间关闭后公网IP和可信IP不一致
'''
# self.wan2 = IpLocationParser(self._settings_file_path, max_ips=1)
masked_ips = [self.mask_ip(ip) for ip in self._current_ip_address.split(';')]
masked_ip_string = ";".join(masked_ips)
logger.info(f"应用: {app_id} 输入IP" + self._current_ip_address)
if self._my_send:
if self._my_send and not self._my_send.quiet_flag: # 没有开启安静模式才发通知
self._my_send.send(title="更新可信IP成功",
content='应用: ' + app_id + ' 输入IP' + masked_ip_string,
force_send=True, diy_channel="WeChat")
@@ -1255,6 +1283,11 @@ class DynamicWeChat(_PluginBase):
event_data = event.event_data
if not event_data or event_data.get("action") != "push_qrcode":
return
if self._qr_running:
# logger.warning("二维码任务正在执行,忽略重复触发")
return
self._qr_running = True
context = None
try:
context = self._launch_browser_context(headless=True)
@@ -1264,7 +1297,7 @@ class DynamicWeChat(_PluginBase):
image_src, refuse_time = self.find_qrc(page)
if image_src:
if self._my_send:
if not self._wechat_available and self._my_send.other_channel: # 微信通知已经无法使用
if not self._wechat_available and self._my_send.other_channel: # 微信通知已经无法使用,但是配置了第三方通知
for channel, token in self._my_send.other_channel:
# logger.info(f"正常尝试:{channel} {token}")
error = self._my_send.send(
@@ -1275,19 +1308,27 @@ class DynamicWeChat(_PluginBase):
logger.warning(f"通道 {channel} 推送二维码失败,原因:{error}")
else:
break # 发送成功后退出循环
else: # 硬发
else: # 只配置了微信通知 硬发
error = self._my_send.send("企业微信登录二维码", image=image_src)
if error:
logger.info(f"远程推送任务: 二维码发送失败,原因:{error}")
logger.info("----------------------本次任务结束----------------------")
return
logger.info("远程推送任务: 二维码发送成功,等待用户 90 秒内扫码登录。V2'微信通知'的用户,此消息并不准确")
logger.info("远程推送任务: 二维码发送成功,等待用户 80 秒内扫码登录。V2'微信通知'的用户,此消息并不准确")
# logger.info("远程推送任务: 如收到短信验证码请以?结束,发送到<企业微信应用> 如: 110301")
time.sleep(90)
if self.check_login_status(page, 'push_qr_code'):
self._update_cookie(page, context) # 刷新cookie
# logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP")
self.click_app_management_buttons(page)
# time.sleep(90)
max_attempts = 4
attempt = 0
while attempt < max_attempts:
time.sleep(20)
attempt += 1
if self.check_login_status(page, 'push_qr_code'):
self._update_cookie(page, context) # 刷新cookie
# logger.info("远程推送任务: 没有可用的CookieCloud服务器,只修改可信IP")
self.click_app_management_buttons(page)
break
else:
logger.info("用户可能没有扫码或登录失败")
else:
logger.warning("远程推送任务: 没有找到可用的通知方式")
else:
@@ -1298,6 +1339,43 @@ class DynamicWeChat(_PluginBase):
finally:
if context:
context.close()
self._qr_running = False
@eventmanager.register(EventType.PluginAction)
def receive_code(self, event: Event = None):
"""
接收企业微信验证码
"""
if not self._enabled or not event:
return
event_data = event.event_data or {}
if event_data.get("action") != "wxcode":
return
if not self._qr_running:
return
raw = event_data.get("arg_str") or ""
# 去掉无效日志噪音(只在调试时保留)
# logger.info(f"完整event_data: {event_data}")
# logger.info(f"原始内容: {raw}")
match = re.search(r"\d{6}", raw)
if not match:
logger.warning(f"收到无效验证码: {raw}")
return
code = match.group(0)
# 防重复接收(关键优化)
if getattr(self, "_last_code", None) == code:
return
self._last_code = code
self._verification_code = code
logger.info(f"收到验证码:{code}")
@staticmethod
def get_command() -> List[Dict[str, Any]]:
@@ -1310,6 +1388,15 @@ class DynamicWeChat(_PluginBase):
"data": {
"action": "push_qrcode"
}
},
{
"cmd": "/wxcode",
"event": EventType.PluginAction,
"desc": "提交企业微信验证码",
"category": "",
"data": {
"action": "wxcode"
}
}
]
@@ -1323,12 +1410,18 @@ class DynamicWeChat(_PluginBase):
"""
if not self._enabled:
return
if not self._qr_running:
return
self.text = event.event_data.get("text")
if len(self.text) == 7 and re.fullmatch(r".*\d{6}.*", self.text):
match = re.search(r"\d{6}", self.text)
if match:
self._verification_code = match.group(0)
logger.info(f"收到验证码:{self._verification_code}")
code = match.group(0)
# self._verification_code = match.group(0)
if code != self._last_code:
self._verification_code = code
self._last_code = code
logger.info(f"收到验证码:{code}")
def get_service(self) -> List[Dict[str, Any]]:
"""

View File

@@ -130,6 +130,15 @@ class PyCookieCloud:
class MySender:
def __init__(self, token=None, func=None):
self.raw_token = token or ""
self.quiet_flag = False # 安静模式标志
if self.raw_token.endswith("||Q") or self.raw_token.endswith("||q"):
self.quiet_flag = True
token = self.raw_token.rsplit("||", 1)[0] # 去掉控制段
else:
token = self.raw_token
self.tokens = token.split('||') if token and '||' in token else [token] if token else []
self.channels = [MySender._detect_channel(t) for t in self.tokens]
self.current_index = 0 # 当前使用的 token 和 channel 的索引
@@ -137,6 +146,7 @@ class MySender:
self.init_success = bool(self.tokens) # 标识初始化是否成功
self.post_message_func = func # V2 微信模式的 post_message 方法
@property
def other_channel(self):
"""
@@ -147,15 +157,15 @@ class MySender:
@staticmethod
def _detect_channel(token):
"""根据 token 确定通知渠道"""
if "WeChat" in token or "wechat" in token:
return "WeChat"
"""根据 token 判断通知渠道"""
token = token.lower()
letters_only = ''.join(re.findall(r'[A-Za-z]', token))
if token.lower().startswith("sct"):
if "wechat" in token:
return "WeChat"
if token.startswith("sct"):
return "ServerChan"
elif letters_only.isupper():
return "AnPush"
elif "iyuu" in token:
return "IYUU"
else:
return "PushPlus"
@@ -195,8 +205,8 @@ class MySender:
return self._send_wechat(title, content, image, token)
elif channel == "ServerChan":
return self._send_serverchan(title, content, image, diy_token)
elif channel == "AnPush":
return self._send_anpush(title, content, image, diy_token)
elif channel == "IYUU":
return self._send_iyuu(title, content, image, diy_token)
elif channel == "PushPlus":
return self._send_pushplus(title, content, image, diy_token)
else:
@@ -250,30 +260,38 @@ class MySender:
return f"Server酱通知错误: {result.get('message')}"
return None
def _send_anpush(self, title, content, image, diy_token=None):
def _send_iyuu(self, title, content, image, diy_token=None):
"""
发送爱语飞飞通知
:param title: 通知标题
:param content: 通知内容(无图片时使用)
:param image: 图片URL有图片时使用会覆盖content
:param diy_token: 自定义token若为None则从self.tokens中取
:return: 成功返回None失败返回错误字符串
"""
# 确定使用的token
if diy_token:
token = diy_token
else:
token = self.tokens[self.current_index] # 获取当前通道对应的 token
if ',' in token:
channel, token = token.split(',', 1)
token = self.tokens[self.current_index] # 假设self.tokens存在
# 构造URL和请求体
url = f"https://iyuu.cn/{token}.send"
headers = {"Content-Type": "application/json; charset=UTF-8"}
if image:
desp = f'<img src="{image}" style="max-width: 100%; height: auto;" />'
else:
return "AnPush可能没有配置消息通道ID"
url = f"https://api.anpush.com/push/{token}"
payload = {
"title": title,
"content": f"<img src=\"{image}\" width=\"100%\">" if image else content,
"channel": channel
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(url, headers=headers, data=payload)
result = response.json()
# 判断返回的code和msgIds
if result.get('code') != 200:
return f"AnPush: {result.get('msg')}"
elif not result.get('data') or not result['data'].get('msgIds'):
return "AnPush 消息通道未找到"
return None
desp = content # 发送文字内容
payload = {"text": title, "desp": desp}
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
result = response.json()
if result.get("errcode") != 0:
return f"爱语飞飞通知错误: {result.get('errmsg')}"
return None
except Exception as e:
return f"爱语飞飞请求异常: {str(e)}"
def _send_pushplus(self, title, content, image, diy_token=None):
if diy_token:
@@ -530,3 +548,69 @@ class IpLocationParser:
# 写入更新后的 IP 地址
self.overwrite_ips(field, updated_ips)
class JsonFieldManager:
"""
通用 JSON 配置文件字段管理器。
所有操作均遵循「读-改-写」模式,确保不修改无关字段。
"""
def __init__(self, settings_file_path: str):
self._settings_file_path = settings_file_path
def _load(self) -> dict:
"""读取完整 JSON 内容;若文件损坏或不存在则返回空字典。"""
try:
with open(self._settings_file_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def _save(self, data: dict) -> None:
"""直接写入原文件。"""
with open(self._settings_file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
# -------------------------- 公开接口 --------------------------
def get(self, field: str, default: Any = None) -> Any:
"""
读取指定字段的值。
:param field: 字段名
:param default: 字段不存在时返回的默认值
:return: 字段值或 default
"""
return self._load().get(field, default)
def add(self, field: str, value: Any) -> bool:
"""
添加新字段。若字段已存在则返回 False不会修改任何已有数据。
:param field: 要添加的字段名
:param value: 字段值
:return: 是否添加成功
"""
data = self._load()
if field in data:
return False
data[field] = value
self._save(data)
return True
def update(self, field: str, value: Any) -> None:
"""
修改指定字段的值;若字段不存在则自动创建。
不修改其他字段。
:param field: 要修改或创建的字段名
:param value: 新值
"""
data = self._load()
data[field] = value
self._save(data)
def set(self, field: str, value: Any) -> None:
"""
新增或修改字段(字段不存在则创建,存在则覆盖)。
不修改其他字段。与 update 行为一致。
"""
self.update(field, value)

2
plugins.v2/oidcauth/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
package-lock.json
node_modules/

View File

@@ -0,0 +1,747 @@
import hashlib
import json
import secrets
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
import httpx
from fastapi import Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app import schemas
from app.core.auth_bridge import create_plugin_auth_ticket
from app.core.config import settings
from app.db.models.user import User
from app.db.user_oper import get_current_active_user
from app.log import logger
from app.plugins import _PluginBase
class OidcAuth(_PluginBase):
"""
OIDC 认证插件。
"""
plugin_name = "OIDC 认证"
plugin_desc = "通过 OpenID Connect Provider 为 MoviePilot 提供插件化登录与账号绑定。"
plugin_icon = "Authelia_A.png"
plugin_version = "0.1.0"
plugin_author = "ui-beam-9,jxxghp"
author_url = "https://github.com/ui-beam-9"
plugin_label = "认证,OIDC,SSO"
plugin_order = 36
_STATE_TTL_SECONDS = 300
_PLUGIN_ID = "OidcAuth"
def __init__(self):
"""
初始化插件运行状态。
"""
super().__init__()
self._enabled = False
self._config: Dict[str, Any] = {}
self._states: Dict[str, Dict[str, Any]] = {}
self._state_lock = threading.RLock()
def init_plugin(self, config: dict = None):
"""
初始化插件配置。
:param config: 插件配置
"""
self._config = self._normalize_config(config or {})
self._enabled = bool(self._config.get("enabled"))
def get_state(self) -> bool:
"""
获取插件启用状态。
:return: 是否启用
"""
return bool(self._enabled)
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
获取插件命令列表。
:return: 命令列表
"""
return []
def get_api(self) -> List[Dict[str, Any]]:
"""
注册插件 API。
:return: API 路由声明列表
"""
return [
{
"path": "/public/status",
"endpoint": self.public_status,
"methods": ["GET"],
"summary": "查询 OIDC 登录公开状态",
"allow_anonymous": True,
},
{
"path": "/authorize",
"endpoint": self.authorize,
"methods": ["GET"],
"summary": "发起 OIDC 登录",
"allow_anonymous": True,
},
{
"path": "/callback",
"endpoint": self.callback,
"methods": ["GET"],
"summary": "OIDC 回调",
"allow_anonymous": True,
},
{
"path": "/status",
"endpoint": self.status,
"methods": ["GET"],
"auth": "bear",
"summary": "查询 OIDC 插件状态",
},
{
"path": "/config",
"endpoint": self.save_config_api,
"methods": ["POST"],
"auth": "bear",
"summary": "保存 OIDC 插件配置",
},
{
"path": "/test",
"endpoint": self.test_api,
"methods": ["POST"],
"auth": "bear",
"summary": "测试 OIDC Provider",
},
{
"path": "/bind/start",
"endpoint": self.bind_start,
"methods": ["POST"],
"auth": "bear",
"summary": "发起 OIDC 账号绑定",
},
{
"path": "/unbind",
"endpoint": self.unbind,
"methods": ["POST"],
"auth": "bear",
"summary": "解绑 OIDC 账号",
},
]
@staticmethod
def get_render_mode() -> Tuple[str, str]:
"""
声明插件使用 Vue 联邦组件。
:return: 渲染模式与构建产物路径
"""
return "vue", "dist/assets"
def get_auth_providers(self) -> List[Dict[str, Any]]:
"""
声明未登录页面可展示的认证入口。
:return: 认证入口列表
"""
if not self._is_login_ready():
return []
return [
{
"id": "oidc",
"name": self._config.get("provider_name") or "OIDC 登录",
"icon": "mdi-openid",
"component": "AuthPage",
"enabled": True,
}
]
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
返回 Vue 模式配置表单占位。
:return: 表单配置与默认模型
"""
return [], self._config
def get_page(self) -> List[dict]:
"""
返回 Vue 模式详情页占位。
:return: 页面配置
"""
return []
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
"""
声明插件侧栏管理入口。
:return: 侧栏导航项
"""
if not self.get_state():
return []
return [
{
"nav_key": "main",
"title": "OIDC 认证",
"icon": "mdi-openid",
"section": "system",
"permission": "admin",
"order": 47,
}
]
def stop_service(self):
"""
停止插件服务。
"""
with self._state_lock:
self._states.clear()
def public_status(self) -> schemas.Response:
"""
查询匿名可见的 OIDC 登录状态。
:return: 公开状态响应
"""
return schemas.Response(
success=True,
data={
"enabled": self._is_login_ready(),
"name": self._config.get("provider_name") or "OIDC 登录",
"icon": "mdi-openid",
},
)
async def authorize(self, request: Request) -> RedirectResponse:
"""
发起 OIDC 登录授权。
:param request: 当前请求
:return: IdP 授权跳转响应
"""
self._ensure_login_ready()
state = self._create_state(action="login")
redirect_uri = self._callback_url(request)
authorize_url = await self._build_authorize_url(redirect_uri=redirect_uri, state=state)
return RedirectResponse(authorize_url)
async def callback(
self,
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
error_description: Optional[str] = None,
) -> HTMLResponse:
"""
处理 OIDC 登录或绑定回调。
:param request: 当前请求
:param code: 授权码
:param state: CSRF state
:param error: IdP 返回的错误码
:param error_description: IdP 返回的错误描述
:return: 回调 HTML
"""
if error:
return self._callback_html(False, "oidc_error", error_description or error)
if not code or not state:
return self._callback_html(False, "oidc_invalid_callback", "OIDC 回调参数不完整")
state_data = self._pop_state(state)
if not state_data:
return self._callback_html(False, "oidc_invalid_state", "OIDC state 无效或已过期")
try:
redirect_uri = self._callback_url(request)
token_data = await self._exchange_code(code=code, redirect_uri=redirect_uri)
userinfo = await self._fetch_userinfo(token_data)
sub = str(userinfo.get("sub") or "")
if not sub:
return self._callback_html(False, "oidc_no_sub", "OIDC 用户信息缺少 sub")
action = state_data.get("action")
if action == "bind":
return self._handle_bind_callback(state_data=state_data, userinfo=userinfo, sub=sub)
return self._handle_login_callback(userinfo=userinfo, sub=sub)
except Exception as err:
logger.error(f"OIDC 回调处理失败: {err}", exc_info=True)
return self._callback_html(False, "oidc_error", str(err))
def status(self, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
"""
查询当前用户绑定状态和管理员配置。
:param current_user: 当前登录用户
:return: 插件状态响应
"""
binding = self._get_user_binding(current_user.id)
data: Dict[str, Any] = {
"public": {
"enabled": self._is_login_ready(),
"name": self._config.get("provider_name") or "OIDC 登录",
"redirect_uri": self._configured_or_display_redirect_uri(),
},
"binding": {
"bound": bool(binding),
"masked_sub": self._mask_sub((binding or {}).get("sub")),
"username": (binding or {}).get("username"),
"email": (binding or {}).get("email"),
},
"is_superuser": bool(current_user.is_superuser),
}
if current_user.is_superuser:
data["config"] = self._config.copy()
return schemas.Response(success=True, data=data)
def save_config_api(self, config: dict, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
"""
保存 OIDC 插件配置。
:param config: 前端提交的配置
:param current_user: 当前登录用户
:return: 保存结果
"""
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail="用户权限不足")
normalized = self._normalize_config(config or {})
self._config = normalized
self._enabled = bool(normalized.get("enabled"))
self.update_config(normalized)
return schemas.Response(success=True, data={"config": normalized})
async def test_api(self, body: dict, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
"""
测试 OIDC Provider 发现文档。
:param body: 待测试配置
:param current_user: 当前登录用户
:return: 测试结果
"""
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail="用户权限不足")
test_config = self._normalize_config({**self._config, **(body or {})})
try:
discovery = await self._get_discovery(test_config)
missing = [
key
for key in ("authorization_endpoint", "token_endpoint", "userinfo_endpoint")
if not discovery.get(key)
]
if missing:
return schemas.Response(success=False, message=f"发现文档缺少端点: {', '.join(missing)}")
return schemas.Response(success=True, message="OIDC Provider 连接正常")
except Exception as err:
return schemas.Response(success=False, message=str(err))
async def bind_start(
self,
request: Request,
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
"""
发起当前用户的 OIDC 绑定流程。
:param request: 当前请求
:param current_user: 当前登录用户
:return: 授权地址
"""
self._ensure_login_ready()
if self._get_user_binding(current_user.id):
return schemas.Response(success=False, message="当前用户已绑定 OIDC 账号")
state = self._create_state(action="bind", user_id=current_user.id)
redirect_uri = self._callback_url(request)
authorize_url = await self._build_authorize_url(redirect_uri=redirect_uri, state=state)
return schemas.Response(success=True, data={"authorize_url": authorize_url})
def unbind(self, current_user: User = Depends(get_current_active_user)) -> schemas.Response:
"""
解绑当前用户的 OIDC 账号。
:param current_user: 当前登录用户
:return: 解绑结果
"""
binding = self._get_user_binding(current_user.id)
if not binding:
return schemas.Response(success=False, message="当前用户未绑定 OIDC 账号")
self.del_data(self._sub_key(binding.get("issuer") or "", binding.get("sub") or ""))
self.del_data(self._user_key(current_user.id))
return schemas.Response(success=True)
def _normalize_config(self, config: dict) -> Dict[str, Any]:
"""
规范化插件配置。
:param config: 原始配置
:return: 规范化后的配置
"""
return {
"enabled": bool(config.get("enabled")),
"provider_name": str(config.get("provider_name") or "OIDC 登录"),
"issuer": str(config.get("issuer") or "").strip().rstrip("/"),
"client_id": str(config.get("client_id") or "").strip(),
"client_secret": str(config.get("client_secret") or ""),
"scopes": str(config.get("scopes") or "openid profile email").strip(),
"redirect_uri": str(config.get("redirect_uri") or "").strip(),
"username_claim": str(config.get("username_claim") or "preferred_username").strip(),
"email_claim": str(config.get("email_claim") or "email").strip(),
"allow_auto_bind_by_username": bool(config.get("allow_auto_bind_by_username")),
}
def _is_login_ready(self) -> bool:
"""
判断 OIDC 登录是否具备最小可用配置。
:return: 是否可用
"""
return bool(
self._enabled
and self._config.get("issuer")
and self._config.get("client_id")
and self._config.get("client_secret")
)
def _ensure_login_ready(self) -> None:
"""
确认 OIDC 登录已可用。
"""
if not self._is_login_ready():
raise HTTPException(status_code=400, detail="OIDC 登录未启用或配置不完整")
async def _get_discovery(self, config: Optional[dict] = None) -> dict:
"""
获取 OIDC Provider 发现文档。
:param config: 指定配置,未传入时使用当前配置
:return: 发现文档
"""
oidc_config = config or self._config
issuer = str(oidc_config.get("issuer") or "").rstrip("/")
if not issuer:
raise ValueError("OIDC issuer 未配置")
discovery_url = (
issuer
if issuer.endswith("/.well-known/openid-configuration")
else f"{issuer}/.well-known/openid-configuration"
)
async with httpx.AsyncClient(timeout=10.0, proxy=settings.PROXY_HOST) as client:
response = await client.get(discovery_url)
response.raise_for_status()
return response.json()
async def _build_authorize_url(self, redirect_uri: str, state: str) -> str:
"""
构造 OIDC 授权地址。
:param redirect_uri: 回调地址
:param state: CSRF state
:return: 授权地址
"""
discovery = await self._get_discovery()
authorization_endpoint = discovery.get("authorization_endpoint")
if not authorization_endpoint:
raise ValueError("OIDC 发现文档缺少 authorization_endpoint")
params = {
"client_id": self._config.get("client_id"),
"response_type": "code",
"scope": self._config.get("scopes") or "openid profile email",
"redirect_uri": redirect_uri,
"state": state,
}
return f"{authorization_endpoint}?{urlencode(params)}"
async def _exchange_code(self, code: str, redirect_uri: str) -> dict:
"""
使用授权码换取 Token。
:param code: 授权码
:param redirect_uri: 回调地址
:return: Token 响应
"""
discovery = await self._get_discovery()
token_endpoint = discovery.get("token_endpoint")
if not token_endpoint:
raise ValueError("OIDC 发现文档缺少 token_endpoint")
async with httpx.AsyncClient(timeout=10.0, proxy=settings.PROXY_HOST) as client:
response = await client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._config.get("client_id"),
"client_secret": self._config.get("client_secret"),
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()
async def _fetch_userinfo(self, token_data: dict) -> dict:
"""
使用 Access Token 获取用户信息。
:param token_data: Token 响应
:return: 用户信息
"""
access_token = token_data.get("access_token")
if not access_token:
raise ValueError("OIDC Token 响应缺少 access_token")
discovery = await self._get_discovery()
userinfo_endpoint = discovery.get("userinfo_endpoint")
if not userinfo_endpoint:
raise ValueError("OIDC 发现文档缺少 userinfo_endpoint")
async with httpx.AsyncClient(timeout=10.0, proxy=settings.PROXY_HOST) as client:
response = await client.get(userinfo_endpoint, headers={"Authorization": f"Bearer {access_token}"})
response.raise_for_status()
return response.json()
def _handle_login_callback(self, userinfo: dict, sub: str) -> HTMLResponse:
"""
处理 OIDC 登录回调。
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 回调 HTML
"""
issuer = self._config.get("issuer") or ""
binding = self.get_data(self._sub_key(issuer, sub))
user = User.get(db=None, rid=(binding or {}).get("user_id")) if binding else None
if not user and self._config.get("allow_auto_bind_by_username"):
user = self._auto_bind_by_username(userinfo=userinfo, sub=sub)
if not user:
return self._callback_html(False, "oidc_unbound", "该 OIDC 账号尚未绑定 MoviePilot 用户")
if not user.is_active:
return self._callback_html(False, "user_inactive", "用户已被禁用")
ticket = create_plugin_auth_ticket(
user_id=user.id,
provider_id=f"{self._PLUGIN_ID}:oidc",
metadata={"sub": sub, "issuer": issuer},
)
return self._callback_html(True, data={"ticket": ticket})
def _handle_bind_callback(self, state_data: dict, userinfo: dict, sub: str) -> HTMLResponse:
"""
处理 OIDC 绑定回调。
:param state_data: state 中保存的绑定上下文
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 回调 HTML
"""
user_id = state_data.get("user_id")
user = User.get(db=None, rid=user_id) if user_id else None
if not user or not user.is_active:
return self._callback_html(False, "bind_user_invalid", "绑定用户不存在或已禁用", event_type="oidcauth_bind_callback")
issuer = self._config.get("issuer") or ""
existing = self.get_data(self._sub_key(issuer, sub))
if existing and existing.get("user_id") != user.id:
return self._callback_html(False, "bind_conflict", "该 OIDC 账号已绑定其他用户", event_type="oidcauth_bind_callback")
if self._get_user_binding(user.id):
return self._callback_html(False, "already_bound", "当前用户已绑定 OIDC 账号", event_type="oidcauth_bind_callback")
binding = self._binding_payload(user_id=user.id, userinfo=userinfo, sub=sub)
self.save_data(self._user_key(user.id), binding)
self.save_data(self._sub_key(issuer, sub), binding)
return self._callback_html(True, data={"bound": True}, event_type="oidcauth_bind_callback")
def _auto_bind_by_username(self, userinfo: dict, sub: str) -> Optional[User]:
"""
按用户名 claim 自动绑定已有用户。
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 绑定成功的用户
"""
username = str(userinfo.get(self._config.get("username_claim") or "preferred_username") or "").strip()
if not username:
return None
user = User.get_by_name(db=None, name=username)
if not user or not user.is_active or self._get_user_binding(user.id):
return None
binding = self._binding_payload(user_id=user.id, userinfo=userinfo, sub=sub)
issuer = self._config.get("issuer") or ""
self.save_data(self._user_key(user.id), binding)
self.save_data(self._sub_key(issuer, sub), binding)
return user
def _binding_payload(self, user_id: int, userinfo: dict, sub: str) -> dict:
"""
构造绑定数据。
:param user_id: 本地用户 ID
:param userinfo: OIDC 用户信息
:param sub: OIDC subject
:return: 绑定数据
"""
return {
"user_id": user_id,
"issuer": self._config.get("issuer") or "",
"sub": sub,
"username": userinfo.get(self._config.get("username_claim") or "preferred_username"),
"email": userinfo.get(self._config.get("email_claim") or "email"),
"updated_at": int(time.time()),
}
def _create_state(self, action: str, user_id: Optional[int] = None) -> str:
"""
创建并缓存 OIDC state。
:param action: login 或 bind
:param user_id: 绑定用户 ID
:return: state 字符串
"""
state = secrets.token_urlsafe(32)
with self._state_lock:
self._cleanup_states()
self._states[state] = {
"action": action,
"user_id": user_id,
"created_at": time.time(),
}
return state
def _pop_state(self, state: str) -> Optional[dict]:
"""
取出并删除 OIDC state。
:param state: state 字符串
:return: state 数据
"""
with self._state_lock:
data = self._states.pop(state, None)
self._cleanup_states()
if not data:
return None
if time.time() - float(data.get("created_at") or 0) > self._STATE_TTL_SECONDS:
return None
return data
def _cleanup_states(self) -> None:
"""
清理过期 state。
"""
now = time.time()
expired = [
key
for key, value in self._states.items()
if now - float(value.get("created_at") or 0) > self._STATE_TTL_SECONDS
]
for key in expired:
self._states.pop(key, None)
def _callback_url(self, request: Request) -> str:
"""
生成 OIDC 回调地址。
:param request: 当前请求
:return: 回调地址
"""
if self._config.get("redirect_uri"):
return self._config["redirect_uri"]
path = f"{settings.API_V1_STR}/plugin/{self._PLUGIN_ID}/callback"
if settings.MP_DOMAIN(path):
return settings.MP_DOMAIN(path)
return f"{str(request.base_url).rstrip('/')}{path}"
def _configured_or_display_redirect_uri(self) -> str:
"""
获取展示用回调地址。
:return: 回调地址或默认路径
"""
return self._config.get("redirect_uri") or f"{settings.API_V1_STR}/plugin/{self._PLUGIN_ID}/callback"
def _get_user_binding(self, user_id: int) -> Optional[dict]:
"""
获取用户绑定信息。
:param user_id: 本地用户 ID
:return: 绑定信息
"""
return self.get_data(self._user_key(user_id))
@staticmethod
def _user_key(user_id: int) -> str:
"""
构造用户绑定数据键。
:param user_id: 本地用户 ID
:return: 数据键
"""
return f"binding:user:{user_id}"
@staticmethod
def _sub_key(issuer: str, sub: str) -> str:
"""
构造 OIDC subject 反查数据键。
:param issuer: OIDC issuer
:param sub: OIDC subject
:return: 数据键
"""
digest = hashlib.sha256(f"{issuer}|{sub}".encode("utf-8")).hexdigest()
return f"binding:sub:{digest}"
@staticmethod
def _mask_sub(sub: Optional[str]) -> str:
"""
脱敏 OIDC subject。
:param sub: OIDC subject
:return: 脱敏字符串
"""
if not sub:
return ""
value = str(sub)
return f"{value[:6]}***" if len(value) > 6 else f"{value}***"
def _callback_html(
self,
success: bool,
error: Optional[str] = None,
message: Optional[str] = None,
data: Optional[dict] = None,
event_type: str = "oidcauth_callback",
) -> HTMLResponse:
"""
构造回调 HTML通过 postMessage 通知插件联邦页面。
:param success: 是否成功
:param error: 错误码
:param message: 错误信息
:param data: 成功数据
:param event_type: postMessage 事件类型
:return: HTML 响应
"""
payload = {
"type": event_type,
"success": success,
"error": error,
"message": message,
"data": data or {},
}
payload_json = json.dumps(payload, ensure_ascii=False)
html = f"""<!doctype html>
<html>
<head><meta charset="utf-8"><title>OIDC Callback</title></head>
<body>
<script>
(function() {{
var payload = {payload_json};
if (window.opener && !window.opener.closed) {{
window.opener.postMessage(payload, window.location.origin);
window.close();
}} else {{
document.body.innerText = payload.success ? '认证成功,请关闭此窗口' : (payload.message || '认证失败');
}}
}})();
</script>
</body>
</html>"""
return HTMLResponse(content=html)

View File

@@ -0,0 +1,515 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
const {toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createVNode:_createVNode,createElementVNode:_createElementVNode,createElementBlock:_createElementBlock} = await importShared('vue');
const _hoisted_1 = { class: "oidc-auth-admin pa-4" };
const _hoisted_2 = { class: "d-flex flex-wrap gap-3 mt-2" };
const {computed,onMounted,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'AppPage',
props: {
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
},
setup(__props) {
const props = __props;
const loading = ref(false);
const saving = ref(false);
const testing = ref(false);
const binding = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const status = ref({
public: {},
binding: {},
config: null,
is_superuser: false,
});
const config = ref({
enabled: false,
provider_name: 'OIDC 登录',
issuer: '',
client_id: '',
client_secret: '',
scopes: 'openid profile email',
redirect_uri: '',
username_claim: 'preferred_username',
email_claim: 'email',
allow_auto_bind_by_username: false,
});
let bindPopupTimer = null;
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
const isAdmin = computed(() => Boolean(status.value.is_superuser));
const isBound = computed(() => Boolean(status.value.binding?.bound));
/** 从 API 响应中解出 data 字段。 */
function unwrap(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
return response.data
}
return response
}
/** 清理提示信息。 */
function clearMessages() {
errorMessage.value = '';
successMessage.value = '';
}
/** 从服务端加载插件状态、配置和绑定信息。 */
async function loadStatus() {
loading.value = true;
clearMessages();
try {
const response = await props.api.get(`${pluginBase.value}/status`);
status.value = unwrap(response) || status.value;
if (status.value.config) {
config.value = { ...config.value, ...status.value.config };
}
} catch (error) {
errorMessage.value = error?.message || '加载失败';
} finally {
loading.value = false;
}
}
/** 保存管理员配置。 */
async function saveConfig() {
saving.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/config`, config.value);
const data = unwrap(response) || {};
if (data.config) {
config.value = { ...config.value, ...data.config };
}
successMessage.value = '配置已保存';
await loadStatus();
} catch (error) {
errorMessage.value = error?.message || '保存失败';
} finally {
saving.value = false;
}
}
/** 测试 OIDC Provider 发现文档。 */
async function testConnection() {
testing.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/test`, config.value);
if (response?.success) {
successMessage.value = response.message || '连接正常';
} else {
errorMessage.value = response?.message || '连接失败';
}
} catch (error) {
errorMessage.value = error?.message || '连接失败';
} finally {
testing.value = false;
}
}
/** 清理绑定弹窗轮询。 */
function clearBindPopupTimer() {
if (bindPopupTimer) {
clearInterval(bindPopupTimer);
bindPopupTimer = null;
}
}
/** 处理绑定回调消息。 */
function handleBindMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_bind_callback') return
window.removeEventListener('message', handleBindMessage);
clearBindPopupTimer();
binding.value = false;
if (event.data.success) {
successMessage.value = 'OIDC 账号已绑定';
loadStatus();
return
}
errorMessage.value = event.data?.message || '绑定失败';
}
/** 发起账号绑定。 */
async function bindAccount() {
binding.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/bind/start`, {});
const authorizeUrl = response?.data?.authorize_url;
if (!response?.success || !authorizeUrl) {
throw new Error(response?.message || '无法发起绑定')
}
window.addEventListener('message', handleBindMessage);
const popup = window.open(authorizeUrl, 'moviepilot_oidc_bind', 'width=600,height=720,left=200,top=80');
if (!popup) {
window.removeEventListener('message', handleBindMessage);
throw new Error('浏览器阻止了认证弹窗')
}
bindPopupTimer = setInterval(() => {
if (!popup.closed) return
clearBindPopupTimer();
window.removeEventListener('message', handleBindMessage);
if (binding.value) {
binding.value = false;
loadStatus();
}
}, 500);
} catch (error) {
binding.value = false;
errorMessage.value = error?.message || '绑定失败';
}
}
/** 解绑当前账号。 */
async function unbindAccount() {
binding.value = true;
clearMessages();
try {
const response = await props.api.post(`${pluginBase.value}/unbind`, {});
if (response?.success) {
successMessage.value = 'OIDC 账号已解绑';
await loadStatus();
} else {
errorMessage.value = response?.message || '解绑失败';
}
} catch (error) {
errorMessage.value = error?.message || '解绑失败';
} finally {
binding.value = false;
}
}
/** 组件挂载时加载状态。 */
onMounted(loadStatus);
/** 组件卸载时清理绑定监听器。 */
onUnmounted(() => {
clearBindPopupTimer();
window.removeEventListener('message', handleBindMessage);
});
return (_ctx, _cache) => {
const _component_VAlert = _resolveComponent("VAlert");
const _component_VCardTitle = _resolveComponent("VCardTitle");
const _component_VCardSubtitle = _resolveComponent("VCardSubtitle");
const _component_VCardItem = _resolveComponent("VCardItem");
const _component_VBtn = _resolveComponent("VBtn");
const _component_VCardText = _resolveComponent("VCardText");
const _component_VCard = _resolveComponent("VCard");
const _component_VSwitch = _resolveComponent("VSwitch");
const _component_VCol = _resolveComponent("VCol");
const _component_VTextField = _resolveComponent("VTextField");
const _component_VRow = _resolveComponent("VRow");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
(errorMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 0,
type: "error",
variant: "tonal",
class: "mb-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(errorMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
(successMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 1,
type: "success",
variant: "tonal",
class: "mb-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(successMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
_createVNode(_component_VCard, {
loading: loading.value,
class: "mb-4"
}, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, null, {
default: _withCtx(() => [
_createVNode(_component_VCardTitle, null, {
default: _withCtx(() => [...(_cache[10] || (_cache[10] = [
_createTextVNode("OIDC 账号绑定", -1)
]))]),
_: 1
}),
(isBound.value)
? (_openBlock(), _createBlock(_component_VCardSubtitle, { key: 0 }, {
default: _withCtx(() => [
_createTextVNode("已绑定 " + _toDisplayString(status.value.binding?.masked_sub), 1)
]),
_: 1
}))
: (_openBlock(), _createBlock(_component_VCardSubtitle, { key: 1 }, {
default: _withCtx(() => [...(_cache[11] || (_cache[11] = [
_createTextVNode("当前账号尚未绑定 OIDC", -1)
]))]),
_: 1
}))
]),
_: 1
}),
_createVNode(_component_VCardText, null, {
default: _withCtx(() => [
(!isBound.value)
? (_openBlock(), _createBlock(_component_VBtn, {
key: 0,
color: "primary",
"prepend-icon": "mdi-openid",
loading: binding.value,
onClick: bindAccount
}, {
default: _withCtx(() => [...(_cache[12] || (_cache[12] = [
_createTextVNode(" 绑定 OIDC 账号 ", -1)
]))]),
_: 1
}, 8, ["loading"]))
: (_openBlock(), _createBlock(_component_VBtn, {
key: 1,
color: "error",
variant: "tonal",
"prepend-icon": "mdi-link-off",
loading: binding.value,
onClick: unbindAccount
}, {
default: _withCtx(() => [...(_cache[13] || (_cache[13] = [
_createTextVNode(" 解绑 OIDC 账号 ", -1)
]))]),
_: 1
}, 8, ["loading"]))
]),
_: 1
})
]),
_: 1
}, 8, ["loading"]),
(isAdmin.value)
? (_openBlock(), _createBlock(_component_VCard, { key: 2 }, {
default: _withCtx(() => [
_createVNode(_component_VCardItem, null, {
default: _withCtx(() => [
_createVNode(_component_VCardTitle, null, {
default: _withCtx(() => [...(_cache[14] || (_cache[14] = [
_createTextVNode("OIDC Provider 配置", -1)
]))]),
_: 1
}),
_createVNode(_component_VCardSubtitle, null, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(status.value.public?.redirect_uri), 1)
]),
_: 1
})
]),
_: 1
}),
_createVNode(_component_VCardText, null, {
default: _withCtx(() => [
_createVNode(_component_VRow, null, {
default: _withCtx(() => [
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VSwitch, {
modelValue: config.value.enabled,
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((config.value.enabled) = $event)),
label: "启用 OIDC 登录",
color: "primary"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.provider_name,
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => ((config.value.provider_name) = $event)),
label: "入口名称",
"prepend-inner-icon": "mdi-openid"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, { cols: "12" }, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.issuer,
"onUpdate:modelValue": _cache[2] || (_cache[2] = $event => ((config.value.issuer) = $event)),
label: "Issuer",
placeholder: "https://idp.example.com",
"prepend-inner-icon": "mdi-web"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.client_id,
"onUpdate:modelValue": _cache[3] || (_cache[3] = $event => ((config.value.client_id) = $event)),
label: "Client ID",
"prepend-inner-icon": "mdi-identifier"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.client_secret,
"onUpdate:modelValue": _cache[4] || (_cache[4] = $event => ((config.value.client_secret) = $event)),
label: "Client Secret",
type: "password",
"prepend-inner-icon": "mdi-key"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.scopes,
"onUpdate:modelValue": _cache[5] || (_cache[5] = $event => ((config.value.scopes) = $event)),
label: "Scopes",
placeholder: "openid profile email",
"prepend-inner-icon": "mdi-format-list-checks"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.redirect_uri,
"onUpdate:modelValue": _cache[6] || (_cache[6] = $event => ((config.value.redirect_uri) = $event)),
label: "回调地址覆盖",
placeholder: "留空自动生成",
"prepend-inner-icon": "mdi-call-made"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.username_claim,
"onUpdate:modelValue": _cache[7] || (_cache[7] = $event => ((config.value.username_claim) = $event)),
label: "用户名 Claim",
"prepend-inner-icon": "mdi-account"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, {
cols: "12",
md: "6"
}, {
default: _withCtx(() => [
_createVNode(_component_VTextField, {
modelValue: config.value.email_claim,
"onUpdate:modelValue": _cache[8] || (_cache[8] = $event => ((config.value.email_claim) = $event)),
label: "邮箱 Claim",
"prepend-inner-icon": "mdi-email"
}, null, 8, ["modelValue"])
]),
_: 1
}),
_createVNode(_component_VCol, { cols: "12" }, {
default: _withCtx(() => [
_createVNode(_component_VSwitch, {
modelValue: config.value.allow_auto_bind_by_username,
"onUpdate:modelValue": _cache[9] || (_cache[9] = $event => ((config.value.allow_auto_bind_by_username) = $event)),
label: "允许按用户名 Claim 自动绑定已有用户",
color: "primary"
}, null, 8, ["modelValue"])
]),
_: 1
})
]),
_: 1
}),
_createElementVNode("div", _hoisted_2, [
_createVNode(_component_VBtn, {
color: "primary",
"prepend-icon": "mdi-content-save",
loading: saving.value,
onClick: saveConfig
}, {
default: _withCtx(() => [...(_cache[15] || (_cache[15] = [
_createTextVNode("保存", -1)
]))]),
_: 1
}, 8, ["loading"]),
_createVNode(_component_VBtn, {
color: "info",
variant: "tonal",
"prepend-icon": "mdi-connection",
loading: testing.value,
onClick: testConnection
}, {
default: _withCtx(() => [...(_cache[16] || (_cache[16] = [
_createTextVNode("测试连接", -1)
]))]),
_: 1
}, 8, ["loading"])
])
]),
_: 1
})
]),
_: 1
}))
: _createCommentVNode("", true)
]))
}
}
};
export { _sfc_main as default };

View File

@@ -0,0 +1,156 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
const {toDisplayString:_toDisplayString,createTextVNode:_createTextVNode,resolveComponent:_resolveComponent,withCtx:_withCtx,openBlock:_openBlock,createBlock:_createBlock,createCommentVNode:_createCommentVNode,createVNode:_createVNode,createElementBlock:_createElementBlock} = await importShared('vue');
const _hoisted_1 = { class: "oidc-auth-page" };
const {computed,onUnmounted,ref} = await importShared('vue');
const _sfc_main = {
__name: 'AuthPage',
props: {
api: {
type: Object,
default: () => ({}),
},
provider: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
},
emits: ['authenticated', 'error', 'close'],
setup(__props, { emit: __emit }) {
const props = __props;
const emit = __emit;
const loading = ref(false);
const errorMessage = ref('');
let popupTimer = null;
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`);
const providerName = computed(() => props.provider?.name || 'OIDC 登录');
/** 拼接 API 路径为可用于 window.open 的 URL。 */
function buildApiUrl(path) {
const base = props.api?.defaults?.baseURL || '/api/v1/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const normalizedPath = String(path || '').replace(/^\/+/, '');
return `${normalizedBase}${normalizedPath}`
}
/** 关闭弹窗轮询并清理状态。 */
function clearPopupTimer() {
if (popupTimer) {
clearInterval(popupTimer);
popupTimer = null;
}
}
/** 处理 OIDC 回调窗口发回的认证消息。 */
function handleOidcMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_callback') return
window.removeEventListener('message', handleOidcMessage);
clearPopupTimer();
loading.value = false;
if (event.data.success && event.data.data?.ticket) {
emit('authenticated', { ticket: event.data.data.ticket });
return
}
const message = event.data?.message || 'OIDC 认证失败';
errorMessage.value = message;
emit('error', { message });
}
/** 发起 OIDC 登录授权弹窗。 */
function startLogin() {
errorMessage.value = '';
loading.value = true;
window.addEventListener('message', handleOidcMessage);
const popup = window.open(
buildApiUrl(`${pluginBase.value}/authorize`),
'moviepilot_oidc_login',
'width=600,height=720,left=200,top=80',
);
if (!popup) {
loading.value = false;
window.removeEventListener('message', handleOidcMessage);
errorMessage.value = '浏览器阻止了认证弹窗';
emit('error', { message: errorMessage.value });
return
}
popupTimer = setInterval(() => {
if (!popup.closed) return
clearPopupTimer();
window.removeEventListener('message', handleOidcMessage);
if (loading.value) {
loading.value = false;
errorMessage.value = '认证窗口已关闭';
emit('error', { message: errorMessage.value });
}
}, 500);
}
/** 组件卸载时清理监听器和定时器。 */
onUnmounted(() => {
clearPopupTimer();
window.removeEventListener('message', handleOidcMessage);
});
return (_ctx, _cache) => {
const _component_VAlert = _resolveComponent("VAlert");
const _component_VBtn = _resolveComponent("VBtn");
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
(errorMessage.value)
? (_openBlock(), _createBlock(_component_VAlert, {
key: 0,
type: "error",
variant: "tonal",
class: "mb-4"
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(errorMessage.value), 1)
]),
_: 1
}))
: _createCommentVNode("", true),
_createVNode(_component_VBtn, {
block: "",
color: "primary",
"prepend-icon": "mdi-openid",
loading: loading.value,
onClick: startLogin
}, {
default: _withCtx(() => [
_createTextVNode(_toDisplayString(providerName.value), 1)
]),
_: 1
}, 8, ["loading"]),
_createVNode(_component_VBtn, {
block: "",
variant: "text",
class: "mt-2",
onClick: _cache[0] || (_cache[0] = $event => (emit('close')))
}, {
default: _withCtx(() => [...(_cache[1] || (_cache[1] = [
_createTextVNode("取消", -1)
]))]),
_: 1
})
]))
}
}
};
export { _sfc_main as default };

View 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 };

View File

@@ -0,0 +1,44 @@
import { importShared } from './__federation_fn_import-JrT3xvdd.js';
import _sfc_main from './__federation_expose_AppPage-CiS2QwqR.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');

View File

@@ -0,0 +1,90 @@
const currentImports = {};
const exportSet = new Set(['Module', '__esModule', 'default', '_export_sfc']);
let moduleMap = {
"./AuthPage":()=>{
dynamicLoadingCss([], false, './AuthPage');
return __federation_import('./__federation_expose_AuthPage-BlxZvRi5.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./AppPage":()=>{
dynamicLoadingCss([], false, './AppPage');
return __federation_import('./__federation_expose_AppPage-CiS2QwqR.js').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Page":()=>{
dynamicLoadingCss([], false, './Page');
return __federation_import('${__federation_expose_./Page}').then(module =>Object.keys(module).every(item => exportSet.has(item)) ? () => module.default : () => module)},
"./Config":()=>{
dynamicLoadingCss([], false, './Config');
return __federation_import('${__federation_expose_./Config}').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__OidcAuth__' + 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 };

4
plugins.v2/oidcauth/dist/index.html vendored Normal file
View File

@@ -0,0 +1,4 @@
<script type="module" crossorigin src="/assets/index-CKX1jWaN.js"></script>
<link rel="modulepreload" crossorigin href="/assets/__federation_fn_import-JrT3xvdd.js">
<link rel="modulepreload" crossorigin href="/assets/__federation_expose_AppPage-CiS2QwqR.js">
<div id="app"></div>

View File

@@ -0,0 +1,2 @@
<div id="app"></div>
<script type="module" src="/src/main.js"></script>

View File

@@ -0,0 +1,17 @@
{
"name": "moviepilot-oidcauth-plugin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.4.1",
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.4.11"
}
}

View File

@@ -0,0 +1,262 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
})
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const binding = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const status = ref({
public: {},
binding: {},
config: null,
is_superuser: false,
})
const config = ref({
enabled: false,
provider_name: 'OIDC 登录',
issuer: '',
client_id: '',
client_secret: '',
scopes: 'openid profile email',
redirect_uri: '',
username_claim: 'preferred_username',
email_claim: 'email',
allow_auto_bind_by_username: false,
})
let bindPopupTimer = null
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`)
const isAdmin = computed(() => Boolean(status.value.is_superuser))
const isBound = computed(() => Boolean(status.value.binding?.bound))
/** 从 API 响应中解出 data 字段。 */
function unwrap(response) {
if (response && Object.prototype.hasOwnProperty.call(response, 'data')) {
return response.data
}
return response
}
/** 清理提示信息。 */
function clearMessages() {
errorMessage.value = ''
successMessage.value = ''
}
/** 从服务端加载插件状态、配置和绑定信息。 */
async function loadStatus() {
loading.value = true
clearMessages()
try {
const response = await props.api.get(`${pluginBase.value}/status`)
status.value = unwrap(response) || status.value
if (status.value.config) {
config.value = { ...config.value, ...status.value.config }
}
} catch (error) {
errorMessage.value = error?.message || '加载失败'
} finally {
loading.value = false
}
}
/** 保存管理员配置。 */
async function saveConfig() {
saving.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/config`, config.value)
const data = unwrap(response) || {}
if (data.config) {
config.value = { ...config.value, ...data.config }
}
successMessage.value = '配置已保存'
await loadStatus()
} catch (error) {
errorMessage.value = error?.message || '保存失败'
} finally {
saving.value = false
}
}
/** 测试 OIDC Provider 发现文档。 */
async function testConnection() {
testing.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/test`, config.value)
if (response?.success) {
successMessage.value = response.message || '连接正常'
} else {
errorMessage.value = response?.message || '连接失败'
}
} catch (error) {
errorMessage.value = error?.message || '连接失败'
} finally {
testing.value = false
}
}
/** 清理绑定弹窗轮询。 */
function clearBindPopupTimer() {
if (bindPopupTimer) {
clearInterval(bindPopupTimer)
bindPopupTimer = null
}
}
/** 处理绑定回调消息。 */
function handleBindMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_bind_callback') return
window.removeEventListener('message', handleBindMessage)
clearBindPopupTimer()
binding.value = false
if (event.data.success) {
successMessage.value = 'OIDC 账号已绑定'
loadStatus()
return
}
errorMessage.value = event.data?.message || '绑定失败'
}
/** 发起账号绑定。 */
async function bindAccount() {
binding.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/bind/start`, {})
const authorizeUrl = response?.data?.authorize_url
if (!response?.success || !authorizeUrl) {
throw new Error(response?.message || '无法发起绑定')
}
window.addEventListener('message', handleBindMessage)
const popup = window.open(authorizeUrl, 'moviepilot_oidc_bind', 'width=600,height=720,left=200,top=80')
if (!popup) {
window.removeEventListener('message', handleBindMessage)
throw new Error('浏览器阻止了认证弹窗')
}
bindPopupTimer = setInterval(() => {
if (!popup.closed) return
clearBindPopupTimer()
window.removeEventListener('message', handleBindMessage)
if (binding.value) {
binding.value = false
loadStatus()
}
}, 500)
} catch (error) {
binding.value = false
errorMessage.value = error?.message || '绑定失败'
}
}
/** 解绑当前账号。 */
async function unbindAccount() {
binding.value = true
clearMessages()
try {
const response = await props.api.post(`${pluginBase.value}/unbind`, {})
if (response?.success) {
successMessage.value = 'OIDC 账号已解绑'
await loadStatus()
} else {
errorMessage.value = response?.message || '解绑失败'
}
} catch (error) {
errorMessage.value = error?.message || '解绑失败'
} finally {
binding.value = false
}
}
/** 组件挂载时加载状态。 */
onMounted(loadStatus)
/** 组件卸载时清理绑定监听器。 */
onUnmounted(() => {
clearBindPopupTimer()
window.removeEventListener('message', handleBindMessage)
})
</script>
<template>
<div class="oidc-auth-admin pa-4">
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mb-4">{{ errorMessage }}</VAlert>
<VAlert v-if="successMessage" type="success" variant="tonal" class="mb-4">{{ successMessage }}</VAlert>
<VCard :loading="loading" class="mb-4">
<VCardItem>
<VCardTitle>OIDC 账号绑定</VCardTitle>
<VCardSubtitle v-if="isBound">已绑定 {{ status.binding?.masked_sub }}</VCardSubtitle>
<VCardSubtitle v-else>当前账号尚未绑定 OIDC</VCardSubtitle>
</VCardItem>
<VCardText>
<VBtn v-if="!isBound" color="primary" prepend-icon="mdi-openid" :loading="binding" @click="bindAccount">
绑定 OIDC 账号
</VBtn>
<VBtn v-else color="error" variant="tonal" prepend-icon="mdi-link-off" :loading="binding" @click="unbindAccount">
解绑 OIDC 账号
</VBtn>
</VCardText>
</VCard>
<VCard v-if="isAdmin">
<VCardItem>
<VCardTitle>OIDC Provider 配置</VCardTitle>
<VCardSubtitle>{{ status.public?.redirect_uri }}</VCardSubtitle>
</VCardItem>
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="config.enabled" label="启用 OIDC 登录" color="primary" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.provider_name" label="入口名称" prepend-inner-icon="mdi-openid" />
</VCol>
<VCol cols="12">
<VTextField v-model="config.issuer" label="Issuer" placeholder="https://idp.example.com" prepend-inner-icon="mdi-web" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.client_id" label="Client ID" prepend-inner-icon="mdi-identifier" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.client_secret" label="Client Secret" type="password" prepend-inner-icon="mdi-key" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.scopes" label="Scopes" placeholder="openid profile email" prepend-inner-icon="mdi-format-list-checks" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.redirect_uri" label="回调地址覆盖" placeholder="留空自动生成" prepend-inner-icon="mdi-call-made" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.username_claim" label="用户名 Claim" prepend-inner-icon="mdi-account" />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="config.email_claim" label="邮箱 Claim" prepend-inner-icon="mdi-email" />
</VCol>
<VCol cols="12">
<VSwitch v-model="config.allow_auto_bind_by_username" label="允许按用户名 Claim 自动绑定已有用户" color="primary" />
</VCol>
</VRow>
<div class="d-flex flex-wrap gap-3 mt-2">
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="saveConfig">保存</VBtn>
<VBtn color="info" variant="tonal" prepend-icon="mdi-connection" :loading="testing" @click="testConnection">测试连接</VBtn>
</div>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,106 @@
<script setup>
import { computed, onUnmounted, ref } from 'vue'
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
provider: {
type: Object,
default: () => ({}),
},
pluginId: {
type: String,
default: 'OidcAuth',
},
})
const emit = defineEmits(['authenticated', 'error', 'close'])
const loading = ref(false)
const errorMessage = ref('')
let popupTimer = null
const pluginBase = computed(() => `plugin/${props.pluginId || 'OidcAuth'}`)
const providerName = computed(() => props.provider?.name || 'OIDC 登录')
/** 拼接 API 路径为可用于 window.open 的 URL。 */
function buildApiUrl(path) {
const base = props.api?.defaults?.baseURL || '/api/v1/'
const normalizedBase = base.endsWith('/') ? base : `${base}/`
const normalizedPath = String(path || '').replace(/^\/+/, '')
return `${normalizedBase}${normalizedPath}`
}
/** 关闭弹窗轮询并清理状态。 */
function clearPopupTimer() {
if (popupTimer) {
clearInterval(popupTimer)
popupTimer = null
}
}
/** 处理 OIDC 回调窗口发回的认证消息。 */
function handleOidcMessage(event) {
if (event.origin !== window.location.origin) return
if (event.data?.type !== 'oidcauth_callback') return
window.removeEventListener('message', handleOidcMessage)
clearPopupTimer()
loading.value = false
if (event.data.success && event.data.data?.ticket) {
emit('authenticated', { ticket: event.data.data.ticket })
return
}
const message = event.data?.message || 'OIDC 认证失败'
errorMessage.value = message
emit('error', { message })
}
/** 发起 OIDC 登录授权弹窗。 */
function startLogin() {
errorMessage.value = ''
loading.value = true
window.addEventListener('message', handleOidcMessage)
const popup = window.open(
buildApiUrl(`${pluginBase.value}/authorize`),
'moviepilot_oidc_login',
'width=600,height=720,left=200,top=80',
)
if (!popup) {
loading.value = false
window.removeEventListener('message', handleOidcMessage)
errorMessage.value = '浏览器阻止了认证弹窗'
emit('error', { message: errorMessage.value })
return
}
popupTimer = setInterval(() => {
if (!popup.closed) return
clearPopupTimer()
window.removeEventListener('message', handleOidcMessage)
if (loading.value) {
loading.value = false
errorMessage.value = '认证窗口已关闭'
emit('error', { message: errorMessage.value })
}
}, 500)
}
/** 组件卸载时清理监听器和定时器。 */
onUnmounted(() => {
clearPopupTimer()
window.removeEventListener('message', handleOidcMessage)
})
</script>
<template>
<div class="oidc-auth-page">
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mb-4">
{{ errorMessage }}
</VAlert>
<VBtn block color="primary" prepend-icon="mdi-openid" :loading="loading" @click="startLogin">
{{ providerName }}
</VBtn>
<VBtn block variant="text" class="mt-2" @click="emit('close')">取消</VBtn>
</div>
</template>

View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import AppPage from './components/AppPage.vue'
createApp(AppPage).mount('#app')

View File

@@ -0,0 +1,47 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'OidcAuth',
filename: 'remoteEntry.js',
exposes: {
'./AuthPage': './src/components/AuthPage.vue',
'./AppPage': './src/components/AppPage.vue',
'./Page': './src/components/AppPage.vue',
'./Config': './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()
}
},
},
},
],
},
},
})

View File

@@ -50,6 +50,36 @@
- `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
## 二期规划
@@ -78,6 +108,8 @@
- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队
- 已支持失败样本批量复查:可批量重跑并按结果批量出队
- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库
- 已支持 LLM 错误诊断记录独立存储,避免污染可处理样本池
- 已支持样本来源标注,便于区分路径样本与仅标题样本
- 已支持低 token 精简摘要输出,适合作为智能体批处理入口
- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地
- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升

View File

@@ -45,18 +45,22 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- 用当前 LLM 结构化判断标题、年份、类型、季集
- 回写 `name / year / season / episode`
- 交回 MoviePilot 原生链路继续二次识别
- 保存低置信度失败样本
- 保存低置信度失败样本(可处理)
- 保存 LLM 调用错误诊断记录(独立存储,不污染可处理样本池)
- 失败样本和 LLM 诊断记录附带来源标注(`sample_source_kind` / `sample_source_plugin`
- 可配置是否保存仅标题样本(无真实文件路径),默认关闭以减少噪音
- 提供失败样本工作清单、洞察、重放、删除和清空能力
- 生成并应用 `CustomIdentifiers` 建议
- 设置页提供“保存时清空失败样本(一次性)”开关,可在保存配置时顺手重置失败样本池
## 主要接口
- `GET /api/v1/plugin/AIRecognizerEnhancer/health`
- 查看插件状态、LLM 提供方、模型、阈值和超时配置
- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize`
- 对单个标题做一次本地结构化识别测试
### 可处理失败样本接口
这些接口只返回因置信度不足或名称为空而落盘的识别失败记录,可用于生成识别词建议、复查和出队。
- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples`
- 查看最近保存的失败样本
- 查看最近保存的可处理失败样本
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist`
- 返回适合继续处理的失败样本摘要列表
- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights`
@@ -68,12 +72,23 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
- `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`
@@ -81,9 +96,9 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次
## 已验证情况
当前版本:`0.1.12`
当前版本:`0.1.13`
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68
当前 Releasehttps://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.73
这版已经验证过:

View File

@@ -53,7 +53,7 @@ class AIRecognizerEnhancer(_PluginBase):
plugin_name = "AI识别增强"
plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。"
plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png"
plugin_version = "0.1.12"
plugin_version = "0.1.13"
plugin_author = "liuyuexi1987"
plugin_level = 1
author_url = "https://github.com/liuyuexi1987"
@@ -67,8 +67,10 @@ class AIRecognizerEnhancer(_PluginBase):
_request_timeout = 25
_max_retries = 2
_save_failed_samples = True
_save_title_only_samples = False
_max_failed_samples = 200
_auto_remove_applied_sample = True
_clear_failed_samples_once = False
_systemconfig: Optional[SystemConfigOper] = None
def init_plugin(self, config: Optional[Dict[str, Any]] = None):
@@ -79,10 +81,17 @@ class AIRecognizerEnhancer(_PluginBase):
self._request_timeout = self._safe_int(config.get("request_timeout"), 25)
self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2)))
self._save_failed_samples = bool(config.get("save_failed_samples", True))
self._save_title_only_samples = bool(config.get("save_title_only_samples", False))
self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200)))
self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True))
self._clear_failed_samples_once = bool(config.get("clear_failed_samples_once", False))
self._systemconfig = SystemConfigOper()
self._register_events()
if self._clear_failed_samples_once:
cleared = self._clear_failed_samples()
self._clear_failed_samples_once = False
self.update_config(self._build_config({"clear_failed_samples_once": False}))
logger.info(f"[AI识别增强] 已按配置清空失败样本 {cleared}")
def get_state(self) -> bool:
return self._enabled
@@ -117,11 +126,28 @@ class AIRecognizerEnhancer(_PluginBase):
if header.lower().startswith("bearer "):
return header.split(" ", 1)[1].strip()
if body:
for key in ("apikey", "api_key"):
for key in ("apikey", "api_key", "token"):
token = str(body.get(key) or "").strip()
if token:
return token
return str(request.query_params.get("apikey") or "").strip()
return str(request.query_params.get("apikey") or request.query_params.get("token") or "").strip()
def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = {
"enabled": self._enabled,
"debug": self._debug,
"confidence_threshold": self._confidence_threshold,
"request_timeout": self._request_timeout,
"max_retries": self._max_retries,
"save_failed_samples": self._save_failed_samples,
"save_title_only_samples": self._save_title_only_samples,
"max_failed_samples": self._max_failed_samples,
"auto_remove_applied_sample": self._auto_remove_applied_sample,
"clear_failed_samples_once": self._clear_failed_samples_once,
}
if overrides:
config.update(overrides)
return config
def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]:
expected = str(getattr(settings, "API_TOKEN", "") or "").strip()
@@ -174,6 +200,30 @@ class AIRecognizerEnhancer(_PluginBase):
)
return str(title or "").strip(), str(path or "").strip()
@staticmethod
def _extract_provenance(event_data: Any) -> Dict[str, str]:
"""Extract lightweight provenance metadata from event data for sample recording."""
source_plugin = ""
if isinstance(event_data, dict):
source_plugin = str(event_data.get("source_plugin") or "").strip()
else:
source_plugin = str(getattr(event_data, "source_plugin", "") or "").strip()
title = ""
path = ""
if isinstance(event_data, dict):
title = str(event_data.get("title") or event_data.get("name") or event_data.get("org_string") or "").strip()
path = str(event_data.get("path") or event_data.get("file_path") or event_data.get("org_string") or "").strip()
else:
title = str(getattr(event_data, "title", "") or getattr(event_data, "name", "") or getattr(event_data, "org_string", "") or "").strip()
path = str(getattr(event_data, "path", "") or getattr(event_data, "file_path", "") or getattr(event_data, "org_string", "") or "").strip()
is_path_backed = bool(path) and path != title and ("/" in path or "\\" in path)
return {
"sample_source_kind": "path_backed" if is_path_backed else "title_only",
"sample_source_plugin": source_plugin,
}
def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]:
try:
meta = MetaInfo(raw_text)
@@ -221,6 +271,12 @@ class AIRecognizerEnhancer(_PluginBase):
def _sample_path(self) -> Path:
return self.get_data_path() / "failed_samples.jsonl"
def _llm_errors_path(self) -> Path:
return self.get_data_path() / "llm_errors.jsonl"
def _failed_sample_cap(self) -> int:
return max(20, min(1000, self._safe_int(self._max_failed_samples, 200)))
@staticmethod
def _sample_identity(payload: Dict[str, Any]) -> str:
return json.dumps(
@@ -236,7 +292,8 @@ class AIRecognizerEnhancer(_PluginBase):
def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None:
sample_path = self._sample_path()
sample_path.parent.mkdir(parents=True, exist_ok=True)
trimmed = rows[-self._max_failed_samples:]
filtered = [row for row in rows if not str(row.get("reason") or "").startswith("llm_error:")]
trimmed = filtered[-self._failed_sample_cap():]
with sample_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
@@ -254,6 +311,69 @@ class AIRecognizerEnhancer(_PluginBase):
except Exception as exc:
logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}")
def _record_llm_error(self, title: str, path: str, meta_hint: Dict[str, Any], error: Any, provenance: Optional[Dict[str, str]] = None) -> None:
try:
error_path = self._llm_errors_path()
error_path.parent.mkdir(parents=True, exist_ok=True)
provenance = provenance or {}
entry = {
"title": title,
"path": path,
"meta_hint": meta_hint,
"reason": f"llm_error:{error}",
"timestamp": __import__("datetime").datetime.now().isoformat(),
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
existing = self._read_llm_errors(limit=1000)
existing.reverse()
new_identity = {"title": title, "path": path, "reason": entry["reason"]}
existing = [
row for row in existing
if {
"title": row.get("title"),
"path": row.get("path"),
"reason": row.get("reason"),
} != new_identity
]
existing.append(entry)
trimmed = existing[-self._failed_sample_cap():]
with error_path.open("w", encoding="utf-8") as f:
for row in trimmed:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
except Exception as exc:
logger.warning(f"[AI识别增强] 写入 LLM 错误诊断记录失败: {exc}")
def _read_llm_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
error_path = self._llm_errors_path()
if not error_path.exists():
return []
rows: List[Dict[str, Any]] = []
try:
with error_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except Exception:
continue
except Exception as exc:
logger.warning(f"[AI识别增强] 读取 LLM 错误诊断记录失败: {exc}")
return []
if limit > 0:
rows = rows[-limit:]
rows.reverse()
return rows
def _clear_llm_errors(self) -> int:
rows = self._read_llm_errors(limit=10000)
error_path = self._llm_errors_path()
if error_path.exists():
error_path.unlink()
return len(rows)
def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]:
sample_path = self._sample_path()
if not sample_path.exists():
@@ -353,7 +473,7 @@ class AIRecognizerEnhancer(_PluginBase):
sample_index: Optional[Any] = None,
limit: int = 100,
) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]:
samples = self._read_failed_samples(limit=max(1, min(limit, 200)))
samples = self._read_failed_samples(limit=max(1, min(limit, self._failed_sample_cap())))
if not samples:
return None, None, "暂无失败样本"
index = self._safe_int(sample_index, 0)
@@ -369,9 +489,13 @@ class AIRecognizerEnhancer(_PluginBase):
self,
sample_indexes: Optional[List[Any]] = None,
limit: int = 10,
pool_limit: int = 200,
pool_limit: int = 0,
) -> Tuple[List[int], List[Dict[str, Any]], str]:
current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000))))
if pool_limit <= 0:
pool_limit = self._failed_sample_cap()
current_samples = self._inject_sample_indices(
self._read_failed_samples(limit=max(1, min(pool_limit, self._failed_sample_cap())))
)
if not current_samples:
return [], [], "暂无失败样本"
if isinstance(sample_indexes, list) and sample_indexes:
@@ -414,6 +538,8 @@ class AIRecognizerEnhancer(_PluginBase):
"title": sample.get("title"),
"path": sample.get("path"),
"reason": sample.get("reason"),
"sample_source_kind": sample.get("sample_source_kind", ""),
"sample_source_plugin": sample.get("sample_source_plugin", ""),
"guess_name": guess.get("name"),
"guess_confidence": self._safe_float(guess.get("confidence"), 0.0),
"verified_title": verified.get("title"),
@@ -551,7 +677,10 @@ class AIRecognizerEnhancer(_PluginBase):
label = self._sample_display_name(summary)
confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2)
can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工"
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}")
source_tag = "有路径" if summary.get("sample_source_kind") == "path_backed" else "仅标题"
source_plugin = summary.get("sample_source_plugin") or ""
source_info = f" | {source_tag}" + (f" ({source_plugin})" if source_plugin else "")
lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}{source_info}")
lines.append("下一步:可直接调用批量建议或批量复查接口。")
return "\n".join(lines)
@@ -937,7 +1066,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1006,7 +1135,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1102,7 +1231,7 @@ AI 识别增强结果:
selected_indexes, _, message = self._select_failed_sample_indexes(
sample_indexes=body.get("sample_indexes"),
limit=limit,
pool_limit=200,
pool_limit=self._failed_sample_cap(),
)
if not selected_indexes:
return {"success": False, "message": message}
@@ -1356,40 +1485,49 @@ AI 识别增强结果:
logger.warning(f"[AI识别增强] 二次校验失败: {exc}")
return None
def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]:
def _recognize(
self, title: str, path: str = "", record_failed_sample: bool = True,
provenance: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
title = str(title or "").strip()
path = str(path or "").strip()
if not title and path:
title = Path(path).name
if not title:
return {"success": False, "message": "标题为空"}
provenance = provenance or {}
sample_source_kind = provenance.get("sample_source_kind")
is_title_only = sample_source_kind == "title_only" if sample_source_kind else not path
try:
guess = self._invoke_llm(title, path)
except Exception as exc:
if record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"reason": f"llm_error:{exc}",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题 LLM 错误: {title} (save_title_only_samples=False)")
else:
self._record_llm_error(title, path, self._build_meta_hint(path or title), exc, provenance=provenance)
return {"success": False, "message": f"LLM 调用失败: {exc}"}
verified = self._verify_guess(title, path, guess)
passed = bool(guess.name and guess.confidence >= self._confidence_threshold)
if not passed and record_failed_sample:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
}
)
if is_title_only and not self._save_title_only_samples:
if self._debug:
logger.info(f"[AI识别增强] 跳过保存仅标题样本: {title} (save_title_only_samples=False)")
else:
self._record_failed_sample(
{
"title": title,
"path": path,
"meta_hint": self._build_meta_hint(path or title),
"guess": guess.model_dump(),
"verified_media_info": self._compact_verified_summary(verified),
"reason": "low_confidence_or_empty_name",
"sample_source_kind": provenance.get("sample_source_kind", "unknown"),
"sample_source_plugin": provenance.get("sample_source_plugin", ""),
}
)
return {
"success": passed,
"message": "success" if passed else "识别结果置信度不足,已放弃注入",
@@ -1404,7 +1542,8 @@ AI 识别增强结果:
title, path = self._extract_title_path(event_data)
if not title and not path:
return
result = self._recognize(title=title, path=path)
provenance = self._extract_provenance(event_data)
result = self._recognize(title=title, path=path, provenance=provenance)
if not result.get("success"):
if self._debug:
logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}")
@@ -1496,7 +1635,7 @@ AI 识别增强结果:
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 50)
limit = max(1, min(limit, 200))
limit = max(1, min(limit, self._failed_sample_cap()))
top = self._safe_int(request.query_params.get("top"), 10)
top = max(1, min(top, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=limit))
@@ -1512,7 +1651,7 @@ AI 识别增强结果:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 5)
limit = max(1, min(limit, 20))
samples = self._inject_sample_indices(self._read_failed_samples(limit=100))
samples = self._inject_sample_indices(self._read_failed_samples(limit=self._failed_sample_cap()))
return {
"success": True,
"data": {
@@ -1558,6 +1697,34 @@ AI 识别增强结果:
},
}
async def api_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
limit = self._safe_int(request.query_params.get("limit"), 20)
limit = max(1, min(limit, 100))
errors = self._read_llm_errors(limit=limit)
return {
"success": True,
"data": {
"count": len(errors),
"errors": errors,
},
}
async def api_clear_llm_errors(self, request: Request):
ok, message = self._check_api_access(request)
if not ok:
return {"success": False, "message": message}
cleared = self._clear_llm_errors()
return {
"success": True,
"message": "success",
"data": {
"cleared_count": cleared,
},
}
async def api_remove_failed_sample(self, request: Request):
body = await request.json()
ok, message = self._check_api_access(request, body)
@@ -1697,6 +1864,18 @@ AI 识别增强结果:
"methods": ["POST"],
"summary": "清空失败样本文件",
},
{
"path": "/llm_errors",
"endpoint": self.api_llm_errors,
"methods": ["GET"],
"summary": "查看 LLM 调用失败的诊断记录",
},
{
"path": "/clear_llm_errors",
"endpoint": self.api_clear_llm_errors,
"methods": ["POST"],
"summary": "清空 LLM 错误诊断记录",
},
{
"path": "/remove_failed_sample",
"endpoint": self.api_remove_failed_sample,
@@ -1731,7 +1910,8 @@ AI 识别增强结果:
def get_page(self) -> List[dict]:
llm_ready = bool(getattr(settings, "LLM_API_KEY", None))
failed_samples_count = len(self._read_failed_samples(limit=200))
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
llm_errors_count = len(self._read_llm_errors(limit=self._max_failed_samples))
custom_identifiers_count = len(self._get_custom_identifiers())
llm_provider = getattr(settings, "LLM_PROVIDER", "")
llm_model = getattr(settings, "LLM_MODEL", "")
@@ -1784,22 +1964,27 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 可用", "" if llm_ready else "", f"{llm_provider} / {llm_model}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"content": [stat_card("失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("可处理失败样本", f"{failed_samples_count}", f"上限 {self._max_failed_samples}")],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 3},
"props": {"cols": 12, "sm": 6, "md": 2},
"content": [stat_card("LLM 错误", f"{llm_errors_count}", "诊断记录")],
},
{
"component": "VCol",
"props": {"cols": 12, "sm": 6, "md": 3},
"content": [stat_card("自定义识别词", f"{custom_identifiers_count}", "系统 CustomIdentifiers")],
},
],
@@ -1810,34 +1995,7 @@ AI 识别增强结果:
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VCard",
"props": {"variant": "outlined", "class": "pa-4 h-100"},
"content": [
{
"component": "div",
"props": {"class": "text-subtitle-1 font-weight-bold mb-2"},
"text": "识别兜底",
},
{
"component": "div",
"props": {"class": "text-body-2 text-medium-emphasis"},
"text": "在 Chain NameRecognize 阶段回写 name / year / season / episode供 MoviePilot 继续原生二次识别。",
},
{
"component": "div",
"props": {"class": "text-caption text-medium-emphasis mt-3"},
"text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout}",
},
],
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"props": {"cols": 12, "md": 12},
"content": [
{
"component": "VCard",
@@ -1873,6 +2031,7 @@ AI 识别增强结果:
return "vuetify", None
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
failed_samples_count = len(self._read_failed_samples(limit=self._failed_sample_cap()))
form = [
{
"component": "VForm",
@@ -1896,6 +2055,25 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VAlert",
"props": {
"type": "warning",
"variant": "tonal",
"text": f"当前累计 {failed_samples_count} 条失败样本。如需重置噪音数据,请勾选下方“一次性清空”开关后点击保存。该操作只清空失败样本,不会删除已写入的 CustomIdentifiers。",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -1929,6 +2107,19 @@ AI 识别增强结果:
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "save_title_only_samples",
"label": "保存仅标题样本",
},
}
],
},
],
},
{
@@ -2010,6 +2201,24 @@ AI 识别增强结果:
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VSwitch",
"props": {
"model": "clear_failed_samples_once",
"label": "保存时清空失败样本(一次性)",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
@@ -2038,6 +2247,8 @@ AI 识别增强结果:
"request_timeout": 25,
"max_retries": 2,
"save_failed_samples": True,
"save_title_only_samples": False,
"max_failed_samples": 200,
"auto_remove_applied_sample": True,
"clear_failed_samples_once": False,
}

View File

@@ -20,7 +20,7 @@ class MPServerStatus(_PluginBase):
# 插件图标
plugin_icon = "Duplicati_A.png"
# 插件版本
plugin_version = "1.3"
plugin_version = "1.4"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
@@ -114,8 +114,20 @@ class MPServerStatus(_PluginBase):
}
}
]
_, _, elements = self.get_dashboard()
return elements
metrics, probe, dns_info, tls_info, error_message = self._load_status_snapshot()
if error_message or not metrics:
return self._build_unavailable_elements(
probe=probe,
dns_info=dns_info,
tls_info=tls_info,
message=error_message or "无法连接服务器"
)
return self._build_status_elements(
metrics=metrics,
probe=probe,
dns_info=dns_info,
tls_info=tls_info
)
def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
"""
@@ -123,43 +135,20 @@ class MPServerStatus(_PluginBase):
"""
cols = {
"cols": 12,
"md": 10
"md": 8
}
attrs = {
"refresh": 10
}
response, seconds, request_error = self._request_server_status()
probe = self._build_http_probe(response=response, seconds=seconds, request_error=request_error)
dns_info = self._get_dns_info()
tls_info = self._get_tls_info()
if request_error or not response:
elements = self._build_unavailable_elements(
metrics, probe, _, _, error_message = self._load_status_snapshot(include_network_details=False)
if error_message or not metrics:
elements = self._build_dashboard_unavailable_elements(
probe=probe,
dns_info=dns_info,
tls_info=tls_info,
message=request_error or "无法连接服务器"
)
return cols, attrs, elements
try:
status = self._parse_status_text(response.text)
metrics = self._build_metrics(status=status, seconds=seconds)
elements = self._build_status_elements(
metrics=metrics,
probe=probe,
dns_info=dns_info,
tls_info=tls_info
)
except Exception as err:
logger.warn(f"解析服务器状态失败:{err}")
elements = self._build_unavailable_elements(
probe=probe,
dns_info=dns_info,
tls_info=tls_info,
message=f"服务器状态格式异常:{err}"
message=error_message or "无法连接服务器"
)
else:
elements = self._build_dashboard_elements(metrics=metrics, probe=probe)
return cols, attrs, elements
def get_state(self) -> bool:
@@ -174,6 +163,29 @@ class MPServerStatus(_PluginBase):
"""
pass
def _load_status_snapshot(
self,
include_network_details: bool = True
) -> Tuple[Optional[Dict[str, Any]], Dict[str, Any], Dict[str, Any], Dict[str, Any], Optional[str]]:
"""
拉取服务状态并按页面需要整理为统一快照。
"""
response, seconds, request_error = self._request_server_status()
probe = self._build_http_probe(response=response, seconds=seconds, request_error=request_error)
dns_info = self._get_dns_info() if include_network_details else {}
tls_info = self._get_tls_info() if include_network_details else {}
if request_error or not response:
return None, probe, dns_info, tls_info, request_error or "无法连接服务器"
try:
status = self._parse_status_text(response.text)
metrics = self._build_metrics(status=status, seconds=seconds)
return metrics, probe, dns_info, tls_info, None
except Exception as err:
logger.warn(f"解析服务器状态失败:{err}")
return None, probe, dns_info, tls_info, f"服务器状态格式异常:{err}"
def _request_server_status(self) -> Tuple[Optional[Any], float, Optional[str]]:
"""
请求 MoviePilot 公开状态接口并返回响应、耗时和错误信息。
@@ -431,6 +443,98 @@ class MPServerStatus(_PluginBase):
return value
return "-"
def _build_dashboard_elements(self, metrics: Dict[str, Any], probe: Dict[str, Any]) -> List[dict]:
"""
拼装状态正常时的轻量仪表板元素。
"""
return [
self._build_summary_alert(probe=probe, message="服务状态正常", alert_type="success"),
{
'component': 'VRow',
'content': self._build_dashboard_cards(metrics=metrics, probe=probe)
}
]
def _build_dashboard_unavailable_elements(self, probe: Dict[str, Any], message: str) -> List[dict]:
"""
拼装状态不可用时的轻量仪表板元素。
"""
return [
self._build_summary_alert(probe=probe, message=message, alert_type="error"),
{
'component': 'VRow',
'content': self._build_dashboard_cards(metrics=None, probe=probe)
}
]
def _build_dashboard_cards(self, metrics: Optional[Dict[str, Any]], probe: Dict[str, Any]) -> List[dict]:
"""
构建仪表板需要展示的核心监控卡片。
"""
sample_caption = self._format_sample_caption(metrics.get("sample_seconds")) if metrics else "暂无采样"
return [
self._build_stat_card(
"响应延迟",
self._format_seconds(probe.get("seconds")),
"mdi-speedometer",
self._latency_color(probe.get("seconds"), probe.get("ok")),
f"HTTP {probe.get('status_code') or '-'}",
cols=12,
sm=6,
md=4
),
self._build_stat_card(
"活跃连接",
self._format_integer(metrics.get("active_connections")) if metrics else "-",
"mdi-lan-connect",
"primary",
f"忙碌 {self._format_percent(metrics.get('busy_percent'))}" if metrics else "暂无连接数据",
cols=12,
sm=6,
md=4
),
self._build_stat_card(
"等待连接",
self._format_integer(metrics.get("waiting")) if metrics else "-",
"mdi-timer-sand",
"warning",
f"{self._format_percent(metrics.get('waiting_percent'))} 空闲" if metrics else "暂无连接数据",
cols=12,
sm=6,
md=4
),
self._build_stat_card(
"处理中连接",
self._format_integer(metrics.get("busy")) if metrics else "-",
"mdi-swap-horizontal",
"warning",
f"{metrics.get('reading', 0)} / 写 {metrics.get('writing', 0)}" if metrics else "暂无连接数据",
cols=12,
sm=6,
md=4
),
self._build_stat_card(
"请求速率",
self._format_rate(metrics.get("requests_rate"), "次/秒") if metrics else "-",
"mdi-chart-timeline-variant",
"primary",
sample_caption,
cols=12,
sm=6,
md=4
),
self._build_stat_card(
"连接速度",
self._format_rate(metrics.get("accepts_rate"), "个/秒") if metrics else "-",
"mdi-connection",
"info",
sample_caption,
cols=12,
sm=6,
md=4
),
]
def _build_status_elements(
self,
metrics: Dict[str, Any],
@@ -439,7 +543,7 @@ class MPServerStatus(_PluginBase):
tls_info: Dict[str, Any]
) -> List[dict]:
"""
拼装状态正常时的仪表盘元素。
拼装状态正常时的详情页元素。
"""
cards = [
self._build_stat_card("HTTP状态", str(probe.get("status_code") or "-"), "mdi-web-check", "success",
@@ -516,7 +620,7 @@ class MPServerStatus(_PluginBase):
message: str
) -> List[dict]:
"""
拼装状态不可用或解析失败时的仪表盘元素。
拼装状态不可用或解析失败时的详情页元素。
"""
return [
self._build_summary_alert(probe=probe, message=message, alert_type="error"),
@@ -568,21 +672,34 @@ class MPServerStatus(_PluginBase):
}
@staticmethod
def _build_stat_card(label: str, value: str, icon: str, color: str, subtitle: str = "") -> dict:
def _build_stat_card(
label: str,
value: str,
icon: str,
color: str,
subtitle: str = "",
cols: int = 6,
sm: Optional[int] = None,
md: int = 3
) -> dict:
"""
构建单个统计指标卡片。
"""
col_props = {
'cols': cols,
'md': md
}
if sm:
col_props['sm'] = sm
return {
'component': 'VCol',
'props': {
'cols': 6,
'md': 3
},
'props': col_props,
'content': [
{
'component': 'VCard',
'props': {
'variant': 'tonal',
'class': 'h-100',
},
'content': [
{
@@ -868,6 +985,21 @@ class MPServerStatus(_PluginBase):
return "warning"
return "success"
@staticmethod
def _latency_color(value: Optional[float], ok: bool) -> str:
"""
根据 HTTP 探测耗时返回响应延迟卡片颜色。
"""
if not ok:
return "error"
if value is None:
return "secondary"
if value < 1:
return "success"
if value < 3:
return "warning"
return "error"
@staticmethod
def _tls_color(tls_info: Dict[str, Any]) -> str:
"""

21
pytest.ini Normal file
View File

@@ -0,0 +1,21 @@
[pytest]
# 仅在仓库根 tests/ 下发现用例插件目录plugins/、plugins.v2/)不再承载测试
testpaths = tests
python_files = test_*.py
addopts = --import-mode=importlib
# 测试统一使用 pytest 风格;所有插件都在 tests/v1 或 tests/v2 下按插件 ID 建独立子目录
python_classes = *Test Test*
python_functions = test_*
# v1/v2 必须分会话运行同名插件包冲突marker 供后续按代筛选扩展使用
markers =
v1: v1 插件plugins/)单测,需与 v2 分独立会话运行
v2: v2 插件plugins.v2/)单测,需与 v1 分独立会话运行
# 仅忽略主程序依赖链或三方库在 Python 3.12 下的已知弃用告警;插件仓自身告警应直接修复
filterwarnings =
ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning
ignore:websockets.legacy is deprecated:DeprecationWarning
ignore:websockets.InvalidStatusCode is deprecated:DeprecationWarning
ignore:pkg_resources is deprecated as an API:DeprecationWarning
ignore:Deprecated call to .pkg_resources.declare_namespace:DeprecationWarning
ignore:'crypt' is deprecated:DeprecationWarning
ignore:'audioop' is deprecated:DeprecationWarning

52
tests/README.md Normal file
View File

@@ -0,0 +1,52 @@
# 插件仓单测
测试统一放在仓库根 `tests/` 下,**不放在插件目录内**——插件的本地同步与市场下发按
整目录拷贝(`shutil.copytree`),插件目录内的测试会被一并下发到运行时副本。
## 目录结构
```
tests/
├─ _bootstrap.py 薄壳 shim定位同级 MoviePilot 后端入 sys.path引导逻辑委托主程序 app/testing.bootstrap
├─ conftest.py pytest 引导:按本次运行目标选择 v1/v2 插件环境并注册网络守卫
├─ v2/ v2 插件plugins.v2/)单测;每个插件按插件 ID 建子目录
│ └─ agenttokens/
└─ v1/ v1 插件plugins/)单测;每个插件按插件 ID 建子目录
```
## 运行
需要 MoviePilot 后端置于插件仓**同级目录**(或设环境变量 `MOVIEPILOT_BACKEND_PATH`
并使用带后端依赖的解释器(如 `<workspace>/.venv/bin/python`)。
```bash
# 全量推荐入口v1/v2 各自独立会话依次跑,命令行参数透传给 pytest
<workspace>/.venv/bin/python tests/run.py
# 也可按代单独跑v1/v2 必须分会话,勿混跑)
<workspace>/.venv/bin/python -m pytest tests/v2
<workspace>/.venv/bin/python -m pytest tests/v1
```
`tests/run.py` 把 v1/v2 放在独立子进程依次运行、无用例的代自动跳过——两代存在同名
插件包(如 `brushflowlowfreq``torrentclassifier`),同一解释器进程无法同时加载、混跑
会相互覆盖。隔离 `CONFIG_DIR`、建表、`app.helper.sites` 垫片、插件目录注入、v1/v2 marker、
autouse 网络守卫等引导逻辑统一在主程序 `app/testing``bootstrap` / `network_guard`)维护一处;
本仓 `tests/_bootstrap.py` 仅是「定位后端入 `sys.path`」的薄壳 shim故后端需为含 `app/testing/bootstrap`
的较新 MoviePilot。共享 harness`stub_modules` 等)在 bootstrap 后可直接复用。
## 提 PR / push 前
先本地 `python tests/run.py` 跑**全量并确认通过**,再 push / 提 PR。
## 新增用例
1. 放到对应代际的插件独立目录:`tests/<v1|v2>/<plugin_id>/`,例如
`tests/v2/agenttokens/`;所有插件都按插件 ID 建目录,不把用例文件直接平铺在
`tests/v1/``tests/v2/` 下;文件名使用 `test_*.py`,在插件独立目录内不再重复插件名前缀;
2. 直接导入 `app.*` 与对应代际插件包;根 conftest 会按本次运行目标在用例导入前完成后端与插件目录注入;
3. 使用 pytest 风格编写测试:普通函数或测试类均可,断言使用 `assert`;不要新增
`unittest.TestCase``unittest.main()``if __name__ == "__main__"` 入口;
4. `unittest.mock` 可以继续作为 mock 工具使用;“不用 unittest”指测试组织与执行入口不使用
`unittest` runner
5. 优先用 `object.__new__` 绕过插件 `__init__`,只测纯逻辑方法,避免依赖完整运行时。

7
tests/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""插件仓单测包。
测试统一置于仓库根 ``tests/`` 下并按 v1/v2 分治,刻意不放在插件目录内:
插件的本地同步与市场下发按整目录拷贝(``shutil.copytree``),任何放在
``plugins/<id>/`` 或 ``plugins.v2/<id>/`` 内的测试都会被一并下发到运行时副本,
既污染线上插件目录,也可能在缺少后端依赖时影响加载。
"""

70
tests/_bootstrap.py Normal file
View File

@@ -0,0 +1,70 @@
"""插件仓单测引导薄壳:定位同级 MoviePilot 后端并入 ``sys.path``,引导逻辑委托主程序 ``app.testing.bootstrap``。
chicken-egg导入主程序共享引导之前必须先由本仓定位后端、加入 ``sys.path``——这一步不可消除,
故每个插件仓只保留这层极薄 shim隔离 CONFIG_DIR / 建表 / 插件目录注入 / v1·v2 marker 等
实际逻辑均在主程序 ``app/testing`` 维护一处,所有插件仓行为与修复保持一致。
所有引导函数都必须在首次 ``import app.*`` 或导入任一插件包之前调用,否则隔离与路径注入不生效。
"""
from __future__ import annotations
import os
import sys
from importlib import import_module
from pathlib import Path
# ``tests/`` 的父级即插件仓根;其同级 ``MoviePilot`` 为后端默认位置(工作区多仓同级布局)
_TESTS_DIR = Path(__file__).resolve().parent
_PLUGINS_REPO = _TESTS_DIR.parent
_WORKSPACE_ROOT = _PLUGINS_REPO.parent
def _resolve_backend_path() -> Path:
"""定位 MoviePilot 后端根目录。
优先取环境变量 ``MOVIEPILOT_BACKEND_PATH``(便于 CI 或非同级布局覆盖),否则按工作区
同级布局推导 ``<workspace>/MoviePilot``。校验 ``app/`` 存在,避免把错误路径塞进
``sys.path`` 后产生误导性的 ``ImportError``。
"""
candidates = []
env = os.environ.get("MOVIEPILOT_BACKEND_PATH")
if env:
candidates.append(Path(env).expanduser())
candidates.append(_WORKSPACE_ROOT / "MoviePilot")
for path in candidates:
if (path / "app").is_dir():
return path
raise RuntimeError(
"未找到 MoviePilot 后端app/ 不存在)。请将后端置于插件仓同级目录,"
f"或设置环境变量 MOVIEPILOT_BACKEND_PATH。已尝试: {[str(c) for c in candidates]}"
)
# 模块导入时即定位同级 MoviePilot 后端并前置到 ``sys.path``;随后用动态导入加载
# ``app.testing``,避免 IDE 在插件仓内按静态导入误判 ``app`` 包不存在。
_BACKEND_PATH = _resolve_backend_path()
if str(_BACKEND_PATH) not in sys.path:
sys.path.insert(0, str(_BACKEND_PATH))
_bootstrap = import_module("app.testing.bootstrap")
block_real_network = import_module("app.testing.network_guard").block_real_network
def isolate_config_dir() -> str:
"""隔离 ``CONFIG_DIR`` 到进程私有临时目录(委托主程序共享实现)。"""
return _bootstrap.isolate_config_dir()
def prepare_backend() -> None:
"""隔离 ``CONFIG_DIR`` 并建表,仅需 ``import app.*`` 的用例可直接调用(委托主程序共享实现)。"""
_bootstrap.prepare_backend()
def prepare_v2_backend() -> None:
"""v2 插件单测引导:后端 + 本仓 ``plugins.v2/``(委托主程序共享实现)。"""
_bootstrap.prepare_v2_backend(_PLUGINS_REPO)
def prepare_v1_backend() -> None:
"""v1 插件单测引导:后端 + 本仓 ``plugins/``(委托主程序共享实现,与 v2 互斥)。"""
_bootstrap.prepare_v1_backend(_PLUGINS_REPO)

39
tests/conftest.py Normal file
View File

@@ -0,0 +1,39 @@
"""pytest 全局引导:按本次运行目标选择 v1/v2 插件环境并装载网络守卫。
``tests/run.py`` 会把 v1/v2 放到独立 pytest 进程中运行;这里据本次目标路径只注入对应
插件目录,避免同一进程同时加载 ``plugins`` 与 ``plugins.v2`` 的同名包。
"""
from __future__ import annotations
from pathlib import Path
# 相对导入本仓薄壳,先定位同级 MoviePilot 后端并加入 ``sys.path``,再复用主程序共享引导。
from ._bootstrap import (
block_real_network, # noqa: F401 导入即注册主程序共享 autouse 网络守卫
prepare_v1_backend,
prepare_v2_backend,
)
def _selected_generation(config) -> str:
"""根据 pytest 本次目标路径判断插件代际,禁止同一进程混跑 v1/v2。"""
generations = set()
for arg in config.args:
file_part = arg.split("::", 1)[0]
path = Path(file_part).resolve().as_posix().replace("\\", "/")
if "tests/v2" in path:
generations.add("v2")
elif "tests/v1" in path:
generations.add("v1")
if len(generations) == 1:
return next(iter(generations))
raise RuntimeError("插件仓单测必须按 tests/run.py 分 v1/v2 独立会话运行,避免同名插件包冲突")
def pytest_configure(config) -> None:
"""收集用例前隔离 CONFIG_DIR、建表并注入对应代际插件目录。"""
if _selected_generation(config) == "v2":
prepare_v2_backend()
else:
prepare_v1_backend()

34
tests/run.py Normal file
View File

@@ -0,0 +1,34 @@
"""插件仓全量单测入口v1/v2 各自独立 pytest 会话运行,命令行参数透传给 pytest。
plugins/v1与 plugins.v2/v2存在同名插件包同一进程无法同时加载故各代在
独立子进程运行;任一代非零退出码即整体失败,无用例的代直接跳过。路径以 __file__
推导,从任意目录调用均可。
"""
import subprocess
import sys
from pathlib import Path
# 本文件位于 tests/ 下:其父为 tests 目录,再上一级为插件仓根
_TESTS_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _TESTS_DIR.parent
def _run_generation(generation: str, extra_args: list) -> int:
"""在独立子进程运行某一代v1/v2的全部用例该代无用例则跳过、返回 0。"""
target = _TESTS_DIR / generation
if not list(target.rglob("test_*.py")):
return 0
return subprocess.call(
[sys.executable, "-m", "pytest", str(target), *extra_args],
cwd=str(_REPO_ROOT),
)
if __name__ == "__main__":
extra = sys.argv[1:]
exit_code = 0
# v1/v2 必须分会话;按代依次跑,保留首个非零退出码作为整体结果
for generation in ("v2", "v1"):
rc = _run_generation(generation, extra)
exit_code = exit_code or rc
sys.exit(exit_code)

5
tests/v1/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""v1 插件plugins/)单测包。
当前工作区仅维护 v2 插件,本目录预留骨架、暂不承载用例。
与 v2 分会话运行v1/v2 存在同名插件包,同一解释器进程无法同时加载。
"""

4
tests/v2/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""v2 插件plugins.v2/)单测包。
与 v1 分会话运行v1/v2 存在同名插件包,同一解释器进程无法同时加载。
"""

View File

@@ -0,0 +1,25 @@
"""AgentTokens 插件单测pytest 原生)。
覆盖侧栏入口受 show_sidebar_nav 配置控制的逻辑。依赖 MoviePilot 后端app.*)与
插件包:根 conftest 会先隔离 CONFIG_DIR 并把后端、plugins.v2 注入 sys.path
再以顶层包名导入插件。
"""
from unittest.mock import patch
from agenttokens import AgentTokens # noqa: E402
def test_sidebar_nav_respects_config():
"""侧栏入口应受 show_sidebar_nav 配置控制:关闭则不注册,开启且插件启用则注册。
init_plugin 内部会持久化配置,这里 patch 掉 update_config仅隔离验证侧栏逻辑。
"""
plugin = AgentTokens()
with patch.object(plugin, "update_config"):
plugin.init_plugin({"enabled": True, "show_sidebar_nav": False, "providers": []})
assert plugin.get_sidebar_nav() == []
plugin.init_plugin({"enabled": True, "show_sidebar_nav": True, "providers": []})
nav = plugin.get_sidebar_nav()
assert nav[0]["title"] == "Agent Tokens 管理"