Files
Auto_Bangumi/backend/src/module/llm_plugins/loader.py
Estrella Pan e6d39e5624 feat: in-app notification center and LLM provider plugin framework
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
2026-07-06 09:43:45 +02:00

66 lines
2.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从已安装的插件树按 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