mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-28 19:25:55 +08:00
Two 3.3 platform features sharing core infra (notification events, DB migrations/wiring, config model, API registration), committed together because they cannot be split at file granularity without broken intermediate builds. Notification center: - New `inboxmessage` table (migration 16) + repo with coalescing (unread rows with the same dedup_key increment a count; `once` kinds like update_available notify per-version) and 500-row pruning. - SystemEvent contract (kind/severity/dedup_key/payload) with new DownloaderUnavailable / UpdateAvailable / UpdateApplied events; NotificationManager.send_event persists to the inbox first, then broadcasts to external providers only when notification.enable. - SSE `notification` frames driven by an in-process revision counter; REST endpoints for list/unread-count/read/read-all/delete/clear. - Wire-ups: qBittorrent auth now reports last_auth_error (credentials|banned|unreachable) and stops retrying rejected credentials to avoid a WebUI IP ban; the startup wait loop aborts early on credential errors and emits a notification; a 24h update-check tick surfaces new versions; apply/rollback emit results. - WebUI bell + unread badge + dropdown inbox (severity, coalesce count, relative time, deep links), SSE-driven with a 30s poll fallback. LLM provider plugin framework + subscription auth: - LLMParser refactored to a thin facade over a provider adapter contract + registry; the three built-ins (openai/anthropic/gemini) move to adapters with zero behaviour change (existing tests unchanged). - base_url presets for zai/minimax/deepseek/kimi (mainland + overseas) reusing the OpenAI-compatible adapter, with a json_object structured- output fallback for providers without json_schema. - Downloadable signed plugins: catalog + installer clone the update system's ed25519 verify / zip-slip-guarded extract pipeline (unsigned refused, catalog signed against downgrade); versioned-module-name loader hot-loads without restart. - Subscription auth: `llmcredential` table (migration 17) stores OAuth tokens server-side only (never sent to the frontend); device-flow auth runs a server-side polling task keyed by an opaque handle so the device_code stays off the wire; AuthExpiredError trips the breaker and notifies. Two grey-area device-flow plugins (github-copilot, codex-chatgpt) under plugins/, flagged at-your-own-risk. - WebUI: grouped provider select (built-in/preset/subscription/ downloadable), install with risk confirmation, device-flow connect dialog, connected-account chip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
66 lines
2.3 KiB
Python
66 lines
2.3 KiB
Python
"""从已安装的插件树按 manifest.entry 加载适配器类。
|
||
|
||
用版本化的模块名(``ab_llm_plugin_{id}_{ver}``)+ spec_from_file_location
|
||
加载,不污染 sys.path,升级时新版本是全新模块名 → 热加载无需重启。
|
||
"""
|
||
|
||
import importlib.util
|
||
import logging
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _module_name(plugin_id: str, version: str) -> str:
|
||
safe = plugin_id.replace("-", "_")
|
||
return f"ab_llm_plugin_{safe}_{version.replace('.', '_')}"
|
||
|
||
|
||
def load_adapter_class(plugin_dir: Path, manifest: dict) -> type:
|
||
"""加载并返回 manifest.entry 指定的适配器类。
|
||
|
||
entry 形如 ``pkg.module:ClassName``。校验类继承 LLMProviderAdapter 且
|
||
info.id 与 manifest id 一致(防止 zip 内混入冒名适配器)。
|
||
"""
|
||
from module.parser.analyser.providers.base import LLMProviderAdapter
|
||
|
||
entry = manifest.get("entry", "")
|
||
if ":" not in entry:
|
||
raise ValueError(f"Invalid plugin entry: {entry!r}")
|
||
module_path, class_name = entry.split(":", 1)
|
||
rel = module_path.replace(".", "/") + ".py"
|
||
file_path = plugin_dir / rel
|
||
if not file_path.exists():
|
||
raise ValueError(f"Plugin entry module not found: {rel}")
|
||
|
||
mod_name = _module_name(manifest["id"], manifest["version"])
|
||
spec = importlib.util.spec_from_file_location(
|
||
mod_name,
|
||
file_path,
|
||
submodule_search_locations=[str(plugin_dir)],
|
||
)
|
||
if spec is None or spec.loader is None:
|
||
raise ValueError(f"Cannot load plugin module: {mod_name}")
|
||
module: Any = importlib.util.module_from_spec(spec)
|
||
sys.modules[mod_name] = module
|
||
try:
|
||
spec.loader.exec_module(module)
|
||
except Exception:
|
||
sys.modules.pop(mod_name, None)
|
||
raise
|
||
|
||
adapter_cls = getattr(module, class_name, None)
|
||
if adapter_cls is None:
|
||
raise ValueError(f"Plugin class not found: {class_name}")
|
||
if not (
|
||
isinstance(adapter_cls, type) and issubclass(adapter_cls, LLMProviderAdapter)
|
||
):
|
||
raise ValueError(f"{class_name} is not an LLMProviderAdapter")
|
||
if adapter_cls.info.id != manifest["id"]:
|
||
raise ValueError(
|
||
f"Adapter id {adapter_cls.info.id!r} != manifest id {manifest['id']!r}"
|
||
)
|
||
return adapter_cls
|