mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-28 19:25:55 +08:00
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
This commit is contained in:
@@ -5,6 +5,7 @@ from .bangumi import router as bangumi_router
|
||||
from .config import router as config_router
|
||||
from .downloader import router as downloader_router
|
||||
from .events import router as events_router
|
||||
from .llm import router as llm_router
|
||||
from .log import router as log_router
|
||||
from .notification import router as notification_router
|
||||
from .passkey import router as passkey_router
|
||||
@@ -31,3 +32,4 @@ v1.include_router(search_router)
|
||||
v1.include_router(setup_router)
|
||||
v1.include_router(notification_router)
|
||||
v1.include_router(update_router)
|
||||
v1.include_router(llm_router)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -96,7 +95,7 @@ async def update_config(config: Config, ctx: AppContext = Depends(get_context)):
|
||||
|
||||
|
||||
class LLMModelsRequest(BaseModel):
|
||||
provider: Literal["openai", "anthropic", "gemini"] = "openai"
|
||||
provider: str = "openai"
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
|
||||
@@ -117,7 +116,8 @@ async def list_llm_models(req: LLMModelsRequest):
|
||||
"""
|
||||
api_key = req.api_key
|
||||
if not api_key or api_key == _MASK:
|
||||
api_key = settings.llm.api_key
|
||||
# 按提供商取已保存密钥(providers[id] 覆盖 → 扁平字段兜底)
|
||||
api_key, _, _ = settings.llm.effective(req.provider)
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="LLM API key is required")
|
||||
try:
|
||||
|
||||
@@ -16,7 +16,9 @@ from module.api.deps import get_context
|
||||
from module.api.log import _read_log_tail
|
||||
from module.conf import LOG_PATH, VERSION
|
||||
from module.core import AppContext
|
||||
from module.database import Database
|
||||
from module.downloader import DownloadClient
|
||||
from module.notification.inbox import inbox_revision
|
||||
from module.security.api import get_current_user
|
||||
from module.update import get_update_progress
|
||||
|
||||
@@ -30,6 +32,7 @@ _TICK_SECONDS = 1
|
||||
_STATUS_EVERY = 3
|
||||
_DOWNLOADER_EVERY = 5
|
||||
_LOG_EVERY = 10
|
||||
_NOTIFICATION_EVERY = 3
|
||||
|
||||
# 下载器查询的超时上限(秒)。下载器不可达时 qB/aria2 的认证重试可能阻塞
|
||||
# 20-30s,而 SSE 是单连接串行推送——不设上限会把 status/log 事件一起卡住。
|
||||
@@ -67,6 +70,15 @@ async def _downloader_payload() -> list[dict] | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _notification_payload() -> dict:
|
||||
"""通知中心未读数 + 最新消息 id(前端据 latest_id 区分新消息与已读操作)。"""
|
||||
async with Database() as db:
|
||||
return {
|
||||
"unread_count": await db.inbox.unread_count(),
|
||||
"latest_id": await db.inbox.latest_id(),
|
||||
}
|
||||
|
||||
|
||||
async def _log_payload() -> str | None:
|
||||
"""读取日志尾部;日志文件不存在时返回 None(跳过本次推送)。"""
|
||||
if not LOG_PATH.exists():
|
||||
@@ -79,6 +91,7 @@ async def _event_generator(request: Request, ctx: AppContext):
|
||||
"""按各自节拍推送 status/downloader/log 事件的核心循环。"""
|
||||
tick = 0
|
||||
last_update: str | None = None
|
||||
last_inbox_rev: int | None = None
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
@@ -106,6 +119,20 @@ async def _event_generator(request: Request, ctx: AppContext):
|
||||
if log_text is not None:
|
||||
yield {"event": "log", "data": log_text}
|
||||
|
||||
# 通知中心:只在修订号变化时查库推送(写入/已读/删除都会 bump),
|
||||
# 平时每 3 tick 只做一次整数比较,不产生数据库查询。
|
||||
if tick % _NOTIFICATION_EVERY == 0:
|
||||
rev = inbox_revision()
|
||||
if rev != last_inbox_rev:
|
||||
try:
|
||||
payload = await _notification_payload()
|
||||
except Exception:
|
||||
logger.debug("SSE: notification payload unavailable", exc_info=True)
|
||||
else:
|
||||
last_inbox_rev = rev
|
||||
payload["revision"] = rev
|
||||
yield {"event": "notification", "data": json.dumps(payload)}
|
||||
|
||||
await asyncio.sleep(_TICK_SECONDS)
|
||||
tick += _TICK_SECONDS
|
||||
|
||||
|
||||
340
backend/src/module/api/llm.py
Normal file
340
backend/src/module/api/llm.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""LLM 提供商与订阅认证 API(/config/llm/*)。
|
||||
|
||||
- providers:内置 + 预设 + 已安装插件的合并视图(含连接状态)。
|
||||
- auth begin/complete/status/disconnect:订阅类提供商的授权流程;
|
||||
PKCE verifier / device_code 等敏感中间态留服务端 ``_PENDING``,
|
||||
device-code 轮询在服务端 asyncio task 完成,token 永不下发前端。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from module.api.deps import get_context
|
||||
from module.conf import VERSION
|
||||
from module.core import AppContext
|
||||
from module.database import Database
|
||||
from module.parser.analyser.llm import _build_http_client
|
||||
from module.parser.analyser.providers.base import (
|
||||
AdapterContext,
|
||||
AuthChallenge,
|
||||
AuthExpiredError,
|
||||
LLMProviderAdapter,
|
||||
)
|
||||
from module.parser.analyser.providers.credentials import (
|
||||
CredentialStore,
|
||||
bump_auth_generation,
|
||||
)
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
from module.security.api import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/config/llm", tags=["config"])
|
||||
|
||||
# 不透明 handle -> 待完成授权的服务端明细。``blob`` 是适配器 begin_auth
|
||||
# 返回的 state(device_code / PKCE verifier 等敏感态),只留服务端,绝不
|
||||
# 下发前端——前端只拿到 handle 用于轮询 status / 回传 redirect code。
|
||||
_PENDING: dict[str, "PendingAuth"] = {}
|
||||
_PENDING_TTL = 900.0
|
||||
# 保留 device-flow 轮询任务的强引用,防止被 GC。
|
||||
_POLL_TASKS: set = set()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingAuth:
|
||||
provider_id: str
|
||||
created: float
|
||||
expires: float
|
||||
blob: str = "" # 适配器 challenge.state(敏感,服务端专用)
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _prune_pending(now: float) -> None:
|
||||
for state in [s for s, p in _PENDING.items() if p.expires < now]:
|
||||
_PENDING.pop(state, None)
|
||||
|
||||
|
||||
def _make_adapter(provider_id: str) -> LLMProviderAdapter:
|
||||
adapter_cls = registry.resolve(provider_id)
|
||||
ctx = AdapterContext(
|
||||
model=adapter_cls.info.default_model,
|
||||
build_http_client=_build_http_client,
|
||||
credentials=CredentialStore(provider_id),
|
||||
)
|
||||
return adapter_cls(ctx)
|
||||
|
||||
|
||||
async def _provider_status(provider_id: str) -> dict:
|
||||
"""返回可下发前端的连接状态(绝不含 token)。"""
|
||||
tokens = await CredentialStore(provider_id).load()
|
||||
if tokens is None:
|
||||
return {"connected": False, "account_label": "", "expires_at": None}
|
||||
return {
|
||||
"connected": True,
|
||||
"account_label": tokens.account_label,
|
||||
"expires_at": tokens.expires_at,
|
||||
}
|
||||
|
||||
|
||||
class ProviderView(BaseModel):
|
||||
id: str
|
||||
display_name: str
|
||||
auth_kind: str
|
||||
builtin: bool
|
||||
needs_base_url: bool
|
||||
preset_base_url: str
|
||||
default_model: str
|
||||
plugin_version: str | None = None
|
||||
connected: bool = False
|
||||
account_label: str = ""
|
||||
expires_at: float | None = None
|
||||
|
||||
|
||||
class ProvidersResponse(BaseModel):
|
||||
providers: list[ProviderView]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/providers",
|
||||
response_model=ProvidersResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def list_providers():
|
||||
infos = registry.list_infos()
|
||||
# 订阅类提供商的连接状态在一个 DB 会话里批量取,避免每个提供商各开一次连接。
|
||||
statuses: dict[str, dict] = {}
|
||||
subscription_ids = [i.id for i in infos if i.auth_kind != "api_key"]
|
||||
if subscription_ids:
|
||||
async with Database() as db:
|
||||
for pid in subscription_ids:
|
||||
tokens = await db.llm_credential.get(pid)
|
||||
statuses[pid] = (
|
||||
{"connected": False, "account_label": "", "expires_at": None}
|
||||
if tokens is None
|
||||
else {
|
||||
"connected": True,
|
||||
"account_label": tokens.account_label,
|
||||
"expires_at": tokens.expires_at,
|
||||
}
|
||||
)
|
||||
views: list[ProviderView] = []
|
||||
for info in infos:
|
||||
status = statuses.get(info.id, {})
|
||||
views.append(
|
||||
ProviderView(
|
||||
id=info.id,
|
||||
display_name=info.display_name,
|
||||
auth_kind=info.auth_kind,
|
||||
builtin=info.builtin,
|
||||
needs_base_url=info.needs_base_url,
|
||||
preset_base_url=info.preset_base_url,
|
||||
default_model=info.default_model,
|
||||
plugin_version=info.plugin_version,
|
||||
connected=status.get("connected", False),
|
||||
account_label=status.get("account_label", ""),
|
||||
expires_at=status.get("expires_at"),
|
||||
)
|
||||
)
|
||||
return ProvidersResponse(providers=views)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/install",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def install_provider(provider_id: str, ctx: AppContext = Depends(get_context)):
|
||||
from module.llm_plugins import PluginInstaller
|
||||
from module.notification import LLMPluginInstallFailedEvent
|
||||
|
||||
installer = PluginInstaller(app_version=VERSION)
|
||||
result = await installer.install(provider_id)
|
||||
if not result.success:
|
||||
try:
|
||||
await ctx.notifier.send_event(
|
||||
LLMPluginInstallFailedEvent(
|
||||
plugin_id=provider_id,
|
||||
version=result.version,
|
||||
message=result.message,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("install-failure notification best-effort failed")
|
||||
raise HTTPException(status_code=400, detail=result.message)
|
||||
return {"success": True, "version": result.version}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/providers/{provider_id}",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def uninstall_provider(provider_id: str):
|
||||
from module.llm_plugins import PluginInstaller
|
||||
|
||||
installer = PluginInstaller(app_version=VERSION)
|
||||
result = await installer.uninstall(provider_id)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=400, detail=result.message)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class AuthCompleteRequest(BaseModel):
|
||||
state: str
|
||||
code: str = ""
|
||||
|
||||
|
||||
def _poll_interval(blob: str) -> int:
|
||||
try:
|
||||
return max(1, int(json.loads(blob).get("interval", 5)))
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
|
||||
async def _device_poll_loop(provider_id: str, handle: str, expires_in: int) -> None:
|
||||
"""device flow 服务端轮询:到期前按 interval 反复调 complete_auth,
|
||||
成功即写库并 bump generation。前端只需轮询 auth/status。"""
|
||||
deadline = time.monotonic() + min(float(expires_in), _PENDING_TTL)
|
||||
try:
|
||||
adapter = _make_adapter(provider_id)
|
||||
except ValueError:
|
||||
_PENDING.pop(handle, None)
|
||||
return
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
pending = _PENDING.get(handle)
|
||||
if pending is None:
|
||||
return # 被取消 / 已完成 / 断开
|
||||
await asyncio.sleep(_poll_interval(pending.blob))
|
||||
pending = _PENDING.get(handle)
|
||||
if pending is None:
|
||||
return
|
||||
try:
|
||||
tokens = await adapter.complete_auth(pending.blob)
|
||||
except AuthExpiredError:
|
||||
continue # authorization_pending / slow_down:继续轮询
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("device auth poll failed for %s: %s", provider_id, e)
|
||||
continue
|
||||
await CredentialStore(provider_id).save(tokens)
|
||||
bump_auth_generation(provider_id)
|
||||
_PENDING.pop(handle, None)
|
||||
logger.info("LLM provider %s connected via device flow", provider_id)
|
||||
return
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
_PENDING.pop(handle, None)
|
||||
|
||||
|
||||
def _store_pending(provider_id: str, challenge: AuthChallenge) -> str:
|
||||
"""把敏感 blob 存服务端,返回给前端的不透明 handle。"""
|
||||
now = time.monotonic()
|
||||
_prune_pending(now)
|
||||
handle = secrets.token_urlsafe(24)
|
||||
_PENDING[handle] = PendingAuth(
|
||||
provider_id=provider_id,
|
||||
created=now,
|
||||
expires=now + min(float(challenge.expires_in), _PENDING_TTL),
|
||||
blob=challenge.state,
|
||||
)
|
||||
return handle
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/auth/begin",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def auth_begin(provider_id: str):
|
||||
try:
|
||||
adapter = _make_adapter(provider_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
try:
|
||||
challenge = await adapter.begin_auth()
|
||||
except NotImplementedError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Provider does not support interactive auth"
|
||||
)
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
|
||||
handle = _store_pending(provider_id, challenge)
|
||||
if challenge.method == "device_code":
|
||||
# 服务端起轮询任务;前端只轮询 auth/status。
|
||||
task = asyncio.create_task(
|
||||
_device_poll_loop(provider_id, handle, challenge.expires_in)
|
||||
)
|
||||
_POLL_TASKS.add(task)
|
||||
task.add_done_callback(_POLL_TASKS.discard)
|
||||
|
||||
# 返回前端:state 用不透明 handle,绝不含 device_code / verifier。
|
||||
return {
|
||||
"method": challenge.method,
|
||||
"authorize_url": challenge.authorize_url,
|
||||
"user_code": challenge.user_code,
|
||||
"verification_uri": challenge.verification_uri,
|
||||
"expires_in": challenge.expires_in,
|
||||
"state": handle,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/auth/complete",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def auth_complete(provider_id: str, req: AuthCompleteRequest):
|
||||
"""redirect_paste 流程:前端回传授权 code;device flow 由服务端轮询完成。"""
|
||||
pending = _PENDING.get(req.state)
|
||||
if pending is None or pending.provider_id != provider_id:
|
||||
raise HTTPException(status_code=400, detail="Unknown or expired auth state")
|
||||
try:
|
||||
adapter = _make_adapter(provider_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
try:
|
||||
tokens = await adapter.complete_auth(pending.blob, req.code)
|
||||
except Exception as e:
|
||||
logger.warning("LLM auth complete failed for %s: %s", provider_id, e)
|
||||
raise HTTPException(status_code=400, detail="Authorization failed")
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
await CredentialStore(provider_id).save(tokens)
|
||||
bump_auth_generation(provider_id)
|
||||
_PENDING.pop(req.state, None)
|
||||
return {"connected": True, "account_label": tokens.account_label}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/providers/{provider_id}/auth/status",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def auth_status(provider_id: str):
|
||||
return await _provider_status(provider_id)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/providers/{provider_id}/auth",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def auth_disconnect(provider_id: str):
|
||||
store = CredentialStore(provider_id)
|
||||
tokens = await store.load()
|
||||
if tokens is not None:
|
||||
try:
|
||||
adapter = _make_adapter(provider_id)
|
||||
except ValueError:
|
||||
adapter = None
|
||||
if adapter is not None:
|
||||
try:
|
||||
await adapter.revoke(tokens)
|
||||
except Exception as e:
|
||||
logger.debug("revoke best-effort failed for %s: %s", provider_id, e)
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
await store.clear()
|
||||
bump_auth_generation(provider_id)
|
||||
return {"connected": False}
|
||||
@@ -1,13 +1,21 @@
|
||||
"""Notification API endpoints."""
|
||||
"""Notification API endpoints.
|
||||
|
||||
包含两部分:外部推送 provider 的测试端点,以及站内通知中心
|
||||
(/notification/messages*)的收件箱增删查改。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from module.database import Database, get_db
|
||||
from module.models import ResponseModel
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
from module.notification import NotificationManager
|
||||
from module.notification.inbox import bump_inbox_revision
|
||||
from module.security.api import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -122,3 +130,145 @@ async def test_provider_config(request: TestProviderConfigRequest):
|
||||
message_zh=f"测试失败: {e}",
|
||||
message_en=f"Test failed: {e}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 站内通知中心(收件箱)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InboxMessageOut(BaseModel):
|
||||
"""收件箱消息。payload 为按 kind 约定的结构化字段,供前端本地化渲染。"""
|
||||
|
||||
id: int
|
||||
kind: str
|
||||
severity: str
|
||||
title: str
|
||||
body: str
|
||||
payload: Optional[dict] = None
|
||||
read: bool
|
||||
count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class InboxListResponse(BaseModel):
|
||||
messages: list[InboxMessageOut]
|
||||
total: int
|
||||
unread_count: int
|
||||
|
||||
|
||||
def _to_out(message) -> InboxMessageOut:
|
||||
payload = None
|
||||
if message.payload:
|
||||
try:
|
||||
payload = json.loads(message.payload)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = None
|
||||
return InboxMessageOut(
|
||||
id=message.id,
|
||||
kind=message.kind,
|
||||
severity=message.severity,
|
||||
title=message.title,
|
||||
body=message.body,
|
||||
payload=payload,
|
||||
read=message.read,
|
||||
count=message.count,
|
||||
created_at=message.created_at,
|
||||
updated_at=message.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/messages",
|
||||
response_model=InboxListResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def list_messages(
|
||||
unread_only: bool = False,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Database = Depends(get_db),
|
||||
):
|
||||
messages = await db.inbox.list(unread_only=unread_only, limit=limit, offset=offset)
|
||||
total = await db.inbox.count_all(unread_only=unread_only)
|
||||
unread = await db.inbox.unread_count()
|
||||
return InboxListResponse(
|
||||
messages=[_to_out(m) for m in messages],
|
||||
total=total,
|
||||
unread_count=unread,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/messages/unread-count",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def unread_count(db: Database = Depends(get_db)):
|
||||
return {"unread_count": await db.inbox.unread_count()}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/messages/read-all",
|
||||
response_model=ResponseModel,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def mark_all_read(db: Database = Depends(get_db)):
|
||||
changed = await db.inbox.mark_all_read()
|
||||
bump_inbox_revision()
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
msg_en=f"Marked {changed} messages as read.",
|
||||
msg_zh=f"已将 {changed} 条消息标记为已读。",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/messages/{message_id}/read",
|
||||
response_model=ResponseModel,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def mark_read(message_id: int, db: Database = Depends(get_db)):
|
||||
if not await db.inbox.mark_read(message_id):
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
bump_inbox_revision()
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
msg_en="Message marked as read.",
|
||||
msg_zh="消息已标记为已读。",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/messages/{message_id}",
|
||||
response_model=ResponseModel,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def delete_message(message_id: int, db: Database = Depends(get_db)):
|
||||
if not await db.inbox.delete(message_id):
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
bump_inbox_revision()
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
msg_en="Message deleted.",
|
||||
msg_zh="消息已删除。",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/messages",
|
||||
response_model=ResponseModel,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def clear_messages(db: Database = Depends(get_db)):
|
||||
removed = await db.inbox.clear()
|
||||
bump_inbox_revision()
|
||||
return ResponseModel(
|
||||
status=True,
|
||||
status_code=200,
|
||||
msg_en=f"Cleared {removed} messages.",
|
||||
msg_zh=f"已清空 {removed} 条消息。",
|
||||
)
|
||||
|
||||
@@ -18,9 +18,13 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from module.conf import settings
|
||||
from module.core import AppContext
|
||||
from module.notification import UpdateAppliedEvent
|
||||
from module.security.api import get_current_user
|
||||
from module.update import updater
|
||||
|
||||
from .deps import get_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/update", tags=["update"])
|
||||
@@ -63,11 +67,28 @@ async def check_update(channel: str | None = None, force: bool = False):
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
async def _notify_apply_result(ctx: AppContext, result) -> None:
|
||||
"""把更新结果写入通知中心(在 schedule_restart 之前落库,重启后仍可见)。"""
|
||||
try:
|
||||
await ctx.notifier.send_event(
|
||||
UpdateAppliedEvent(
|
||||
version=result.version or "",
|
||||
success=result.success,
|
||||
message=result.message or "",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to emit update-result notification", exc_info=True)
|
||||
|
||||
|
||||
@router.post("/apply", dependencies=[Depends(get_current_user)])
|
||||
async def apply_update(channel: str | None = None):
|
||||
async def apply_update(
|
||||
channel: str | None = None, ctx: AppContext = Depends(get_context)
|
||||
):
|
||||
"""下载并落地最新更新;成功后安排进程重启以生效。"""
|
||||
ch = channel or settings.update.channel
|
||||
result = await updater.apply_update(ch)
|
||||
await _notify_apply_result(ctx, result)
|
||||
if result.success and result.restart_required:
|
||||
schedule_restart()
|
||||
return JSONResponse(
|
||||
@@ -77,9 +98,10 @@ async def apply_update(channel: str | None = None):
|
||||
|
||||
|
||||
@router.post("/rollback", dependencies=[Depends(get_current_user)])
|
||||
async def rollback_update():
|
||||
async def rollback_update(ctx: AppContext = Depends(get_context)):
|
||||
"""回滚到上一个覆盖层版本(无备份则回退到镜像版本);成功后安排重启。"""
|
||||
result = await updater.rollback()
|
||||
await _notify_apply_result(ctx, result)
|
||||
if result.success and result.restart_required:
|
||||
schedule_restart()
|
||||
return JSONResponse(
|
||||
|
||||
@@ -55,26 +55,43 @@ class Checker:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def check_downloader() -> bool:
|
||||
async def check_downloader_detail() -> tuple[bool, str | None]:
|
||||
"""下载器可达性检查,附失败原因。
|
||||
|
||||
返回 ``(ok, reason)``;reason 取值 unreachable | credentials | banned,
|
||||
成功时为 None。上层(启动等待循环)据此区分「等它启动」和「密码错误,
|
||||
等也没用」两类故障。
|
||||
"""
|
||||
from module.downloader import DownloadClient
|
||||
|
||||
# Mock downloader always succeeds
|
||||
if settings.downloader.type == "mock":
|
||||
logger.info("Using MockDownloader - skipping connection check")
|
||||
return True
|
||||
return True, None
|
||||
|
||||
# Delegate to the concrete client's own auth/capabilities instead of
|
||||
# grepping the downloader's homepage HTML (which only ever matches
|
||||
# qBittorrent, so aria2 and future backends could never pass).
|
||||
# DownloadClient() 的构造本身可能抛(下载器类型无法识别),必须一并
|
||||
# 纳入 try——否则一个配置错误会击穿启动等待循环。
|
||||
client = None
|
||||
try:
|
||||
async with DownloadClient() as dl_client:
|
||||
return dl_client.authed
|
||||
client = DownloadClient()
|
||||
async with client as dl_client:
|
||||
return dl_client.authed, None
|
||||
except ConnectionError:
|
||||
logger.error("Downloader connect failed.")
|
||||
return False
|
||||
reason = getattr(client, "last_auth_error", None)
|
||||
return False, reason or "unreachable"
|
||||
except Exception as e:
|
||||
logger.error(f"Downloader connect failed: {e}")
|
||||
return False
|
||||
reason = getattr(client, "last_auth_error", None)
|
||||
return False, reason or "unreachable"
|
||||
|
||||
@staticmethod
|
||||
async def check_downloader() -> bool:
|
||||
ok, _ = await Checker.check_downloader_detail()
|
||||
return ok
|
||||
|
||||
@staticmethod
|
||||
def check_img_cache() -> bool:
|
||||
|
||||
@@ -15,7 +15,7 @@ from module.database import Database
|
||||
from module.downloader.download_client import shutdown as downloader_shutdown
|
||||
from module.models import ResponseModel
|
||||
from module.network.request_url import reset_shared_client
|
||||
from module.notification import NotificationManager
|
||||
from module.notification import DownloaderUnavailableEvent, NotificationManager
|
||||
from module.parser.analyser.mikan_parser import reset_cache as reset_mikan_cache
|
||||
from module.parser.analyser.tmdb_parser import reset_cache as reset_tmdb_cache
|
||||
from module.parser.title_parser import reset_cache as reset_llm_parser
|
||||
@@ -30,7 +30,13 @@ from module.update import (
|
||||
run_migrations,
|
||||
)
|
||||
|
||||
from .loops import calendar_tick, offset_scan_tick, rename_tick, rss_tick
|
||||
from .loops import (
|
||||
calendar_tick,
|
||||
offset_scan_tick,
|
||||
rename_tick,
|
||||
rss_tick,
|
||||
update_check_tick,
|
||||
)
|
||||
from .scheduler import PeriodicTask, Scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,8 +47,10 @@ DOWNLOADER_STATUS_TTL = 60
|
||||
# Background loop cadence (seconds).
|
||||
OFFSET_SCAN_INTERVAL = 6 * 60 * 60
|
||||
CALENDAR_REFRESH_INTERVAL = 24 * 60 * 60
|
||||
UPDATE_CHECK_INTERVAL = 24 * 60 * 60
|
||||
OFFSET_SCAN_INITIAL_DELAY = 60
|
||||
CALENDAR_INITIAL_DELAY = 120
|
||||
UPDATE_CHECK_INITIAL_DELAY = 300
|
||||
|
||||
# Downloader wait-retry loop on startup.
|
||||
_DOWNLOADER_MAX_RETRIES = 10
|
||||
@@ -76,6 +84,7 @@ class AppContext:
|
||||
self.analyser = analyser
|
||||
# Downloader-status TTL cache (was ProgramStatus.check_downloader_status).
|
||||
self._downloader_status = False
|
||||
self._downloader_reason: str | None = None
|
||||
self._downloader_last_check: float = 0.0
|
||||
self._startup_done = False
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
@@ -126,6 +135,13 @@ class AppContext:
|
||||
interval=lambda: CALENDAR_REFRESH_INTERVAL,
|
||||
initial_delay=CALENDAR_INITIAL_DELAY,
|
||||
),
|
||||
PeriodicTask(
|
||||
name="update_check",
|
||||
run=lambda: update_check_tick(notifier),
|
||||
interval=lambda: UPDATE_CHECK_INTERVAL,
|
||||
initial_delay=UPDATE_CHECK_INITIAL_DELAY,
|
||||
enabled=lambda: settings_obj.update.auto_check,
|
||||
),
|
||||
]
|
||||
)
|
||||
return cls(settings_obj, notifier, scheduler, analyser)
|
||||
@@ -154,7 +170,10 @@ class AppContext:
|
||||
not self._downloader_status
|
||||
or (now - self._downloader_last_check) >= DOWNLOADER_STATUS_TTL
|
||||
):
|
||||
self._downloader_status = await Checker.check_downloader()
|
||||
(
|
||||
self._downloader_status,
|
||||
self._downloader_reason,
|
||||
) = await Checker.check_downloader_detail()
|
||||
self._downloader_last_check = now
|
||||
return self._downloader_status
|
||||
|
||||
@@ -217,6 +236,17 @@ class AppContext:
|
||||
async def _wait_for_downloader(self) -> None:
|
||||
retry_count = 0
|
||||
while not await self.check_downloader_status():
|
||||
reason = self._downloader_reason or "unreachable"
|
||||
if reason in ("credentials", "banned"):
|
||||
# 凭据错误/IP 被封时等待没有意义,继续探测反而会累积失败
|
||||
# 登录、触发(或加剧)qB 的 WebUI IP ban——立即放弃并通知。
|
||||
logger.error(
|
||||
"Downloader rejected authentication (%s); giving up. "
|
||||
"Program will continue but download functions will not work.",
|
||||
reason,
|
||||
)
|
||||
await self._notify_downloader_unavailable(reason)
|
||||
return
|
||||
retry_count += 1
|
||||
logger.warning(
|
||||
f"Downloader is not running. (attempt {retry_count}/{_DOWNLOADER_MAX_RETRIES})"
|
||||
@@ -227,10 +257,23 @@ class AppContext:
|
||||
"Please check downloader settings and network/proxy configuration. "
|
||||
"Program will continue but download functions will not work."
|
||||
)
|
||||
await self._notify_downloader_unavailable("unreachable")
|
||||
break
|
||||
logger.info("Waiting for downloader to start...")
|
||||
await asyncio.sleep(_DOWNLOADER_RETRY_INTERVAL)
|
||||
|
||||
async def _notify_downloader_unavailable(self, reason: str) -> None:
|
||||
try:
|
||||
await self.notifier.send_event(
|
||||
DownloaderUnavailableEvent(
|
||||
host=self.settings.downloader.host, reason=reason
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to emit downloader-unavailable notification", exc_info=True
|
||||
)
|
||||
|
||||
async def _run_start_tasks(self) -> None:
|
||||
"""Body of the background start task: wait for the downloader, then start loops."""
|
||||
await self._wait_for_downloader()
|
||||
|
||||
@@ -11,8 +11,9 @@ from module.conf import settings
|
||||
from module.database import Database
|
||||
from module.downloader import DownloadClient
|
||||
from module.manager import Renamer, TorrentManager, eps_complete
|
||||
from module.notification import NotificationManager
|
||||
from module.notification import NotificationManager, UpdateAvailableEvent
|
||||
from module.rss import RSSAnalyser, RSSEngine
|
||||
from module.update import updater
|
||||
|
||||
from .offset_scanner import OffsetScanner
|
||||
|
||||
@@ -46,7 +47,9 @@ async def rss_tick(analyser: RSSAnalyser, notifier: NotificationManager) -> None
|
||||
)
|
||||
# Run RSS Engine
|
||||
events = await engine.refresh_rss(client)
|
||||
if settings.notification.enable and events:
|
||||
if events:
|
||||
# 站内通知中心要求事件总是送达 notifier(send_event 内部先落库,
|
||||
# 再按 settings.notification.enable 决定是否外发)。
|
||||
# One provider's exception never blocks another event (send_event
|
||||
# itself gathers across providers); gathering across events too
|
||||
# means a tick with several RSS failures doesn't serialize their
|
||||
@@ -73,10 +76,32 @@ async def offset_scan_tick(notifier: NotificationManager) -> None:
|
||||
# 扫描结果由 OffsetScanner.scan_all 自己记录,这里不再重复记一行
|
||||
scanner = OffsetScanner()
|
||||
events = await scanner.scan_all()
|
||||
if settings.notification.enable and events:
|
||||
if events:
|
||||
await asyncio.gather(*[notifier.send_event(e) for e in events])
|
||||
|
||||
|
||||
async def update_check_tick(notifier: NotificationManager) -> None:
|
||||
"""每日检查一次 GitHub Release,发现新版本时写入通知中心。
|
||||
|
||||
``UpdateAvailableEvent.once=True``(按最新版本号去重),同一版本终生只
|
||||
提醒一次;检查失败(网络/限流)静默跳过,避免每天误报。
|
||||
"""
|
||||
result = await updater.check_update(settings.update.channel, force=False)
|
||||
if result.error:
|
||||
logger.debug("Update check failed: %s", result.error)
|
||||
return
|
||||
if not result.has_update:
|
||||
return
|
||||
await notifier.send_event(
|
||||
UpdateAvailableEvent(
|
||||
current=result.current,
|
||||
latest=result.latest or "",
|
||||
channel=result.channel,
|
||||
notes=result.notes or "",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def calendar_tick() -> None:
|
||||
"""Refresh bangumi calendar metadata."""
|
||||
async with Database() as db:
|
||||
|
||||
@@ -9,6 +9,8 @@ from module.models import Bangumi, User
|
||||
from .aria2 import Aria2GidDatabase
|
||||
from .bangumi import BangumiDatabase
|
||||
from .engine import async_engine, async_session_factory
|
||||
from .inbox import InboxDatabase
|
||||
from .llm_credential import LLMCredentialDatabase
|
||||
from .migrations import ( # noqa: F401 (re-exported for existing importers)
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
MIGRATIONS,
|
||||
@@ -43,6 +45,8 @@ class Database:
|
||||
self.bangumi = BangumiDatabase(self.session)
|
||||
self.user = UserDatabase(self.session)
|
||||
self.aria2 = Aria2GidDatabase(self.session)
|
||||
self.inbox = InboxDatabase(self.session)
|
||||
self.llm_credential = LLMCredentialDatabase(self.session)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
155
backend/src/module/database/inbox.py
Normal file
155
backend/src/module/database/inbox.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""通知中心消息表的仓储。合并/去重语义见 ``upsert``。"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import delete, func, select
|
||||
|
||||
from module.models.inbox import InboxMessage, utcnow_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_KEEP = 500
|
||||
|
||||
|
||||
class InboxDatabase:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
severity: str = "info",
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
payload: Optional[str] = None,
|
||||
dedup_key: Optional[str] = None,
|
||||
once: bool = False,
|
||||
keep: int = DEFAULT_KEEP,
|
||||
) -> Optional[InboxMessage]:
|
||||
"""写入一条通知。
|
||||
|
||||
- ``once=True``:同 ``dedup_key`` 的行(无论已读与否)存在则跳过,
|
||||
返回 None(如"新版本可用"每个版本只提醒一次)。
|
||||
- 否则若存在同 ``dedup_key`` 的**未读**行:合并——count+1、刷新
|
||||
updated_at、内容以最新为准(持续性故障不刷屏)。
|
||||
- 其余情况插入新行,并裁剪到最近 ``keep`` 条。
|
||||
"""
|
||||
if dedup_key is not None:
|
||||
statement = select(InboxMessage).where(InboxMessage.dedup_key == dedup_key)
|
||||
if not once:
|
||||
statement = statement.where(InboxMessage.read == False) # noqa: E712
|
||||
result = await self.session.execute(statement)
|
||||
existing = result.scalars().first()
|
||||
if existing is not None:
|
||||
if once:
|
||||
return None
|
||||
existing.count += 1
|
||||
existing.severity = severity
|
||||
existing.title = title
|
||||
existing.body = body
|
||||
existing.payload = payload
|
||||
existing.updated_at = utcnow_iso()
|
||||
self.session.add(existing)
|
||||
await self.session.commit()
|
||||
return existing
|
||||
|
||||
message = InboxMessage(
|
||||
kind=kind,
|
||||
severity=severity,
|
||||
title=title,
|
||||
body=body,
|
||||
payload=payload,
|
||||
dedup_key=dedup_key,
|
||||
)
|
||||
self.session.add(message)
|
||||
await self.session.commit()
|
||||
await self.prune(keep=keep)
|
||||
return message
|
||||
|
||||
async def list(
|
||||
self, unread_only: bool = False, limit: int = 50, offset: int = 0
|
||||
) -> list[InboxMessage]:
|
||||
statement = select(InboxMessage)
|
||||
if unread_only:
|
||||
statement = statement.where(InboxMessage.read == False) # noqa: E712
|
||||
statement = (
|
||||
statement.order_by(
|
||||
InboxMessage.updated_at.desc(), # type: ignore[attr-defined]
|
||||
InboxMessage.id.desc(), # type: ignore[attr-defined]
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self.session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_all(self, unread_only: bool = False) -> int:
|
||||
statement = select(func.count()).select_from(InboxMessage)
|
||||
if unread_only:
|
||||
statement = statement.where(InboxMessage.read == False) # noqa: E712
|
||||
result = await self.session.execute(statement)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def unread_count(self) -> int:
|
||||
return await self.count_all(unread_only=True)
|
||||
|
||||
async def latest_id(self) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.max(InboxMessage.id)).select_from(InboxMessage)
|
||||
)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
async def mark_read(self, message_id: int) -> bool:
|
||||
message = await self.session.get(InboxMessage, message_id)
|
||||
if message is None:
|
||||
return False
|
||||
message.read = True
|
||||
self.session.add(message)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def mark_all_read(self) -> int:
|
||||
result = await self.session.execute(
|
||||
update(InboxMessage)
|
||||
.where(InboxMessage.read == False) # noqa: E712
|
||||
.values(read=True)
|
||||
)
|
||||
await self.session.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
async def delete(self, message_id: int) -> bool:
|
||||
message = await self.session.get(InboxMessage, message_id)
|
||||
if message is None:
|
||||
return False
|
||||
await self.session.delete(message)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def clear(self) -> int:
|
||||
result = await self.session.execute(delete(InboxMessage))
|
||||
await self.session.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
async def prune(self, keep: int = DEFAULT_KEEP) -> int:
|
||||
"""删除最近 ``keep`` 条以外的旧消息,返回删除数量。"""
|
||||
stale_ids_stmt = (
|
||||
select(InboxMessage.id)
|
||||
.order_by(
|
||||
InboxMessage.updated_at.desc(), # type: ignore[attr-defined]
|
||||
InboxMessage.id.desc(), # type: ignore[attr-defined]
|
||||
)
|
||||
.offset(keep)
|
||||
)
|
||||
result = await self.session.execute(stale_ids_stmt)
|
||||
stale_ids = list(result.scalars().all())
|
||||
if not stale_ids:
|
||||
return 0
|
||||
await self.session.execute(
|
||||
delete(InboxMessage).where(InboxMessage.id.in_(stale_ids)) # type: ignore[attr-defined]
|
||||
)
|
||||
await self.session.commit()
|
||||
return len(stale_ids)
|
||||
69
backend/src/module/database/llm_credential.py
Normal file
69
backend/src/module/database/llm_credential.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""订阅提供商凭据仓储。读写以 TokenSet 为交换格式(extra 透明 JSON 编解码)。"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import func, select
|
||||
|
||||
from module.models.llm_credential import LLMCredential, utcnow_iso
|
||||
from module.parser.analyser.providers.base import TokenSet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMCredentialDatabase:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _row(self, provider_id: str) -> Optional[LLMCredential]:
|
||||
result = await self.session.execute(
|
||||
select(LLMCredential).where(LLMCredential.provider_id == provider_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get(self, provider_id: str) -> Optional[TokenSet]:
|
||||
row = await self._row(provider_id)
|
||||
if row is None:
|
||||
return None
|
||||
extra: dict = {}
|
||||
if row.extra:
|
||||
try:
|
||||
extra = json.loads(row.extra)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Corrupt credential extra for %s", provider_id)
|
||||
return TokenSet(
|
||||
access_token=row.access_token,
|
||||
refresh_token=row.refresh_token,
|
||||
expires_at=row.expires_at,
|
||||
account_label=row.account_label,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
async def upsert(self, provider_id: str, tokens: TokenSet) -> None:
|
||||
row = await self._row(provider_id)
|
||||
if row is None:
|
||||
row = LLMCredential(provider_id=provider_id)
|
||||
row.access_token = tokens.access_token
|
||||
row.refresh_token = tokens.refresh_token
|
||||
row.expires_at = tokens.expires_at
|
||||
row.account_label = tokens.account_label
|
||||
row.extra = json.dumps(tokens.extra, ensure_ascii=False)
|
||||
row.updated_at = utcnow_iso()
|
||||
self.session.add(row)
|
||||
await self.session.commit()
|
||||
|
||||
async def delete(self, provider_id: str) -> bool:
|
||||
row = await self._row(provider_id)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def count(self) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.count()).select_from(LLMCredential)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
@@ -18,6 +18,8 @@ from sqlalchemy import Connection, Engine, inspect, text
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from module.models import Bangumi, User
|
||||
from module.models.inbox import InboxMessage
|
||||
from module.models.llm_credential import LLMCredential
|
||||
from module.models.passkey import Passkey
|
||||
from module.models.rss import RSSItem
|
||||
from module.models.torrent import Torrent
|
||||
@@ -25,7 +27,15 @@ from module.models.torrent import Torrent
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 所有需要进行空值填充的表模型
|
||||
TABLE_MODELS: list[type[SQLModel]] = [Bangumi, RSSItem, Torrent, User, Passkey]
|
||||
TABLE_MODELS: list[type[SQLModel]] = [
|
||||
Bangumi,
|
||||
RSSItem,
|
||||
Torrent,
|
||||
User,
|
||||
Passkey,
|
||||
InboxMessage,
|
||||
LLMCredential,
|
||||
]
|
||||
|
||||
# already_applied 守卫:接收 inspector,返回该迁移是否已生效
|
||||
AppliedCheck = Callable[[Any], bool]
|
||||
@@ -330,6 +340,49 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
("ALTER TABLE aria2_gid ADD COLUMN renamed_paths TEXT DEFAULT NULL",),
|
||||
column_exists("aria2_gid", "renamed_paths"),
|
||||
),
|
||||
Migration(
|
||||
16,
|
||||
"create inboxmessage table for the in-app notification center",
|
||||
(
|
||||
"""CREATE TABLE IF NOT EXISTS inboxmessage (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT,
|
||||
dedup_key TEXT,
|
||||
read BOOLEAN NOT NULL DEFAULT 0,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_inboxmessage_kind ON inboxmessage(kind)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_inboxmessage_dedup_key "
|
||||
"ON inboxmessage(dedup_key)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_inboxmessage_read ON inboxmessage(read)",
|
||||
),
|
||||
table_exists("inboxmessage"),
|
||||
),
|
||||
Migration(
|
||||
17,
|
||||
"create llmcredential table for subscription LLM provider tokens",
|
||||
(
|
||||
"""CREATE TABLE IF NOT EXISTS llmcredential (
|
||||
id INTEGER PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL DEFAULT '',
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at REAL,
|
||||
account_label TEXT NOT NULL DEFAULT '',
|
||||
extra TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)""",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_llmcredential_provider_id "
|
||||
"ON llmcredential(provider_id)",
|
||||
),
|
||||
table_exists("llmcredential"),
|
||||
),
|
||||
)
|
||||
|
||||
# 由迁移列表派生,新增迁移时无需手动同步
|
||||
|
||||
@@ -33,6 +33,9 @@ class QbDownloader:
|
||||
self.ssl = ssl
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._authed = False
|
||||
# 最近一次 auth 失败的原因(unreachable | credentials | banned),
|
||||
# 供上层(checker/startup 等待循环)区分故障类型;成功后清空。
|
||||
self.last_auth_error: str | None = None
|
||||
# Single-flights the reactive 403 re-login below so concurrent
|
||||
# requests don't each fire their own login attempt and trip
|
||||
# qBittorrent's IP ban (#1046-adjacent).
|
||||
@@ -81,18 +84,34 @@ class QbDownloader:
|
||||
resp.status_code == 200 and resp.text.startswith("Ok")
|
||||
) or resp.status_code == 204:
|
||||
self._authed = True
|
||||
self.last_auth_error = None
|
||||
return True
|
||||
elif (
|
||||
resp.status_code == 200 and resp.text.startswith("Fails")
|
||||
) or resp.status_code == 401:
|
||||
# 密码错误重试只会触发 qB 的 WebUI IP ban(默认 5 次失败
|
||||
# 即封禁,之后全部 403),必须立刻停下并明确提示。
|
||||
self.last_auth_error = "credentials"
|
||||
logger.error(
|
||||
"qBittorrent rejected the username or password. "
|
||||
"Check the downloader credentials in AutoBangumi settings; "
|
||||
"not retrying to avoid a WebUI IP ban."
|
||||
)
|
||||
break
|
||||
elif resp.status_code == 403:
|
||||
self.last_auth_error = "banned"
|
||||
logger.error("Login refused by qBittorrent Server")
|
||||
logger.info("Please release the IP in qBittorrent Server")
|
||||
break
|
||||
else:
|
||||
self.last_auth_error = "unreachable"
|
||||
logger.error(
|
||||
f"Can't login qBittorrent Server {self.host} by {self.username}, retry in 5 seconds."
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
times += 1
|
||||
except httpx.ConnectError as e:
|
||||
self.last_auth_error = "unreachable"
|
||||
if use_https:
|
||||
logger.error(
|
||||
"Cannot connect to qBittorrent Server via HTTPS. "
|
||||
@@ -105,6 +124,7 @@ class QbDownloader:
|
||||
await asyncio.sleep(10)
|
||||
times += 1
|
||||
except Exception as e:
|
||||
self.last_auth_error = "unreachable"
|
||||
if use_https and "ssl" in str(e).lower():
|
||||
logger.error(
|
||||
"TLS/SSL error connecting to qBittorrent. "
|
||||
|
||||
@@ -132,6 +132,14 @@ class DownloadClient:
|
||||
logger.error("Unsupported downloader type: %s", downloader_type)
|
||||
raise Exception(f"Unsupported downloader type: {downloader_type}")
|
||||
|
||||
@property
|
||||
def last_auth_error(self) -> str | None:
|
||||
"""最近一次认证失败的原因(unreachable | credentials | banned)。
|
||||
|
||||
仅 qBittorrent 客户端会区分原因;aria2/mock 无此属性时返回 None。
|
||||
"""
|
||||
return getattr(self.client, "last_auth_error", None)
|
||||
|
||||
def _supports(self, capability: str, op: str) -> bool:
|
||||
"""Whether the concrete client can perform ``op`` (log once if not)."""
|
||||
caps = getattr(self.client, "capabilities", None)
|
||||
|
||||
3
backend/src/module/llm_plugins/__init__.py
Normal file
3
backend/src/module/llm_plugins/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .installer import DEFAULT_PLUGINS_ROOT, InstallResult, PluginInstaller
|
||||
|
||||
__all__ = ["DEFAULT_PLUGINS_ROOT", "InstallResult", "PluginInstaller"]
|
||||
240
backend/src/module/llm_plugins/installer.py
Normal file
240
backend/src/module/llm_plugins/installer.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""LLM 提供商插件安装管线(克隆 update.updater 的下载/验签/解包模式)。
|
||||
|
||||
安全边界与更新系统一致:
|
||||
1. catalog.json 本身也验签(防索引篡改/降级);
|
||||
2. 每个插件 zip 校验 sha256 + ed25519 签名,拒绝未签名/坏签名;
|
||||
3. _safe_extract 防 zip-slip;
|
||||
4. manifest schema/id/entry/min_ab_version 逐项校验。
|
||||
插件依赖仅限 stdlib + httpx + pydantic(应用已有),无 pip 安装。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import semver
|
||||
|
||||
from module.database import Database
|
||||
from module.update.signing import DEFAULT_PUBKEY_PATH, verify_bundle_signature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 插件目录与 catalog 托管位置。
|
||||
GITHUB_OWNER = "EstrellaXD"
|
||||
GITHUB_REPO = "Auto_Bangumi"
|
||||
_CATALOG_TAG = "llm-plugins"
|
||||
_RELEASE_BASE = (
|
||||
f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{_CATALOG_TAG}"
|
||||
)
|
||||
_DL_HEADERS = {"User-Agent": "AutoBangumi-Plugins"}
|
||||
DEFAULT_PLUGINS_ROOT = Path("config") / "plugins"
|
||||
|
||||
_MANIFEST_SCHEMA = 1
|
||||
|
||||
|
||||
def _meets_min_version(app_version: str, min_ab: str) -> bool:
|
||||
"""app_version 是否满足 min_ab。非 semver 的开发版(DEV_VERSION)放行。"""
|
||||
try:
|
||||
return semver.VersionInfo.parse(app_version).compare(min_ab) >= 0
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallResult:
|
||||
success: bool
|
||||
version: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
def _safe_extract(zip_path: Path, dest: Path) -> None:
|
||||
"""解压 zip 到 dest,拒绝绝对路径/`..` 越界成员(防 zip-slip)。"""
|
||||
import zipfile
|
||||
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
dest_resolved = dest.resolve()
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for member in zf.namelist():
|
||||
target = (dest / member).resolve()
|
||||
if not target.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Unsafe path in plugin: {member}")
|
||||
zf.extractall(dest)
|
||||
|
||||
|
||||
def _sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class PluginInstaller:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
root: Optional[Path] = None,
|
||||
pubkey_path: Optional[Path] = None,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
app_version: str = "0.0.0",
|
||||
) -> None:
|
||||
self.root = root if root is not None else DEFAULT_PLUGINS_ROOT
|
||||
self.pubkey_path = pubkey_path or DEFAULT_PUBKEY_PATH
|
||||
self._client = client
|
||||
self.app_version = app_version
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
from module.network.request_url import get_shared_client
|
||||
|
||||
return await get_shared_client()
|
||||
|
||||
async def _download_bytes(self, url: str) -> bytes:
|
||||
client = await self._get_client()
|
||||
resp = await client.get(url, headers=_DL_HEADERS)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def _download_text(self, url: str) -> str:
|
||||
client = await self._get_client()
|
||||
resp = await client.get(url, headers=_DL_HEADERS)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
async def fetch_catalog(self) -> list[dict]:
|
||||
"""拉取并验签 catalog.json,返回插件条目列表。"""
|
||||
catalog_bytes = await self._download_bytes(f"{_RELEASE_BASE}/catalog.json")
|
||||
sig = await self._download_text(f"{_RELEASE_BASE}/catalog.json.sig")
|
||||
tmp = self.root / ".catalog.json"
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
tmp.write_bytes(catalog_bytes)
|
||||
try:
|
||||
if not verify_bundle_signature(tmp, sig, self.pubkey_path):
|
||||
raise ValueError("catalog signature verification failed")
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
catalog = json.loads(catalog_bytes)
|
||||
if not isinstance(catalog, dict) or catalog.get("schema") != _MANIFEST_SCHEMA:
|
||||
raise ValueError("Unsupported catalog schema")
|
||||
plugins = catalog.get("plugins", [])
|
||||
return plugins if isinstance(plugins, list) else []
|
||||
|
||||
def _is_builtin(self, plugin_id: str) -> bool:
|
||||
from module.parser.analyser.providers.builtin import BUILTIN
|
||||
from module.parser.analyser.providers.presets import PRESET_ADAPTERS
|
||||
|
||||
return plugin_id in BUILTIN or plugin_id in PRESET_ADAPTERS
|
||||
|
||||
async def install(self, plugin_id: str) -> InstallResult:
|
||||
async with self._lock:
|
||||
try:
|
||||
return await self._install(plugin_id)
|
||||
except Exception as e: # noqa: BLE001 - 统一转成失败结果
|
||||
logger.warning("Plugin install failed for %s: %s", plugin_id, e)
|
||||
return InstallResult(success=False, message=str(e))
|
||||
|
||||
async def _install(self, plugin_id: str) -> InstallResult:
|
||||
if self._is_builtin(plugin_id):
|
||||
return InstallResult(
|
||||
success=False, message=f"{plugin_id} is a builtin provider"
|
||||
)
|
||||
catalog = await self.fetch_catalog()
|
||||
entry = next((p for p in catalog if p.get("id") == plugin_id), None)
|
||||
if entry is None:
|
||||
return InstallResult(
|
||||
success=False, message=f"Plugin not found in catalog: {plugin_id}"
|
||||
)
|
||||
version = entry.get("version", "")
|
||||
min_ab = entry.get("min_ab_version", "0.0.0")
|
||||
if not _meets_min_version(self.app_version, min_ab):
|
||||
return InstallResult(
|
||||
success=False,
|
||||
message=f"Plugin requires AutoBangumi >= {min_ab}",
|
||||
)
|
||||
|
||||
asset = entry.get("asset") or f"{plugin_id}-{version}.zip"
|
||||
zip_bytes = await self._download_bytes(f"{_RELEASE_BASE}/{asset}")
|
||||
if _sha256_bytes(zip_bytes) != entry.get("sha256"):
|
||||
return InstallResult(
|
||||
success=False, message="Plugin sha256 checksum mismatch"
|
||||
)
|
||||
|
||||
staging = self.root / ".staging" / plugin_id
|
||||
staging.parent.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = self.root / ".staging" / f"{plugin_id}.zip"
|
||||
zip_path.write_bytes(zip_bytes)
|
||||
sig = await self._download_text(f"{_RELEASE_BASE}/{asset}.sig")
|
||||
if not verify_bundle_signature(zip_path, sig, self.pubkey_path):
|
||||
zip_path.unlink(missing_ok=True)
|
||||
return InstallResult(success=False, message="Plugin signature invalid")
|
||||
|
||||
_safe_extract(zip_path, staging)
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
manifest = self._read_manifest(staging)
|
||||
problem = self._validate_manifest(manifest, plugin_id)
|
||||
if problem:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return InstallResult(success=False, message=problem)
|
||||
|
||||
# promote:config/plugins/<id>/<version>/ + installed.json
|
||||
target = self.root / plugin_id / version
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(staging), str(target))
|
||||
(self.root / plugin_id / "installed.json").write_text(
|
||||
json.dumps({"version": version}), encoding="utf-8"
|
||||
)
|
||||
|
||||
self._reload_registry(plugin_id)
|
||||
return InstallResult(success=True, version=version)
|
||||
|
||||
def _read_manifest(self, unpacked: Path) -> dict:
|
||||
data = json.loads((unpacked / "plugin.json").read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("plugin.json is not an object")
|
||||
return data
|
||||
|
||||
def _validate_manifest(self, manifest: dict, plugin_id: str) -> Optional[str]:
|
||||
if manifest.get("schema") != _MANIFEST_SCHEMA:
|
||||
return "Unsupported plugin manifest schema"
|
||||
if manifest.get("id") != plugin_id:
|
||||
return "Plugin id mismatch between catalog and manifest"
|
||||
entry = manifest.get("entry", "")
|
||||
if ":" not in entry:
|
||||
return "Invalid plugin entry"
|
||||
min_ab = manifest.get("min_ab_version", "0.0.0")
|
||||
if not _meets_min_version(self.app_version, min_ab):
|
||||
return f"Plugin requires AutoBangumi >= {min_ab}"
|
||||
return None
|
||||
|
||||
def _reload_registry(self, plugin_id: str) -> None:
|
||||
from module.parser import title_parser
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
# 让全局注册表从本安装器的插件根扫描(生产环境两者默认同为
|
||||
# config/plugins;测试用 tmp_path 时也据此对齐)。
|
||||
registry._plugins_root = self.root
|
||||
registry.invalidate(plugin_id)
|
||||
title_parser.reset_cache()
|
||||
|
||||
async def uninstall(self, plugin_id: str) -> InstallResult:
|
||||
async with self._lock:
|
||||
if self._is_builtin(plugin_id):
|
||||
return InstallResult(
|
||||
success=False, message=f"{plugin_id} is a builtin provider"
|
||||
)
|
||||
plugin_dir = self.root / plugin_id
|
||||
if plugin_dir.exists():
|
||||
shutil.rmtree(plugin_dir, ignore_errors=True)
|
||||
async with Database() as db:
|
||||
await db.llm_credential.delete(plugin_id)
|
||||
self._reload_registry(plugin_id)
|
||||
return InstallResult(success=True)
|
||||
65
backend/src/module/llm_plugins/loader.py
Normal file
65
backend/src/module/llm_plugins/loader.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""从已安装的插件树按 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
|
||||
@@ -1,5 +1,7 @@
|
||||
from .bangumi import Bangumi, BangumiUpdate, Episode, Notification
|
||||
from .config import Config
|
||||
from .inbox import InboxMessage
|
||||
from .llm_credential import LLMCredential
|
||||
from .passkey import Passkey, PasskeyCreate, PasskeyDelete, PasskeyList
|
||||
from .response import APIResponse, ResponseModel
|
||||
from .rss import RSSItem, RSSUpdate
|
||||
|
||||
@@ -232,17 +232,26 @@ class Notification(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class LLMProviderOverride(BaseModel):
|
||||
"""单个提供商的凭据/模型/端点覆盖(键名含 api_key,掩码机制自动生效)。"""
|
||||
|
||||
api_key: str = Field(default="", description="Provider API key")
|
||||
model: str = Field(default="", description="Model override")
|
||||
base_url: str = Field(default="", description="Base URL override")
|
||||
|
||||
|
||||
class LLM(BaseModel):
|
||||
"""LLM 标题解析配置,支持多提供商。
|
||||
|
||||
``provider="openai"`` 表示任意 OpenAI 兼容端点(DeepSeek/Ollama/
|
||||
LM Studio/OpenRouter/OneAPI 等均可通过 ``base_url`` 接入)。
|
||||
扁平的 ``api_key/model/base_url`` 是历史字段(老用户零迁移);
|
||||
``providers`` 按提供商 id 存放各自的覆盖值,取值见 ``effective()``。
|
||||
"""
|
||||
|
||||
enable: bool = Field(default=False, description="Enable LLM parser")
|
||||
provider: Literal["openai", "anthropic", "gemini"] = Field(
|
||||
default="openai", description="LLM provider"
|
||||
)
|
||||
# 开放为任意注册表 id:内置三家 + base_url 预设 + 已安装插件
|
||||
provider: str = Field(default="openai", min_length=1, description="LLM provider id")
|
||||
api_key: str = Field(default="", description="LLM api key")
|
||||
model: str = Field(default="gpt-5-mini", description="LLM model name")
|
||||
base_url: str = Field(
|
||||
@@ -280,6 +289,24 @@ class LLM(BaseModel):
|
||||
ge=0,
|
||||
description="Seconds to skip LLM calls after failure_threshold is reached",
|
||||
)
|
||||
providers: dict[str, LLMProviderOverride] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-provider overrides keyed by provider id",
|
||||
)
|
||||
|
||||
def effective(self, provider_id: str | None = None) -> tuple[str, str, str]:
|
||||
"""返回 (api_key, model, base_url)。
|
||||
|
||||
有 providers[id] 覆盖项时用其值(可为空,交由适配器回退到该提供商
|
||||
自己的默认端点/型号);不跨提供商回退到扁平的 openai 字段——否则
|
||||
DeepSeek 等会误收到 openai 的 model/base_url。仅当无覆盖项(内置
|
||||
openai 默认路径)才用扁平字段。
|
||||
"""
|
||||
pid = provider_id or self.provider
|
||||
override = self.providers.get(pid)
|
||||
if override is None:
|
||||
return self.api_key, self.model, self.base_url
|
||||
return override.api_key, override.model, override.base_url
|
||||
|
||||
|
||||
# [Deprecated] 旧版 OpenAI 解析配置,仅保留用于读取旧配置文件(向后兼容)。
|
||||
@@ -357,7 +384,8 @@ class Update(BaseModel):
|
||||
"""在线自动更新配置。
|
||||
|
||||
``channel`` 决定检查更新时挑选稳定版还是包含预发布(beta)版本;
|
||||
``auto_check`` 控制前端是否在进入设置页时自动检查一次更新。
|
||||
``auto_check`` 控制自动检查更新:前端进入设置页时检查一次,
|
||||
后端每 24 小时检查一次并把新版本写入通知中心。
|
||||
"""
|
||||
|
||||
channel: Literal["stable", "beta"] = Field(
|
||||
|
||||
29
backend/src/module/models/inbox.py
Normal file
29
backend/src/module/models/inbox.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""站内通知(通知中心)消息模型。
|
||||
|
||||
一行 = 一条通知。``kind`` + JSON ``payload`` 供前端按语言渲染文案,
|
||||
``title``/``body`` 为 ``describe()`` 生成的中文兜底文案(未知 kind 时直接展示)。
|
||||
时间戳沿用 RSSItem 的约定:UTC ISO-8601 字符串(字典序即时间序)。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class InboxMessage(SQLModel, table=True):
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
kind: str = Field("", index=True)
|
||||
severity: str = Field("info") # info | warning | error
|
||||
title: str = Field("")
|
||||
body: str = Field("")
|
||||
payload: Optional[str] = Field(None) # JSON 字符串
|
||||
dedup_key: Optional[str] = Field(None, index=True)
|
||||
read: bool = Field(False, index=True)
|
||||
count: int = Field(1) # 合并计数(同 dedup_key 未读重复出现的次数)
|
||||
created_at: str = Field(default_factory=utcnow_iso)
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
25
backend/src/module/models/llm_credential.py
Normal file
25
backend/src/module/models/llm_credential.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""订阅类 LLM 提供商的凭据行(OAuth/device-flow token)。
|
||||
|
||||
刻意存数据库而非 config:token 永远不进 ``settings.dict()``,因此不可能
|
||||
经 ``GET /config/get`` 泄漏;前端只能通过 auth/status 看到连接状态。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class LLMCredential(SQLModel, table=True):
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
provider_id: str = Field("", index=True, unique=True)
|
||||
access_token: str = Field("")
|
||||
refresh_token: str = Field("")
|
||||
expires_at: Optional[float] = Field(None) # epoch 秒
|
||||
account_label: str = Field("")
|
||||
extra: Optional[str] = Field(None) # JSON 字符串(provider 自定义)
|
||||
updated_at: str = Field(default_factory=utcnow_iso)
|
||||
@@ -1,19 +1,29 @@
|
||||
from .base import NotificationProvider
|
||||
from .events import (
|
||||
DownloaderUnavailableEvent,
|
||||
DownloadFailureEvent,
|
||||
LLMAuthFailureEvent,
|
||||
LLMPluginInstallFailedEvent,
|
||||
OffsetReviewEvent,
|
||||
RssFailureEvent,
|
||||
SystemEvent,
|
||||
UpdateAppliedEvent,
|
||||
UpdateAvailableEvent,
|
||||
)
|
||||
from .manager import NotificationManager
|
||||
from .providers import PROVIDER_REGISTRY
|
||||
|
||||
__all__ = [
|
||||
"DownloadFailureEvent",
|
||||
"DownloaderUnavailableEvent",
|
||||
"LLMAuthFailureEvent",
|
||||
"LLMPluginInstallFailedEvent",
|
||||
"NotificationManager",
|
||||
"NotificationProvider",
|
||||
"OffsetReviewEvent",
|
||||
"PROVIDER_REGISTRY",
|
||||
"RssFailureEvent",
|
||||
"SystemEvent",
|
||||
"UpdateAppliedEvent",
|
||||
"UpdateAvailableEvent",
|
||||
]
|
||||
|
||||
@@ -3,19 +3,38 @@
|
||||
刻意与 ``Notification``(module/models/bangumi.py,仅描述「新集数下载完成」)
|
||||
分开:RSS 订阅失败、下载添加失败、偏移量待确认三类事件携带的字段与集数通知完全
|
||||
无关,硬塞进 ``Notification`` 只会制造出没有意义的 season/episode 占位值。
|
||||
|
||||
每个事件同时服务两个消费方:
|
||||
|
||||
- 外部推送(Telegram/Bark 等):``describe() -> (标题, 正文)`` 渲染中文文案;
|
||||
- 站内通知中心:``kind``/``severity``/``once``/``dedup_key()``/``payload()``
|
||||
提供结构化字段,前端按 kind + payload 做多语言渲染,describe() 文案作为
|
||||
未知 kind 的兜底展示。``once=True`` 表示同 dedup_key 终生只入库一次
|
||||
(如"新版本可用",已读后同一版本不再提醒)。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RssFailureEvent:
|
||||
"""RSS 订阅连接状态从正常变为异常(仅状态翻转时触发一次,而非每个 tick)。"""
|
||||
|
||||
kind: ClassVar[str] = "rss_failure"
|
||||
severity: ClassVar[str] = "error"
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
rss_name: str
|
||||
rss_url: str
|
||||
error: str
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return f"rss_failure:{self.rss_url}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {"rss_name": self.rss_name, "rss_url": self.rss_url, "error": self.error}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
"""返回该事件的默认 (标题, 正文)。"""
|
||||
return (
|
||||
@@ -28,9 +47,22 @@ class RssFailureEvent:
|
||||
class DownloadFailureEvent:
|
||||
"""匹配到的种子在(内置 HTTP 重试后)仍添加下载器失败。"""
|
||||
|
||||
kind: ClassVar[str] = "download_failure"
|
||||
severity: ClassVar[str] = "error"
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
official_title: str
|
||||
torrent_name: str
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return f"download_failure:{self.official_title}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {
|
||||
"official_title": self.official_title,
|
||||
"torrent_name": self.torrent_name,
|
||||
}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
return (
|
||||
"种子添加失败",
|
||||
@@ -43,9 +75,19 @@ class DownloadFailureEvent:
|
||||
class OffsetReviewEvent:
|
||||
"""番剧被标记为需要人工确认季度/集数偏移。"""
|
||||
|
||||
kind: ClassVar[str] = "offset_review"
|
||||
severity: ClassVar[str] = "warning"
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
official_title: str
|
||||
reason: str
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return f"offset_review:{self.official_title}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {"official_title": self.official_title, "reason": self.reason}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
return (
|
||||
"集数偏移待确认",
|
||||
@@ -53,5 +95,167 @@ class OffsetReviewEvent:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DownloaderUnavailableEvent:
|
||||
"""下载器不可用:连不上、用户名/密码错误或 IP 被封禁。
|
||||
|
||||
``reason`` 只进 payload 不进 dedup_key——同一台下载器从 unreachable 翻转
|
||||
到 credentials 时合并为一条(内容以最新为准),不产生两行。
|
||||
"""
|
||||
|
||||
kind: ClassVar[str] = "downloader_unavailable"
|
||||
severity: ClassVar[str] = "error"
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
host: str
|
||||
reason: str # unreachable | credentials | banned
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return f"downloader:{self.host}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {"host": self.host, "reason": self.reason}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
details = {
|
||||
"credentials": "用户名或密码错误,请在设置中检查下载器凭据。",
|
||||
"banned": "IP 已被下载器封禁,请在下载器 WebUI 中解封或重启下载器。",
|
||||
"unreachable": "无法连接下载器,请检查地址、端口和网络。",
|
||||
}
|
||||
detail = details.get(self.reason, details["unreachable"])
|
||||
return ("下载器连接异常", f"下载器:{self.host}\n{detail}")
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class UpdateAvailableEvent:
|
||||
"""检查到可用的新版本。"""
|
||||
|
||||
kind: ClassVar[str] = "update_available"
|
||||
severity: ClassVar[str] = "info"
|
||||
once: ClassVar[bool] = True
|
||||
|
||||
current: str
|
||||
latest: str
|
||||
channel: str
|
||||
notes: str = ""
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return f"update_available:{self.latest}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {
|
||||
"current": self.current,
|
||||
"latest": self.latest,
|
||||
"channel": self.channel,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
return (
|
||||
"发现新版本",
|
||||
f"当前版本:{self.current}\n最新版本:{self.latest}({self.channel} 频道)\n"
|
||||
"可前往 设置 → 软件更新 升级。",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class UpdateAppliedEvent:
|
||||
"""更新应用结果(成功或失败)。每次结果都单独入库,不做去重。"""
|
||||
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
version: str
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return "update_applied" if self.success else "update_failed"
|
||||
|
||||
@property
|
||||
def severity(self) -> str:
|
||||
return "info" if self.success else "error"
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {"version": self.version, "message": self.message}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
if self.success:
|
||||
return ("程序更新完成", f"已更新到 {self.version},重启后生效。")
|
||||
return ("程序更新失败", f"版本:{self.version}\n原因:{self.message}")
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LLMAuthFailureEvent:
|
||||
"""订阅类 LLM 提供商凭据失效(刷新失败),需要用户重新连接。"""
|
||||
|
||||
kind: ClassVar[str] = "llm_auth_failure"
|
||||
severity: ClassVar[str] = "error"
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
provider_id: str
|
||||
account_label: str = ""
|
||||
message: str = ""
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return f"llm_auth:{self.provider_id}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"account_label": self.account_label,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
account = f"({self.account_label})" if self.account_label else ""
|
||||
return (
|
||||
"LLM 提供商凭据失效",
|
||||
f"提供商:{self.provider_id}{account}\n{self.message}\n"
|
||||
"请前往 设置 → LLM 解析器 重新连接。",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LLMPluginInstallFailedEvent:
|
||||
"""LLM 提供商插件安装失败(下载/签名校验/兼容性)。"""
|
||||
|
||||
kind: ClassVar[str] = "llm_plugin_install_failed"
|
||||
severity: ClassVar[str] = "error"
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
plugin_id: str
|
||||
version: str = ""
|
||||
message: str = ""
|
||||
|
||||
def dedup_key(self) -> Optional[str]:
|
||||
return f"llm_plugin_install:{self.plugin_id}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {
|
||||
"plugin_id": self.plugin_id,
|
||||
"version": self.version,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
return (
|
||||
"LLM 插件安装失败",
|
||||
f"插件:{self.plugin_id} {self.version}\n原因:{self.message}",
|
||||
)
|
||||
|
||||
|
||||
# 除「新集数」以外的通知事件的联合类型。
|
||||
SystemEvent = RssFailureEvent | DownloadFailureEvent | OffsetReviewEvent
|
||||
SystemEvent = (
|
||||
RssFailureEvent
|
||||
| DownloadFailureEvent
|
||||
| OffsetReviewEvent
|
||||
| DownloaderUnavailableEvent
|
||||
| UpdateAvailableEvent
|
||||
| UpdateAppliedEvent
|
||||
| LLMAuthFailureEvent
|
||||
| LLMPluginInstallFailedEvent
|
||||
)
|
||||
|
||||
48
backend/src/module/notification/inbox.py
Normal file
48
backend/src/module/notification/inbox.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""站内通知中心的持久化 sink。
|
||||
|
||||
``record_event`` 把 SystemEvent 落库(``async with Database()``,
|
||||
session-per-operation);``inbox_revision`` 是进程内单调递增的修订号,
|
||||
任何入库/已读/删除操作都会 bump,SSE 循环据此决定是否需要查库推送,
|
||||
避免每个 tick 都打一次数据库。AB 为单进程部署,进程内计数即可。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from module.database import Database
|
||||
from module.notification.events import SystemEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INBOX_KEEP = 500
|
||||
|
||||
_revision: int = 1
|
||||
|
||||
|
||||
def inbox_revision() -> int:
|
||||
return _revision
|
||||
|
||||
|
||||
def bump_inbox_revision() -> None:
|
||||
global _revision
|
||||
_revision += 1
|
||||
|
||||
|
||||
async def record_event(event: SystemEvent) -> int:
|
||||
"""事件入库,返回消息 id;被 once 去重跳过时返回 0。"""
|
||||
title, body = event.describe()
|
||||
async with Database() as db:
|
||||
message = await db.inbox.upsert(
|
||||
kind=event.kind,
|
||||
severity=event.severity,
|
||||
title=title,
|
||||
body=body,
|
||||
payload=json.dumps(event.payload(), ensure_ascii=False),
|
||||
dedup_key=event.dedup_key(),
|
||||
once=event.once,
|
||||
keep=INBOX_KEEP,
|
||||
)
|
||||
if message is None:
|
||||
return 0
|
||||
bump_inbox_revision()
|
||||
return message.id
|
||||
@@ -8,6 +8,7 @@ from module.conf import settings
|
||||
from module.database import Database
|
||||
from module.models.bangumi import Notification
|
||||
from module.notification.events import SystemEvent
|
||||
from module.notification.inbox import record_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
@@ -115,12 +116,24 @@ class NotificationManager:
|
||||
await self._broadcast("notification", send_one)
|
||||
|
||||
async def send_event(self, event: SystemEvent):
|
||||
"""Send a system event (RSS failure, download failure, offset review)
|
||||
to all enabled providers.
|
||||
"""Persist a system event to the in-app inbox, then broadcast it.
|
||||
|
||||
持久化不受 ``settings.notification.enable`` 影响(该开关只管外部
|
||||
推送),失败也不阻塞外部广播。
|
||||
|
||||
Args:
|
||||
event: The system event to send.
|
||||
"""
|
||||
try:
|
||||
await record_event(event)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist inbox message for %s",
|
||||
type(event).__name__,
|
||||
exc_info=True,
|
||||
)
|
||||
if not settings.notification.enable:
|
||||
return
|
||||
|
||||
async def send_one(provider: "NotificationProvider"):
|
||||
await provider.send_event(event)
|
||||
|
||||
@@ -1,100 +1,45 @@
|
||||
"""多提供商 LLM 标题解析器。
|
||||
"""多提供商 LLM 标题解析器(门面)。
|
||||
|
||||
支持三类提供商:
|
||||
- ``openai``:任意 OpenAI 兼容端点(官方 API、DeepSeek、Ollama、
|
||||
LM Studio、OpenRouter、OneAPI 等,经 ``base_url`` 接入)
|
||||
- ``anthropic``:Anthropic Claude
|
||||
- ``gemini``:Google Gemini
|
||||
具体提供商逻辑在 ``providers/`` 包中(内置 openai/anthropic/gemini 适配器
|
||||
+ 注册表);本模块保留三样东西以维持既有 patch/import 路径:
|
||||
|
||||
各提供商的 ``parse()`` 返回统一的 Episode 形状 dict;任何 API 错误、
|
||||
拒答或输出不可解析时返回 None,由调用方(title_parser)决定回退策略。
|
||||
- ``_build_http_client``:代理感知的 httpx 客户端工厂(测试 patch
|
||||
``module.parser.analyser.llm.httpx`` / ``AsyncProxyTransport``);
|
||||
- ``Episode``/``EPISODE_JSON_SCHEMA``/``DEFAULT_PROMPT`` 等共享契约的
|
||||
重导出(物理定义在 providers/schema.py);
|
||||
- ``LLMParser``:构造签名不变的薄门面,将 ``_openai_client`` 等内部
|
||||
客户端属性双向委托给适配器(测试直接改写/重绑这些属性)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx_socks import AsyncProxyTransport
|
||||
from pydantic import BaseModel
|
||||
|
||||
from module.conf import settings
|
||||
|
||||
# anthropic / openai / google-genai 合计导入约 0.7s,而 LLM 默认关闭。
|
||||
# 这些重型 SDK 改为在真正构造/调用某提供商时才导入(见 __init__ 与各
|
||||
# _parse_* 方法),把这段耗时移出容器启动路径。httpx / httpx_socks 保持
|
||||
# 顶层导入:它们本就在网络层被加载,且测试按模块级名字 patch 它们。
|
||||
from .providers.base import AdapterContext
|
||||
from .providers.credentials import CredentialStore
|
||||
from .providers.registry import registry
|
||||
from .providers.schema import ( # noqa: F401 (re-exported for existing importers)
|
||||
DEFAULT_PROMPT,
|
||||
EPISODE_JSON_SCHEMA,
|
||||
GEMINI_JSON_INSTRUCTION,
|
||||
Episode,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Episode(BaseModel):
|
||||
"""LLM 结构化输出的目标形状(与 models.bangumi.Episode 字段一致)。"""
|
||||
|
||||
title_en: Optional[str]
|
||||
title_zh: Optional[str]
|
||||
title_jp: Optional[str]
|
||||
season: int
|
||||
season_raw: str
|
||||
episode: int
|
||||
sub: str
|
||||
group: str
|
||||
resolution: str
|
||||
source: str
|
||||
|
||||
|
||||
# Anthropic 结构化输出要求:所有属性均列入 required,
|
||||
# additionalProperties 为 false,且不带 min/max 等约束
|
||||
EPISODE_JSON_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title_en": {"type": ["string", "null"]},
|
||||
"title_zh": {"type": ["string", "null"]},
|
||||
"title_jp": {"type": ["string", "null"]},
|
||||
"season": {"type": "integer"},
|
||||
"season_raw": {"type": "string"},
|
||||
"episode": {"type": "integer"},
|
||||
"sub": {"type": "string"},
|
||||
"group": {"type": "string"},
|
||||
"resolution": {"type": "string"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"title_en",
|
||||
"title_zh",
|
||||
"title_jp",
|
||||
"season",
|
||||
"season_raw",
|
||||
"episode",
|
||||
"sub",
|
||||
"group",
|
||||
"resolution",
|
||||
"source",
|
||||
],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_PROMPT = """\
|
||||
You will now play the role of a super assistant.
|
||||
Your task is to extract structured data from unstructured text content and output it in JSON format.
|
||||
If you are unable to extract any information, please keep all fields and leave the field empty or default value like `''`, `None`.
|
||||
But Do not fabricate data!
|
||||
"""
|
||||
|
||||
# Gemini 走 response_mime_type + 提示词描述 JSON 形状(避免 SDK schema
|
||||
# 类型与 mypy 冲突),因此在提示词里显式给出字段列表
|
||||
GEMINI_JSON_INSTRUCTION = (
|
||||
"Output a single JSON object with exactly these keys: "
|
||||
"title_en (string or null), title_zh (string or null), "
|
||||
"title_jp (string or null), season (integer), season_raw (string), "
|
||||
"episode (integer), sub (string), group (string), "
|
||||
"resolution (string), source (string)."
|
||||
# 测试通过这些属性名直接操作/重绑底层 SDK 客户端;门面把它们的读写
|
||||
# 都转发给适配器实例。
|
||||
_ADAPTER_ATTRS = frozenset(
|
||||
{"_openai_client", "_anthropic_client", "_gemini_client", "_http_client"}
|
||||
)
|
||||
|
||||
|
||||
class LLMParser:
|
||||
"""按 provider 分发的 LLM 标题解析器。"""
|
||||
"""按 provider 分发的 LLM 标题解析器(适配器门面)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -104,76 +49,51 @@ class LLMParser:
|
||||
base_url: str = "",
|
||||
timeout: float = 20.0,
|
||||
) -> None:
|
||||
"""初始化解析器并构建对应提供商的异步客户端。
|
||||
"""初始化解析器并构建对应提供商的适配器。
|
||||
|
||||
Args:
|
||||
api_key: 提供商 API 密钥,必填。
|
||||
provider: "openai" | "anthropic" | "gemini"。
|
||||
api_key: 提供商 API 密钥(api_key 类提供商必填)。
|
||||
provider: 注册表中的提供商 id。
|
||||
model: 模型名。
|
||||
base_url: 自定义端点,仅 openai 提供商使用;空串表示官方 API。
|
||||
base_url: 自定义端点;空串表示官方 API。
|
||||
timeout: 单次 LLM 请求超时秒数。
|
||||
|
||||
Raises:
|
||||
ValueError: api_key 为空或 provider 不受支持。
|
||||
ValueError: api_key 为空(api_key 类提供商)或 provider 不受支持。
|
||||
"""
|
||||
if not api_key:
|
||||
adapter_cls = registry.resolve(provider)
|
||||
if adapter_cls.info.auth_kind == "api_key" and not api_key:
|
||||
raise ValueError("API key is required.")
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
if provider == "openai":
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
self._http_client = _build_http_client(timeout)
|
||||
self._openai_client = AsyncOpenAI(
|
||||
self._adapter = adapter_cls(
|
||||
AdapterContext(
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
base_url=base_url or None,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
http_client=self._http_client,
|
||||
build_http_client=_build_http_client,
|
||||
# 订阅类适配器 parse/list_models 时读取凭据;api_key 类忽略。
|
||||
credentials=CredentialStore(provider),
|
||||
)
|
||||
elif provider == "anthropic":
|
||||
import anthropic
|
||||
)
|
||||
|
||||
self._http_client = _build_http_client(timeout)
|
||||
self._anthropic_client = anthropic.AsyncAnthropic(
|
||||
api_key=api_key,
|
||||
timeout=timeout,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
elif provider == "gemini":
|
||||
from google import genai
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
# 仅在常规查找失败时触发:把适配器持有的客户端属性透出。
|
||||
return getattr(object.__getattribute__(self, "_adapter"), name)
|
||||
|
||||
self._gemini_client = genai.Client(api_key=api_key)
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if name in _ADAPTER_ATTRS:
|
||||
setattr(self._adapter, name, value)
|
||||
else:
|
||||
raise ValueError(f"Unsupported LLM provider: {provider}")
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._http_client is not None:
|
||||
await self._http_client.aclose()
|
||||
await self._adapter.aclose()
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
"""列出当前提供商可用的模型 id(升序)。
|
||||
|
||||
openai 走 ``/models``(自定义 base_url 时即该端点的模型表);
|
||||
gemini 只保留支持 generateContent 的模型并去掉 ``models/`` 前缀。
|
||||
SDK 异常原样抛出,由调用方决定如何呈现。
|
||||
"""
|
||||
ids: list[str]
|
||||
if self.provider == "openai":
|
||||
openai_page = await self._openai_client.models.list()
|
||||
ids = [m.id for m in openai_page.data]
|
||||
elif self.provider == "anthropic":
|
||||
anthropic_page = await self._anthropic_client.models.list()
|
||||
ids = [m.id for m in anthropic_page.data]
|
||||
else:
|
||||
ids = []
|
||||
async for m in await self._gemini_client.aio.models.list():
|
||||
actions = getattr(m, "supported_actions", None)
|
||||
if actions and "generateContent" not in actions:
|
||||
continue
|
||||
name = m.name or ""
|
||||
ids.append(name.removeprefix("models/"))
|
||||
return sorted(ids)
|
||||
"""列出当前提供商可用的模型 id(升序)。SDK 异常原样抛出。"""
|
||||
return await self._adapter.list_models()
|
||||
|
||||
async def parse(self, raw: str, asdict: bool = True) -> dict | None:
|
||||
"""解析种子标题,返回 Episode 形状的 dict;失败返回 None。
|
||||
@@ -182,93 +102,10 @@ class LLMParser:
|
||||
raw: 待解析的原始标题。
|
||||
asdict: 保留的兼容参数,结果始终为 dict(或 None)。
|
||||
"""
|
||||
if self.provider == "openai":
|
||||
result = await self._parse_openai(raw)
|
||||
elif self.provider == "anthropic":
|
||||
result = await self._parse_anthropic(raw)
|
||||
else:
|
||||
result = await self._parse_gemini(raw)
|
||||
result = await self._adapter.parse(raw)
|
||||
logger.debug("LLM(%s) parsed result: %s", self.provider, result)
|
||||
return result
|
||||
|
||||
async def _parse_openai(self, raw: str) -> dict | None:
|
||||
import openai as openai_sdk
|
||||
|
||||
try:
|
||||
resp = await self._openai_client.beta.chat.completions.parse(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": DEFAULT_PROMPT},
|
||||
{"role": "user", "content": raw},
|
||||
],
|
||||
response_format=Episode,
|
||||
# temperature 置 0,让结果更稳定可复现
|
||||
temperature=0,
|
||||
)
|
||||
parsed = resp.choices[0].message.parsed
|
||||
except openai_sdk.OpenAIError as e:
|
||||
logger.warning("OpenAI parse failed for '%s': %s", raw, e)
|
||||
return None
|
||||
if parsed is None:
|
||||
logger.warning("OpenAI returned no parsed content for '%s'", raw)
|
||||
return None
|
||||
return parsed.model_dump()
|
||||
|
||||
async def _parse_anthropic(self, raw: str) -> dict | None:
|
||||
import anthropic
|
||||
|
||||
try:
|
||||
resp = await self._anthropic_client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=1024,
|
||||
system=DEFAULT_PROMPT,
|
||||
messages=[{"role": "user", "content": raw}],
|
||||
output_config={
|
||||
"format": {"type": "json_schema", "schema": EPISODE_JSON_SCHEMA}
|
||||
},
|
||||
)
|
||||
except (
|
||||
anthropic.RateLimitError,
|
||||
anthropic.APIStatusError,
|
||||
anthropic.APIConnectionError,
|
||||
) as e:
|
||||
logger.warning("Anthropic parse failed for '%s': %s", raw, e)
|
||||
return None
|
||||
if resp.stop_reason == "refusal":
|
||||
logger.warning("Anthropic refused to parse '%s'", raw)
|
||||
return None
|
||||
try:
|
||||
text = next(b.text for b in resp.content if b.type == "text")
|
||||
return json.loads(text)
|
||||
except (StopIteration, json.JSONDecodeError) as e:
|
||||
logger.warning("Cannot decode Anthropic output for '%s': %s", raw, e)
|
||||
return None
|
||||
|
||||
async def _parse_gemini(self, raw: str) -> dict | None:
|
||||
from google.genai import errors as genai_errors
|
||||
from google.genai import types as genai_types
|
||||
|
||||
try:
|
||||
resp = await self._gemini_client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=f"{DEFAULT_PROMPT}\n{GEMINI_JSON_INSTRUCTION}\n\n{raw}",
|
||||
config=genai_types.GenerateContentConfig(
|
||||
response_mime_type="application/json"
|
||||
),
|
||||
)
|
||||
text = resp.text
|
||||
except genai_errors.APIError as e:
|
||||
logger.warning("Gemini parse failed for '%s': %s", raw, e)
|
||||
return None
|
||||
if not text:
|
||||
logger.warning("Gemini returned no text for '%s'", raw)
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Cannot decode Gemini output for '%s': %s", raw, e)
|
||||
return None
|
||||
|
||||
|
||||
def _build_http_client(timeout: float) -> httpx.AsyncClient:
|
||||
http_timeout = httpx.Timeout(
|
||||
|
||||
20
backend/src/module/parser/analyser/providers/__init__.py
Normal file
20
backend/src/module/parser/analyser/providers/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from .base import (
|
||||
AdapterContext,
|
||||
AuthChallenge,
|
||||
AuthExpiredError,
|
||||
LLMProviderAdapter,
|
||||
ProviderInfo,
|
||||
TokenSet,
|
||||
)
|
||||
from .registry import ProviderRegistry, registry
|
||||
|
||||
__all__ = [
|
||||
"AdapterContext",
|
||||
"AuthChallenge",
|
||||
"AuthExpiredError",
|
||||
"LLMProviderAdapter",
|
||||
"ProviderInfo",
|
||||
"ProviderRegistry",
|
||||
"TokenSet",
|
||||
"registry",
|
||||
]
|
||||
110
backend/src/module/parser/analyser/providers/base.py
Normal file
110
backend/src/module/parser/analyser/providers/base.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""LLM 提供商适配器契约。
|
||||
|
||||
一个提供商 = 一个 `LLMProviderAdapter` 子类:负责客户端构造、
|
||||
`parse`/`list_models`,以及(订阅类提供商)认证流程钩子。内置三家与
|
||||
国产预设走 api_key;订阅插件(device_code/oauth)另行实现认证钩子。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, ClassVar, Literal, Optional
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
"""提供商静态描述,驱动前端表单与注册表列举。"""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
auth_kind: Literal["api_key", "oauth", "device_code"] = "api_key"
|
||||
builtin: bool = False
|
||||
needs_base_url: bool = False
|
||||
preset_base_url: str = ""
|
||||
default_model: str = ""
|
||||
# OpenAI 兼容端点是否支持 json_schema 结构化输出;False 时降级为
|
||||
# json_object + schema 写入提示词(DeepSeek/MiniMax)。
|
||||
supports_json_schema: bool = True
|
||||
plugin_version: Optional[str] = None
|
||||
|
||||
|
||||
class AuthChallenge(BaseModel):
|
||||
"""begin_auth 的产物:引导用户完成授权所需的全部信息。"""
|
||||
|
||||
method: Literal["redirect_paste", "device_code"]
|
||||
authorize_url: Optional[str] = None
|
||||
user_code: Optional[str] = None
|
||||
verification_uri: Optional[str] = None
|
||||
expires_in: int = 600
|
||||
state: str
|
||||
|
||||
|
||||
class TokenSet(BaseModel):
|
||||
"""一次成功认证得到的凭据集(服务端持久化,永不下发前端)。"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str = ""
|
||||
expires_at: Optional[float] = None # epoch 秒
|
||||
account_label: str = ""
|
||||
extra: dict = {}
|
||||
|
||||
|
||||
class AuthExpiredError(Exception):
|
||||
"""凭据失效且刷新失败——调用方应立即熔断并通知用户重新连接。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterContext:
|
||||
"""适配器可触达的全部依赖(除此之外不读全局配置)。"""
|
||||
|
||||
model: str
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
timeout: float = 20.0
|
||||
# llm._build_http_client——代理感知的 httpx 客户端工厂(保持其
|
||||
# patch 路径在 llm.py,因此以依赖注入方式传入)。
|
||||
build_http_client: Callable[[float], httpx.AsyncClient] = None # type: ignore[assignment]
|
||||
# M3 起为 CredentialStore;api_key 类适配器恒为 None。
|
||||
credentials: Any = None
|
||||
|
||||
|
||||
class LLMProviderAdapter(ABC):
|
||||
"""提供商适配器基类。"""
|
||||
|
||||
info: ClassVar[ProviderInfo]
|
||||
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
self.ctx = ctx
|
||||
# 模型留空时回退到本提供商的默认型号(预设/插件各有自己的默认,
|
||||
# 不会误用 openai 的 gpt-5-mini)。
|
||||
self.model = ctx.model or self.info.default_model
|
||||
|
||||
@abstractmethod
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
"""解析种子标题为 Episode 形状 dict;失败返回 None。"""
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> list[str]:
|
||||
"""列出可用模型 id(升序)。"""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._http_client is not None:
|
||||
await self._http_client.aclose()
|
||||
|
||||
# ------------------------------------------------------------ auth hooks
|
||||
# 仅 oauth / device_code 适配器覆写;api_key 类默认不支持。
|
||||
|
||||
async def begin_auth(self) -> AuthChallenge:
|
||||
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
|
||||
|
||||
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
|
||||
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
|
||||
|
||||
async def refresh(self, tokens: TokenSet) -> TokenSet:
|
||||
raise NotImplementedError(f"{self.info.id} does not support token refresh")
|
||||
|
||||
async def revoke(self, tokens: TokenSet) -> None:
|
||||
"""注销远端凭据,尽力而为;默认无操作。"""
|
||||
226
backend/src/module/parser/analyser/providers/builtin.py
Normal file
226
backend/src/module/parser/analyser/providers/builtin.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""内置三家提供商适配器(openai / anthropic / gemini,API-Key 直连)。
|
||||
|
||||
解析与客户端构造逻辑自 LLMParser 原样搬入;重型 SDK 仍然懒导入
|
||||
(LLM 默认关闭,导入 ~0.7s 不应进启动路径)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from .base import AdapterContext, LLMProviderAdapter, ProviderInfo
|
||||
from .schema import (
|
||||
DEFAULT_PROMPT,
|
||||
EPISODE_JSON_SCHEMA,
|
||||
GEMINI_JSON_INSTRUCTION,
|
||||
Episode,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenAIAdapter(LLMProviderAdapter):
|
||||
"""任意 OpenAI 兼容端点(官方 API、DeepSeek、Ollama、OneAPI 等)。"""
|
||||
|
||||
info = ProviderInfo(
|
||||
id="openai",
|
||||
display_name="OpenAI",
|
||||
auth_kind="api_key",
|
||||
builtin=True,
|
||||
needs_base_url=True,
|
||||
default_model="gpt-5-mini",
|
||||
)
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
super().__init__(ctx)
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
self._http_client = ctx.build_http_client(ctx.timeout)
|
||||
self._openai_client = AsyncOpenAI(
|
||||
api_key=ctx.api_key,
|
||||
# 配置留空时回退到预设端点(国产提供商预设子类)
|
||||
base_url=ctx.base_url or self.info.preset_base_url or None,
|
||||
timeout=ctx.timeout,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
if not self.info.supports_json_schema:
|
||||
return await self._parse_json_object(raw)
|
||||
return await self._parse_structured(raw)
|
||||
|
||||
async def _parse_structured(self, raw: str) -> dict | None:
|
||||
import openai as openai_sdk
|
||||
|
||||
try:
|
||||
resp = await self._openai_client.beta.chat.completions.parse(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": DEFAULT_PROMPT},
|
||||
{"role": "user", "content": raw},
|
||||
],
|
||||
response_format=Episode,
|
||||
# temperature 置 0,让结果更稳定可复现
|
||||
temperature=0,
|
||||
)
|
||||
parsed = resp.choices[0].message.parsed
|
||||
except openai_sdk.OpenAIError as e:
|
||||
logger.warning("OpenAI parse failed for '%s': %s", raw, e)
|
||||
return None
|
||||
if parsed is None:
|
||||
logger.warning("OpenAI returned no parsed content for '%s'", raw)
|
||||
return None
|
||||
return parsed.model_dump()
|
||||
|
||||
async def _parse_json_object(self, raw: str) -> dict | None:
|
||||
"""json_object 模式:schema 写入提示词,响应本地校验为 Episode。"""
|
||||
import openai as openai_sdk
|
||||
from pydantic import ValidationError
|
||||
|
||||
try:
|
||||
resp = await self._openai_client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": f"{DEFAULT_PROMPT}\n{GEMINI_JSON_INSTRUCTION}",
|
||||
},
|
||||
{"role": "user", "content": raw},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
except openai_sdk.OpenAIError as e:
|
||||
logger.warning("OpenAI-compatible parse failed for '%s': %s", raw, e)
|
||||
return None
|
||||
if not content:
|
||||
logger.warning("OpenAI-compatible returned no content for '%s'", raw)
|
||||
return None
|
||||
try:
|
||||
return Episode(**json.loads(content)).model_dump()
|
||||
except (json.JSONDecodeError, TypeError, ValidationError) as e:
|
||||
logger.warning(
|
||||
"Cannot decode OpenAI-compatible output for '%s': %s", raw, e
|
||||
)
|
||||
return None
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
openai_page = await self._openai_client.models.list()
|
||||
return sorted(m.id for m in openai_page.data)
|
||||
|
||||
|
||||
class AnthropicAdapter(LLMProviderAdapter):
|
||||
"""Anthropic Claude(官方 API Key)。"""
|
||||
|
||||
info = ProviderInfo(
|
||||
id="anthropic",
|
||||
display_name="Anthropic",
|
||||
auth_kind="api_key",
|
||||
builtin=True,
|
||||
default_model="claude-haiku-4-5",
|
||||
)
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
super().__init__(ctx)
|
||||
import anthropic
|
||||
|
||||
self._http_client = ctx.build_http_client(ctx.timeout)
|
||||
self._anthropic_client = anthropic.AsyncAnthropic(
|
||||
api_key=ctx.api_key,
|
||||
timeout=ctx.timeout,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
import anthropic
|
||||
|
||||
try:
|
||||
resp = await self._anthropic_client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=1024,
|
||||
system=DEFAULT_PROMPT,
|
||||
messages=[{"role": "user", "content": raw}],
|
||||
output_config={
|
||||
"format": {"type": "json_schema", "schema": EPISODE_JSON_SCHEMA}
|
||||
},
|
||||
)
|
||||
except (
|
||||
anthropic.RateLimitError,
|
||||
anthropic.APIStatusError,
|
||||
anthropic.APIConnectionError,
|
||||
) as e:
|
||||
logger.warning("Anthropic parse failed for '%s': %s", raw, e)
|
||||
return None
|
||||
if resp.stop_reason == "refusal":
|
||||
logger.warning("Anthropic refused to parse '%s'", raw)
|
||||
return None
|
||||
try:
|
||||
text = next(b.text for b in resp.content if b.type == "text")
|
||||
return json.loads(text)
|
||||
except (StopIteration, json.JSONDecodeError) as e:
|
||||
logger.warning("Cannot decode Anthropic output for '%s': %s", raw, e)
|
||||
return None
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
anthropic_page = await self._anthropic_client.models.list()
|
||||
return sorted(m.id for m in anthropic_page.data)
|
||||
|
||||
|
||||
class GeminiAdapter(LLMProviderAdapter):
|
||||
"""Google Gemini(AI Studio API Key)。"""
|
||||
|
||||
info = ProviderInfo(
|
||||
id="gemini",
|
||||
display_name="Google Gemini",
|
||||
auth_kind="api_key",
|
||||
builtin=True,
|
||||
default_model="gemini-2.5-flash",
|
||||
)
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
super().__init__(ctx)
|
||||
from google import genai
|
||||
|
||||
self._gemini_client = genai.Client(api_key=ctx.api_key)
|
||||
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
from google.genai import errors as genai_errors
|
||||
from google.genai import types as genai_types
|
||||
|
||||
try:
|
||||
resp = await self._gemini_client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=f"{DEFAULT_PROMPT}\n{GEMINI_JSON_INSTRUCTION}\n\n{raw}",
|
||||
config=genai_types.GenerateContentConfig(
|
||||
response_mime_type="application/json"
|
||||
),
|
||||
)
|
||||
text = resp.text
|
||||
except genai_errors.APIError as e:
|
||||
logger.warning("Gemini parse failed for '%s': %s", raw, e)
|
||||
return None
|
||||
if not text:
|
||||
logger.warning("Gemini returned no text for '%s'", raw)
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Cannot decode Gemini output for '%s': %s", raw, e)
|
||||
return None
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
ids: list[str] = []
|
||||
async for m in await self._gemini_client.aio.models.list():
|
||||
actions = getattr(m, "supported_actions", None)
|
||||
if actions and "generateContent" not in actions:
|
||||
continue
|
||||
name = m.name or ""
|
||||
ids.append(name.removeprefix("models/"))
|
||||
return sorted(ids)
|
||||
|
||||
|
||||
BUILTIN: dict[str, type[LLMProviderAdapter]] = {
|
||||
"openai": OpenAIAdapter,
|
||||
"anthropic": AnthropicAdapter,
|
||||
"gemini": GeminiAdapter,
|
||||
}
|
||||
50
backend/src/module/parser/analyser/providers/credentials.py
Normal file
50
backend/src/module/parser/analyser/providers/credentials.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""订阅提供商凭据访问 + 进程内 auth generation 计数。
|
||||
|
||||
- ``CredentialStore``:适配器唯一被允许的凭据通道(provider 域内
|
||||
load/save/clear),内部 session-per-operation 开库。
|
||||
- ``auth_generation``:连接/断开时 bump(静默刷新不 bump),
|
||||
title_parser 把它掺进单例键实现"换号即重建、刷新不抖动"。
|
||||
AB 单进程部署,进程内计数即可。
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import TokenSet
|
||||
|
||||
_generations: dict[str, int] = {}
|
||||
|
||||
|
||||
def auth_generation(provider_id: str) -> int:
|
||||
return _generations.get(provider_id, 0)
|
||||
|
||||
|
||||
def bump_auth_generation(provider_id: str) -> None:
|
||||
_generations[provider_id] = auth_generation(provider_id) + 1
|
||||
|
||||
|
||||
class CredentialStore:
|
||||
"""单一提供商的凭据读写句柄(注入 AdapterContext.credentials)。"""
|
||||
|
||||
def __init__(self, provider_id: str) -> None:
|
||||
self.provider_id = provider_id
|
||||
|
||||
async def load(self) -> Optional[TokenSet]:
|
||||
# 延迟导入打破 database ↔ providers 循环(database 的 repo 依赖
|
||||
# providers.base 的 TokenSet)。
|
||||
from module.database import Database
|
||||
|
||||
async with Database() as db:
|
||||
return await db.llm_credential.get(self.provider_id)
|
||||
|
||||
async def save(self, tokens: TokenSet) -> None:
|
||||
"""持久化凭据(含静默刷新——不 bump generation)。"""
|
||||
from module.database import Database
|
||||
|
||||
async with Database() as db:
|
||||
await db.llm_credential.upsert(self.provider_id, tokens)
|
||||
|
||||
async def clear(self) -> None:
|
||||
from module.database import Database
|
||||
|
||||
async with Database() as db:
|
||||
await db.llm_credential.delete(self.provider_id)
|
||||
91
backend/src/module/parser/analyser/providers/presets.py
Normal file
91
backend/src/module/parser/analyser/providers/presets.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""国产提供商 base_url 预设(纯数据,全部复用 OpenAIAdapter)。
|
||||
|
||||
这些提供商的订阅(Coding Plan 等)都是"发 API Key + OpenAI 兼容端点",
|
||||
无需 OAuth;预设只是替用户省去手抄 base_url。大陆/海外端点分开列
|
||||
(智谱 Coding Plan 的 Key 只在 coding 专用端点有效,与普通 v4 不互通)。
|
||||
|
||||
端点核实日期:2026-07(来源:各家官方文档 / Claude Code 接入指南)。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .base import ProviderInfo
|
||||
from .builtin import OpenAIAdapter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderPreset:
|
||||
id: str
|
||||
display_name: str
|
||||
base_url: str
|
||||
default_model: str
|
||||
supports_json_schema: bool = True
|
||||
|
||||
|
||||
PRESETS: tuple[ProviderPreset, ...] = (
|
||||
ProviderPreset(
|
||||
id="zai",
|
||||
display_name="智谱 GLM (Z.ai 海外)",
|
||||
base_url="https://api.z.ai/api/paas/v4",
|
||||
default_model="glm-4.7",
|
||||
),
|
||||
ProviderPreset(
|
||||
id="zai-cn",
|
||||
display_name="智谱 GLM (大陆 Coding Plan)",
|
||||
base_url="https://open.bigmodel.cn/api/coding/paas/v4",
|
||||
default_model="glm-4.7",
|
||||
),
|
||||
ProviderPreset(
|
||||
id="minimax",
|
||||
display_name="MiniMax (海外)",
|
||||
base_url="https://api.minimax.io/v1",
|
||||
default_model="MiniMax-M2.5",
|
||||
supports_json_schema=False,
|
||||
),
|
||||
ProviderPreset(
|
||||
id="minimax-cn",
|
||||
display_name="MiniMax (大陆)",
|
||||
base_url="https://api.minimaxi.com/v1",
|
||||
default_model="MiniMax-M2.5",
|
||||
supports_json_schema=False,
|
||||
),
|
||||
ProviderPreset(
|
||||
id="deepseek",
|
||||
display_name="DeepSeek",
|
||||
base_url="https://api.deepseek.com",
|
||||
default_model="deepseek-v4-flash",
|
||||
supports_json_schema=False,
|
||||
),
|
||||
ProviderPreset(
|
||||
id="kimi",
|
||||
display_name="Moonshot Kimi (海外)",
|
||||
base_url="https://api.moonshot.ai/v1",
|
||||
default_model="kimi-k2.5",
|
||||
),
|
||||
ProviderPreset(
|
||||
id="kimi-cn",
|
||||
display_name="Moonshot Kimi (大陆)",
|
||||
base_url="https://api.moonshot.cn/v1",
|
||||
default_model="kimi-k2.5",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_adapter(preset: ProviderPreset) -> type[OpenAIAdapter]:
|
||||
info = ProviderInfo(
|
||||
id=preset.id,
|
||||
display_name=preset.display_name,
|
||||
auth_kind="api_key",
|
||||
builtin=False,
|
||||
needs_base_url=True,
|
||||
preset_base_url=preset.base_url,
|
||||
default_model=preset.default_model,
|
||||
supports_json_schema=preset.supports_json_schema,
|
||||
)
|
||||
class_name = f"{preset.id.replace('-', '_').title().replace('_', '')}PresetAdapter"
|
||||
return type(class_name, (OpenAIAdapter,), {"info": info})
|
||||
|
||||
|
||||
PRESET_ADAPTERS: dict[str, type[OpenAIAdapter]] = {
|
||||
preset.id: _make_adapter(preset) for preset in PRESETS
|
||||
}
|
||||
99
backend/src/module/parser/analyser/providers/registry.py
Normal file
99
backend/src/module/parser/analyser/providers/registry.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""LLM 提供商注册表:内置适配器 + base_url 预设 + 已安装插件的统一入口。
|
||||
|
||||
- ``list_infos()`` 只读静态描述(内置/预设直接读 info;插件读 manifest,
|
||||
首次不触发插件 Python 导入);
|
||||
- ``resolve()`` 返回适配器类,插件在首次 resolve 时才 import(懒加载);
|
||||
- ``invalidate()`` 在安装/卸载/升级后丢弃插件缓存。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .base import LLMProviderAdapter, ProviderInfo
|
||||
from .builtin import BUILTIN
|
||||
from .presets import PRESET_ADAPTERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
def __init__(self, plugins_root: Optional[Path] = None) -> None:
|
||||
# 延迟到实际使用时再解析默认值,避免 import 期触达文件系统
|
||||
self._plugins_root = plugins_root
|
||||
self._plugin_cache: dict[str, type[LLMProviderAdapter]] = {}
|
||||
|
||||
@property
|
||||
def plugins_root(self) -> Path:
|
||||
if self._plugins_root is not None:
|
||||
return self._plugins_root
|
||||
return Path("config") / "plugins"
|
||||
|
||||
def _installed_manifests(self) -> dict[str, tuple[Path, dict]]:
|
||||
"""扫描已安装插件目录,返回 {id: (版本目录, manifest)},不导入代码。"""
|
||||
result: dict[str, tuple[Path, dict]] = {}
|
||||
root = self.plugins_root
|
||||
if not root.exists():
|
||||
return result
|
||||
for plugin_dir in root.iterdir():
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
|
||||
continue
|
||||
pointer = plugin_dir / "installed.json"
|
||||
if not pointer.exists():
|
||||
continue
|
||||
try:
|
||||
version = json.loads(pointer.read_text(encoding="utf-8"))["version"]
|
||||
version_dir = plugin_dir / version
|
||||
manifest = json.loads(
|
||||
(version_dir / "plugin.json").read_text(encoding="utf-8")
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - 单个坏插件不应拖垮列举
|
||||
logger.warning("Skipping broken plugin %s: %s", plugin_dir.name, e)
|
||||
continue
|
||||
result[manifest["id"]] = (version_dir, manifest)
|
||||
return result
|
||||
|
||||
def list_infos(self) -> list[ProviderInfo]:
|
||||
"""列出全部可用提供商的静态描述(内置 → 预设 → 已安装插件)。"""
|
||||
infos = [cls.info for cls in (*BUILTIN.values(), *PRESET_ADAPTERS.values())]
|
||||
for plugin_id, (_, manifest) in self._installed_manifests().items():
|
||||
infos.append(
|
||||
ProviderInfo(
|
||||
id=plugin_id,
|
||||
display_name=manifest.get("name", plugin_id),
|
||||
auth_kind=manifest.get("auth_kind", "api_key"),
|
||||
builtin=False,
|
||||
needs_base_url=manifest.get("needs_base_url", False),
|
||||
default_model=manifest.get("default_model", ""),
|
||||
plugin_version=manifest.get("version"),
|
||||
)
|
||||
)
|
||||
return infos
|
||||
|
||||
def resolve(self, provider_id: str) -> type[LLMProviderAdapter]:
|
||||
"""按 id 解析适配器类;未知 id 抛 ValueError。"""
|
||||
adapter_cls = BUILTIN.get(provider_id) or PRESET_ADAPTERS.get(provider_id)
|
||||
if adapter_cls is not None:
|
||||
return adapter_cls
|
||||
if provider_id in self._plugin_cache:
|
||||
return self._plugin_cache[provider_id]
|
||||
installed = self._installed_manifests().get(provider_id)
|
||||
if installed is not None:
|
||||
from module.llm_plugins.loader import load_adapter_class
|
||||
|
||||
version_dir, manifest = installed
|
||||
loaded = load_adapter_class(version_dir, manifest)
|
||||
self._plugin_cache[provider_id] = loaded
|
||||
return loaded
|
||||
raise ValueError(f"Unsupported LLM provider: {provider_id}")
|
||||
|
||||
def invalidate(self, provider_id: str | None = None) -> None:
|
||||
"""插件安装/卸载后失效缓存。"""
|
||||
if provider_id is None:
|
||||
self._plugin_cache.clear()
|
||||
else:
|
||||
self._plugin_cache.pop(provider_id, None)
|
||||
|
||||
|
||||
registry = ProviderRegistry()
|
||||
74
backend/src/module/parser/analyser/providers/schema.py
Normal file
74
backend/src/module/parser/analyser/providers/schema.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""LLM 解析的共享输出契约:Episode 形状、JSON schema 与提示词。
|
||||
|
||||
物理上从 llm.py 抽出以打破 llm ↔ providers 的循环导入;llm.py 原地
|
||||
重导出这些名字,外部 import 路径(含测试)保持不变。
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Episode(BaseModel):
|
||||
"""LLM 结构化输出的目标形状(与 models.bangumi.Episode 字段一致)。"""
|
||||
|
||||
title_en: Optional[str]
|
||||
title_zh: Optional[str]
|
||||
title_jp: Optional[str]
|
||||
season: int
|
||||
season_raw: str
|
||||
episode: int
|
||||
sub: str
|
||||
group: str
|
||||
resolution: str
|
||||
source: str
|
||||
|
||||
|
||||
# Anthropic 结构化输出要求:所有属性均列入 required,
|
||||
# additionalProperties 为 false,且不带 min/max 等约束
|
||||
EPISODE_JSON_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title_en": {"type": ["string", "null"]},
|
||||
"title_zh": {"type": ["string", "null"]},
|
||||
"title_jp": {"type": ["string", "null"]},
|
||||
"season": {"type": "integer"},
|
||||
"season_raw": {"type": "string"},
|
||||
"episode": {"type": "integer"},
|
||||
"sub": {"type": "string"},
|
||||
"group": {"type": "string"},
|
||||
"resolution": {"type": "string"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"title_en",
|
||||
"title_zh",
|
||||
"title_jp",
|
||||
"season",
|
||||
"season_raw",
|
||||
"episode",
|
||||
"sub",
|
||||
"group",
|
||||
"resolution",
|
||||
"source",
|
||||
],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_PROMPT = """\
|
||||
You will now play the role of a super assistant.
|
||||
Your task is to extract structured data from unstructured text content and output it in JSON format.
|
||||
If you are unable to extract any information, please keep all fields and leave the field empty or default value like `''`, `None`.
|
||||
But Do not fabricate data!
|
||||
"""
|
||||
|
||||
# Gemini 走 response_mime_type + 提示词描述 JSON 形状(避免 SDK schema
|
||||
# 类型与 mypy 冲突),因此在提示词里显式给出字段列表
|
||||
GEMINI_JSON_INSTRUCTION = (
|
||||
"Output a single JSON object with exactly these keys: "
|
||||
"title_en (string or null), title_zh (string or null), "
|
||||
"title_jp (string or null), season (integer), season_raw (string), "
|
||||
"episode (integer), sub (string), group (string), "
|
||||
"resolution (string), source (string)."
|
||||
)
|
||||
@@ -13,10 +13,31 @@ from module.parser.analyser import (
|
||||
tmdb_parser,
|
||||
torrent_parser,
|
||||
)
|
||||
from module.parser.analyser.providers.base import AuthExpiredError
|
||||
from module.parser.analyser.providers.credentials import auth_generation
|
||||
from module.parser.analyser.raw_parser import _detect_non_episodic_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 保留 fire-and-forget 通知任务的强引用,防止其在运行完成前被 GC。
|
||||
_notify_tasks: set = set()
|
||||
|
||||
|
||||
def _notify_auth_failure(provider_id: str, message: str) -> None:
|
||||
"""凭据失效时向通知中心投递事件(fire-and-forget,不阻塞解析路径)。"""
|
||||
from module.notification import LLMAuthFailureEvent, NotificationManager
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
event = LLMAuthFailureEvent(provider_id=provider_id, message=message)
|
||||
task = loop.create_task(NotificationManager().send_event(event))
|
||||
_notify_tasks.add(task)
|
||||
task.add_done_callback(_notify_tasks.discard)
|
||||
|
||||
|
||||
# Lazy singleton: building an LLMParser (and its underlying SDK client) is not
|
||||
# free, so keep one around and only rebuild it when the relevant settings change.
|
||||
# mode 不影响客户端构建(调用时读取),不参与缓存键。
|
||||
@@ -32,7 +53,9 @@ _llm_semaphore_limit: int | None = None
|
||||
def _get_llm_parser(kwargs: dict) -> LLMParser:
|
||||
global _llm_parser, _llm_parser_kwargs
|
||||
if _llm_parser is None or _llm_parser_kwargs != kwargs:
|
||||
_llm_parser = LLMParser(**kwargs)
|
||||
# auth_gen 只参与"是否重建"的比较,不是构造参数
|
||||
ctor_kwargs = {k: v for k, v in kwargs.items() if k != "auth_gen"}
|
||||
_llm_parser = LLMParser(**ctor_kwargs)
|
||||
_llm_parser_kwargs = kwargs
|
||||
return _llm_parser
|
||||
|
||||
@@ -96,12 +119,15 @@ async def _llm_parse(raw: str) -> Episode | None:
|
||||
if _llm_breaker_until > now:
|
||||
logger.warning("LLM parser breaker is open; skipping '%s'", raw)
|
||||
return None
|
||||
api_key, model, base_url = conf.effective()
|
||||
parser_kwargs = {
|
||||
"provider": conf.provider,
|
||||
"api_key": conf.api_key,
|
||||
"model": conf.model,
|
||||
"base_url": conf.base_url,
|
||||
"api_key": api_key,
|
||||
"model": model,
|
||||
"base_url": base_url,
|
||||
"timeout": conf.timeout,
|
||||
# 换号/断开时 bump → 单例重建;静默刷新不 bump(token 不进键)。
|
||||
"auth_gen": auth_generation(conf.provider),
|
||||
}
|
||||
cache_key = (tuple(sorted(parser_kwargs.items())), raw)
|
||||
if conf.cache_ttl > 0:
|
||||
@@ -112,7 +138,19 @@ async def _llm_parse(raw: str) -> Episode | None:
|
||||
return cached_episode
|
||||
_llm_cache.pop(cache_key, None)
|
||||
try:
|
||||
llm = _get_llm_parser(parser_kwargs)
|
||||
try:
|
||||
llm = _get_llm_parser(parser_kwargs)
|
||||
except ValueError as e:
|
||||
# 未知 provider id(如配置里手改出拼写错误):明确指出并熔断,
|
||||
# 避免每个标题都刷一行含糊的失败日志。
|
||||
logger.error(
|
||||
"Unknown LLM provider '%s' (%s); check settings.llm.provider",
|
||||
conf.provider,
|
||||
e,
|
||||
)
|
||||
_llm_breaker_until = time.monotonic() + conf.failure_backoff
|
||||
_cache_llm_result(cache_key, None, conf.cache_ttl)
|
||||
return None
|
||||
semaphore = _get_llm_semaphore(conf.max_concurrency)
|
||||
async with semaphore:
|
||||
episode_dict = await asyncio.wait_for(
|
||||
@@ -127,6 +165,17 @@ async def _llm_parse(raw: str) -> Episode | None:
|
||||
_llm_breaker_until = 0.0
|
||||
_cache_llm_result(cache_key, episode, conf.cache_ttl)
|
||||
return episode
|
||||
except AuthExpiredError as e:
|
||||
# 刷新已失败,重试没有意义:跳过失败阈值直接熔断,等用户重连。
|
||||
logger.error(
|
||||
"LLM credentials for '%s' expired and refresh failed: %s",
|
||||
conf.provider,
|
||||
e,
|
||||
)
|
||||
_llm_breaker_until = time.monotonic() + conf.failure_backoff
|
||||
_cache_llm_result(cache_key, None, conf.cache_ttl)
|
||||
_notify_auth_failure(conf.provider, str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM cannot parse '{raw}': {type(e).__name__}: {e}")
|
||||
_record_llm_failure(conf)
|
||||
|
||||
@@ -613,7 +613,8 @@ class TestListLLMModels:
|
||||
def test_masked_key_falls_back_to_saved_key(self, authed_client):
|
||||
parser = self._mock_parser(models=["m"])
|
||||
saved = MagicMock()
|
||||
saved.llm.api_key = "sk-saved"
|
||||
# 掩码回退现在按提供商取值(providers[id] 覆盖 → 扁平字段兜底)
|
||||
saved.llm.effective.return_value = ("sk-saved", "", "")
|
||||
with (
|
||||
patch("module.api.config.settings", saved),
|
||||
patch("module.api.config.LLMParser", return_value=parser) as cls,
|
||||
@@ -627,7 +628,7 @@ class TestListLLMModels:
|
||||
|
||||
def test_missing_key_returns_400(self, authed_client):
|
||||
saved = MagicMock()
|
||||
saved.llm.api_key = ""
|
||||
saved.llm.effective.return_value = ("", "", "")
|
||||
with patch("module.api.config.settings", saved):
|
||||
response = authed_client.post(
|
||||
"/api/v1/config/llm/models",
|
||||
|
||||
@@ -206,3 +206,83 @@ class TestLogPayload:
|
||||
result = await _log_payload()
|
||||
|
||||
assert result == "hello world\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notification event(通知中心未读数推送)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotificationEvent:
|
||||
def _generator(self, tmp_path):
|
||||
from module.api.events import _event_generator
|
||||
|
||||
request = AsyncMock()
|
||||
request.is_disconnected = AsyncMock(return_value=False)
|
||||
ctx = MagicMock()
|
||||
ctx.is_running = True
|
||||
ctx.first_run = False
|
||||
patches = (
|
||||
patch(
|
||||
"module.api.events._downloader_payload",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("module.api.events.LOG_PATH", tmp_path / "missing.log"),
|
||||
patch(
|
||||
"module.api.events._notification_payload",
|
||||
new=AsyncMock(return_value={"unread_count": 2, "latest_id": 9}),
|
||||
),
|
||||
patch("module.api.events.asyncio.sleep", new=AsyncMock()),
|
||||
)
|
||||
return _event_generator(request, ctx), patches
|
||||
|
||||
async def _collect(self, gen, n):
|
||||
events = []
|
||||
for _ in range(n):
|
||||
events.append(await asyncio.wait_for(gen.__anext__(), timeout=1.0))
|
||||
return events
|
||||
|
||||
async def test_first_tick_emits_notification_frame(self, tmp_path):
|
||||
from module.notification.inbox import inbox_revision
|
||||
|
||||
gen, patches = self._generator(tmp_path)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
try:
|
||||
events = await self._collect(gen, 3)
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
by_name = {e["event"]: e for e in events}
|
||||
assert set(by_name) == {"status", "downloader", "notification"}
|
||||
data = json.loads(by_name["notification"]["data"])
|
||||
assert data["unread_count"] == 2
|
||||
assert data["latest_id"] == 9
|
||||
assert data["revision"] == inbox_revision()
|
||||
|
||||
async def test_no_frame_when_revision_unchanged(self, tmp_path):
|
||||
gen, patches = self._generator(tmp_path)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
try:
|
||||
await self._collect(gen, 3) # 第一个 tick
|
||||
# tick3 status、tick5 downloader —— 中间不应再有 notification
|
||||
more = await self._collect(gen, 2)
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
assert [e["event"] for e in more] == ["status", "downloader"]
|
||||
|
||||
async def test_frame_emitted_after_revision_bump(self, tmp_path):
|
||||
from module.notification.inbox import bump_inbox_revision, inbox_revision
|
||||
|
||||
gen, patches = self._generator(tmp_path)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
try:
|
||||
await self._collect(gen, 3) # 第一个 tick
|
||||
bump_inbox_revision()
|
||||
# tick3:status + notification(修订号变了)
|
||||
more = await self._collect(gen, 2)
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
assert [e["event"] for e in more] == ["status", "notification"]
|
||||
assert json.loads(more[1]["data"])["revision"] == inbox_revision()
|
||||
|
||||
255
backend/src/test/test_api_llm.py
Normal file
255
backend/src/test/test_api_llm.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""LLM 提供商/认证 API:/config/llm/providers + auth begin/complete/status/disconnect。"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from module.api import v1
|
||||
from module.parser.analyser.providers.base import (
|
||||
AuthChallenge,
|
||||
LLMProviderAdapter,
|
||||
ProviderInfo,
|
||||
TokenSet,
|
||||
)
|
||||
from module.security.api import get_current_user
|
||||
|
||||
|
||||
class FakeDeviceAdapter(LLMProviderAdapter):
|
||||
info = ProviderInfo(
|
||||
id="fake-device",
|
||||
display_name="Fake Device",
|
||||
auth_kind="device_code",
|
||||
builtin=False,
|
||||
)
|
||||
|
||||
async def parse(self, raw):
|
||||
return None
|
||||
|
||||
async def list_models(self):
|
||||
return []
|
||||
|
||||
async def begin_auth(self) -> AuthChallenge:
|
||||
return AuthChallenge(
|
||||
method="device_code",
|
||||
user_code="WXYZ-1234",
|
||||
verification_uri="https://example.com/device",
|
||||
state="state-123",
|
||||
)
|
||||
|
||||
async def complete_auth(self, state, user_input=""):
|
||||
return TokenSet(access_token="at", account_label="tester@example.com")
|
||||
|
||||
async def revoke(self, tokens):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = FastAPI()
|
||||
app.include_router(v1, prefix="/api")
|
||||
app.state.ctx = MagicMock()
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_client(app):
|
||||
async def mock_user():
|
||||
return "testuser"
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_user
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unauthed_client(app):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_pending():
|
||||
from module.api import llm as llm_api
|
||||
|
||||
llm_api._PENDING.clear()
|
||||
yield
|
||||
for task in list(llm_api._POLL_TASKS):
|
||||
task.cancel()
|
||||
llm_api._POLL_TASKS.clear()
|
||||
llm_api._PENDING.clear()
|
||||
|
||||
|
||||
@patch("module.security.api.DEV_AUTH_BYPASS", False)
|
||||
def test_providers_requires_auth(unauthed_client):
|
||||
assert unauthed_client.get("/api/v1/config/llm/providers").status_code == 401
|
||||
|
||||
|
||||
def test_list_providers_includes_builtins_and_presets(authed_client):
|
||||
# 全为 api_key 提供商(无插件安装)→ 不触碰 DB
|
||||
resp = authed_client.get("/api/v1/config/llm/providers")
|
||||
assert resp.status_code == 200
|
||||
ids = {p["id"] for p in resp.json()["providers"]}
|
||||
assert {"openai", "anthropic", "gemini", "kimi", "deepseek"} <= ids
|
||||
|
||||
|
||||
def test_auth_begin_returns_opaque_handle_and_hides_device_code(authed_client):
|
||||
from module.api import llm as llm_api
|
||||
|
||||
async def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
|
||||
# 让后台轮询任务空转,避免它在断言前清掉 pending
|
||||
patch("module.api.llm._device_poll_loop", new=_noop),
|
||||
):
|
||||
resp = authed_client.post("/api/v1/config/llm/providers/fake-device/auth/begin")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["user_code"] == "WXYZ-1234"
|
||||
assert body["method"] == "device_code"
|
||||
# 返回的是不透明 handle,绝非适配器原始 state(device_code 藏服务端 blob)
|
||||
handle = body["state"]
|
||||
assert handle and handle != "state-123"
|
||||
assert "state-123" not in json.dumps(body)
|
||||
pending = llm_api._PENDING[handle]
|
||||
assert pending.provider_id == "fake-device"
|
||||
assert pending.blob == "state-123" # 敏感态只在服务端
|
||||
|
||||
|
||||
def test_auth_complete_saves_credential(authed_client):
|
||||
from module.api import llm as llm_api
|
||||
from module.api.llm import PendingAuth
|
||||
|
||||
llm_api._PENDING["handle-1"] = PendingAuth(
|
||||
provider_id="fake-device", created=0.0, expires=9e18, blob="blob-secret"
|
||||
)
|
||||
save = AsyncMock()
|
||||
with (
|
||||
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
|
||||
patch("module.api.llm.CredentialStore") as store_cls,
|
||||
):
|
||||
store_cls.return_value.save = save
|
||||
resp = authed_client.post(
|
||||
"/api/v1/config/llm/providers/fake-device/auth/complete",
|
||||
json={"state": "handle-1", "code": ""},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["connected"] is True
|
||||
assert resp.json()["account_label"] == "tester@example.com"
|
||||
save.assert_awaited_once()
|
||||
assert "handle-1" not in llm_api._PENDING # 用完即清
|
||||
|
||||
|
||||
async def test_device_poll_loop_saves_on_success():
|
||||
"""服务端轮询:complete_auth 成功即写库 + bump generation + 清 pending。"""
|
||||
from module.api import llm as llm_api
|
||||
from module.api.llm import PendingAuth
|
||||
|
||||
llm_api._PENDING["h"] = PendingAuth(
|
||||
provider_id="fake-device",
|
||||
created=0.0,
|
||||
expires=9e18,
|
||||
blob=json.dumps({"device_code": "DC", "interval": 1}),
|
||||
)
|
||||
save = AsyncMock()
|
||||
try:
|
||||
with (
|
||||
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
|
||||
patch("module.api.llm.CredentialStore") as store_cls,
|
||||
patch("module.api.llm.bump_auth_generation") as bump,
|
||||
patch("module.api.llm.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
store_cls.return_value.save = save
|
||||
await llm_api._device_poll_loop("fake-device", "h", expires_in=900)
|
||||
|
||||
save.assert_awaited_once()
|
||||
bump.assert_called_once_with("fake-device")
|
||||
assert "h" not in llm_api._PENDING
|
||||
finally:
|
||||
llm_api._PENDING.pop("h", None)
|
||||
|
||||
|
||||
def test_auth_complete_unknown_state_returns_400(authed_client):
|
||||
resp = authed_client.post(
|
||||
"/api/v1/config/llm/providers/fake-device/auth/complete",
|
||||
json={"state": "nope", "code": ""},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_auth_status_reports_connected(authed_client):
|
||||
with patch("module.api.llm.CredentialStore") as store_cls:
|
||||
store_cls.return_value.load = AsyncMock(
|
||||
return_value=TokenSet(access_token="at", account_label="me@x.com")
|
||||
)
|
||||
resp = authed_client.get("/api/v1/config/llm/providers/fake-device/auth/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["connected"] is True
|
||||
assert body["account_label"] == "me@x.com"
|
||||
assert "access_token" not in body # token 绝不下发
|
||||
|
||||
|
||||
def test_install_success(authed_client):
|
||||
from module.llm_plugins import InstallResult
|
||||
|
||||
installer = MagicMock()
|
||||
installer.install = AsyncMock(
|
||||
return_value=InstallResult(success=True, version="1.0.0")
|
||||
)
|
||||
with patch("module.llm_plugins.PluginInstaller", return_value=installer):
|
||||
resp = authed_client.post("/api/v1/config/llm/providers/fake-copilot/install")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_install_failure_returns_400_and_notifies(authed_client, app):
|
||||
from module.llm_plugins import InstallResult
|
||||
from module.notification import LLMPluginInstallFailedEvent
|
||||
|
||||
app.state.ctx.notifier.send_event = AsyncMock()
|
||||
installer = MagicMock()
|
||||
installer.install = AsyncMock(
|
||||
return_value=InstallResult(success=False, message="Plugin signature invalid")
|
||||
)
|
||||
with patch("module.llm_plugins.PluginInstaller", return_value=installer):
|
||||
resp = authed_client.post("/api/v1/config/llm/providers/fake-copilot/install")
|
||||
assert resp.status_code == 400
|
||||
event = app.state.ctx.notifier.send_event.await_args.args[0]
|
||||
assert isinstance(event, LLMPluginInstallFailedEvent)
|
||||
assert event.plugin_id == "fake-copilot"
|
||||
|
||||
|
||||
def test_uninstall_builtin_returns_400(authed_client):
|
||||
from module.llm_plugins import InstallResult
|
||||
|
||||
installer = MagicMock()
|
||||
installer.uninstall = AsyncMock(
|
||||
return_value=InstallResult(
|
||||
success=False, message="openai is a builtin provider"
|
||||
)
|
||||
)
|
||||
with patch("module.llm_plugins.PluginInstaller", return_value=installer):
|
||||
resp = authed_client.delete("/api/v1/config/llm/providers/openai")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_disconnect_clears_and_bumps_generation(authed_client):
|
||||
from module.api import llm as llm_api
|
||||
|
||||
clear = AsyncMock()
|
||||
with (
|
||||
patch.object(llm_api.registry, "resolve", return_value=FakeDeviceAdapter),
|
||||
patch("module.api.llm.CredentialStore") as store_cls,
|
||||
patch("module.api.llm.bump_auth_generation") as bump,
|
||||
):
|
||||
store_cls.return_value.clear = clear
|
||||
store_cls.return_value.load = AsyncMock(return_value=TokenSet(access_token="a"))
|
||||
resp = authed_client.delete("/api/v1/config/llm/providers/fake-device/auth")
|
||||
assert resp.status_code == 200
|
||||
clear.assert_awaited_once()
|
||||
bump.assert_called_once_with("fake-device")
|
||||
165
backend/src/test/test_api_notification_inbox.py
Normal file
165
backend/src/test/test_api_notification_inbox.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""通知中心 REST API(/notification/messages*)。"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from module.api import v1
|
||||
from module.database import get_db
|
||||
from module.models import InboxMessage
|
||||
from module.security.api import get_current_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(mock_db):
|
||||
app = FastAPI()
|
||||
app.include_router(v1, prefix="/api")
|
||||
|
||||
async def _override_get_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_client(app):
|
||||
async def mock_user():
|
||||
return "testuser"
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_user
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unauthed_client(app):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _message(**overrides) -> InboxMessage:
|
||||
fields = dict(
|
||||
id=1,
|
||||
kind="rss_failure",
|
||||
severity="error",
|
||||
title="RSS 订阅连接异常",
|
||||
body="feed down",
|
||||
payload='{"rss_name": "Feed", "error": "boom"}',
|
||||
dedup_key="rss_failure:u",
|
||||
read=False,
|
||||
count=3,
|
||||
created_at="2026-07-05T10:00:00+00:00",
|
||||
updated_at="2026-07-05T12:00:00+00:00",
|
||||
)
|
||||
fields.update(overrides)
|
||||
return InboxMessage(**fields)
|
||||
|
||||
|
||||
@patch("module.security.api.DEV_AUTH_BYPASS", False)
|
||||
def test_messages_require_auth(unauthed_client):
|
||||
assert unauthed_client.get("/api/v1/notification/messages").status_code == 401
|
||||
|
||||
|
||||
def test_list_messages_returns_parsed_payload(authed_client, mock_db):
|
||||
mock_db.inbox.list = AsyncMock(return_value=[_message()])
|
||||
mock_db.inbox.count_all = AsyncMock(return_value=1)
|
||||
mock_db.inbox.unread_count = AsyncMock(return_value=1)
|
||||
|
||||
resp = authed_client.get("/api/v1/notification/messages")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["unread_count"] == 1
|
||||
message = data["messages"][0]
|
||||
assert message["kind"] == "rss_failure"
|
||||
assert message["payload"] == {"rss_name": "Feed", "error": "boom"}
|
||||
assert message["count"] == 3
|
||||
|
||||
|
||||
def test_list_messages_passes_query_params(authed_client, mock_db):
|
||||
mock_db.inbox.list = AsyncMock(return_value=[])
|
||||
mock_db.inbox.count_all = AsyncMock(return_value=0)
|
||||
mock_db.inbox.unread_count = AsyncMock(return_value=0)
|
||||
|
||||
resp = authed_client.get(
|
||||
"/api/v1/notification/messages?unread_only=true&limit=10&offset=20"
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_db.inbox.list.assert_awaited_once_with(unread_only=True, limit=10, offset=20)
|
||||
|
||||
|
||||
def test_unread_count(authed_client, mock_db):
|
||||
mock_db.inbox.unread_count = AsyncMock(return_value=4)
|
||||
|
||||
resp = authed_client.get("/api/v1/notification/messages/unread-count")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["unread_count"] == 4
|
||||
|
||||
|
||||
def test_mark_read_bumps_revision(authed_client, mock_db):
|
||||
mock_db.inbox.mark_read = AsyncMock(return_value=True)
|
||||
|
||||
with patch("module.api.notification.bump_inbox_revision") as bump:
|
||||
resp = authed_client.post("/api/v1/notification/messages/5/read")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_db.inbox.mark_read.assert_awaited_once_with(5)
|
||||
bump.assert_called_once()
|
||||
|
||||
|
||||
def test_mark_read_unknown_id_returns_404(authed_client, mock_db):
|
||||
mock_db.inbox.mark_read = AsyncMock(return_value=False)
|
||||
|
||||
resp = authed_client.post("/api/v1/notification/messages/999/read")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_mark_all_read(authed_client, mock_db):
|
||||
mock_db.inbox.mark_all_read = AsyncMock(return_value=2)
|
||||
|
||||
with patch("module.api.notification.bump_inbox_revision") as bump:
|
||||
resp = authed_client.post("/api/v1/notification/messages/read-all")
|
||||
|
||||
assert resp.status_code == 200
|
||||
bump.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_message(authed_client, mock_db):
|
||||
mock_db.inbox.delete = AsyncMock(return_value=True)
|
||||
|
||||
with patch("module.api.notification.bump_inbox_revision") as bump:
|
||||
resp = authed_client.delete("/api/v1/notification/messages/5")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_db.inbox.delete.assert_awaited_once_with(5)
|
||||
bump.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_unknown_id_returns_404(authed_client, mock_db):
|
||||
mock_db.inbox.delete = AsyncMock(return_value=False)
|
||||
|
||||
resp = authed_client.delete("/api/v1/notification/messages/999")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_clear_all(authed_client, mock_db):
|
||||
mock_db.inbox.clear = AsyncMock(return_value=7)
|
||||
|
||||
with patch("module.api.notification.bump_inbox_revision") as bump:
|
||||
resp = authed_client.delete("/api/v1/notification/messages")
|
||||
|
||||
assert resp.status_code == 200
|
||||
bump.assert_called_once()
|
||||
32
backend/src/test/test_checker.py
Normal file
32
backend/src/test/test_checker.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Checker.check_downloader_detail —— 构造错误也要被捕获,不得击穿启动循环。"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from module.checker import Checker
|
||||
|
||||
|
||||
async def test_construction_error_returns_false_not_raises():
|
||||
"""下载器类型无法识别时 DownloadClient() 构造即抛,必须捕获为
|
||||
(False, 'unreachable'),否则会击穿 _wait_for_downloader 的等待循环。"""
|
||||
settings = MagicMock()
|
||||
settings.downloader.type = "bogus-type"
|
||||
with (
|
||||
patch("module.checker.checker.settings", settings),
|
||||
patch(
|
||||
"module.downloader.DownloadClient",
|
||||
side_effect=Exception("Unsupported downloader type: bogus-type"),
|
||||
),
|
||||
):
|
||||
ok, reason = await Checker.check_downloader_detail()
|
||||
|
||||
assert ok is False
|
||||
assert reason == "unreachable"
|
||||
|
||||
|
||||
async def test_mock_downloader_short_circuits_ok():
|
||||
settings = MagicMock()
|
||||
settings.downloader.type = "mock"
|
||||
with patch("module.checker.checker.settings", settings):
|
||||
ok, reason = await Checker.check_downloader_detail()
|
||||
assert ok is True
|
||||
assert reason is None
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from module.conf import settings as real_settings
|
||||
from module.core.context import AppContext
|
||||
from module.models import ResponseModel
|
||||
from module.notification import DownloaderUnavailableEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -43,10 +44,10 @@ def ctx():
|
||||
|
||||
|
||||
class TestBuild:
|
||||
def test_build_registers_four_named_tasks(self):
|
||||
def test_build_registers_five_named_tasks(self):
|
||||
built = AppContext.build(real_settings)
|
||||
names = [t.name for t in built.scheduler.tasks]
|
||||
assert names == ["rss", "rename", "offset_scan", "calendar"]
|
||||
assert names == ["rss", "rename", "offset_scan", "calendar", "update_check"]
|
||||
|
||||
def test_build_does_no_io(self):
|
||||
"""build() is pure wiring: not running, startup not done."""
|
||||
@@ -133,8 +134,8 @@ class TestStartTasks:
|
||||
patch("module.core.context.reset_shared_client", new=AsyncMock()),
|
||||
patch("module.core.context.Checker.check_first_run", return_value=False),
|
||||
patch(
|
||||
"module.core.context.Checker.check_downloader",
|
||||
new=AsyncMock(return_value=True),
|
||||
"module.core.context.Checker.check_downloader_detail",
|
||||
new=AsyncMock(return_value=(True, None)),
|
||||
),
|
||||
):
|
||||
resp = await ctx.start_tasks()
|
||||
@@ -155,8 +156,8 @@ class TestStartTasks:
|
||||
patch("module.core.context.reset_shared_client", new=AsyncMock()),
|
||||
patch("module.core.context.Checker.check_first_run", return_value=False),
|
||||
patch(
|
||||
"module.core.context.Checker.check_downloader",
|
||||
new=AsyncMock(return_value=True),
|
||||
"module.core.context.Checker.check_downloader_detail",
|
||||
new=AsyncMock(return_value=(True, None)),
|
||||
),
|
||||
):
|
||||
await ctx.start_tasks()
|
||||
@@ -169,14 +170,14 @@ class TestStartTasks:
|
||||
|
||||
async def check_downloader():
|
||||
await start_gate.wait()
|
||||
return True
|
||||
return (True, None)
|
||||
|
||||
with (
|
||||
patch("module.core.context.settings"),
|
||||
patch("module.core.context.reset_shared_client", new=AsyncMock()),
|
||||
patch("module.core.context.Checker.check_first_run", return_value=False),
|
||||
patch(
|
||||
"module.core.context.Checker.check_downloader",
|
||||
"module.core.context.Checker.check_downloader_detail",
|
||||
new=AsyncMock(side_effect=check_downloader),
|
||||
),
|
||||
):
|
||||
@@ -187,6 +188,52 @@ class TestStartTasks:
|
||||
ctx.scheduler.start_all.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wait_for_downloader()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWaitForDownloader:
|
||||
async def test_gives_up_after_max_retries_and_emits_unreachable(self, ctx):
|
||||
"""连不上时按原有节奏重试到上限,然后写入一条 unreachable 通知。"""
|
||||
ctx.notifier.send_event = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.core.context.Checker.check_downloader_detail",
|
||||
new=AsyncMock(return_value=(False, "unreachable")),
|
||||
),
|
||||
patch("module.core.context.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
await ctx._wait_for_downloader()
|
||||
|
||||
ctx.notifier.send_event.assert_awaited_once()
|
||||
event = ctx.notifier.send_event.await_args.args[0]
|
||||
assert isinstance(event, DownloaderUnavailableEvent)
|
||||
assert event.reason == "unreachable"
|
||||
|
||||
async def test_aborts_immediately_on_credentials_error(self, ctx):
|
||||
"""密码错误重试没有意义且会触发 qB 封禁——立即放弃并通知。"""
|
||||
ctx.notifier.send_event = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.core.context.Checker.check_downloader_detail",
|
||||
new=AsyncMock(return_value=(False, "credentials")),
|
||||
) as mock_check,
|
||||
patch(
|
||||
"module.core.context.asyncio.sleep", new_callable=AsyncMock
|
||||
) as mock_sleep,
|
||||
):
|
||||
await ctx._wait_for_downloader()
|
||||
|
||||
assert mock_check.await_count == 1
|
||||
mock_sleep.assert_not_awaited()
|
||||
event = ctx.notifier.send_event.await_args.args[0]
|
||||
assert isinstance(event, DownloaderUnavailableEvent)
|
||||
assert event.reason == "credentials"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stop()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
170
backend/src/test/test_database_inbox.py
Normal file
170
backend/src/test/test_database_inbox.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""InboxDatabase — 通知中心消息表的增删查改与合并语义。"""
|
||||
|
||||
import json
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from module.database.inbox import InboxDatabase
|
||||
from module.models import InboxMessage
|
||||
|
||||
|
||||
async def _rows(db_session) -> list[InboxMessage]:
|
||||
result = await db_session.execute(select(InboxMessage))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def test_upsert_inserts_new_message(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
msg = await db.upsert(
|
||||
kind="rss_failure",
|
||||
severity="error",
|
||||
title="RSS 失效",
|
||||
body="feed x",
|
||||
payload=json.dumps({"rss_url": "http://a/rss"}),
|
||||
dedup_key="rss_failure:http://a/rss",
|
||||
)
|
||||
assert msg is not None
|
||||
assert msg.id is not None
|
||||
assert msg.count == 1
|
||||
assert msg.read is False
|
||||
assert len(await _rows(db_session)) == 1
|
||||
|
||||
|
||||
async def test_upsert_unread_same_dedup_coalesces(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
first = await db.upsert(
|
||||
kind="downloader_unavailable",
|
||||
severity="error",
|
||||
payload=json.dumps({"reason": "unreachable"}),
|
||||
dedup_key="downloader:http://qb:8080",
|
||||
)
|
||||
# 人为回拨时间戳,验证合并时会刷新 updated_at
|
||||
first.updated_at = "2020-01-01T00:00:00+00:00"
|
||||
db_session.add(first)
|
||||
await db_session.commit()
|
||||
|
||||
second = await db.upsert(
|
||||
kind="downloader_unavailable",
|
||||
severity="error",
|
||||
payload=json.dumps({"reason": "credentials"}),
|
||||
dedup_key="downloader:http://qb:8080",
|
||||
)
|
||||
|
||||
rows = await _rows(db_session)
|
||||
assert len(rows) == 1
|
||||
assert second.id == first.id
|
||||
assert second.count == 2
|
||||
assert json.loads(second.payload)["reason"] == "credentials" # 最新覆盖
|
||||
assert second.updated_at > "2020-01-01T00:00:00+00:00"
|
||||
|
||||
|
||||
async def test_upsert_read_same_dedup_inserts_new_row(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
first = await db.upsert(kind="rss_failure", dedup_key="rss:1")
|
||||
await db.mark_read(first.id)
|
||||
|
||||
second = await db.upsert(kind="rss_failure", dedup_key="rss:1")
|
||||
|
||||
rows = await _rows(db_session)
|
||||
assert len(rows) == 2
|
||||
assert second.id != first.id
|
||||
assert second.read is False
|
||||
|
||||
|
||||
async def test_upsert_once_skips_when_any_row_exists(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
first = await db.upsert(
|
||||
kind="update_available", dedup_key="update_available:3.3.1", once=True
|
||||
)
|
||||
assert first is not None
|
||||
await db.mark_read(first.id)
|
||||
|
||||
second = await db.upsert(
|
||||
kind="update_available", dedup_key="update_available:3.3.1", once=True
|
||||
)
|
||||
|
||||
assert second is None
|
||||
assert len(await _rows(db_session)) == 1
|
||||
|
||||
|
||||
async def test_upsert_without_dedup_always_inserts(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
await db.upsert(kind="update_applied")
|
||||
await db.upsert(kind="update_applied")
|
||||
assert len(await _rows(db_session)) == 2
|
||||
|
||||
|
||||
async def test_prune_keeps_newest(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
for i in range(5):
|
||||
msg = await db.upsert(kind="download_failure")
|
||||
msg.updated_at = f"2026-01-0{i + 1}T00:00:00+00:00"
|
||||
db_session.add(msg)
|
||||
await db_session.commit()
|
||||
|
||||
removed = await db.prune(keep=2)
|
||||
|
||||
assert removed == 3
|
||||
rows = await _rows(db_session)
|
||||
assert len(rows) == 2
|
||||
assert {r.updated_at[:10] for r in rows} == {"2026-01-04", "2026-01-05"}
|
||||
|
||||
|
||||
async def test_upsert_prunes_beyond_keep(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
for _ in range(4):
|
||||
await db.upsert(kind="download_failure", keep=3)
|
||||
assert len(await _rows(db_session)) == 3
|
||||
|
||||
|
||||
async def test_unread_count_and_mark_read(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
first = await db.upsert(kind="rss_failure")
|
||||
await db.upsert(kind="download_failure")
|
||||
assert await db.unread_count() == 2
|
||||
|
||||
assert await db.mark_read(first.id) is True
|
||||
assert await db.unread_count() == 1
|
||||
assert await db.mark_read(9999) is False
|
||||
|
||||
|
||||
async def test_mark_all_read(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
await db.upsert(kind="rss_failure")
|
||||
await db.upsert(kind="download_failure")
|
||||
|
||||
changed = await db.mark_all_read()
|
||||
|
||||
assert changed == 2
|
||||
assert await db.unread_count() == 0
|
||||
|
||||
|
||||
async def test_delete_and_clear(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
first = await db.upsert(kind="rss_failure")
|
||||
await db.upsert(kind="download_failure")
|
||||
|
||||
assert await db.delete(first.id) is True
|
||||
assert await db.delete(first.id) is False
|
||||
assert await db.clear() == 1
|
||||
assert len(await _rows(db_session)) == 0
|
||||
|
||||
|
||||
async def test_list_pagination_and_unread_filter(db_session):
|
||||
db = InboxDatabase(db_session)
|
||||
for i in range(3):
|
||||
msg = await db.upsert(kind=f"kind_{i}")
|
||||
msg.updated_at = f"2026-01-0{i + 1}T00:00:00+00:00"
|
||||
db_session.add(msg)
|
||||
await db_session.commit()
|
||||
newest = (await db.list(unread_only=False, limit=1, offset=0))[0]
|
||||
assert newest.kind == "kind_2" # updated_at 倒序
|
||||
|
||||
second_page = await db.list(unread_only=False, limit=1, offset=1)
|
||||
assert second_page[0].kind == "kind_1"
|
||||
|
||||
await db.mark_read(newest.id)
|
||||
unread = await db.list(unread_only=True, limit=10, offset=0)
|
||||
assert {m.kind for m in unread} == {"kind_0", "kind_1"}
|
||||
assert await db.count_all(unread_only=False) == 3
|
||||
assert await db.count_all(unread_only=True) == 2
|
||||
41
backend/src/test/test_database_llm_credential.py
Normal file
41
backend/src/test/test_database_llm_credential.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""LLMCredentialDatabase — 订阅提供商凭据表(token 只存服务端)。"""
|
||||
|
||||
from module.database.llm_credential import LLMCredentialDatabase
|
||||
from module.parser.analyser.providers.base import TokenSet
|
||||
|
||||
|
||||
async def test_upsert_inserts_then_updates(db_session):
|
||||
db = LLMCredentialDatabase(db_session)
|
||||
tokens = TokenSet(
|
||||
access_token="at-1",
|
||||
refresh_token="rt-1",
|
||||
expires_at=1000.0,
|
||||
account_label="user@example.com",
|
||||
extra={"account_id": "acc-1"},
|
||||
)
|
||||
await db.upsert("github-copilot", tokens)
|
||||
|
||||
loaded = await db.get("github-copilot")
|
||||
assert loaded is not None
|
||||
assert loaded.access_token == "at-1"
|
||||
assert loaded.account_label == "user@example.com"
|
||||
assert loaded.extra == {"account_id": "acc-1"}
|
||||
|
||||
await db.upsert("github-copilot", TokenSet(access_token="at-2"))
|
||||
updated = await db.get("github-copilot")
|
||||
assert updated.access_token == "at-2"
|
||||
assert await db.count() == 1 # 同 provider 只有一行
|
||||
|
||||
|
||||
async def test_get_unknown_provider_returns_none(db_session):
|
||||
db = LLMCredentialDatabase(db_session)
|
||||
assert await db.get("nonexistent") is None
|
||||
|
||||
|
||||
async def test_delete_removes_row(db_session):
|
||||
db = LLMCredentialDatabase(db_session)
|
||||
await db.upsert("codex-chatgpt", TokenSet(access_token="at"))
|
||||
|
||||
assert await db.delete("codex-chatgpt") is True
|
||||
assert await db.get("codex-chatgpt") is None
|
||||
assert await db.delete("codex-chatgpt") is False
|
||||
110
backend/src/test/test_llm_credentials_store.py
Normal file
110
backend/src/test/test_llm_credentials_store.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""CredentialStore + auth generation 计数 + AuthExpiredError 熔断/通知。"""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from module.notification.events import LLMAuthFailureEvent, LLMPluginInstallFailedEvent
|
||||
from module.parser.analyser.providers.base import TokenSet
|
||||
from module.parser.analyser.providers.credentials import (
|
||||
CredentialStore,
|
||||
auth_generation,
|
||||
bump_auth_generation,
|
||||
)
|
||||
|
||||
|
||||
def _async_cm(entered_value):
|
||||
cm = AsyncMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=entered_value)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
class TestAuthGeneration:
|
||||
def test_bump_increments_per_provider(self):
|
||||
before = auth_generation("prov-a")
|
||||
bump_auth_generation("prov-a")
|
||||
assert auth_generation("prov-a") == before + 1
|
||||
# 互不影响
|
||||
assert auth_generation("prov-b") == 0 or isinstance(
|
||||
auth_generation("prov-b"), int
|
||||
)
|
||||
|
||||
|
||||
class TestCredentialStore:
|
||||
async def test_load_save_clear_scoped_to_provider(self):
|
||||
db = MagicMock()
|
||||
db.llm_credential.get = AsyncMock(return_value=TokenSet(access_token="at"))
|
||||
db.llm_credential.upsert = AsyncMock()
|
||||
db.llm_credential.delete = AsyncMock(return_value=True)
|
||||
|
||||
store = CredentialStore("github-copilot")
|
||||
# Database 在方法内延迟导入(打破循环),因此 patch 源模块
|
||||
with patch("module.database.Database", return_value=_async_cm(db)):
|
||||
loaded = await store.load()
|
||||
await store.save(TokenSet(access_token="at-2"))
|
||||
await store.clear()
|
||||
|
||||
assert loaded.access_token == "at"
|
||||
db.llm_credential.get.assert_awaited_once_with("github-copilot")
|
||||
db.llm_credential.upsert.assert_awaited_once()
|
||||
assert db.llm_credential.upsert.call_args.args[0] == "github-copilot"
|
||||
db.llm_credential.delete.assert_awaited_once_with("github-copilot")
|
||||
|
||||
|
||||
class TestEventContracts:
|
||||
def test_llm_auth_failure_event(self):
|
||||
event = LLMAuthFailureEvent(
|
||||
provider_id="codex-chatgpt", account_label="u@x.com", message="expired"
|
||||
)
|
||||
assert event.kind == "llm_auth_failure"
|
||||
assert event.severity == "error"
|
||||
assert event.dedup_key() == "llm_auth:codex-chatgpt"
|
||||
assert event.payload() == {
|
||||
"provider_id": "codex-chatgpt",
|
||||
"account_label": "u@x.com",
|
||||
"message": "expired",
|
||||
}
|
||||
title, body = event.describe()
|
||||
assert "LLM" in title
|
||||
|
||||
def test_llm_plugin_install_failed_event(self):
|
||||
event = LLMPluginInstallFailedEvent(
|
||||
plugin_id="github-copilot", version="1.0.0", message="bad signature"
|
||||
)
|
||||
assert event.kind == "llm_plugin_install_failed"
|
||||
assert event.severity == "error"
|
||||
assert event.dedup_key() == "llm_plugin_install:github-copilot"
|
||||
assert "bad signature" in event.describe()[1]
|
||||
|
||||
|
||||
class TestAuthExpiredBreaker:
|
||||
async def test_auth_expired_opens_breaker_and_notifies(self):
|
||||
"""AuthExpiredError:跳过失败计数阈值直接熔断,并发通知事件。"""
|
||||
from module.parser import title_parser as tp
|
||||
from module.parser.analyser.providers.base import AuthExpiredError
|
||||
|
||||
tp.reset_cache()
|
||||
mock_parser = MagicMock()
|
||||
mock_parser.parse = AsyncMock(side_effect=AuthExpiredError("refresh failed"))
|
||||
|
||||
with (
|
||||
patch.object(tp, "_get_llm_parser", return_value=mock_parser),
|
||||
patch.object(tp, "_llm_config") as mock_conf,
|
||||
patch.object(tp, "_notify_auth_failure") as mock_notify,
|
||||
):
|
||||
mock_conf.return_value = MagicMock(
|
||||
provider="codex-chatgpt",
|
||||
timeout=5.0,
|
||||
cache_ttl=0,
|
||||
max_concurrency=1,
|
||||
failure_threshold=3,
|
||||
failure_backoff=300,
|
||||
effective=lambda provider_id=None: ("", "m", ""),
|
||||
)
|
||||
result = await tp._llm_parse("raw title")
|
||||
|
||||
assert result is None
|
||||
assert tp._llm_breaker_until > time.monotonic() # 立即熔断
|
||||
mock_notify.assert_called_once()
|
||||
assert mock_notify.call_args.args[0] == "codex-chatgpt"
|
||||
tp.reset_cache()
|
||||
231
backend/src/test/test_llm_plugin_installer.py
Normal file
231
backend/src/test/test_llm_plugin_installer.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""LLM 插件安装管线:签名下载 → 校验 → 解包 → 加载 → 解析(克隆 updater 模式)。
|
||||
|
||||
用测试 ed25519 密钥对 + 内存构造的假插件 zip + httpx.MockTransport + tmp_path,
|
||||
覆盖安全路径:sha 不符、无签名、坏签名、zip-slip、版本不兼容、id 不匹配全部拒绝。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
PublicFormat,
|
||||
)
|
||||
|
||||
from module.llm_plugins.installer import PluginInstaller
|
||||
|
||||
PLUGIN_ID = "fake-copilot"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
ADAPTER_SOURCE = """
|
||||
from module.parser.analyser.providers.base import (
|
||||
LLMProviderAdapter, ProviderInfo,
|
||||
)
|
||||
|
||||
|
||||
class FakeCopilotAdapter(LLMProviderAdapter):
|
||||
info = ProviderInfo(
|
||||
id="fake-copilot",
|
||||
display_name="Fake Copilot",
|
||||
auth_kind="device_code",
|
||||
builtin=False,
|
||||
)
|
||||
|
||||
async def parse(self, raw):
|
||||
return {"title_en": "ok", "title_zh": None, "title_jp": None,
|
||||
"season": 1, "season_raw": "", "episode": 1,
|
||||
"sub": "", "group": "", "resolution": "", "source": ""}
|
||||
|
||||
async def list_models(self):
|
||||
return ["fake-model"]
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keypair(tmp_path):
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
pubkey_path = tmp_path / "pub.pem"
|
||||
pubkey_path.write_bytes(
|
||||
pub.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
|
||||
)
|
||||
return priv, pubkey_path
|
||||
|
||||
|
||||
def _make_plugin_zip(
|
||||
manifest_extra: dict | None = None, member_name: str | None = None
|
||||
) -> bytes:
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"id": PLUGIN_ID,
|
||||
"name": "Fake Copilot",
|
||||
"version": VERSION,
|
||||
"min_ab_version": "3.3.0",
|
||||
"entry": "ab_fake_copilot.adapter:FakeCopilotAdapter",
|
||||
"auth_kind": "device_code",
|
||||
}
|
||||
manifest.update(manifest_extra or {})
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("plugin.json", json.dumps(manifest))
|
||||
adapter_path = member_name or "ab_fake_copilot/adapter.py"
|
||||
zf.writestr(adapter_path, ADAPTER_SOURCE)
|
||||
if member_name is None:
|
||||
zf.writestr("ab_fake_copilot/__init__.py", "")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _catalog(zip_bytes: bytes, priv, extra: dict | None = None) -> dict:
|
||||
entry = {
|
||||
"schema": 1,
|
||||
"id": PLUGIN_ID,
|
||||
"name": "Fake Copilot",
|
||||
"version": VERSION,
|
||||
"min_ab_version": "3.3.0",
|
||||
"auth_kind": "device_code",
|
||||
"asset": f"{PLUGIN_ID}-{VERSION}.zip",
|
||||
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
|
||||
}
|
||||
entry.update(extra or {})
|
||||
return {"schema": 1, "plugins": [entry]}
|
||||
|
||||
|
||||
def _sign(priv, data: bytes) -> str:
|
||||
return base64.b64encode(priv.sign(data)).decode()
|
||||
|
||||
|
||||
def _installer(tmp_path, keypair, zip_bytes, *, catalog=None, tamper_sig=False):
|
||||
priv, pubkey_path = keypair
|
||||
catalog = catalog if catalog is not None else _catalog(zip_bytes, priv)
|
||||
catalog_bytes = json.dumps(catalog).encode()
|
||||
sig = _sign(priv, zip_bytes)
|
||||
if tamper_sig:
|
||||
sig = base64.b64encode(b"\x00" * 64).decode()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if url.endswith("catalog.json"):
|
||||
return httpx.Response(200, content=catalog_bytes)
|
||||
if url.endswith("catalog.json.sig"):
|
||||
return httpx.Response(200, text=_sign(priv, catalog_bytes))
|
||||
if url.endswith(".zip"):
|
||||
return httpx.Response(200, content=zip_bytes)
|
||||
if url.endswith(".zip.sig"):
|
||||
return httpx.Response(200, text=sig)
|
||||
if url.endswith(".zip.sha256"):
|
||||
return httpx.Response(200, text=hashlib.sha256(zip_bytes).hexdigest())
|
||||
return httpx.Response(404)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
return PluginInstaller(
|
||||
root=tmp_path / "plugins",
|
||||
pubkey_path=pubkey_path,
|
||||
client=client,
|
||||
app_version="3.3.0",
|
||||
)
|
||||
|
||||
|
||||
class TestInstallHappyPath:
|
||||
async def test_install_then_resolve_and_parse(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
zip_bytes = _make_plugin_zip()
|
||||
installer = _installer(tmp_path, keypair, zip_bytes)
|
||||
|
||||
result = await installer.install(PLUGIN_ID)
|
||||
assert result.success is True
|
||||
assert result.version == VERSION
|
||||
|
||||
adapter_cls = registry.resolve(PLUGIN_ID)
|
||||
assert adapter_cls.info.id == PLUGIN_ID
|
||||
assert adapter_cls.info.auth_kind == "device_code"
|
||||
|
||||
# 装好的插件出现在列举里
|
||||
assert PLUGIN_ID in {i.id for i in registry.list_infos()}
|
||||
|
||||
await installer.uninstall(PLUGIN_ID)
|
||||
registry.invalidate()
|
||||
|
||||
|
||||
class TestInstallRejections:
|
||||
async def test_sha_mismatch_refused(self, tmp_path, keypair):
|
||||
priv, _ = keypair
|
||||
zip_bytes = _make_plugin_zip()
|
||||
bad_catalog = _catalog(zip_bytes, priv, extra={"sha256": "deadbeef"})
|
||||
installer = _installer(tmp_path, keypair, zip_bytes, catalog=bad_catalog)
|
||||
result = await installer.install(PLUGIN_ID)
|
||||
assert result.success is False
|
||||
assert (
|
||||
"sha256" in result.message.lower() or "checksum" in result.message.lower()
|
||||
)
|
||||
|
||||
async def test_bad_signature_refused(self, tmp_path, keypair):
|
||||
zip_bytes = _make_plugin_zip()
|
||||
installer = _installer(tmp_path, keypair, zip_bytes, tamper_sig=True)
|
||||
result = await installer.install(PLUGIN_ID)
|
||||
assert result.success is False
|
||||
assert "signature" in result.message.lower()
|
||||
|
||||
async def test_zip_slip_refused(self, tmp_path, keypair):
|
||||
zip_bytes = _make_plugin_zip(member_name="../evil.py")
|
||||
installer = _installer(tmp_path, keypair, zip_bytes)
|
||||
result = await installer.install(PLUGIN_ID)
|
||||
assert result.success is False
|
||||
|
||||
async def test_incompatible_min_version_refused(self, tmp_path, keypair):
|
||||
priv, _ = keypair
|
||||
zip_bytes = _make_plugin_zip(manifest_extra={"min_ab_version": "9.9.0"})
|
||||
catalog = _catalog(zip_bytes, priv, extra={"min_ab_version": "9.9.0"})
|
||||
installer = _installer(tmp_path, keypair, zip_bytes, catalog=catalog)
|
||||
result = await installer.install(PLUGIN_ID)
|
||||
assert result.success is False
|
||||
assert "9.9.0" in result.message
|
||||
|
||||
async def test_id_mismatch_refused(self, tmp_path, keypair):
|
||||
zip_bytes = _make_plugin_zip(manifest_extra={"id": "someone-else"})
|
||||
installer = _installer(tmp_path, keypair, zip_bytes)
|
||||
result = await installer.install(PLUGIN_ID)
|
||||
assert result.success is False
|
||||
|
||||
async def test_unknown_plugin_id_refused(self, tmp_path, keypair):
|
||||
zip_bytes = _make_plugin_zip()
|
||||
installer = _installer(tmp_path, keypair, zip_bytes)
|
||||
result = await installer.install("nonexistent-in-catalog")
|
||||
assert result.success is False
|
||||
|
||||
async def test_uninstall_builtin_refused(self, tmp_path, keypair):
|
||||
zip_bytes = _make_plugin_zip()
|
||||
installer = _installer(tmp_path, keypair, zip_bytes)
|
||||
result = await installer.uninstall("openai")
|
||||
assert result.success is False
|
||||
|
||||
|
||||
class TestUninstallClearsCredential:
|
||||
async def test_uninstall_deletes_credential_row(self, tmp_path, keypair):
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
zip_bytes = _make_plugin_zip()
|
||||
installer = _installer(tmp_path, keypair, zip_bytes)
|
||||
await installer.install(PLUGIN_ID)
|
||||
|
||||
db = AsyncMock()
|
||||
|
||||
def _cm(v):
|
||||
cm = AsyncMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=v)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
return cm
|
||||
|
||||
with patch("module.llm_plugins.installer.Database", return_value=_cm(db)):
|
||||
await installer.uninstall(PLUGIN_ID)
|
||||
|
||||
db.llm_credential.delete.assert_awaited_once_with(PLUGIN_ID)
|
||||
registry.invalidate()
|
||||
386
backend/src/test/test_llm_plugins_grey.py
Normal file
386
backend/src/test/test_llm_plugins_grey.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""M6 灰色插件(github-copilot / codex-chatgpt):装载 + device flow + parse。
|
||||
|
||||
从 ``plugins/<id>/`` 真实源码打包 zip → 用测试密钥签名 → 走安装管线装载真实
|
||||
适配器 → 用 MockTransport 模拟各家 HTTP 端点,验证 begin/complete/parse。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from module.llm_plugins.installer import PluginInstaller
|
||||
from module.parser.analyser.providers.base import AdapterContext, AuthExpiredError
|
||||
|
||||
PLUGINS_SRC = Path(__file__).resolve().parents[3] / "plugins"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keypair(tmp_path):
|
||||
priv = Ed25519PrivateKey.generate()
|
||||
pubkey_path = tmp_path / "pub.pem"
|
||||
pubkey_path.write_bytes(
|
||||
priv.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
|
||||
)
|
||||
return priv, pubkey_path
|
||||
|
||||
|
||||
def _zip_plugin(plugin_id: str) -> bytes:
|
||||
src = PLUGINS_SRC / plugin_id
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for path in sorted(src.rglob("*")):
|
||||
if path.is_file() and "__pycache__" not in path.parts:
|
||||
zf.write(path, path.relative_to(src).as_posix())
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def _install(tmp_path, keypair, plugin_id):
|
||||
priv, pubkey_path = keypair
|
||||
zip_bytes = _zip_plugin(plugin_id)
|
||||
manifest = json.loads((PLUGINS_SRC / plugin_id / "plugin.json").read_text())
|
||||
catalog = {
|
||||
"schema": 1,
|
||||
"plugins": [
|
||||
{
|
||||
"id": plugin_id,
|
||||
"name": manifest["name"],
|
||||
"version": manifest["version"],
|
||||
"min_ab_version": manifest["min_ab_version"],
|
||||
"auth_kind": manifest["auth_kind"],
|
||||
"asset": f"{plugin_id}-{manifest['version']}.zip",
|
||||
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
|
||||
}
|
||||
],
|
||||
}
|
||||
catalog_bytes = json.dumps(catalog).encode()
|
||||
|
||||
def handler(request):
|
||||
url = str(request.url)
|
||||
if url.endswith("catalog.json"):
|
||||
return httpx.Response(200, content=catalog_bytes)
|
||||
if url.endswith("catalog.json.sig"):
|
||||
return httpx.Response(
|
||||
200, text=base64.b64encode(priv.sign(catalog_bytes)).decode()
|
||||
)
|
||||
if url.endswith(".zip"):
|
||||
return httpx.Response(200, content=zip_bytes)
|
||||
if url.endswith(".zip.sig"):
|
||||
return httpx.Response(
|
||||
200, text=base64.b64encode(priv.sign(zip_bytes)).decode()
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
installer = PluginInstaller(
|
||||
root=tmp_path / "plugins",
|
||||
pubkey_path=pubkey_path,
|
||||
client=client,
|
||||
app_version="3.3.0",
|
||||
)
|
||||
result = await installer.install(plugin_id)
|
||||
assert result.success, result.message
|
||||
return installer
|
||||
|
||||
|
||||
def _ctx(handler, credentials=None, model=""):
|
||||
"""构造注入 MockTransport-http-client 的 AdapterContext。"""
|
||||
|
||||
def build_client(_timeout):
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
return AdapterContext(
|
||||
model=model,
|
||||
build_http_client=build_client,
|
||||
credentials=credentials or AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# github-copilot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGithubCopilot:
|
||||
async def test_install_and_resolve(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "github-copilot")
|
||||
cls = registry.resolve("github-copilot")
|
||||
assert cls.info.id == "github-copilot"
|
||||
assert cls.info.auth_kind == "device_code"
|
||||
registry.invalidate()
|
||||
|
||||
async def test_begin_auth_returns_user_code(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "github-copilot")
|
||||
cls = registry.resolve("github-copilot")
|
||||
|
||||
def handler(request):
|
||||
assert "login/device/code" in str(request.url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"device_code": "DC-secret",
|
||||
"user_code": "WXYZ-1234",
|
||||
"verification_uri": "https://github.com/login/device",
|
||||
"interval": 5,
|
||||
"expires_in": 900,
|
||||
},
|
||||
)
|
||||
|
||||
adapter = cls(_ctx(handler))
|
||||
challenge = await adapter.begin_auth()
|
||||
assert challenge.user_code == "WXYZ-1234"
|
||||
assert challenge.method == "device_code"
|
||||
# device_code 藏在 state 里(不作为 user_code 下发)
|
||||
assert "DC-secret" in challenge.state
|
||||
assert "DC-secret" != challenge.user_code
|
||||
await adapter.aclose()
|
||||
registry.invalidate()
|
||||
|
||||
async def test_complete_auth_pending_raises(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "github-copilot")
|
||||
cls = registry.resolve("github-copilot")
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"error": "authorization_pending"})
|
||||
|
||||
adapter = cls(_ctx(handler))
|
||||
with pytest.raises(AuthExpiredError):
|
||||
await adapter.complete_auth(json.dumps({"device_code": "DC"}))
|
||||
await adapter.aclose()
|
||||
registry.invalidate()
|
||||
|
||||
async def test_parse_uses_tool_call_and_copilot_token(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.base import TokenSet
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "github-copilot")
|
||||
cls = registry.resolve("github-copilot")
|
||||
|
||||
episode = {
|
||||
"title_en": "Test",
|
||||
"title_zh": None,
|
||||
"title_jp": None,
|
||||
"season": 1,
|
||||
"season_raw": "",
|
||||
"episode": 5,
|
||||
"sub": "CHS",
|
||||
"group": "G",
|
||||
"resolution": "1080p",
|
||||
"source": "Web",
|
||||
}
|
||||
seen = {"copilot_token_fetched": False, "editor_header": False}
|
||||
|
||||
def handler(request):
|
||||
url = str(request.url)
|
||||
if "copilot_internal/v2/token" in url:
|
||||
seen["copilot_token_fetched"] = True
|
||||
assert request.headers["authorization"].startswith("token ghu_")
|
||||
return httpx.Response(
|
||||
200, json={"token": "cop_tok", "expires_at": 9e18}
|
||||
)
|
||||
if "chat/completions" in url:
|
||||
seen["editor_header"] = "editor-version" in request.headers
|
||||
assert request.headers["authorization"] == "Bearer cop_tok"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{"function": {"arguments": json.dumps(episode)}}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
creds = AsyncMock()
|
||||
creds.load = AsyncMock(return_value=TokenSet(access_token="ghu_abc"))
|
||||
adapter = cls(_ctx(handler, credentials=creds))
|
||||
result = await adapter.parse("[G] Test - 05 [1080p]")
|
||||
assert result == episode
|
||||
assert seen["copilot_token_fetched"] is True
|
||||
assert seen["editor_header"] is True
|
||||
await adapter.aclose()
|
||||
registry.invalidate()
|
||||
|
||||
async def test_parse_without_credentials_raises(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "github-copilot")
|
||||
cls = registry.resolve("github-copilot")
|
||||
creds = AsyncMock()
|
||||
creds.load = AsyncMock(return_value=None)
|
||||
adapter = cls(_ctx(lambda r: httpx.Response(404), credentials=creds))
|
||||
with pytest.raises(AuthExpiredError):
|
||||
await adapter.parse("raw")
|
||||
await adapter.aclose()
|
||||
registry.invalidate()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# codex-chatgpt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCodexChatgpt:
|
||||
async def test_install_and_resolve(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "codex-chatgpt")
|
||||
cls = registry.resolve("codex-chatgpt")
|
||||
assert cls.info.id == "codex-chatgpt"
|
||||
registry.invalidate()
|
||||
|
||||
async def test_complete_auth_decodes_account_id(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "codex-chatgpt")
|
||||
cls = registry.resolve("codex-chatgpt")
|
||||
|
||||
# 构造带 chatgpt_account_id claim 的假 JWT
|
||||
header = base64.urlsafe_b64encode(b'{"alg":"none"}').decode().rstrip("=")
|
||||
claims = (
|
||||
base64.urlsafe_b64encode(
|
||||
json.dumps({"chatgpt_account_id": "acc-42"}).encode()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
fake_jwt = f"{header}.{claims}.sig"
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": fake_jwt,
|
||||
"refresh_token": "rt-1",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
|
||||
adapter = cls(_ctx(handler))
|
||||
tokens = await adapter.complete_auth(json.dumps({"device_code": "DC"}))
|
||||
assert tokens.access_token == fake_jwt
|
||||
assert tokens.extra["account_id"] == "acc-42"
|
||||
assert tokens.refresh_token == "rt-1"
|
||||
await adapter.aclose()
|
||||
registry.invalidate()
|
||||
|
||||
async def test_parse_sends_originator_and_account_headers(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.base import TokenSet
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "codex-chatgpt")
|
||||
cls = registry.resolve("codex-chatgpt")
|
||||
|
||||
episode = {
|
||||
"title_en": "X",
|
||||
"title_zh": None,
|
||||
"title_jp": None,
|
||||
"season": 1,
|
||||
"season_raw": "",
|
||||
"episode": 1,
|
||||
"sub": "",
|
||||
"group": "",
|
||||
"resolution": "",
|
||||
"source": "",
|
||||
}
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["originator"] = request.headers.get("originator")
|
||||
seen["account"] = request.headers.get("chatgpt-account-id")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"output": [{"content": [{"text": json.dumps(episode)}]}]},
|
||||
)
|
||||
|
||||
creds = AsyncMock()
|
||||
creds.load = AsyncMock(
|
||||
return_value=TokenSet(
|
||||
access_token="at", expires_at=9e18, extra={"account_id": "acc-42"}
|
||||
)
|
||||
)
|
||||
adapter = cls(_ctx(handler, credentials=creds))
|
||||
result = await adapter.parse("raw title")
|
||||
assert result == episode
|
||||
assert seen["originator"] == "codex_cli_rs"
|
||||
assert seen["account"] == "acc-42"
|
||||
await adapter.aclose()
|
||||
registry.invalidate()
|
||||
|
||||
async def test_expired_token_triggers_refresh(self, tmp_path, keypair):
|
||||
from module.parser.analyser.providers.base import TokenSet
|
||||
from module.parser.analyser.providers.registry import registry
|
||||
|
||||
await _install(tmp_path, keypair, "codex-chatgpt")
|
||||
cls = registry.resolve("codex-chatgpt")
|
||||
refreshed_saved = {}
|
||||
|
||||
def handler(request):
|
||||
url = str(request.url)
|
||||
if url.endswith("oauth/token"):
|
||||
return httpx.Response(
|
||||
200, json={"access_token": "new-at", "expires_in": 3600}
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": json.dumps(
|
||||
{
|
||||
"title_en": None,
|
||||
"title_zh": None,
|
||||
"title_jp": None,
|
||||
"season": 1,
|
||||
"season_raw": "",
|
||||
"episode": 1,
|
||||
"sub": "",
|
||||
"group": "",
|
||||
"resolution": "",
|
||||
"source": "",
|
||||
}
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
creds = AsyncMock()
|
||||
creds.load = AsyncMock(
|
||||
return_value=TokenSet(
|
||||
access_token="old", refresh_token="rt", expires_at=0.0
|
||||
)
|
||||
)
|
||||
creds.save = AsyncMock(
|
||||
side_effect=lambda t: refreshed_saved.update(at=t.access_token)
|
||||
)
|
||||
adapter = cls(_ctx(handler, credentials=creds))
|
||||
await adapter.parse("raw")
|
||||
# 过期 → 刷新 → 保存新 token
|
||||
assert refreshed_saved.get("at") == "new-at"
|
||||
await adapter.aclose()
|
||||
registry.invalidate()
|
||||
167
backend/src/test/test_llm_providers_presets.py
Normal file
167
backend/src/test/test_llm_providers_presets.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""国产提供商 base_url 预设 + OpenAI 兼容降级路径 + 按提供商配置覆盖。"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from module.api.config import _restore_masked, _sanitize_dict
|
||||
from module.models.config import LLM, LLMProviderOverride
|
||||
from module.parser.analyser.llm import LLMParser
|
||||
from module.parser.analyser.providers import registry
|
||||
from module.parser.analyser.providers.builtin import OpenAIAdapter
|
||||
|
||||
EPISODE_DICT = {
|
||||
"title_en": "Test",
|
||||
"title_zh": None,
|
||||
"title_jp": None,
|
||||
"season": 1,
|
||||
"season_raw": "",
|
||||
"episode": 5,
|
||||
"sub": "CHS",
|
||||
"group": "G",
|
||||
"resolution": "1080p",
|
||||
"source": "Web",
|
||||
}
|
||||
|
||||
|
||||
class TestPresetRegistry:
|
||||
def test_presets_listed_with_endpoints(self):
|
||||
infos = {i.id: i for i in registry.list_infos()}
|
||||
assert infos["deepseek"].preset_base_url == "https://api.deepseek.com"
|
||||
assert infos["zai"].preset_base_url == "https://api.z.ai/api/paas/v4"
|
||||
assert (
|
||||
infos["zai-cn"].preset_base_url
|
||||
== "https://open.bigmodel.cn/api/coding/paas/v4"
|
||||
)
|
||||
assert infos["kimi"].preset_base_url == "https://api.moonshot.ai/v1"
|
||||
assert infos["kimi-cn"].preset_base_url == "https://api.moonshot.cn/v1"
|
||||
assert infos["minimax"].preset_base_url == "https://api.minimax.io/v1"
|
||||
for preset_id in ("zai", "zai-cn", "minimax", "minimax-cn", "deepseek", "kimi"):
|
||||
assert infos[preset_id].auth_kind == "api_key"
|
||||
assert infos[preset_id].builtin is False
|
||||
assert infos[preset_id].needs_base_url is True
|
||||
|
||||
def test_preset_resolves_to_openai_dialect_adapter(self):
|
||||
adapter_cls = registry.resolve("kimi")
|
||||
assert issubclass(adapter_cls, OpenAIAdapter)
|
||||
assert adapter_cls.info.id == "kimi"
|
||||
|
||||
def test_preset_base_url_fills_when_config_blank(self):
|
||||
parser = LLMParser(api_key="sk-test", provider="deepseek", model="m")
|
||||
assert str(parser._openai_client.base_url).startswith(
|
||||
"https://api.deepseek.com"
|
||||
)
|
||||
|
||||
def test_explicit_base_url_overrides_preset(self):
|
||||
parser = LLMParser(
|
||||
api_key="sk-test",
|
||||
provider="deepseek",
|
||||
model="m",
|
||||
base_url="http://localhost:11434/v1",
|
||||
)
|
||||
assert str(parser._openai_client.base_url).startswith(
|
||||
"http://localhost:11434/v1"
|
||||
)
|
||||
|
||||
|
||||
class TestJsonObjectFallback:
|
||||
"""json_schema 不受支持的预设(DeepSeek/MiniMax)降级为
|
||||
json_object + schema 写入提示词。"""
|
||||
|
||||
def _completion(self, content: str):
|
||||
return SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content=content))]
|
||||
)
|
||||
|
||||
async def test_deepseek_uses_json_object_mode(self):
|
||||
parser = LLMParser(api_key="sk-test", provider="deepseek", model="m")
|
||||
mock_create = AsyncMock(return_value=self._completion(json.dumps(EPISODE_DICT)))
|
||||
parser._openai_client.chat.completions.create = mock_create
|
||||
|
||||
result = await parser.parse("[G] Test - 05 [1080p]")
|
||||
|
||||
assert result == EPISODE_DICT
|
||||
kwargs = mock_create.call_args.kwargs
|
||||
assert kwargs["response_format"] == {"type": "json_object"}
|
||||
# schema 字段清单进入系统提示词
|
||||
assert "title_en" in kwargs["messages"][0]["content"]
|
||||
|
||||
async def test_json_object_mode_invalid_json_returns_none(self):
|
||||
parser = LLMParser(api_key="sk-test", provider="minimax", model="m")
|
||||
parser._openai_client.chat.completions.create = AsyncMock(
|
||||
return_value=self._completion("not json")
|
||||
)
|
||||
assert await parser.parse("raw") is None
|
||||
|
||||
async def test_openai_still_uses_beta_parse(self):
|
||||
"""内置 openai 提供商不受降级影响,仍走 beta 结构化输出。"""
|
||||
parser = LLMParser(api_key="sk-test", provider="openai", model="m")
|
||||
mock_parse = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
parsed=SimpleNamespace(model_dump=lambda: EPISODE_DICT)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
parser._openai_client.beta.chat.completions.parse = mock_parse
|
||||
|
||||
assert await parser.parse("raw") == EPISODE_DICT
|
||||
mock_parse.assert_awaited_once()
|
||||
|
||||
|
||||
class TestLLMEffective:
|
||||
def test_effective_prefers_provider_override(self):
|
||||
conf = LLM(
|
||||
provider="kimi",
|
||||
api_key="flat-key",
|
||||
model="flat-model",
|
||||
base_url="flat-url",
|
||||
providers={
|
||||
"kimi": LLMProviderOverride(
|
||||
api_key="kimi-key", model="kimi-k2.5", base_url="https://k/v1"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert conf.effective() == ("kimi-key", "kimi-k2.5", "https://k/v1")
|
||||
|
||||
def test_effective_falls_back_to_flat_fields(self):
|
||||
conf = LLM(provider="openai", api_key="flat-key", model="m0", base_url="b0")
|
||||
assert conf.effective() == ("flat-key", "m0", "b0")
|
||||
|
||||
def test_effective_blank_override_fields_stay_blank(self):
|
||||
"""有覆盖项时不跨提供商回退到扁平 openai 字段:空模型保持空,
|
||||
由适配器回退到本提供商默认(避免把 gpt-5-mini 发给 Kimi)。"""
|
||||
conf = LLM(
|
||||
provider="kimi",
|
||||
api_key="flat-key",
|
||||
model="flat-model",
|
||||
base_url="flat-url",
|
||||
providers={"kimi": LLMProviderOverride(api_key="kimi-key")},
|
||||
)
|
||||
api_key, model, base_url = conf.effective()
|
||||
assert api_key == "kimi-key"
|
||||
assert model == "" # 不再串到 flat-model
|
||||
assert base_url == "" # 不再串到 flat-url
|
||||
|
||||
def test_blank_model_falls_back_to_provider_default(self):
|
||||
"""空模型的预设适配器使用自己的 default_model,而非 openai 的默认。"""
|
||||
parser = LLMParser(api_key="sk", provider="kimi", model="")
|
||||
assert parser._adapter.model == "kimi-k2.5"
|
||||
|
||||
|
||||
class TestNestedProviderMasking:
|
||||
def test_sanitize_masks_nested_provider_api_key(self):
|
||||
data = {"llm": {"providers": {"kimi": {"api_key": "sk-secret", "model": "m"}}}}
|
||||
masked = _sanitize_dict(data)
|
||||
assert masked["llm"]["providers"]["kimi"]["api_key"] == "********"
|
||||
assert masked["llm"]["providers"]["kimi"]["model"] == "m"
|
||||
|
||||
def test_restore_swaps_mask_back_to_saved_value(self):
|
||||
saved = {"llm": {"providers": {"kimi": {"api_key": "sk-secret"}}}}
|
||||
incoming = {"llm": {"providers": {"kimi": {"api_key": "********"}}}}
|
||||
_restore_masked(incoming, saved)
|
||||
assert incoming["llm"]["providers"]["kimi"]["api_key"] == "sk-secret"
|
||||
32
backend/src/test/test_llm_providers_registry.py
Normal file
32
backend/src/test/test_llm_providers_registry.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""ProviderRegistry — LLM 提供商注册表(内置适配器解析与列举)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from module.parser.analyser.providers import registry
|
||||
from module.parser.analyser.providers.base import LLMProviderAdapter
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_resolves_builtin_providers(self):
|
||||
for provider_id in ("openai", "anthropic", "gemini"):
|
||||
adapter_cls = registry.resolve(provider_id)
|
||||
assert issubclass(adapter_cls, LLMProviderAdapter)
|
||||
assert adapter_cls.info.id == provider_id
|
||||
assert adapter_cls.info.builtin is True
|
||||
|
||||
def test_unknown_provider_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Unsupported LLM provider"):
|
||||
registry.resolve("nonexistent")
|
||||
|
||||
|
||||
class TestListInfos:
|
||||
def test_lists_builtins_with_api_key_auth(self):
|
||||
infos = {info.id: info for info in registry.list_infos()}
|
||||
assert {"openai", "anthropic", "gemini"} <= set(infos)
|
||||
for provider_id in ("openai", "anthropic", "gemini"):
|
||||
assert infos[provider_id].auth_kind == "api_key"
|
||||
|
||||
def test_openai_needs_base_url(self):
|
||||
infos = {info.id: info for info in registry.list_infos()}
|
||||
assert infos["openai"].needs_base_url is True
|
||||
assert infos["anthropic"].needs_base_url is False
|
||||
@@ -10,11 +10,20 @@ returned events into ``notifier.send_event`` calls, gated by
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from module.core.loops import offset_scan_tick, rename_tick, rss_tick
|
||||
from module.core.loops import (
|
||||
offset_scan_tick,
|
||||
rename_tick,
|
||||
rss_tick,
|
||||
update_check_tick,
|
||||
)
|
||||
from module.models.bangumi import Notification
|
||||
from module.notification.events import OffsetReviewEvent, RssFailureEvent
|
||||
from module.notification.events import (
|
||||
OffsetReviewEvent,
|
||||
RssFailureEvent,
|
||||
UpdateAvailableEvent,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# offset_scan_tick
|
||||
@@ -35,8 +44,9 @@ class TestOffsetScanTick:
|
||||
|
||||
notifier.send_event.assert_awaited_once_with(event)
|
||||
|
||||
async def test_no_events_sent_when_notifications_disabled(self):
|
||||
"""settings.notification.enable=False suppresses all sends."""
|
||||
async def test_events_forwarded_even_when_external_push_disabled(self):
|
||||
"""事件总是送达 notifier:站内落库不受 enable 开关影响(外发 gating
|
||||
在 NotificationManager.send_event 内部)。"""
|
||||
event = OffsetReviewEvent(official_title="Test Anime", reason="mismatch")
|
||||
notifier = AsyncMock()
|
||||
|
||||
@@ -46,7 +56,7 @@ class TestOffsetScanTick:
|
||||
mock_settings.notification.enable = False
|
||||
await offset_scan_tick(notifier)
|
||||
|
||||
notifier.send_event.assert_not_awaited()
|
||||
notifier.send_event.assert_awaited_once_with(event)
|
||||
|
||||
async def test_no_events_flagged_sends_nothing(self):
|
||||
"""An empty scan result sends no notifications."""
|
||||
@@ -101,8 +111,8 @@ class TestRssTick:
|
||||
|
||||
notifier.send_event.assert_awaited_once_with(events[0])
|
||||
|
||||
async def test_no_events_sent_when_notifications_disabled(self):
|
||||
"""settings.notification.enable=False suppresses all sends."""
|
||||
async def test_events_forwarded_even_when_external_push_disabled(self):
|
||||
"""事件总是送达 notifier(站内落库),外发 gating 在 manager 内部。"""
|
||||
events = [RssFailureEvent(rss_name="Feed", rss_url="u", error="e")]
|
||||
notifier = AsyncMock()
|
||||
analyser = AsyncMock()
|
||||
@@ -125,7 +135,7 @@ class TestRssTick:
|
||||
mock_settings.bangumi_manage.eps_complete = False
|
||||
await rss_tick(analyser, notifier)
|
||||
|
||||
notifier.send_event.assert_not_awaited()
|
||||
notifier.send_event.assert_awaited_once_with(events[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -211,3 +221,71 @@ class TestRenameTick:
|
||||
# Serial would take ~0.25s (5 x 0.05s); concurrent should stay well
|
||||
# under 2x a single call's latency.
|
||||
assert elapsed < 0.15
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_check_tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCheckTick:
|
||||
def _result(self, **overrides):
|
||||
result = MagicMock()
|
||||
result.has_update = overrides.get("has_update", True)
|
||||
result.error = overrides.get("error", None)
|
||||
result.current = overrides.get("current", "3.3.0")
|
||||
result.latest = overrides.get("latest", "3.3.1")
|
||||
result.channel = overrides.get("channel", "beta")
|
||||
result.notes = overrides.get("notes", "release notes")
|
||||
return result
|
||||
|
||||
async def test_emits_event_when_update_available(self):
|
||||
notifier = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.core.loops.updater.check_update",
|
||||
new=AsyncMock(return_value=self._result()),
|
||||
),
|
||||
patch("module.core.loops.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.update.channel = "beta"
|
||||
await update_check_tick(notifier)
|
||||
|
||||
notifier.send_event.assert_awaited_once()
|
||||
event = notifier.send_event.await_args.args[0]
|
||||
assert isinstance(event, UpdateAvailableEvent)
|
||||
assert event.latest == "3.3.1"
|
||||
assert event.current == "3.3.0"
|
||||
assert event.channel == "beta"
|
||||
|
||||
async def test_silent_when_no_update(self):
|
||||
notifier = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.core.loops.updater.check_update",
|
||||
new=AsyncMock(return_value=self._result(has_update=False)),
|
||||
),
|
||||
patch("module.core.loops.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.update.channel = "stable"
|
||||
await update_check_tick(notifier)
|
||||
|
||||
notifier.send_event.assert_not_awaited()
|
||||
|
||||
async def test_silent_on_check_error(self):
|
||||
"""网络失败等 error 情况不产生通知(避免每天误报一条)。"""
|
||||
notifier = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.core.loops.updater.check_update",
|
||||
new=AsyncMock(return_value=self._result(error="rate limited")),
|
||||
),
|
||||
patch("module.core.loops.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.update.channel = "stable"
|
||||
await update_check_tick(notifier)
|
||||
|
||||
notifier.send_event.assert_not_awaited()
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestCreateApp:
|
||||
app = main.create_app()
|
||||
assert isinstance(app.state.ctx, AppContext)
|
||||
names = [t.name for t in app.state.ctx.scheduler.tasks]
|
||||
assert names == ["rss", "rename", "offset_scan", "calendar"]
|
||||
assert names == ["rss", "rename", "offset_scan", "calendar", "update_check"]
|
||||
|
||||
|
||||
class TestLifespan:
|
||||
|
||||
@@ -207,6 +207,30 @@ class TestRunMigrations:
|
||||
aria2_cols = {c["name"] for c in inspector.get_columns("aria2_gid")}
|
||||
assert "renamed_paths" in aria2_cols
|
||||
|
||||
def test_creates_inboxmessage_table(self):
|
||||
"""v16 creates the in-app notification center table with its indexes."""
|
||||
engine = _make_v0_engine()
|
||||
run_migrations(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
assert "inboxmessage" in inspector.get_table_names()
|
||||
inbox_indexes = {ix["name"] for ix in inspector.get_indexes("inboxmessage")}
|
||||
assert {
|
||||
"ix_inboxmessage_kind",
|
||||
"ix_inboxmessage_dedup_key",
|
||||
"ix_inboxmessage_read",
|
||||
} <= inbox_indexes
|
||||
|
||||
def test_creates_llmcredential_table(self):
|
||||
"""v17 creates the subscription-LLM credential table (unique provider_id)."""
|
||||
engine = _make_v0_engine()
|
||||
run_migrations(engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
assert "llmcredential" in inspector.get_table_names()
|
||||
indexes = {ix["name"]: ix for ix in inspector.get_indexes("llmcredential")}
|
||||
assert indexes["ix_llmcredential_provider_id"]["unique"]
|
||||
|
||||
def test_is_idempotent(self):
|
||||
engine = _make_v0_engine()
|
||||
run_migrations(engine)
|
||||
|
||||
@@ -8,7 +8,14 @@ from module.models import Notification
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
from module.network import RequestContent
|
||||
from module.notification import PROVIDER_REGISTRY, NotificationManager
|
||||
from module.notification.events import OffsetReviewEvent, RssFailureEvent
|
||||
from module.notification.events import (
|
||||
DownloaderUnavailableEvent,
|
||||
DownloadFailureEvent,
|
||||
OffsetReviewEvent,
|
||||
RssFailureEvent,
|
||||
UpdateAppliedEvent,
|
||||
UpdateAvailableEvent,
|
||||
)
|
||||
from module.notification.providers import (
|
||||
BarkProvider,
|
||||
DiscordProvider,
|
||||
@@ -448,7 +455,12 @@ class TestSendEvent:
|
||||
provider.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
event = OffsetReviewEvent(official_title="Test Anime", reason="mismatch")
|
||||
await manager.send_event(event)
|
||||
with (
|
||||
patch("module.notification.manager.record_event", new_callable=AsyncMock),
|
||||
patch("module.notification.manager.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.notification.enable = True
|
||||
await manager.send_event(event)
|
||||
|
||||
for provider in manager.providers:
|
||||
provider.send_event.assert_called_once_with( # type: ignore[attr-defined]
|
||||
@@ -456,6 +468,80 @@ class TestSendEvent:
|
||||
)
|
||||
|
||||
|
||||
class TestEventInboxContract:
|
||||
"""每个 SystemEvent 提供 kind/severity/once/dedup_key()/payload(),供通知中心持久化。"""
|
||||
|
||||
def test_rss_failure_event_contract(self):
|
||||
event = RssFailureEvent(rss_name="Feed", rss_url="http://x/rss", error="boom")
|
||||
assert event.kind == "rss_failure"
|
||||
assert event.severity == "error"
|
||||
assert event.once is False
|
||||
assert event.dedup_key() == "rss_failure:http://x/rss"
|
||||
assert event.payload() == {
|
||||
"rss_name": "Feed",
|
||||
"rss_url": "http://x/rss",
|
||||
"error": "boom",
|
||||
}
|
||||
|
||||
def test_download_failure_event_contract(self):
|
||||
event = DownloadFailureEvent(official_title="Anime", torrent_name="t.torrent")
|
||||
assert event.kind == "download_failure"
|
||||
assert event.severity == "error"
|
||||
assert event.dedup_key() == "download_failure:Anime"
|
||||
assert event.payload() == {
|
||||
"official_title": "Anime",
|
||||
"torrent_name": "t.torrent",
|
||||
}
|
||||
|
||||
def test_offset_review_event_contract(self):
|
||||
event = OffsetReviewEvent(official_title="Anime", reason="mismatch")
|
||||
assert event.kind == "offset_review"
|
||||
assert event.severity == "warning"
|
||||
assert event.dedup_key() == "offset_review:Anime"
|
||||
assert event.payload() == {"official_title": "Anime", "reason": "mismatch"}
|
||||
|
||||
def test_downloader_unavailable_event_contract(self):
|
||||
event = DownloaderUnavailableEvent(host="http://qb:8080", reason="credentials")
|
||||
assert event.kind == "downloader_unavailable"
|
||||
assert event.severity == "error"
|
||||
assert event.once is False
|
||||
# reason 不进 dedup_key:unreachable→credentials 的翻转合并为一条
|
||||
assert event.dedup_key() == "downloader:http://qb:8080"
|
||||
assert event.payload() == {"host": "http://qb:8080", "reason": "credentials"}
|
||||
title, body = event.describe()
|
||||
assert "下载器" in title
|
||||
assert "密码" in body
|
||||
|
||||
def test_update_available_event_contract(self):
|
||||
event = UpdateAvailableEvent(current="3.3.0", latest="3.3.1", channel="beta")
|
||||
assert event.kind == "update_available"
|
||||
assert event.severity == "info"
|
||||
assert event.once is True # 每个版本只提醒一次,已读后不再重建
|
||||
assert event.dedup_key() == "update_available:3.3.1"
|
||||
assert event.payload() == {
|
||||
"current": "3.3.0",
|
||||
"latest": "3.3.1",
|
||||
"channel": "beta",
|
||||
"notes": "",
|
||||
}
|
||||
title, body = event.describe()
|
||||
assert "3.3.1" in body
|
||||
|
||||
def test_update_applied_event_kind_follows_success(self):
|
||||
ok = UpdateAppliedEvent(version="3.3.1", success=True)
|
||||
assert ok.kind == "update_applied"
|
||||
assert ok.severity == "info"
|
||||
assert ok.dedup_key() is None
|
||||
assert ok.payload() == {"version": "3.3.1", "message": ""}
|
||||
|
||||
failed = UpdateAppliedEvent(version="3.3.1", success=False, message="sig error")
|
||||
assert failed.kind == "update_failed"
|
||||
assert failed.severity == "error"
|
||||
title, body = failed.describe()
|
||||
assert "失败" in title
|
||||
assert "sig error" in body
|
||||
|
||||
|
||||
class TestDeliverText:
|
||||
"""Each provider's own ``_deliver_text`` hook, exercised directly."""
|
||||
|
||||
|
||||
121
backend/src/test/test_notification_inbox.py
Normal file
121
backend/src/test/test_notification_inbox.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""通知中心持久化 sink:record_event / send_event 的 persist-first 语义。"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from module.notification.events import RssFailureEvent, UpdateAvailableEvent
|
||||
from module.notification.inbox import (
|
||||
bump_inbox_revision,
|
||||
inbox_revision,
|
||||
record_event,
|
||||
)
|
||||
from module.notification.manager import NotificationManager
|
||||
|
||||
|
||||
def _async_cm(entered_value):
|
||||
cm = AsyncMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=entered_value)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
def _mock_db(upsert_return):
|
||||
db = MagicMock()
|
||||
db.inbox.upsert = AsyncMock(return_value=upsert_return)
|
||||
return db
|
||||
|
||||
|
||||
EVENT = RssFailureEvent(rss_name="Feed", rss_url="http://x/rss", error="boom")
|
||||
|
||||
|
||||
class TestRecordEvent:
|
||||
async def test_record_event_upserts_and_bumps_revision(self):
|
||||
row = MagicMock(id=7)
|
||||
db = _mock_db(row)
|
||||
before = inbox_revision()
|
||||
|
||||
with patch("module.notification.inbox.Database", return_value=_async_cm(db)):
|
||||
result = await record_event(EVENT)
|
||||
|
||||
assert result == 7
|
||||
assert inbox_revision() == before + 1
|
||||
kwargs = db.inbox.upsert.call_args.kwargs
|
||||
assert kwargs["kind"] == "rss_failure"
|
||||
assert kwargs["severity"] == "error"
|
||||
assert kwargs["dedup_key"] == "rss_failure:http://x/rss"
|
||||
assert kwargs["once"] is False
|
||||
assert "boom" in kwargs["payload"]
|
||||
assert kwargs["title"] == "RSS 订阅连接异常"
|
||||
|
||||
async def test_record_event_once_skip_returns_zero_without_bump(self):
|
||||
db = _mock_db(None) # upsert 返回 None = once 去重跳过
|
||||
before = inbox_revision()
|
||||
event = UpdateAvailableEvent(current="3.3.0", latest="3.3.1", channel="beta")
|
||||
|
||||
with patch("module.notification.inbox.Database", return_value=_async_cm(db)):
|
||||
result = await record_event(event)
|
||||
|
||||
assert result == 0
|
||||
assert inbox_revision() == before
|
||||
|
||||
def test_bump_inbox_revision_increments(self):
|
||||
before = inbox_revision()
|
||||
bump_inbox_revision()
|
||||
assert inbox_revision() == before + 1
|
||||
|
||||
|
||||
class TestSendEventPersistFirst:
|
||||
def _manager_with_provider(self):
|
||||
with patch("module.notification.manager.settings") as mock_settings:
|
||||
mock_settings.notification.providers = []
|
||||
manager = NotificationManager()
|
||||
provider = AsyncMock()
|
||||
provider.send_event = AsyncMock(return_value=True)
|
||||
provider.__aenter__ = AsyncMock(return_value=provider)
|
||||
provider.__aexit__ = AsyncMock(return_value=None)
|
||||
manager.providers = [provider]
|
||||
return manager, provider
|
||||
|
||||
async def test_persists_but_does_not_broadcast_when_disabled(self):
|
||||
manager, provider = self._manager_with_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.notification.manager.record_event", new_callable=AsyncMock
|
||||
) as mock_record,
|
||||
patch("module.notification.manager.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.notification.enable = False
|
||||
await manager.send_event(EVENT)
|
||||
|
||||
mock_record.assert_awaited_once_with(EVENT)
|
||||
provider.send_event.assert_not_awaited()
|
||||
|
||||
async def test_persists_and_broadcasts_when_enabled(self):
|
||||
manager, provider = self._manager_with_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.notification.manager.record_event", new_callable=AsyncMock
|
||||
) as mock_record,
|
||||
patch("module.notification.manager.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.notification.enable = True
|
||||
await manager.send_event(EVENT)
|
||||
|
||||
mock_record.assert_awaited_once_with(EVENT)
|
||||
provider.send_event.assert_awaited_once_with(EVENT)
|
||||
|
||||
async def test_persist_failure_does_not_block_broadcast(self):
|
||||
manager, provider = self._manager_with_provider()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.notification.manager.record_event", new_callable=AsyncMock
|
||||
) as mock_record,
|
||||
patch("module.notification.manager.settings") as mock_settings,
|
||||
):
|
||||
mock_record.side_effect = RuntimeError("db locked")
|
||||
mock_settings.notification.enable = True
|
||||
await manager.send_event(EVENT)
|
||||
|
||||
provider.send_event.assert_awaited_once_with(EVENT)
|
||||
@@ -586,6 +586,134 @@ class TestAuthQb52Compat:
|
||||
|
||||
assert result is False
|
||||
|
||||
async def test_auth_sets_last_auth_error_credentials_on_fails_body(self):
|
||||
"""密码错误后 last_auth_error 标记为 credentials,供上层区分故障类型。"""
|
||||
qb = QbDownloader(
|
||||
host="localhost:8080", username="admin", password="wrong", ssl=False
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = "Fails."
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
await qb.auth(retry=3)
|
||||
|
||||
assert qb.last_auth_error == "credentials"
|
||||
|
||||
async def test_auth_sets_last_auth_error_banned_on_403(self):
|
||||
qb = QbDownloader(
|
||||
host="localhost:8080", username="admin", password="pass", ssl=False
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
mock_resp.text = "Forbidden"
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
await qb.auth(retry=3)
|
||||
|
||||
assert qb.last_auth_error == "banned"
|
||||
|
||||
async def test_auth_sets_last_auth_error_unreachable_on_connect_error(self):
|
||||
qb = QbDownloader(
|
||||
host="localhost:8080", username="admin", password="pass", ssl=False
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
),
|
||||
patch(
|
||||
"module.downloader.client.qb_downloader.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await qb.auth(retry=1)
|
||||
|
||||
assert qb.last_auth_error == "unreachable"
|
||||
|
||||
async def test_auth_clears_last_auth_error_on_success(self):
|
||||
qb = QbDownloader(
|
||||
host="localhost:8080", username="admin", password="pass", ssl=False
|
||||
)
|
||||
qb.last_auth_error = "credentials"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = "Ok."
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await qb.auth()
|
||||
|
||||
assert result is True
|
||||
assert qb.last_auth_error is None
|
||||
|
||||
async def test_auth_stops_immediately_on_200_fails_body(self, caplog):
|
||||
"""Wrong credentials (200 + 'Fails.') must not be retried — repeated
|
||||
failed logins trigger qBittorrent's WebUI IP ban — and the log must
|
||||
say the credentials are wrong."""
|
||||
qb = QbDownloader(
|
||||
host="localhost:8080", username="admin", password="wrong", ssl=False
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.text = "Fails."
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await qb.auth(retry=3)
|
||||
|
||||
assert result is False
|
||||
assert mock_client.post.call_count == 1
|
||||
assert "username or password" in caplog.text
|
||||
|
||||
async def test_auth_stops_immediately_on_401(self, caplog):
|
||||
"""401 also means bad credentials; same no-retry behaviour."""
|
||||
qb = QbDownloader(
|
||||
host="localhost:8080", username="admin", password="wrong", ssl=False
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
mock_resp.text = ""
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await qb.auth(retry=3)
|
||||
|
||||
assert result is False
|
||||
assert mock_client.post.call_count == 1
|
||||
assert "username or password" in caplog.text
|
||||
|
||||
async def test_auth_returns_false_on_200_html_body(self):
|
||||
"""A 200 + HTML page (proxy, wrong service) is not a successful login."""
|
||||
qb = QbDownloader(
|
||||
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
import sqlite3
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -803,6 +803,10 @@ class TestBootOverlay:
|
||||
def app():
|
||||
app = FastAPI()
|
||||
app.include_router(v1, prefix="/api")
|
||||
# apply/rollback 通过 ctx.notifier 发更新结果通知
|
||||
ctx = MagicMock()
|
||||
ctx.notifier.send_event = AsyncMock()
|
||||
app.state.ctx = ctx
|
||||
return app
|
||||
|
||||
|
||||
@@ -879,6 +883,43 @@ class TestUpdateApiContract:
|
||||
assert resp.status_code == 200
|
||||
sched.assert_called_once()
|
||||
|
||||
def test_apply_emits_update_applied_event(self, authed_client, app):
|
||||
from module.notification import UpdateAppliedEvent
|
||||
from module.update.updater import ApplyResult
|
||||
|
||||
async def fake_apply(channel="stable"):
|
||||
return ApplyResult(
|
||||
success=True, version="3.3.0", restart_required=True, message="ok"
|
||||
)
|
||||
|
||||
with (
|
||||
patch("module.api.update.updater.apply_update", side_effect=fake_apply),
|
||||
patch("module.api.update.schedule_restart"),
|
||||
):
|
||||
authed_client.post("/api/v1/update/apply")
|
||||
|
||||
app.state.ctx.notifier.send_event.assert_awaited_once()
|
||||
event = app.state.ctx.notifier.send_event.await_args.args[0]
|
||||
assert isinstance(event, UpdateAppliedEvent)
|
||||
assert event.kind == "update_applied"
|
||||
assert event.version == "3.3.0"
|
||||
|
||||
def test_apply_failure_emits_update_failed_event(self, authed_client, app):
|
||||
from module.update.updater import ApplyResult
|
||||
|
||||
async def fake_apply(channel="stable"):
|
||||
return ApplyResult(success=False, message="signature mismatch")
|
||||
|
||||
with (
|
||||
patch("module.api.update.updater.apply_update", side_effect=fake_apply),
|
||||
patch("module.api.update.schedule_restart"),
|
||||
):
|
||||
authed_client.post("/api/v1/update/apply")
|
||||
|
||||
event = app.state.ctx.notifier.send_event.await_args.args[0]
|
||||
assert event.kind == "update_failed"
|
||||
assert "signature mismatch" in event.message
|
||||
|
||||
def test_apply_failure_returns_400(self, authed_client):
|
||||
from module.update.updater import ApplyResult
|
||||
|
||||
|
||||
0
plugins/codex-chatgpt/ab_llm_codex/__init__.py
Normal file
0
plugins/codex-chatgpt/ab_llm_codex/__init__.py
Normal file
198
plugins/codex-chatgpt/ab_llm_codex/adapter.py
Normal file
198
plugins/codex-chatgpt/ab_llm_codex/adapter.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""ChatGPT (Codex) 订阅适配器(device flow → codex responses 端点)。
|
||||
|
||||
⚠️ 自担风险:ChatGPT 订阅凭据仅供官方 Codex 客户端使用,token 被限定到
|
||||
Codex client。第三方使用未获官方书面许可(灰色默许),originator 白名单
|
||||
是其执法手段,可能收紧。
|
||||
|
||||
⚠️ 待核实(研究标注中/低置信度):device code 端点 URL、responses 端点的
|
||||
精确请求头大小写。发货前须对 openai/codex 源码复核;下方以结构化占位实现,
|
||||
便于单元测试与后续替换。
|
||||
|
||||
流程:
|
||||
1. device flow:POST auth.openai.com/oauth/device/code → user_code;
|
||||
服务端轮询 oauth/token 换 access_token(JWT,含 chatgpt_account_id)。
|
||||
2. 刷新:refresh_token 换新 access_token(临期主动刷新)。
|
||||
3. 推理:POST chatgpt.com/backend-api/codex/responses(Responses API),
|
||||
头带 Bearer + ChatGPT-Account-Id + originator=codex_cli_rs。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
from module.parser.analyser.providers.base import (
|
||||
AdapterContext,
|
||||
AuthChallenge,
|
||||
AuthExpiredError,
|
||||
LLMProviderAdapter,
|
||||
ProviderInfo,
|
||||
TokenSet,
|
||||
)
|
||||
from module.parser.analyser.providers.schema import DEFAULT_PROMPT, EPISODE_JSON_SCHEMA
|
||||
|
||||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # Codex CLI 公开应用
|
||||
# TODO(ship): 对 openai/codex 源码复核以下端点/头
|
||||
DEVICE_CODE_URL = "https://auth.openai.com/oauth/device/code"
|
||||
TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
DEFAULT_MODEL = "gpt-5.1-codex"
|
||||
|
||||
_ORIGINATOR = "codex_cli_rs"
|
||||
|
||||
|
||||
def _decode_account_id(access_token: str) -> str:
|
||||
"""从 JWT 的 chatgpt_account_id claim 取账号 id(best-effort,失败返回空)。"""
|
||||
try:
|
||||
payload_b64 = access_token.split(".")[1]
|
||||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
return claims.get("chatgpt_account_id", "") or claims.get(
|
||||
"https://api.openai.com/auth", {}
|
||||
).get("chatgpt_account_id", "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
class CodexChatgptAdapter(LLMProviderAdapter):
|
||||
info = ProviderInfo(
|
||||
id="codex-chatgpt",
|
||||
display_name="ChatGPT (Codex)",
|
||||
auth_kind="device_code",
|
||||
builtin=False,
|
||||
default_model=DEFAULT_MODEL,
|
||||
)
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
super().__init__(ctx)
|
||||
self.model = ctx.model or DEFAULT_MODEL
|
||||
self._http_client = ctx.build_http_client(ctx.timeout)
|
||||
|
||||
# ------------------------------------------------------------ device flow
|
||||
|
||||
async def begin_auth(self) -> AuthChallenge:
|
||||
resp = await self._http_client.post(
|
||||
DEVICE_CODE_URL,
|
||||
data={"client_id": CLIENT_ID, "scope": "openid profile email"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
state = json.dumps(
|
||||
{"device_code": data["device_code"], "interval": data.get("interval", 5)}
|
||||
)
|
||||
return AuthChallenge(
|
||||
method="device_code",
|
||||
user_code=data["user_code"],
|
||||
verification_uri=data.get("verification_uri_complete")
|
||||
or data.get("verification_uri"),
|
||||
expires_in=data.get("expires_in", 900),
|
||||
state=state,
|
||||
)
|
||||
|
||||
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
|
||||
blob = json.loads(state)
|
||||
resp = await self._http_client.post(
|
||||
TOKEN_URL,
|
||||
data={
|
||||
"client_id": CLIENT_ID,
|
||||
"device_code": blob["device_code"],
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "access_token" not in data:
|
||||
raise AuthExpiredError(data.get("error", "authorization_pending"))
|
||||
return self._tokens_from_response(data)
|
||||
|
||||
def _tokens_from_response(self, data: dict) -> TokenSet:
|
||||
access = data["access_token"]
|
||||
account_id = _decode_account_id(access)
|
||||
expires_at = time.time() + float(data.get("expires_in", 3600))
|
||||
return TokenSet(
|
||||
access_token=access,
|
||||
refresh_token=data.get("refresh_token", ""),
|
||||
expires_at=expires_at,
|
||||
account_label="ChatGPT",
|
||||
extra={"account_id": account_id},
|
||||
)
|
||||
|
||||
async def refresh(self, tokens: TokenSet) -> TokenSet:
|
||||
if not tokens.refresh_token:
|
||||
raise AuthExpiredError("no refresh token")
|
||||
resp = await self._http_client.post(
|
||||
TOKEN_URL,
|
||||
data={
|
||||
"client_id": CLIENT_ID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": tokens.refresh_token,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise AuthExpiredError("refresh failed")
|
||||
data = resp.json()
|
||||
refreshed = self._tokens_from_response(data)
|
||||
# 有些实现刷新响应不回 refresh_token,沿用旧的
|
||||
if not refreshed.refresh_token:
|
||||
refreshed = refreshed.model_copy(
|
||||
update={"refresh_token": tokens.refresh_token}
|
||||
)
|
||||
return refreshed
|
||||
|
||||
# ------------------------------------------------------------ inference
|
||||
|
||||
async def _valid_tokens(self) -> TokenSet:
|
||||
tokens = await self.ctx.credentials.load()
|
||||
if tokens is None or not tokens.access_token:
|
||||
raise AuthExpiredError("ChatGPT is not connected")
|
||||
if tokens.expires_at is not None and tokens.expires_at - 60 < time.time():
|
||||
tokens = await self.refresh(tokens)
|
||||
await self.ctx.credentials.save(tokens)
|
||||
return tokens
|
||||
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
tokens = await self._valid_tokens()
|
||||
resp = await self._http_client.post(
|
||||
RESPONSES_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {tokens.access_token}",
|
||||
"ChatGPT-Account-Id": tokens.extra.get("account_id", ""),
|
||||
"originator": _ORIGINATOR,
|
||||
"User-Agent": _ORIGINATOR,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"instructions": DEFAULT_PROMPT,
|
||||
"input": raw,
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "episode",
|
||||
"schema": EPISODE_JSON_SCHEMA,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise AuthExpiredError("ChatGPT token rejected")
|
||||
resp.raise_for_status()
|
||||
return self._extract_json(resp.json())
|
||||
|
||||
def _extract_json(self, data: dict) -> dict | None:
|
||||
# Responses API:output[].content[].text 承载 JSON 字符串
|
||||
try:
|
||||
for item in data.get("output", []):
|
||||
for block in item.get("content", []):
|
||||
text = block.get("text")
|
||||
if text:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
# Codex 后端不暴露稳定的 /models;返回已知默认,交由用户自定义
|
||||
return [DEFAULT_MODEL, "gpt-5.1"]
|
||||
15
plugins/codex-chatgpt/plugin.json
Normal file
15
plugins/codex-chatgpt/plugin.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "codex-chatgpt",
|
||||
"name": "ChatGPT (Codex)",
|
||||
"version": "1.0.0",
|
||||
"min_ab_version": "3.3.0",
|
||||
"entry": "ab_llm_codex.adapter:CodexChatgptAdapter",
|
||||
"auth_kind": "device_code",
|
||||
"needs_base_url": false,
|
||||
"default_model": "gpt-5.1-codex",
|
||||
"description_en": "Use a ChatGPT Plus/Pro subscription for title parsing via the Codex device-flow login.",
|
||||
"description_zh": "通过 Codex device flow 登录,用 ChatGPT Plus/Pro 订阅解析标题。",
|
||||
"risk_notice_en": "ChatGPT subscription credentials are scoped to the official Codex client; third-party use is unsanctioned and may be blocked. Use at your own risk.",
|
||||
"risk_notice_zh": "ChatGPT 订阅凭据被限定到官方 Codex 客户端,第三方使用未获许可、可能被封锁。请自担风险。"
|
||||
}
|
||||
0
plugins/github-copilot/ab_llm_copilot/__init__.py
Normal file
0
plugins/github-copilot/ab_llm_copilot/__init__.py
Normal file
185
plugins/github-copilot/ab_llm_copilot/adapter.py
Normal file
185
plugins/github-copilot/ab_llm_copilot/adapter.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""GitHub Copilot 订阅适配器(device flow → OpenAI 兼容端点)。
|
||||
|
||||
⚠️ 自担风险:Copilot 内部端点仅供官方客户端使用,第三方调用可能违反
|
||||
GitHub 条款并触发滥用检测。低请求量降低但不消除风险。
|
||||
|
||||
流程(2026-07 核实):
|
||||
1. device flow:POST github.com/login/device/code → 展示 user_code;
|
||||
服务端轮询 login/oauth/access_token 换取长期 ``ghu_`` token。
|
||||
2. 每次调用前用 ``ghu_`` 换短命 Copilot token(~30min,主动刷新):
|
||||
GET api.github.com/copilot_internal/v2/token。
|
||||
3. 推理走 api.githubcopilot.com/chat/completions(OpenAI 兼容),
|
||||
仿编辑器请求头。结构化输出用 tool-calling 兜底。
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from module.parser.analyser.providers.base import (
|
||||
AdapterContext,
|
||||
AuthChallenge,
|
||||
AuthExpiredError,
|
||||
LLMProviderAdapter,
|
||||
ProviderInfo,
|
||||
TokenSet,
|
||||
)
|
||||
from module.parser.analyser.providers.schema import DEFAULT_PROMPT, EPISODE_JSON_SCHEMA
|
||||
|
||||
CLIENT_ID = "Iv1.b507a08c87ecfe98" # VS Code Copilot 公开应用
|
||||
DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
API_BASE = "https://api.githubcopilot.com"
|
||||
DEFAULT_MODEL = "gpt-4o"
|
||||
|
||||
# 仿 VS Code Copilot 客户端的请求头(值会随版本漂移,保持可配置)。
|
||||
_EDITOR_HEADERS = {
|
||||
"Editor-Version": "vscode/1.95.0",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.22.0",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
"User-Agent": "GithubCopilot/1.155.0",
|
||||
}
|
||||
|
||||
_EPISODE_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "emit_episode",
|
||||
"description": "Return the structured anime episode fields.",
|
||||
"parameters": EPISODE_JSON_SCHEMA,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class GithubCopilotAdapter(LLMProviderAdapter):
|
||||
info = ProviderInfo(
|
||||
id="github-copilot",
|
||||
display_name="GitHub Copilot",
|
||||
auth_kind="device_code",
|
||||
builtin=False,
|
||||
default_model=DEFAULT_MODEL,
|
||||
)
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
super().__init__(ctx)
|
||||
self.model = ctx.model or DEFAULT_MODEL
|
||||
self._http_client = ctx.build_http_client(ctx.timeout)
|
||||
# (copilot_token, expires_at) 短命缓存
|
||||
self._copilot_token: tuple[str, float] | None = None
|
||||
|
||||
# ------------------------------------------------------------ device flow
|
||||
|
||||
async def begin_auth(self) -> AuthChallenge:
|
||||
resp = await self._http_client.post(
|
||||
DEVICE_CODE_URL,
|
||||
data={"client_id": CLIENT_ID, "scope": "read:user"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# device_code 是敏感态,藏进 state(服务端 pending 存储,不下发前端)
|
||||
state = json.dumps(
|
||||
{"device_code": data["device_code"], "interval": data.get("interval", 5)}
|
||||
)
|
||||
return AuthChallenge(
|
||||
method="device_code",
|
||||
user_code=data["user_code"],
|
||||
verification_uri=data["verification_uri"],
|
||||
expires_in=data.get("expires_in", 900),
|
||||
state=state,
|
||||
)
|
||||
|
||||
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
|
||||
"""轮询一次 access_token 端点(服务端轮询循环每次调用一次本方法)。"""
|
||||
blob = json.loads(state)
|
||||
resp = await self._http_client.post(
|
||||
ACCESS_TOKEN_URL,
|
||||
data={
|
||||
"client_id": CLIENT_ID,
|
||||
"device_code": blob["device_code"],
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "access_token" not in data:
|
||||
# authorization_pending / slow_down:告知调用方继续轮询
|
||||
raise AuthExpiredError(data.get("error", "authorization_pending"))
|
||||
return TokenSet(
|
||||
access_token=data["access_token"], # ghu_ 长期 token
|
||||
account_label="GitHub Copilot",
|
||||
)
|
||||
|
||||
async def refresh(self, tokens: TokenSet) -> TokenSet:
|
||||
# ghu_ token 本身长期有效;短命 Copilot token 在 _ensure_copilot_token 刷新
|
||||
return tokens
|
||||
|
||||
# ------------------------------------------------------------ inference
|
||||
|
||||
async def _ensure_copilot_token(self, ghu_token: str) -> str:
|
||||
now = time.time()
|
||||
if self._copilot_token is not None and self._copilot_token[1] - 60 > now:
|
||||
return self._copilot_token[0]
|
||||
resp = await self._http_client.get(
|
||||
COPILOT_TOKEN_URL,
|
||||
headers={
|
||||
"Authorization": f"token {ghu_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
token = data["token"]
|
||||
expires_at = float(data.get("expires_at", now + 1500))
|
||||
self._copilot_token = (token, expires_at)
|
||||
return token
|
||||
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
tokens = await self.ctx.credentials.load()
|
||||
if tokens is None or not tokens.access_token:
|
||||
raise AuthExpiredError("GitHub Copilot is not connected")
|
||||
copilot_token = await self._ensure_copilot_token(tokens.access_token)
|
||||
resp = await self._http_client.post(
|
||||
f"{API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {copilot_token}",
|
||||
"Content-Type": "application/json",
|
||||
**_EDITOR_HEADERS,
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": DEFAULT_PROMPT},
|
||||
{"role": "user", "content": raw},
|
||||
],
|
||||
"tools": [_EPISODE_TOOL],
|
||||
"tool_choice": {
|
||||
"type": "function",
|
||||
"function": {"name": "emit_episode"},
|
||||
},
|
||||
"temperature": 0,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
self._copilot_token = None
|
||||
raise AuthExpiredError("Copilot token rejected")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
try:
|
||||
call = data["choices"][0]["message"]["tool_calls"][0]
|
||||
return json.loads(call["function"]["arguments"])
|
||||
except (KeyError, IndexError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
tokens = await self.ctx.credentials.load()
|
||||
if tokens is None or not tokens.access_token:
|
||||
raise AuthExpiredError("GitHub Copilot is not connected")
|
||||
copilot_token = await self._ensure_copilot_token(tokens.access_token)
|
||||
resp = await self._http_client.get(
|
||||
f"{API_BASE}/models",
|
||||
headers={"Authorization": f"Bearer {copilot_token}", **_EDITOR_HEADERS},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return sorted(m["id"] for m in data.get("data", []))
|
||||
15
plugins/github-copilot/plugin.json
Normal file
15
plugins/github-copilot/plugin.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "github-copilot",
|
||||
"name": "GitHub Copilot",
|
||||
"version": "1.0.0",
|
||||
"min_ab_version": "3.3.0",
|
||||
"entry": "ab_llm_copilot.adapter:GithubCopilotAdapter",
|
||||
"auth_kind": "device_code",
|
||||
"needs_base_url": false,
|
||||
"default_model": "gpt-4o",
|
||||
"description_en": "Use a GitHub Copilot subscription for title parsing via the device-flow login.",
|
||||
"description_zh": "通过 device flow 登录,用 GitHub Copilot 订阅解析标题。",
|
||||
"risk_notice_en": "Copilot's internal endpoint is for official clients only; third-party use may violate GitHub's terms and trigger abuse detection. Use at your own risk.",
|
||||
"risk_notice_zh": "Copilot 内部端点仅供官方客户端使用,第三方调用可能违反 GitHub 条款并触发滥用检测。请自担风险。"
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Config, LLMProvider } from '#/config';
|
||||
import type { TupleToUnion } from '#/utils';
|
||||
import type { Config, LLMProviderId } from '#/config';
|
||||
import type { ApiSuccess } from '#/api';
|
||||
|
||||
export const apiConfig = {
|
||||
@@ -28,7 +27,7 @@ export const apiConfig = {
|
||||
* (api_key 传掩码时后端回退到已保存的密钥)
|
||||
*/
|
||||
async getLLMModels(payload: {
|
||||
provider: TupleToUnion<LLMProvider>;
|
||||
provider: LLMProviderId;
|
||||
api_key: string;
|
||||
base_url: string;
|
||||
}) {
|
||||
|
||||
78
webui/src/api/llm.ts
Normal file
78
webui/src/api/llm.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type LLMAuthKind = 'api_key' | 'oauth' | 'device_code';
|
||||
|
||||
export interface LLMProviderView {
|
||||
id: string;
|
||||
display_name: string;
|
||||
auth_kind: LLMAuthKind;
|
||||
builtin: boolean;
|
||||
needs_base_url: boolean;
|
||||
preset_base_url: string;
|
||||
default_model: string;
|
||||
plugin_version: string | null;
|
||||
connected: boolean;
|
||||
account_label: string;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export interface LLMAuthChallenge {
|
||||
method: 'redirect_paste' | 'device_code';
|
||||
authorize_url: string | null;
|
||||
user_code: string | null;
|
||||
verification_uri: string | null;
|
||||
expires_in: number;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface LLMAuthStatus {
|
||||
connected: boolean;
|
||||
account_label: string;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export const apiLLM = {
|
||||
async getProviders() {
|
||||
const { data } = await axios.get<{ providers: LLMProviderView[] }>(
|
||||
'api/v1/config/llm/providers',
|
||||
{ silent: true }
|
||||
);
|
||||
return data!.providers;
|
||||
},
|
||||
|
||||
async installProvider(id: string) {
|
||||
const { data } = await axios.post<{ success: boolean; version: string }>(
|
||||
`api/v1/config/llm/providers/${id}/install`
|
||||
);
|
||||
return data!;
|
||||
},
|
||||
|
||||
async uninstallProvider(id: string) {
|
||||
await axios.delete(`api/v1/config/llm/providers/${id}`);
|
||||
},
|
||||
|
||||
async beginAuth(id: string) {
|
||||
const { data } = await axios.post<LLMAuthChallenge>(
|
||||
`api/v1/config/llm/providers/${id}/auth/begin`
|
||||
);
|
||||
return data!;
|
||||
},
|
||||
|
||||
async completeAuth(id: string, payload: { state: string; code?: string }) {
|
||||
const { data } = await axios.post<{
|
||||
connected: boolean;
|
||||
account_label: string;
|
||||
}>(`api/v1/config/llm/providers/${id}/auth/complete`, payload);
|
||||
return data!;
|
||||
},
|
||||
|
||||
async getAuthStatus(id: string) {
|
||||
const { data } = await axios.get<LLMAuthStatus>(
|
||||
`api/v1/config/llm/providers/${id}/auth/status`,
|
||||
{ silent: true }
|
||||
);
|
||||
return data!;
|
||||
},
|
||||
|
||||
async disconnect(id: string) {
|
||||
await axios.delete(`api/v1/config/llm/providers/${id}/auth`);
|
||||
},
|
||||
};
|
||||
@@ -26,6 +26,33 @@ export interface TestResponse {
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export type InboxSeverity = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface InboxMessage {
|
||||
id: number;
|
||||
kind: string;
|
||||
severity: InboxSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
payload: Record<string, string | number> | null;
|
||||
read: boolean;
|
||||
count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface InboxListResponse {
|
||||
messages: InboxMessage[];
|
||||
total: number;
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
export interface InboxListParams {
|
||||
unread_only?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const apiNotification = {
|
||||
/**
|
||||
* Test a configured provider by index
|
||||
@@ -48,4 +75,38 @@ export const apiNotification = {
|
||||
);
|
||||
return { data };
|
||||
},
|
||||
|
||||
/** 收件箱:分页列表(后台刷新,静默失败) */
|
||||
async getMessages(params: InboxListParams = {}) {
|
||||
const { data } = await axios.get<InboxListResponse>(
|
||||
'api/v1/notification/messages',
|
||||
{ params, silent: true }
|
||||
);
|
||||
return data!;
|
||||
},
|
||||
|
||||
/** 收件箱:未读数(SSE 断开时的轮询回退) */
|
||||
async getUnreadCount() {
|
||||
const { data } = await axios.get<{ unread_count: number }>(
|
||||
'api/v1/notification/messages/unread-count',
|
||||
{ silent: true }
|
||||
);
|
||||
return data!.unread_count;
|
||||
},
|
||||
|
||||
async markRead(id: number) {
|
||||
await axios.post(`api/v1/notification/messages/${id}/read`);
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
await axios.post('api/v1/notification/messages/read-all');
|
||||
},
|
||||
|
||||
async removeMessage(id: number) {
|
||||
await axios.delete(`api/v1/notification/messages/${id}`);
|
||||
},
|
||||
|
||||
async clearAll() {
|
||||
await axios.delete('api/v1/notification/messages');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
|
||||
import { AddOne, International, System } from '@icon-park/vue-next';
|
||||
import { AddOne, System, Translate } from '@icon-park/vue-next';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -40,7 +40,7 @@ function abLabel(label: string | (() => string)) {
|
||||
aria-label="Switch language"
|
||||
@click="() => $emit('changeLang')"
|
||||
>
|
||||
<International theme="outline" size="1em" />
|
||||
<Translate theme="outline" size="1em" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -86,11 +86,6 @@ function abLabel(label: string | (() => string)) {
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 18px;
|
||||
|
||||
@include forTablet {
|
||||
gap: 6px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar-btn {
|
||||
@@ -110,17 +105,12 @@ function abLabel(label: string | (() => string)) {
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
// Transparent background — a full 44px hit area costs nothing visually.
|
||||
// 仅触屏放大命中区域;桌面指针设备保持 36px 紧凑排布。
|
||||
@include forTouch {
|
||||
min-width: var(--touch-target);
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
@include forTablet {
|
||||
min-width: var(--touch-target);
|
||||
min-height: var(--touch-target);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
transform: scale(1.1);
|
||||
|
||||
440
webui/src/components/layout/ab-notification-center.vue
Normal file
440
webui/src/components/layout/ab-notification-center.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<script lang="ts" setup>
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue';
|
||||
import { Attention, CloseOne, Delete, Info, Remind } from '@icon-park/vue-next';
|
||||
import type { InboxMessage } from '@/api/notification';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const router = useRouter();
|
||||
const store = useNotificationStore();
|
||||
const { messages, unreadCount, panelOpen } = storeToRefs(store);
|
||||
|
||||
// 点条目跳转的目标页面;未列出的 kind 不跳转
|
||||
const KIND_ROUTES: Record<string, string> = {
|
||||
update_available: '/config',
|
||||
update_applied: '/config',
|
||||
update_failed: '/config',
|
||||
downloader_unavailable: '/config',
|
||||
rss_failure: '/rss',
|
||||
offset_review: '/bangumi',
|
||||
download_failure: '/bangumi',
|
||||
};
|
||||
|
||||
const SEVERITY_ICONS = {
|
||||
error: CloseOne,
|
||||
warning: Attention,
|
||||
info: Info,
|
||||
} as const;
|
||||
|
||||
const badgeText = computed(() =>
|
||||
unreadCount.value > 99 ? '99+' : String(unreadCount.value)
|
||||
);
|
||||
|
||||
const confirmClear = ref(false);
|
||||
|
||||
// PopoverPanel 挂载/卸载即打开/关闭:打开时拉取列表,关闭时复位确认态。
|
||||
function onPanelRef(el: unknown) {
|
||||
const isOpen = !!el;
|
||||
if (isOpen && !panelOpen.value) {
|
||||
store.fetchMessages();
|
||||
}
|
||||
if (!isOpen) {
|
||||
confirmClear.value = false;
|
||||
}
|
||||
panelOpen.value = isOpen;
|
||||
}
|
||||
|
||||
function severityIcon(msg: InboxMessage) {
|
||||
return SEVERITY_ICONS[msg.severity] ?? Info;
|
||||
}
|
||||
|
||||
function onItemClick(msg: InboxMessage, close: () => void) {
|
||||
store.markRead(msg.id);
|
||||
const route = KIND_ROUTES[msg.kind];
|
||||
if (route) {
|
||||
close();
|
||||
router.push(route);
|
||||
}
|
||||
}
|
||||
|
||||
function onClearClick() {
|
||||
if (!confirmClear.value) {
|
||||
confirmClear.value = true;
|
||||
return;
|
||||
}
|
||||
confirmClear.value = false;
|
||||
store.clearAll();
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const minutes = Math.floor((Date.now() - then) / 60000);
|
||||
if (minutes < 1) return t('notifications.time.just_now');
|
||||
if (minutes < 60) return t('notifications.time.minutes_ago', { n: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return t('notifications.time.hours_ago', { n: hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return t('notifications.time.days_ago', { n: days });
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover class="notification-center">
|
||||
<PopoverButton
|
||||
class="notification-bell"
|
||||
:aria-label="$t('notifications.title')"
|
||||
>
|
||||
<Remind theme="outline" size="1em" />
|
||||
<span v-if="unreadCount > 0" class="notification-badge">{{
|
||||
badgeText
|
||||
}}</span>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel v-slot="{ close }" class="notification-panel">
|
||||
<div :ref="onPanelRef" class="notification-panel-inner">
|
||||
<div class="notification-head">
|
||||
<span class="notification-head-title">
|
||||
{{ $t('notifications.title') }}
|
||||
<template v-if="unreadCount > 0">
|
||||
· {{ $t('notifications.unread', { n: unreadCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="notification-head-actions">
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
class="notification-action"
|
||||
@click="store.markAllRead()"
|
||||
>
|
||||
{{ $t('notifications.mark_all_read') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="messages.length > 0"
|
||||
class="notification-action notification-action--danger"
|
||||
@click="onClearClick"
|
||||
>
|
||||
{{
|
||||
confirmClear
|
||||
? $t('notifications.clear_confirm')
|
||||
: $t('notifications.clear_all')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length === 0" class="notification-empty">
|
||||
{{ $t('notifications.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="notification-item"
|
||||
:class="{ 'notification-item--unread': !msg.read }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onItemClick(msg, close)"
|
||||
@keydown.enter="onItemClick(msg, close)"
|
||||
>
|
||||
<div class="notification-sev" :class="`is-${msg.severity}`">
|
||||
<Component :is="severityIcon(msg)" theme="outline" size="14" />
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">
|
||||
{{ store.titleOf(msg) }}
|
||||
<span v-if="msg.count > 1" class="notification-count"
|
||||
>×{{ msg.count }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="notification-body">{{ store.bodyOf(msg) }}</div>
|
||||
<div class="notification-meta">
|
||||
{{ relativeTime(msg.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="notification-delete"
|
||||
:aria-label="$t('notifications.delete')"
|
||||
@click.stop="store.remove(msg.id)"
|
||||
>
|
||||
<Delete theme="outline" size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<!-- 不加 scoped:@headlessui/vue 的 Popover/PopoverButton/PopoverPanel 渲染的
|
||||
根元素不继承父组件的 scope 属性,scoped 选择器无法命中(position 等样式
|
||||
会静默失效)。所有类名以 notification- 前缀隔离,避免全局冲突。 -->
|
||||
<style lang="scss">
|
||||
.notification-center {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 与 ab-status-bar 的 .status-bar-btn 保持一致
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-fast), transform var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
|
||||
// 与 ab-status-bar 保持一致:仅触屏放大命中区域,桌面保持紧凑。
|
||||
@include forTouch {
|
||||
min-width: var(--touch-target);
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-panel {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
animation: notification-dropdown-in 150ms ease-out;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
@keyframes notification-dropdown-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.notification-head-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.notification-head-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notification-action {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: var(--color-danger, #ef4444);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 32px 14px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
max-height: min(420px, 60vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
|
||||
.notification-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&--unread {
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
&:not(.notification-item--unread) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-sev {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-top: 2px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.is-error {
|
||||
color: var(--color-danger, #ef4444);
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
|
||||
}
|
||||
|
||||
&.is-info {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.notification-count {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.notification-delete {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
@include forTouch {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -128,6 +128,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Right side actions -->
|
||||
<div class="topbar-right">
|
||||
<ab-notification-center />
|
||||
<ab-status-bar
|
||||
:items="items"
|
||||
:running="running"
|
||||
@@ -244,9 +245,9 @@ onUnmounted(() => {
|
||||
|
||||
.topbar-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,115 @@
|
||||
<script lang="ts" setup>
|
||||
import { Info } from '@icon-park/vue-next';
|
||||
import { NSelect } from 'naive-ui';
|
||||
import type { SelectOption } from 'naive-ui';
|
||||
import { NButton, NSelect } from 'naive-ui';
|
||||
import type { SelectGroupOption, SelectOption } from 'naive-ui';
|
||||
import type { LLMProviderView } from '@/api/llm';
|
||||
import type { SelectItem } from '#/components';
|
||||
import type { LLMProvider } from '#/config';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const message = useMessage();
|
||||
const { getSettingGroup } = useConfigStore();
|
||||
|
||||
const llm = getSettingGroup('llm');
|
||||
const providers: LLMProvider = ['openai', 'anthropic', 'gemini'];
|
||||
|
||||
// --- 提供商列表(内置 / 已安装 / 可下载)-----------------------------------
|
||||
const providerList = ref<LLMProviderView[]>([]);
|
||||
// 已在 /config/llm/providers 中出现的都算"可用";不在列表里的 = 可下载插件
|
||||
const KNOWN_DOWNLOADABLE: { id: string; display_name: string }[] = [
|
||||
{ id: 'github-copilot', display_name: 'GitHub Copilot' },
|
||||
{ id: 'codex-chatgpt', display_name: 'ChatGPT (Codex)' },
|
||||
];
|
||||
const busyProvider = ref('');
|
||||
const providersLoaded = ref(false);
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
providerList.value = await apiLLM.getProviders();
|
||||
} catch {
|
||||
providerList.value = [];
|
||||
} finally {
|
||||
providersLoaded.value = true;
|
||||
}
|
||||
}
|
||||
onMounted(loadProviders);
|
||||
|
||||
const selected = computed(() =>
|
||||
providerList.value.find((p) => p.id === llm.value.provider)
|
||||
);
|
||||
const isSubscription = computed(
|
||||
() =>
|
||||
selected.value?.auth_kind === 'oauth' ||
|
||||
selected.value?.auth_kind === 'device_code'
|
||||
);
|
||||
// 可下载 = 列表已加载、未在列表中、且是已知可下载插件。
|
||||
const isDownloadable = computed(
|
||||
() =>
|
||||
providersLoaded.value &&
|
||||
!selected.value &&
|
||||
KNOWN_DOWNLOADABLE.some((p) => p.id === llm.value.provider)
|
||||
);
|
||||
// API-Key 表单:选中的是 api_key 类,或列表未加载/加载失败时的安全兜底
|
||||
// (避免给内置 openai 误显示"未安装/安装")。
|
||||
const isBuiltinLike = computed(
|
||||
() =>
|
||||
selected.value?.auth_kind === 'api_key' ||
|
||||
(!selected.value && !isDownloadable.value && !isSubscription.value)
|
||||
);
|
||||
|
||||
const providerOptions = computed<SelectGroupOption[]>(() => {
|
||||
const builtin = providerList.value.filter((p) => p.builtin);
|
||||
const installed = providerList.value.filter(
|
||||
(p) => !p.builtin && p.auth_kind === 'api_key'
|
||||
);
|
||||
const subscription = providerList.value.filter((p) => !p.builtin && p.auth_kind !== 'api_key');
|
||||
const installedIds = new Set(providerList.value.map((p) => p.id));
|
||||
const downloadable = KNOWN_DOWNLOADABLE.filter((p) => !installedIds.has(p.id));
|
||||
const groups: SelectGroupOption[] = [];
|
||||
const toOpt = (p: { id: string; display_name: string }) => ({
|
||||
label: p.display_name,
|
||||
value: p.id,
|
||||
});
|
||||
if (builtin.length)
|
||||
groups.push({ type: 'group', label: t('config.llm_set.group_builtin'), key: 'builtin', children: builtin.map(toOpt) });
|
||||
if (installed.length)
|
||||
groups.push({ type: 'group', label: t('config.llm_set.group_installed'), key: 'installed', children: installed.map(toOpt) });
|
||||
if (subscription.length)
|
||||
groups.push({ type: 'group', label: t('config.llm_set.group_subscription'), key: 'subscription', children: subscription.map(toOpt) });
|
||||
if (downloadable.length)
|
||||
groups.push({ type: 'group', label: t('config.llm_set.group_available'), key: 'available', children: downloadable.map(toOpt) });
|
||||
return groups;
|
||||
});
|
||||
|
||||
// --- 按提供商读写凭据/模型/端点:内置写扁平字段,其它写 providers[id] -------
|
||||
function fieldGet(field: 'api_key' | 'model' | 'base_url'): string {
|
||||
const pid = llm.value.provider;
|
||||
const builtin = selected.value?.builtin ?? pid === 'openai';
|
||||
if (builtin) return llm.value[field];
|
||||
return llm.value.providers?.[pid]?.[field] ?? '';
|
||||
}
|
||||
function fieldSet(field: 'api_key' | 'model' | 'base_url', value: string) {
|
||||
const pid = llm.value.provider;
|
||||
const builtin = selected.value?.builtin ?? pid === 'openai';
|
||||
if (builtin) {
|
||||
llm.value[field] = value;
|
||||
return;
|
||||
}
|
||||
if (!llm.value.providers) llm.value.providers = {};
|
||||
if (!llm.value.providers[pid])
|
||||
llm.value.providers[pid] = { api_key: '', model: '', base_url: '' };
|
||||
llm.value.providers[pid][field] = value;
|
||||
}
|
||||
const apiKey = computed({
|
||||
get: () => fieldGet('api_key'),
|
||||
set: (v: string) => fieldSet('api_key', v),
|
||||
});
|
||||
const model = computed({
|
||||
get: () => fieldGet('model'),
|
||||
set: (v: string) => fieldSet('model', v),
|
||||
});
|
||||
const baseUrl = computed({
|
||||
get: () => fieldGet('base_url'),
|
||||
set: (v: string) => fieldSet('base_url', v),
|
||||
});
|
||||
|
||||
// 解析模式选项需要本地化标签,用 computed 保证语言切换时同步更新
|
||||
const modeItems = computed<SelectItem[]>(() => [
|
||||
@@ -18,24 +117,71 @@ const modeItems = computed<SelectItem[]>(() => [
|
||||
{ id: 2, label: t('config.llm_set.mode_primary'), value: 'primary' },
|
||||
]);
|
||||
|
||||
// 模型占位符跟随所选服务商,给出各家经济型号作示例
|
||||
const modelPlaceholder = computed(() => {
|
||||
const M: Record<string, string> = {
|
||||
openai: 'gpt-5-mini',
|
||||
anthropic: 'claude-haiku-4-5',
|
||||
gemini: 'gemini-2.5-flash',
|
||||
};
|
||||
return M[llm.value.provider] ?? 'gpt-5-mini';
|
||||
});
|
||||
const modelPlaceholder = computed(
|
||||
() => selected.value?.default_model || 'gpt-5-mini'
|
||||
);
|
||||
const baseUrlPlaceholder = computed(
|
||||
() => selected.value?.preset_base_url || 'https://api.openai.com/v1'
|
||||
);
|
||||
|
||||
// 模型列表按需从提供商 API 拉取;下拉支持搜索与自由输入(tag)
|
||||
// --- 安装 / 卸载 / 认证 -----------------------------------------------------
|
||||
const showAuthDialog = ref(false);
|
||||
const confirmRisk = ref(false);
|
||||
const pendingInstallId = ref('');
|
||||
|
||||
// Copilot/Codex 属于 ToS 灰色区,安装前弹风险确认
|
||||
function needsRiskNotice(id: string): boolean {
|
||||
return id === 'github-copilot' || id === 'codex-chatgpt';
|
||||
}
|
||||
|
||||
async function onInstall(id: string) {
|
||||
if (needsRiskNotice(id) && !confirmRisk.value) {
|
||||
pendingInstallId.value = id;
|
||||
confirmRisk.value = true;
|
||||
return;
|
||||
}
|
||||
confirmRisk.value = false;
|
||||
busyProvider.value = id;
|
||||
try {
|
||||
await apiLLM.installProvider(id);
|
||||
await loadProviders();
|
||||
message.success(t('config.llm_set.install_success'));
|
||||
} catch {
|
||||
// 失败详情由通知中心呈现
|
||||
message.error(t('config.llm_set.install_failed'));
|
||||
} finally {
|
||||
busyProvider.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function onUninstall(id: string) {
|
||||
busyProvider.value = id;
|
||||
try {
|
||||
await apiLLM.uninstallProvider(id);
|
||||
await loadProviders();
|
||||
} catch {
|
||||
message.error(t('config.llm_set.install_failed'));
|
||||
} finally {
|
||||
busyProvider.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function onDisconnect(id: string) {
|
||||
await apiLLM.disconnect(id);
|
||||
await loadProviders();
|
||||
}
|
||||
|
||||
function onAuthConnected() {
|
||||
loadProviders();
|
||||
}
|
||||
|
||||
// --- 模型列表按需拉取(api_key 类)-----------------------------------------
|
||||
const modelOptions = ref<SelectOption[]>([]);
|
||||
const modelsLoading = ref(false);
|
||||
// 记录上次成功拉取时的表单指纹,凭据变化后自动重新拉取
|
||||
let fetchedFor = '';
|
||||
|
||||
function credentialsKey(): string {
|
||||
return [llm.value.provider, llm.value.api_key, llm.value.base_url].join('|');
|
||||
return [llm.value.provider, apiKey.value, baseUrl.value].join('|');
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
@@ -44,8 +190,8 @@ async function fetchModels() {
|
||||
try {
|
||||
const models = await apiConfig.getLLMModels({
|
||||
provider: llm.value.provider,
|
||||
api_key: llm.value.api_key,
|
||||
base_url: llm.value.base_url,
|
||||
api_key: apiKey.value,
|
||||
base_url: baseUrl.value,
|
||||
});
|
||||
modelOptions.value = models.map((m) => ({ label: m, value: m }));
|
||||
fetchedFor = credentialsKey();
|
||||
@@ -105,12 +251,14 @@ const tuningItems = computed(() => [
|
||||
|
||||
<transition name="slide-fade">
|
||||
<div v-if="llm.enable" class="llm-config">
|
||||
<ab-setting
|
||||
v-model:data="llm.provider"
|
||||
:label="() => t('config.llm_set.provider')"
|
||||
type="select"
|
||||
:prop="{ items: providers }"
|
||||
/>
|
||||
<ab-label :label="() => t('config.llm_set.provider')">
|
||||
<NSelect
|
||||
v-model:value="llm.provider"
|
||||
class="model-select"
|
||||
:options="providerOptions"
|
||||
:aria-label="t('config.llm_set.provider')"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-setting
|
||||
v-model:data="llm.mode"
|
||||
@@ -119,35 +267,104 @@ const tuningItems = computed(() => [
|
||||
:prop="{ items: modeItems }"
|
||||
/>
|
||||
|
||||
<ab-setting
|
||||
v-model:data="llm.api_key"
|
||||
:label="() => t('config.llm_set.api_key')"
|
||||
type="input"
|
||||
:prop="{ type: 'password', placeholder: 'sk-...' }"
|
||||
/>
|
||||
<!-- 订阅类提供商:连接状态 chip 或 Connect 按钮 -->
|
||||
<div v-if="isSubscription" class="llm-subscription">
|
||||
<div v-if="selected?.connected" class="llm-connected">
|
||||
<span class="llm-connected-dot" />
|
||||
<span class="llm-connected-label">{{
|
||||
t('config.llm_set.connected_as', {
|
||||
account: selected.account_label || selected.display_name,
|
||||
})
|
||||
}}</span>
|
||||
<NButton size="tiny" quaternary @click="onDisconnect(selected.id)">{{
|
||||
t('config.llm_set.disconnect')
|
||||
}}</NButton>
|
||||
</div>
|
||||
<NButton
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="showAuthDialog = true"
|
||||
>{{ t('config.llm_set.connect') }}</NButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<ab-label :label="() => t('config.llm_set.model')">
|
||||
<NSelect
|
||||
v-model:value="llm.model"
|
||||
class="model-select"
|
||||
filterable
|
||||
tag
|
||||
:options="modelOptions"
|
||||
:loading="modelsLoading"
|
||||
:placeholder="modelPlaceholder"
|
||||
:aria-label="t('config.llm_set.model')"
|
||||
@update:show="onModelDropdownShow"
|
||||
<!-- api_key 类(内置 + 预设):密钥 + 模型 + base_url -->
|
||||
<template v-if="isBuiltinLike">
|
||||
<ab-setting
|
||||
v-model:data="apiKey"
|
||||
:label="() => t('config.llm_set.api_key')"
|
||||
type="input"
|
||||
:prop="{ type: 'password', placeholder: 'sk-...' }"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<!-- base_url 仅对 openai 提供商生效(自定义 OpenAI 兼容端点) -->
|
||||
<ab-setting
|
||||
v-if="llm.provider === 'openai'"
|
||||
v-model:data="llm.base_url"
|
||||
:label="() => t('config.llm_set.base_url')"
|
||||
type="input"
|
||||
:prop="{ type: 'url', placeholder: 'https://api.openai.com/v1' }"
|
||||
/>
|
||||
<ab-label :label="() => t('config.llm_set.model')">
|
||||
<NSelect
|
||||
v-model:value="model"
|
||||
class="model-select"
|
||||
filterable
|
||||
tag
|
||||
:options="modelOptions"
|
||||
:loading="modelsLoading"
|
||||
:placeholder="modelPlaceholder"
|
||||
:aria-label="t('config.llm_set.model')"
|
||||
@update:show="onModelDropdownShow"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<ab-setting
|
||||
v-if="selected?.needs_base_url"
|
||||
v-model:data="baseUrl"
|
||||
:label="() => t('config.llm_set.base_url')"
|
||||
type="input"
|
||||
:prop="{ type: 'url', placeholder: baseUrlPlaceholder }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 可下载插件:安装按钮(灰色插件先弹风险确认) -->
|
||||
<div v-if="isDownloadable" class="llm-install">
|
||||
<p class="llm-install-hint">
|
||||
{{ t('config.llm_set.not_installed') }}
|
||||
</p>
|
||||
<NButton
|
||||
v-if="!confirmRisk"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="busyProvider === llm.provider"
|
||||
@click="onInstall(llm.provider)"
|
||||
>{{ t('config.llm_set.install') }}</NButton
|
||||
>
|
||||
<div v-else class="llm-risk">
|
||||
<p class="llm-risk-text">{{ t('config.llm_set.risk_notice') }}</p>
|
||||
<div class="llm-risk-actions">
|
||||
<NButton size="small" @click="confirmRisk = false">{{
|
||||
t('config.llm_set.risk_cancel')
|
||||
}}</NButton>
|
||||
<NButton
|
||||
type="warning"
|
||||
size="small"
|
||||
:loading="busyProvider === llm.provider"
|
||||
@click="onInstall(llm.provider)"
|
||||
>{{ t('config.llm_set.risk_confirm') }}</NButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已安装的非内置提供商:卸载入口 -->
|
||||
<div
|
||||
v-if="selected && !selected.builtin && selected.plugin_version"
|
||||
class="llm-uninstall"
|
||||
>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
:loading="busyProvider === selected.id"
|
||||
@click="onUninstall(selected.id)"
|
||||
>{{ t('config.llm_set.uninstall') }}</NButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 超时/缓存/并发/熔断调优项 -->
|
||||
<advanced-section v-model:open="showTuning">
|
||||
@@ -164,6 +381,14 @@ const tuningItems = computed(() => [
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<llm-auth-dialog
|
||||
v-if="selected"
|
||||
v-model:show="showAuthDialog"
|
||||
:provider-id="selected.id"
|
||||
:display-name="selected.display_name"
|
||||
@connected="onAuthConnected"
|
||||
/>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
@@ -201,6 +426,57 @@ const tuningItems = computed(() => [
|
||||
}
|
||||
}
|
||||
|
||||
.llm-subscription,
|
||||
.llm-install,
|
||||
.llm-uninstall {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.llm-connected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.llm-connected-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.llm-connected-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.llm-install-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.llm-risk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-warning-bg, #fef9ed);
|
||||
border: 1px solid var(--color-warning-border, #fde68a);
|
||||
}
|
||||
|
||||
.llm-risk-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-warning-text, #92400e);
|
||||
}
|
||||
|
||||
.llm-risk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
191
webui/src/components/setting/llm-auth-dialog.vue
Normal file
191
webui/src/components/setting/llm-auth-dialog.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton, NInput, NModal } from 'naive-ui';
|
||||
import type { LLMAuthChallenge } from '@/api/llm';
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
(e: 'connected'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const message = useMessage();
|
||||
|
||||
const challenge = ref<LLMAuthChallenge | null>(null);
|
||||
const loading = ref(false);
|
||||
const pastedCode = ref('');
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopPolling();
|
||||
challenge.value = null;
|
||||
pastedCode.value = '';
|
||||
emit('update:show', false);
|
||||
}
|
||||
|
||||
async function begin() {
|
||||
loading.value = true;
|
||||
try {
|
||||
challenge.value = await apiLLM.beginAuth(props.providerId);
|
||||
if (challenge.value.method === 'device_code') startPolling();
|
||||
} catch {
|
||||
message.error(t('config.llm_set.auth_failed'));
|
||||
close();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const status = await apiLLM.getAuthStatus(props.providerId);
|
||||
if (status.connected) {
|
||||
stopPolling();
|
||||
message.success(t('config.llm_set.connected_as', { account: status.account_label }));
|
||||
emit('connected');
|
||||
close();
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败静默重试
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// redirect_paste:用户在自己设备打开授权链接,把回调 code/URL 粘回来
|
||||
async function submitCode() {
|
||||
if (!challenge.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiLLM.completeAuth(props.providerId, {
|
||||
state: challenge.value.state,
|
||||
code: pastedCode.value.trim(),
|
||||
});
|
||||
message.success(t('config.llm_set.connected_as', { account: res.account_label }));
|
||||
emit('connected');
|
||||
close();
|
||||
} catch {
|
||||
message.error(t('config.llm_set.auth_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) begin();
|
||||
else stopPolling();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`${t('config.llm_set.connect')} · ${displayName}`"
|
||||
style="max-width: 460px"
|
||||
@update:show="(v: boolean) => !v && close()"
|
||||
>
|
||||
<div v-if="challenge" class="auth-body">
|
||||
<template v-if="challenge.method === 'device_code'">
|
||||
<p class="auth-step">{{ t('config.llm_set.auth_device_hint') }}</p>
|
||||
<a
|
||||
v-if="challenge.verification_uri"
|
||||
:href="challenge.verification_uri"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="auth-link"
|
||||
>{{ challenge.verification_uri }}</a
|
||||
>
|
||||
<div class="auth-code">{{ challenge.user_code }}</div>
|
||||
<p class="auth-pending">{{ t('config.llm_set.auth_pending') }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="auth-step">{{ t('config.llm_set.auth_open_link') }}</p>
|
||||
<a
|
||||
v-if="challenge.authorize_url"
|
||||
:href="challenge.authorize_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="auth-link"
|
||||
>{{ challenge.authorize_url }}</a
|
||||
>
|
||||
<NInput
|
||||
v-model:value="pastedCode"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('config.llm_set.auth_paste_code')"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="!pastedCode.trim()"
|
||||
@click="submitCode"
|
||||
>{{ t('config.llm_set.connect') }}</NButton
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="auth-body auth-loading">
|
||||
{{ t('config.llm_set.auth_pending') }}
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-step {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.auth-code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.auth-pending {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.auth-loading {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,12 @@ export interface StatusPayload {
|
||||
first_run: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationStreamPayload {
|
||||
unread_count: number;
|
||||
latest_id: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1000;
|
||||
const RECONNECT_MAX_DELAY_MS = 15000;
|
||||
|
||||
@@ -27,6 +33,7 @@ export const useEventStream = createSharedComposable(() => {
|
||||
const downloaderData = ref<QbTorrentInfo[] | null>(null);
|
||||
const logData = ref<string | null>(null);
|
||||
const updateData = ref<UpdateProgress | null>(null);
|
||||
const notificationData = ref<NotificationStreamPayload | null>(null);
|
||||
|
||||
let source: EventSource | null = null;
|
||||
let retryCount = 0;
|
||||
@@ -92,6 +99,14 @@ export const useEventStream = createSharedComposable(() => {
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('notification', (e) => {
|
||||
try {
|
||||
notificationData.value = JSON.parse((e as MessageEvent).data);
|
||||
} catch {
|
||||
// Ignore malformed frames; the next tick will retry.
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
teardown();
|
||||
scheduleReconnect();
|
||||
@@ -125,5 +140,6 @@ export const useEventStream = createSharedComposable(() => {
|
||||
downloaderData,
|
||||
logData,
|
||||
updateData,
|
||||
notificationData,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -37,7 +37,27 @@
|
||||
"model": "Model",
|
||||
"provider": "Provider",
|
||||
"timeout": "Request timeout (seconds)",
|
||||
"title": "LLM Parser"
|
||||
"title": "LLM Parser",
|
||||
"group_builtin": "Built-in",
|
||||
"group_installed": "Installed presets",
|
||||
"group_subscription": "Subscription",
|
||||
"group_available": "Available to download",
|
||||
"not_installed": "This provider is not installed yet.",
|
||||
"install": "Install",
|
||||
"install_success": "Provider installed",
|
||||
"install_failed": "Install failed",
|
||||
"uninstall": "Uninstall",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"connected_as": "Connected as {account}",
|
||||
"risk_notice": "This provider uses an unofficial subscription flow. Third-party use may violate the vendor's terms and could get your account restricted. Continue at your own risk.",
|
||||
"risk_cancel": "Cancel",
|
||||
"risk_confirm": "Install anyway",
|
||||
"auth_open_link": "Open this link on any device, authorize, then paste the code below:",
|
||||
"auth_paste_code": "Paste the code or redirect URL here",
|
||||
"auth_device_hint": "Open this link and enter the code to authorize:",
|
||||
"auth_pending": "Waiting for authorization…",
|
||||
"auth_failed": "Authorization failed"
|
||||
},
|
||||
"manage_set": {
|
||||
"delete_bad_torrent": "Delete Bad Torrent",
|
||||
@@ -558,5 +578,62 @@
|
||||
},
|
||||
"shutdown": "Shutdown",
|
||||
"start": "Start"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"unread": "{n} unread",
|
||||
"empty": "No notifications",
|
||||
"mark_all_read": "Mark all read",
|
||||
"clear_all": "Clear all",
|
||||
"clear_confirm": "Confirm?",
|
||||
"delete": "Delete",
|
||||
"time": {
|
||||
"just_now": "just now",
|
||||
"minutes_ago": "{n} min ago",
|
||||
"hours_ago": "{n} h ago",
|
||||
"days_ago": "{n} d ago"
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "Cannot connect to the downloader. Check the host, port and network.",
|
||||
"credentials": "Username or password rejected. Check the downloader credentials in settings.",
|
||||
"banned": "IP banned by the downloader. Unban it in the downloader WebUI or restart the downloader."
|
||||
},
|
||||
"kind": {
|
||||
"rss_failure": {
|
||||
"title": "RSS feed error",
|
||||
"body": "{rss_name}: {error}"
|
||||
},
|
||||
"download_failure": {
|
||||
"title": "Torrent add failed",
|
||||
"body": "{official_title} — {torrent_name}"
|
||||
},
|
||||
"offset_review": {
|
||||
"title": "Episode offset needs review",
|
||||
"body": "{official_title}: {reason}"
|
||||
},
|
||||
"downloader_unavailable": {
|
||||
"title": "Downloader unavailable"
|
||||
},
|
||||
"update_available": {
|
||||
"title": "New version available",
|
||||
"body": "{current} → {latest} ({channel} channel)"
|
||||
},
|
||||
"update_applied": {
|
||||
"title": "Update applied",
|
||||
"body": "Updated to {version}. It takes effect after restart."
|
||||
},
|
||||
"update_failed": {
|
||||
"title": "Update failed",
|
||||
"body": "{message}"
|
||||
},
|
||||
"llm_auth_failure": {
|
||||
"title": "LLM credentials expired",
|
||||
"body": "{provider_id}: {message}"
|
||||
},
|
||||
"llm_plugin_install_failed": {
|
||||
"title": "LLM plugin install failed",
|
||||
"body": "{plugin_id}: {message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,27 @@
|
||||
"model": "模型",
|
||||
"provider": "服务商",
|
||||
"timeout": "请求超时(秒)",
|
||||
"title": "LLM 解析器"
|
||||
"title": "LLM 解析器",
|
||||
"group_builtin": "内置",
|
||||
"group_installed": "已安装预设",
|
||||
"group_subscription": "订阅账号",
|
||||
"group_available": "可下载",
|
||||
"not_installed": "该提供商尚未安装。",
|
||||
"install": "安装",
|
||||
"install_success": "提供商已安装",
|
||||
"install_failed": "安装失败",
|
||||
"uninstall": "卸载",
|
||||
"connect": "连接",
|
||||
"disconnect": "断开",
|
||||
"connected_as": "已连接:{account}",
|
||||
"risk_notice": "该提供商使用非官方订阅流程,第三方使用可能违反厂商条款、导致账号被限制。请自担风险后继续。",
|
||||
"risk_cancel": "取消",
|
||||
"risk_confirm": "仍然安装",
|
||||
"auth_open_link": "在任意设备打开此链接授权,然后把回调 code 粘贴到下方:",
|
||||
"auth_paste_code": "粘贴授权 code 或回调 URL",
|
||||
"auth_device_hint": "打开链接并输入下方代码完成授权:",
|
||||
"auth_pending": "等待授权中…",
|
||||
"auth_failed": "授权失败"
|
||||
},
|
||||
"manage_set": {
|
||||
"delete_bad_torrent": "删除坏种",
|
||||
@@ -558,5 +578,62 @@
|
||||
},
|
||||
"shutdown": "关闭",
|
||||
"start": "启动"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"unread": "{n} 条未读",
|
||||
"empty": "暂无通知",
|
||||
"mark_all_read": "全部已读",
|
||||
"clear_all": "清空",
|
||||
"clear_confirm": "确认清空?",
|
||||
"delete": "删除",
|
||||
"time": {
|
||||
"just_now": "刚刚",
|
||||
"minutes_ago": "{n} 分钟前",
|
||||
"hours_ago": "{n} 小时前",
|
||||
"days_ago": "{n} 天前"
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "无法连接下载器,请检查地址、端口和网络。",
|
||||
"credentials": "用户名或密码错误,请在设置中检查下载器凭据。",
|
||||
"banned": "IP 已被下载器封禁,请在下载器 WebUI 解封或重启下载器。"
|
||||
},
|
||||
"kind": {
|
||||
"rss_failure": {
|
||||
"title": "RSS 订阅连接异常",
|
||||
"body": "{rss_name}:{error}"
|
||||
},
|
||||
"download_failure": {
|
||||
"title": "种子添加失败",
|
||||
"body": "{official_title} — {torrent_name}"
|
||||
},
|
||||
"offset_review": {
|
||||
"title": "集数偏移待确认",
|
||||
"body": "{official_title}:{reason}"
|
||||
},
|
||||
"downloader_unavailable": {
|
||||
"title": "下载器连接异常"
|
||||
},
|
||||
"update_available": {
|
||||
"title": "发现新版本",
|
||||
"body": "{current} → {latest}({channel} 频道)"
|
||||
},
|
||||
"update_applied": {
|
||||
"title": "程序更新完成",
|
||||
"body": "已更新到 {version},重启后生效。"
|
||||
},
|
||||
"update_failed": {
|
||||
"title": "程序更新失败",
|
||||
"body": "{message}"
|
||||
},
|
||||
"llm_auth_failure": {
|
||||
"title": "LLM 提供商凭据失效",
|
||||
"body": "{provider_id}:{message}"
|
||||
},
|
||||
"llm_plugin_install_failed": {
|
||||
"title": "LLM 插件安装失败",
|
||||
"body": "{plugin_id}:{message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
180
webui/src/store/notification.ts
Normal file
180
webui/src/store/notification.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useIntervalFn } from '@vueuse/core';
|
||||
import type { InboxMessage } from '@/api/notification';
|
||||
|
||||
/** 有 i18n 文案的消息类型;未知 kind 回退到后端存储的 title/body。 */
|
||||
const KNOWN_KINDS = [
|
||||
'rss_failure',
|
||||
'download_failure',
|
||||
'offset_review',
|
||||
'downloader_unavailable',
|
||||
'update_available',
|
||||
'update_applied',
|
||||
'update_failed',
|
||||
'llm_auth_failure',
|
||||
'llm_plugin_install_failed',
|
||||
] as const;
|
||||
|
||||
export const useNotificationStore = defineStore('notification', () => {
|
||||
const message = useMessage();
|
||||
const { t } = useMyI18n();
|
||||
const { isLoggedIn } = useAuth();
|
||||
const { connected: sseConnected, notificationData } = useEventStream();
|
||||
|
||||
const messages = ref<InboxMessage[]>([]);
|
||||
const total = ref(0);
|
||||
const unreadCount = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const panelOpen = ref(false);
|
||||
// null = 尚未收到第一帧;第一帧只同步未读数,不为历史积压弹 toast
|
||||
let lastLatestId: number | null = null;
|
||||
|
||||
function titleOf(msg: InboxMessage): string {
|
||||
if (!(KNOWN_KINDS as readonly string[]).includes(msg.kind)) {
|
||||
return msg.title;
|
||||
}
|
||||
return t(`notifications.kind.${msg.kind}.title`);
|
||||
}
|
||||
|
||||
function bodyOf(msg: InboxMessage): string {
|
||||
if (!(KNOWN_KINDS as readonly string[]).includes(msg.kind)) {
|
||||
return msg.body;
|
||||
}
|
||||
const payload = msg.payload ?? {};
|
||||
if (msg.kind === 'downloader_unavailable') {
|
||||
const reason = String(payload.reason || 'unreachable');
|
||||
return `${payload.host ?? ''}: ${t(`notifications.reason.${reason}`)}`;
|
||||
}
|
||||
return t(`notifications.kind.${msg.kind}.body`, payload);
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
if (!isLoggedIn.value) return;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res = await apiNotification.getMessages({ limit: 50 });
|
||||
messages.value = res.messages;
|
||||
total.value = res.total;
|
||||
unreadCount.value = res.unread_count;
|
||||
} catch {
|
||||
// 后台刷新失败保留旧数据
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// SSE 帧:同步未读数;latest_id 增大 = 有新消息 → 刷新列表并对
|
||||
// error 级新消息弹 toast(跳过登录后的第一帧,避免为历史积压刷屏)。
|
||||
watch(notificationData, (frame) => {
|
||||
if (!frame) return;
|
||||
unreadCount.value = frame.unread_count;
|
||||
const isFirstFrame = lastLatestId === null;
|
||||
const hasNew = !isFirstFrame && frame.latest_id > (lastLatestId as number);
|
||||
lastLatestId = frame.latest_id;
|
||||
if (hasNew) {
|
||||
fetchMessages().then(() => {
|
||||
const head = messages.value[0];
|
||||
if (head && !head.read && head.severity === 'error') {
|
||||
message.error(titleOf(head));
|
||||
}
|
||||
});
|
||||
} else if (panelOpen.value && !isFirstFrame) {
|
||||
// 其它端的已读/删除操作也让打开中的面板保持同步
|
||||
fetchMessages();
|
||||
}
|
||||
});
|
||||
|
||||
// SSE 断开时的回退:30s 轮询一次未读数(静默)。
|
||||
async function pollUnread() {
|
||||
if (sseConnected.value || document.hidden || !isLoggedIn.value) return;
|
||||
try {
|
||||
unreadCount.value = await apiNotification.getUnreadCount();
|
||||
} catch {
|
||||
// Silent poll — keep the last count on a transient failure.
|
||||
}
|
||||
}
|
||||
|
||||
const { pause: offPoll, resume: onPoll } = useIntervalFn(pollUnread, 30000, {
|
||||
immediate: false,
|
||||
immediateCallback: true,
|
||||
});
|
||||
|
||||
watch(
|
||||
isLoggedIn,
|
||||
(loggedIn) => {
|
||||
if (loggedIn) {
|
||||
onPoll();
|
||||
} else {
|
||||
offPoll();
|
||||
messages.value = [];
|
||||
total.value = 0;
|
||||
unreadCount.value = 0;
|
||||
lastLatestId = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function markRead(id: number) {
|
||||
const target = messages.value.find((m) => m.id === id);
|
||||
if (!target || target.read) return;
|
||||
try {
|
||||
await apiNotification.markRead(id);
|
||||
target.read = true;
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
} catch {
|
||||
// 失败保持原状,下一次 SSE 帧会校正
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
await apiNotification.markAllRead();
|
||||
messages.value.forEach((m) => {
|
||||
m.read = true;
|
||||
});
|
||||
unreadCount.value = 0;
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
try {
|
||||
await apiNotification.removeMessage(id);
|
||||
const target = messages.value.find((m) => m.id === id);
|
||||
messages.value = messages.value.filter((m) => m.id !== id);
|
||||
total.value = Math.max(0, total.value - 1);
|
||||
if (target && !target.read) {
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
}
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
try {
|
||||
await apiNotification.clearAll();
|
||||
messages.value = [];
|
||||
total.value = 0;
|
||||
unreadCount.value = 0;
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
total,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
panelOpen,
|
||||
fetchMessages,
|
||||
markRead,
|
||||
markAllRead,
|
||||
remove,
|
||||
clearAll,
|
||||
titleOf,
|
||||
bodyOf,
|
||||
};
|
||||
});
|
||||
@@ -19,8 +19,14 @@ export type NotificationType = [
|
||||
'pushover',
|
||||
'webhook',
|
||||
];
|
||||
/** LLM 提供商(openai 表示任意 OpenAI 兼容端点) */
|
||||
export type LLMProvider = ['openai', 'anthropic', 'gemini'];
|
||||
/** LLM 提供商 id(内置三家 + 预设/插件,开放集合) */
|
||||
export type LLMProviderId = string;
|
||||
/** 单个提供商的凭据/模型/端点覆盖 */
|
||||
export interface LLMProviderOverride {
|
||||
api_key: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
}
|
||||
/** LLM 解析模式(fallback:正则优先;primary:LLM 优先) */
|
||||
export type LLMParseMode = ['fallback', 'primary'];
|
||||
/** OpenAI Model List */
|
||||
@@ -92,7 +98,7 @@ export interface Notification {
|
||||
}
|
||||
export interface LLM {
|
||||
enable: boolean;
|
||||
provider: TupleToUnion<LLMProvider>;
|
||||
provider: LLMProviderId;
|
||||
api_key: string;
|
||||
model: string;
|
||||
/** 自定义端点,仅 openai 提供商使用;空串表示官方 API */
|
||||
@@ -108,6 +114,8 @@ export interface LLM {
|
||||
failure_threshold: number;
|
||||
/** 熔断后暂停调用的时长(秒) */
|
||||
failure_backoff: number;
|
||||
/** 按提供商 id 存放的凭据/模型/端点覆盖 */
|
||||
providers: Record<string, LLMProviderOverride>;
|
||||
}
|
||||
|
||||
/** @deprecated 旧版 OpenAI 解析配置,已被 LLM 段取代(保留向后兼容) */
|
||||
@@ -210,6 +218,7 @@ export const initConfig: Config = {
|
||||
max_concurrency: 2,
|
||||
failure_threshold: 3,
|
||||
failure_backoff: 300,
|
||||
providers: {},
|
||||
},
|
||||
experimental_openai: {
|
||||
enable: false,
|
||||
|
||||
2
webui/types/dts/auto-imports.d.ts
vendored
2
webui/types/dts/auto-imports.d.ts
vendored
@@ -11,6 +11,7 @@ declare global {
|
||||
const apiConfig: typeof import('../../src/api/config')['apiConfig']
|
||||
const apiDownload: typeof import('../../src/api/download')['apiDownload']
|
||||
const apiDownloader: typeof import('../../src/api/downloader')['apiDownloader']
|
||||
const apiLLM: typeof import('../../src/api/llm')['apiLLM']
|
||||
const apiLog: typeof import('../../src/api/log')['apiLog']
|
||||
const apiNotification: typeof import('../../src/api/notification')['apiNotification']
|
||||
const apiPasskey: typeof import('../../src/api/passkey')['apiPasskey']
|
||||
@@ -115,6 +116,7 @@ declare global {
|
||||
const useLogStore: typeof import('../../src/store/log')['useLogStore']
|
||||
const useMessage: typeof import('../../src/hooks/useMessage')['useMessage']
|
||||
const useMyI18n: typeof import('../../src/hooks/useMyI18n')['useMyI18n']
|
||||
const useNotificationStore: typeof import('../../src/store/notification')['useNotificationStore']
|
||||
const usePasskey: typeof import('../../src/hooks/usePasskey')['usePasskey']
|
||||
const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
|
||||
2
webui/types/dts/components.d.ts
vendored
2
webui/types/dts/components.d.ts
vendored
@@ -23,6 +23,7 @@ declare module '@vue/runtime-core' {
|
||||
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
|
||||
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
|
||||
AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']
|
||||
AbNotificationCenter: typeof import('./../../src/components/layout/ab-notification-center.vue')['default']
|
||||
AbOffsetMismatchDialog: typeof import('./../../src/components/basic/ab-offset-mismatch-dialog.vue')['default']
|
||||
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
|
||||
AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']
|
||||
@@ -63,6 +64,7 @@ declare module '@vue/runtime-core' {
|
||||
ConfigProxy: typeof import('./../../src/components/setting/config-proxy.vue')['default']
|
||||
ConfigSearchProvider: typeof import('./../../src/components/setting/config-search-provider.vue')['default']
|
||||
ConfigSecurity: typeof import('./../../src/components/setting/config-security.vue')['default']
|
||||
LlmAuthDialog: typeof import('./../../src/components/setting/llm-auth-dialog.vue')['default']
|
||||
MediaQuery: typeof import('./../../src/components/media-query.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
Reference in New Issue
Block a user