Compare commits

..

5 Commits

Author SHA1 Message Date
jxxghp
d0dcf6660f chore: update application and frontend version to v2.13.9 2026-06-14 16:28:34 +08:00
jxxghp
4e3eddec10 feat: add captcha recognition agent tool 2026-06-14 16:24:04 +08:00
jxxghp
93713ba662 feat: sync plugin markets from wiki 2026-06-14 12:57:40 +08:00
jxxghp
0f3e9574ab Configure subagent profiles from runtime files 2026-06-14 10:27:26 +08:00
nazoko
25dbe491fe fix(jellyfin): 修复播放通知封面缺失问题 (#5938) 2026-06-14 06:24:42 +08:00
23 changed files with 1209 additions and 230 deletions

View File

@@ -0,0 +1,20 @@
---
version: 1
subagent_id: download-diagnostician
label: 下载诊断
description: Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.
include_tags:
- download
- transfer
- library
- directory
- file
- media
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state.

View File

@@ -0,0 +1,35 @@
---
version: 1
subagent_id: general-purpose
label: 通用调查
description: General read-only investigation subagent for cross-domain MoviePilot analysis and execution recommendations.
include_tags:
- media
- resource
- site
- subscription
- download
- library
- transfer
- system
- settings
- plugin
- workflow
- scheduler
- file
- directory
- web
- command
- filter_rule
- persona
- slash_command
- recommendation
- metadata
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in synthesizing media, site, subscription, download, and system status signals.

View File

@@ -0,0 +1,19 @@
---
version: 1
subagent_id: media-researcher
label: 媒体研究
description: Media research subagent for title recognition, people, episodes, metadata, and library existence checks.
include_tags:
- media
- library
- recommendation
- metadata
- web
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in media identity resolution, metadata validation, person credits, and library status analysis.

View File

@@ -0,0 +1,19 @@
---
version: 1
subagent_id: moviepilot-explorer
label: 代码探索
description: MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.
include_tags:
- system
- settings
- file
- directory
- command
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state.

View File

@@ -0,0 +1,18 @@
---
version: 1
subagent_id: resource-searcher
label: 资源搜索
description: Site and resource search subagent for site checks, torrent search, and resource quality analysis.
include_tags:
- resource
- site
- web
- media
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in site status, site user data, torrent search results, and resource quality judgment.

View File

@@ -0,0 +1,18 @@
---
version: 1
subagent_id: subscription-analyst
label: 订阅分析
description: Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.
include_tags:
- subscription
- filter_rule
- settings
- media
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions.

View File

@@ -0,0 +1,25 @@
---
version: 1
subagent_id: system-diagnostician
label: 系统诊断
description: System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.
include_tags:
- system
- settings
- plugin
- workflow
- scheduler
- file
- directory
- web
- command
- persona
- slash_command
exclude_tags:
- write
- message
- user_interaction
---
# SUBAGENT
You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics.

View File

@@ -24,6 +24,7 @@ from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.runtime import SubAgentDefinition, agent_runtime_manager
from app.agent.tools.tags import ToolTag
from app.log import logger
@@ -87,7 +88,7 @@ Requirements:
@dataclass(frozen=True)
class _SubAgentProfile:
"""内置子代理定义。"""
"""子代理运行时定义。"""
name: str
description: str
@@ -197,40 +198,15 @@ def builtin_subagent_names() -> frozenset[str]:
@lru_cache(maxsize=1)
def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
"""构建 MoviePilot 默认内置子代理定义。"""
default_exclude_tags = frozenset(
{
ToolTag.Write.value,
ToolTag.Message.value,
ToolTag.UserInteraction.value,
}
"""从运行时配置目录加载 MoviePilot 子代理定义。"""
definitions = agent_runtime_manager.list_subagents()
profiles = tuple(
_profile_from_runtime_definition(definition)
for definition in definitions
)
general_tags = frozenset(
{
ToolTag.Media.value,
ToolTag.Resource.value,
ToolTag.Site.value,
ToolTag.Subscription.value,
ToolTag.Download.value,
ToolTag.Library.value,
ToolTag.Transfer.value,
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.Plugin.value,
ToolTag.Workflow.value,
ToolTag.Scheduler.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Web.value,
ToolTag.Command.value,
ToolTag.FilterRule.value,
ToolTag.Persona.value,
ToolTag.SlashCommand.value,
ToolTag.Recommendation.value,
ToolTag.Metadata.value,
}
)
if profiles:
return profiles
logger.warning("未加载到任何子代理定义,使用通用兜底子代理。")
return (
_SubAgentProfile(
name="general-purpose",
@@ -239,126 +215,34 @@ def _builtin_subagent_profiles() -> tuple[_SubAgentProfile, ...]:
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in synthesizing media, site, subscription, download, and system status signals."
),
include_tags=general_tags,
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="media-researcher",
description="Media research subagent for title recognition, people, episodes, metadata, and library existence checks.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in media identity resolution, metadata validation, person credits, and library status analysis."
),
include_tags=frozenset(
include_tags=frozenset(tag.value for tag in ToolTag),
exclude_tags=frozenset(
{
ToolTag.Media.value,
ToolTag.Library.value,
ToolTag.Recommendation.value,
ToolTag.Metadata.value,
ToolTag.Web.value,
ToolTag.Write.value,
ToolTag.Message.value,
ToolTag.UserInteraction.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="moviepilot-explorer",
description="MoviePilot exploration subagent for source-code inspection, configuration structure analysis, logs, and code-level troubleshooting clues.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in MoviePilot source-code structure, local configuration files, directory layout, logs or read-only command output, and code-level root-cause troubleshooting. "
"Prefer reading relevant code paths before judging behavior, and distinguish code/config evidence from runtime system state."
),
include_tags=frozenset(
{
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Command.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="resource-searcher",
description="Site and resource search subagent for site checks, torrent search, and resource quality analysis.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in site status, site user data, torrent search results, and resource quality judgment."
),
include_tags=frozenset(
{
ToolTag.Resource.value,
ToolTag.Site.value,
ToolTag.Web.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="subscription-analyst",
description="Subscription analysis subagent for subscriptions, history, filter rules, and custom identifiers.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in current subscription state, subscription history, filter rules, and subscription optimization suggestions."
),
include_tags=frozenset(
{
ToolTag.Subscription.value,
ToolTag.FilterRule.value,
ToolTag.Settings.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="system-diagnostician",
description="System diagnosis subagent for read-only inspection of settings, schedulers, workflows, plugins, directories, and command output.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in settings, plugins, scheduled tasks, workflows, directories, and read-only command diagnostics."
),
include_tags=frozenset(
{
ToolTag.System.value,
ToolTag.Settings.value,
ToolTag.Plugin.value,
ToolTag.Workflow.value,
ToolTag.Scheduler.value,
ToolTag.File.value,
ToolTag.Directory.value,
ToolTag.Web.value,
ToolTag.Command.value,
ToolTag.Persona.value,
ToolTag.SlashCommand.value,
}
),
exclude_tags=default_exclude_tags,
),
_SubAgentProfile(
name="download-diagnostician",
description="Download and transfer diagnosis subagent for downloaders, download tasks, transfer history, and library status.",
prompt=(
f"{SUBAGENT_BASE_PROMPT}\n"
"You specialize in downloaders, download tasks, transfer history, directory settings, and library ingestion state."
),
include_tags=frozenset(
{
ToolTag.Download.value,
ToolTag.Transfer.value,
ToolTag.Library.value,
ToolTag.Directory.value,
ToolTag.File.value,
ToolTag.Media.value,
}
),
exclude_tags=default_exclude_tags,
),
)
def _profile_from_runtime_definition(
definition: SubAgentDefinition,
) -> _SubAgentProfile:
"""把运行时子代理定义转换为中间件可用的 profile。"""
prompt_parts = [SUBAGENT_BASE_PROMPT]
if definition.text.strip():
prompt_parts.append(definition.text.strip())
return _SubAgentProfile(
name=definition.subagent_id,
description=definition.description,
prompt="\n".join(prompt_parts),
include_tags=frozenset(definition.include_tags),
exclude_tags=frozenset(definition.exclude_tags),
)
def _tool_tag_values(tool: BaseTool) -> set[str]:
"""读取工具实例上的标签集合。"""
tags = getattr(tool, "tags", None) or []
@@ -1055,6 +939,8 @@ def create_subagent_middlewares(
stream_handler: Any = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""创建子代理中间件列表和任务工具列表。"""
_builtin_subagent_profiles.cache_clear()
builtin_subagent_names.cache_clear()
profiles = _builtin_subagent_profiles()
subagent_middleware = _try_create_deepagents_middleware(
profiles=profiles,

View File

@@ -22,8 +22,11 @@ JOBS_DIR = "jobs"
ACTIVITY_DIR = "activity"
PERSONAS_DIR = "personas"
PERSONA_FILE = "PERSONA.md"
SUBAGENTS_DIR = "subagents"
SUBAGENT_FILE = "SUBAGENT.md"
CURRENT_PERSONA_SCHEMA_VERSION = 3
PERSONA_SCHEMA_VERSION = 1
SUBAGENT_SCHEMA_VERSION = 1
DEFAULT_PERSONA_ID = "default"
PERSONA_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -111,6 +114,41 @@ class PersonaDefinition:
}
@dataclass
class SubAgentDefinition:
"""单个子代理定义。"""
subagent_id: str
path: Path
description: str
text: str
include_tags: list[str]
exclude_tags: list[str]
version: int = SUBAGENT_SCHEMA_VERSION
label: str = ""
def summary_line(self) -> str:
"""渲染可读的一行子代理摘要。"""
parts = [f"`{self.subagent_id}`"]
if self.label and self.label != self.subagent_id:
parts.append(self.label)
if self.description:
parts.append(self.description)
return " - ".join(parts)
def to_dict(self) -> dict[str, Any]:
"""输出给查询或调试入口的结构化信息。"""
return {
"subagent_id": self.subagent_id,
"label": self.label,
"description": self.description,
"include_tags": self.include_tags,
"exclude_tags": self.exclude_tags,
"version": self.version,
"path": str(self.path),
}
@dataclass
class AgentRuntimeConfig:
"""一次加载后的根层配置快照。"""
@@ -120,6 +158,7 @@ class AgentRuntimeConfig:
current_persona_path: Path
persona: PersonaDefinition
available_personas: list[PersonaDefinition]
available_subagents: list[SubAgentDefinition]
extra_context_paths: list[Path]
extra_contexts: list[tuple[Path, str]]
warnings: list[str] = field(default_factory=list)
@@ -135,6 +174,12 @@ class AgentRuntimeConfig:
if self.available_personas:
sections.append("- Available personas:")
sections.extend(f" - {persona.summary_line()}" for persona in self.available_personas)
if self.available_subagents:
sections.append("- Available subagents:")
sections.extend(
f" - {subagent.summary_line()}"
for subagent in self.available_subagents
)
sections.append("</agent_runtime_config>")
if self.warnings:
@@ -201,6 +246,7 @@ class AgentRuntimeManager:
self.skills_dir = self.agent_root_dir / SKILLS_DIR
self.jobs_dir = self.agent_root_dir / JOBS_DIR
self.activity_dir = self.agent_root_dir / ACTIVITY_DIR
self.subagents_dir = self.runtime_dir / SUBAGENTS_DIR
self.bundled_defaults_dir = bundled_defaults_dir or (
Path(__file__).parent / "defaults"
)
@@ -216,6 +262,7 @@ class AgentRuntimeManager:
self.skills_dir.mkdir(parents=True, exist_ok=True)
self.jobs_dir.mkdir(parents=True, exist_ok=True)
self.activity_dir.mkdir(parents=True, exist_ok=True)
self.subagents_dir.mkdir(parents=True, exist_ok=True)
self._migrate_root_runtime_files()
self._remove_obsolete_runtime_files()
self._sync_bundled_defaults()
@@ -278,6 +325,10 @@ class AgentRuntimeManager:
"""列出当前可用人格。"""
return self.load_runtime_config().available_personas
def list_subagents(self) -> list[SubAgentDefinition]:
"""列出当前可用子代理。"""
return self.load_runtime_config().available_subagents
def update_persona_definition(
self,
persona_query: str,
@@ -382,7 +433,7 @@ class AgentRuntimeManager:
return tuple(entries)
def _sync_bundled_defaults(self) -> None:
"""仅复制缺失的默认运行时文件,避免覆盖用户自定义。"""
"""同步默认运行时文件,并按版本更新内置子代理定义。"""
if not self.bundled_defaults_dir.exists():
return
for path in sorted(self.bundled_defaults_dir.rglob("*")):
@@ -392,11 +443,43 @@ class AgentRuntimeManager:
target.mkdir(parents=True, exist_ok=True)
continue
if target.exists():
if self._should_update_bundled_subagent(relative, path, target):
shutil.copy2(path, target)
logger.info(f"已更新默认 Agent 子代理定义: {target}")
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
logger.info("已同步默认 Agent 运行时文件: %s", target)
@classmethod
def _should_update_bundled_subagent(
cls,
relative_path: Path,
source_path: Path,
target_path: Path,
) -> bool:
"""判断是否需要用更高版本的内置子代理定义覆盖用户目录副本。"""
parts = relative_path.parts
if len(parts) < 3 or parts[0] != SUBAGENTS_DIR or relative_path.name != SUBAGENT_FILE:
return False
source_version = cls._read_markdown_version(source_path)
target_version = cls._read_markdown_version(target_path)
return source_version > target_version
@staticmethod
def _read_markdown_version(path: Path) -> int:
"""读取 Markdown frontmatter 中的整数版本,失败时按 0 处理。"""
try:
document = AgentRuntimeManager._read_markdown(path)
except AgentRuntimeConfigError as err:
logger.warning(f"读取 Agent 运行时文件版本失败 {path}: {err}")
return 0
return AgentRuntimeManager._coerce_int_metadata(
document.metadata.get("version"),
default=0,
)
def _migrate_root_runtime_files(self) -> None:
"""兼容早期直接放在 `config/agent` 根目录的 CURRENT_PERSONA。"""
source = self.agent_root_dir / CURRENT_PERSONA_FILE
@@ -451,6 +534,7 @@ class AgentRuntimeManager:
available_personas = self._load_personas(root)
persona = self._resolve_persona_definition(active_persona, available_personas)
available_subagents = self._load_subagents(root)
extra_contexts = [
(path, self._read_markdown(path).body)
for path in extra_context_paths
@@ -468,6 +552,7 @@ class AgentRuntimeManager:
current_persona_path=current_persona_path,
persona=persona,
available_personas=available_personas,
available_subagents=available_subagents,
extra_context_paths=extra_context_paths,
extra_contexts=extra_contexts,
warnings=warnings,
@@ -513,6 +598,71 @@ class AgentRuntimeManager:
raise AgentRuntimeConfigError(f"{personas_root} 中未找到任何人格定义")
return personas
def _load_subagents(self, root: Path) -> list[SubAgentDefinition]:
"""扫描并解析所有可用子代理。"""
subagents_root = root / SUBAGENTS_DIR
if not subagents_root.exists():
raise AgentRuntimeConfigError(f"缺少 subagents 目录: {subagents_root}")
subagents: list[SubAgentDefinition] = []
seen_ids: set[str] = set()
for subagent_dir in sorted(subagents_root.iterdir()):
if not subagent_dir.is_dir():
continue
subagent_path = subagent_dir / SUBAGENT_FILE
if not subagent_path.exists():
continue
document = self._read_markdown(subagent_path)
subagent_id = str(
document.metadata.get("subagent_id") or subagent_dir.name
).strip()
if not subagent_id:
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 subagent_id")
if not PERSONA_ID_PATTERN.fullmatch(subagent_id):
raise AgentRuntimeConfigError(
f"{subagent_path} 的 subagent_id 只能使用小写字母、数字、下划线和中划线,且必须以字母或数字开头"
)
if subagent_id in seen_ids:
raise AgentRuntimeConfigError(f"检测到重复的子代理 ID: {subagent_id}")
seen_ids.add(subagent_id)
description = str(document.metadata.get("description") or "").strip()
if not description:
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 description")
include_tags = self._normalize_string_list(
document.metadata.get("include_tags"),
f"{subagent_path}.include_tags",
)
if not include_tags:
raise AgentRuntimeConfigError(f"{subagent_path} 缺少 include_tags")
exclude_tags = self._normalize_string_list(
document.metadata.get("exclude_tags"),
f"{subagent_path}.exclude_tags",
)
text = self._normalize_subagent_body(document.body)
if not text:
raise AgentRuntimeConfigError(f"{subagent_path} 子代理正文不能为空")
subagents.append(
SubAgentDefinition(
subagent_id=subagent_id,
path=subagent_path,
label=str(document.metadata.get("label") or subagent_id).strip(),
description=description,
text=text,
include_tags=include_tags,
exclude_tags=exclude_tags,
version=self._coerce_int_metadata(
document.metadata.get("version"),
default=SUBAGENT_SCHEMA_VERSION,
),
)
)
if not subagents:
raise AgentRuntimeConfigError(f"{subagents_root} 中未找到任何子代理定义")
return subagents
@staticmethod
def _resolve_persona_definition(
persona_query: str,
@@ -653,6 +803,27 @@ class AgentRuntimeManager:
return remainder.strip()
return normalized
@staticmethod
def _normalize_subagent_body(body: Optional[str]) -> str:
"""去掉重复的 SUBAGENT 标题,保持正文可安全加载。"""
normalized = (body or "").strip()
if not normalized:
return ""
if normalized.startswith("# SUBAGENT"):
_, _, remainder = normalized.partition("\n")
return remainder.strip()
return normalized
@staticmethod
def _coerce_int_metadata(value: Any, *, default: int = 0) -> int:
"""将 frontmatter 中的整数型元数据规范化。"""
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _validate_runtime_config(
self,
*,

View File

@@ -37,6 +37,7 @@ from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
from app.agent.tools.impl.get_search_results import GetSearchResultsTool
from app.agent.tools.impl.search_web import SearchWebTool
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
from app.agent.tools.impl.send_message import SendMessageTool
from app.agent.tools.impl.ask_user_choice import AskUserChoiceTool
from app.agent.tools.impl.send_local_file import SendLocalFileTool
@@ -165,6 +166,7 @@ class MoviePilotToolFactory:
SearchTorrentsTool,
GetSearchResultsTool,
SearchWebTool,
RecognizeCaptchaTool,
AddDownloadTool,
QuerySubscribesTool,
QuerySubscribeSharesTool,

View File

@@ -0,0 +1,167 @@
"""识别图形验证码工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.helper.browser import BrowserSessionHelper
from app.helper.ocr import OcrHelper
from app.log import logger
class RecognizeCaptchaInput(BaseModel):
"""识别图形验证码工具的输入参数模型。"""
explanation: Optional[str] = Field(
None,
description="Clear explanation of why this captcha image needs to be recognized",
)
image_url: str = Field(
...,
description=(
"Captcha image URL obtained from the browser page, usually an img.src value. "
"Supports http/https URLs and data:image/...;base64,... URLs."
),
)
cookie: Optional[str] = Field(
None,
description=(
"Optional Cookie header used to download the captcha image when the image URL "
"requires the same authenticated browser session."
),
)
user_agent: Optional[str] = Field(
None,
description="Optional User-Agent used when downloading the captcha image.",
)
allow_private_network: bool = Field(
False,
description="Allow captcha image URLs on localhost, loopback, private, or link-local addresses.",
)
class RecognizeCaptchaTool(MoviePilotTool):
"""
图形验证码识别工具,供 Agent 在浏览器自动化登录时读取验证码文本。
"""
name: str = "recognize_captcha"
tags: list[str] = [
ToolTag.Read,
ToolTag.Web,
]
description: str = (
"Recognize a graphic captcha image and return the captcha text. "
"Use this after browser automation extracts a captcha img.src from the page. "
"Pass cookie and user_agent when the image URL requires the current browser session. "
"Supports http/https image URLs and data:image/...;base64,... URLs. "
"For safety, localhost and private network URLs are blocked by default unless "
"allow_private_network is true."
)
args_schema: Type[BaseModel] = RecognizeCaptchaInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据验证码图片参数生成友好的提示消息。"""
image_url = str(kwargs.get("image_url") or "")
if image_url.lower().startswith("data:image/"):
return "识别图形验证码: data image"
return f"识别图形验证码: {image_url}"
@staticmethod
def _recognize_captcha_sync(
image_url: str,
cookie: Optional[str] = None,
user_agent: Optional[str] = None,
allow_private_network: bool = False,
) -> str:
"""
在线程池中下载并识别验证码图片。
:param image_url: 验证码图片地址
:param cookie: 下载图片时使用的 Cookie
:param user_agent: 下载图片时使用的 User-Agent
:param allow_private_network: 是否允许访问本机或私网地址
:return: 验证码文本,失败时返回空字符串
"""
clean_url = (image_url or "").strip()
if not clean_url:
return ""
if not clean_url.lower().startswith("data:image/"):
BrowserSessionHelper.validate_url(
clean_url,
allow_private_network=allow_private_network,
)
return OcrHelper().get_captcha_text(
image_url=clean_url,
cookie=cookie,
ua=user_agent,
)
async def run(
self,
image_url: str,
cookie: Optional[str] = None,
user_agent: Optional[str] = None,
allow_private_network: bool = False,
**kwargs,
) -> str:
"""
识别指定图片地址中的图形验证码文本。
:param image_url: 验证码图片地址
:param cookie: 下载图片时使用的 Cookie
:param user_agent: 下载图片时使用的 User-Agent
:param allow_private_network: 是否允许访问本机或私网地址
:return: JSON 格式的识别结果
"""
logger.info(f"执行工具: {self.name}, 参数: image_url={image_url}")
try:
captcha_text = await self.run_blocking(
"web",
self._recognize_captcha_sync,
image_url,
cookie,
user_agent,
allow_private_network,
)
if captcha_text:
return json.dumps(
{
"success": True,
"captcha_text": captcha_text,
"message": "验证码识别成功",
},
ensure_ascii=False,
)
return json.dumps(
{
"success": False,
"captcha_text": "",
"message": "验证码识别失败或未返回内容",
},
ensure_ascii=False,
)
except ValueError as err:
logger.warning(f"验证码图片地址校验失败: {str(err)}")
return json.dumps(
{
"success": False,
"captcha_text": "",
"message": str(err),
},
ensure_ascii=False,
)
except Exception as err:
logger.error(f"识别图形验证码失败: {str(err)}", exc_info=True)
return json.dumps(
{
"success": False,
"captcha_text": "",
"message": f"识别图形验证码时发生错误: {str(err)}",
},
ensure_ascii=False,
)

View File

@@ -68,6 +68,98 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
_LOG_DOWNLOAD_LIMIT = 10
_LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
_PLUGIN_MARKET_WIKI_START = "<!-- plugin-market-repos:start -->"
_PLUGIN_MARKET_WIKI_END = "<!-- plugin-market-repos:end -->"
_PLUGIN_MARKET_WIKI_URL = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
_PLUGIN_MARKET_REPO_PATTERN = re.compile(
r"https?://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?",
re.IGNORECASE,
)
def _normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
"""
规范化插件仓库地址,便于跨来源合并去重。
"""
repo_url = (repo_url or "").strip().rstrip("/")
if not repo_url:
return None
repo_url = repo_url.removesuffix(".git")
parsed_url = urlparse(repo_url)
if parsed_url.scheme not in {"http", "https"}:
return None
if (parsed_url.hostname or "").lower() != "github.com":
return None
paths = [item for item in parsed_url.path.split("/") if item]
if len(paths) < 2:
return None
return f"https://github.com/{paths[0]}/{paths[1]}"
def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
"""
校验插件市场 Wiki 地址是否属于固定文档源。
"""
parsed_url = urlparse(wiki_url)
if parsed_url.scheme != "https":
return False
if (parsed_url.hostname or "").lower() != "raw.githubusercontent.com":
return False
return bool(
re.fullmatch(
r"/jxxghp/MoviePilot-Wiki/[^/]+/plugin\.md",
parsed_url.path,
)
)
def _split_plugin_market_repo_urls(value: Optional[str]) -> list[str]:
"""
拆分插件市场仓库配置并保持原有顺序去重。
"""
repos: list[str] = []
seen_repos = set()
for item in re.split(r"[\n,]+", value or ""):
normalized_repo = _normalize_plugin_market_repo_url(item)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return repos
def _extract_plugin_market_repos_from_wiki(markdown: str) -> list[str]:
"""
从 Wiki 插件文档中提取插件仓库地址。
"""
content = markdown or ""
if _PLUGIN_MARKET_WIKI_START in content and _PLUGIN_MARKET_WIKI_END in content:
content = content.split(_PLUGIN_MARKET_WIKI_START, 1)[1].split(_PLUGIN_MARKET_WIKI_END, 1)[0]
repos: list[str] = []
seen_repos = set()
for item in _PLUGIN_MARKET_REPO_PATTERN.findall(content):
normalized_repo = _normalize_plugin_market_repo_url(item)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return repos
def _merge_plugin_market_repos(local_repos: list[str], wiki_repos: list[str]) -> list[str]:
"""
合并本地与 Wiki 插件仓库地址,保留本地顺序并追加 Wiki 新地址。
"""
merged_repos: list[str] = []
seen_repos = set()
for repo in local_repos + wiki_repos:
normalized_repo = _normalize_plugin_market_repo_url(repo)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
merged_repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return merged_repos
def _match_nettest_prefix(url: str, prefix: str) -> bool:
@@ -723,6 +815,73 @@ async def get_public_setting(
return schemas.Response(success=True, data={"value": value})
@router.post(
"/setting/PLUGIN_MARKET/sync-wiki",
summary="从Wiki同步插件市场仓库",
response_model=schemas.Response,
)
async def sync_plugin_market_from_wiki(
request: Optional[schemas.PluginMarketSyncRequest] = Body(default=None),
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""
从 Wiki 插件文档同步插件市场仓库地址。
"""
wiki_url = (request.wiki_url if request else None) or _PLUGIN_MARKET_WIKI_URL
wiki_url = wiki_url.strip()
if not _is_allowed_plugin_market_wiki_url(wiki_url):
return schemas.Response(success=False, message="不支持的 Wiki 同步地址")
res = await AsyncRequestUtils(
ua=settings.USER_AGENT,
proxies=settings.PROXY,
timeout=30,
content_type=None,
accept_type="text/plain,*/*",
).get_res(wiki_url)
if res is None:
return schemas.Response(success=False, message="无法访问 Wiki 插件仓库清单")
if res.status_code != 200:
return schemas.Response(
success=False,
message=f"访问 Wiki 插件仓库清单失败,状态码:{res.status_code}",
)
wiki_repos = _extract_plugin_market_repos_from_wiki(res.text)
if not wiki_repos:
return schemas.Response(success=False, message="未在 Wiki 中识别到插件仓库地址")
local_repos = _split_plugin_market_repo_urls(settings.PLUGIN_MARKET)
local_repo_keys = {repo.lower() for repo in local_repos}
added_count = len([repo for repo in wiki_repos if repo.lower() not in local_repo_keys])
merged_repos = _merge_plugin_market_repos(local_repos, wiki_repos)
merged_value = ",".join(merged_repos)
success, message = settings.update_setting("PLUGIN_MARKET", merged_value)
if success:
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key="PLUGIN_MARKET", value=merged_value, change_type="update"
),
)
elif success is None:
success = True
return schemas.Response(
success=success,
message=message,
data={
"value": merged_value,
"repos": merged_repos,
"wiki_repos": wiki_repos,
"added_count": added_count,
"total_count": len(merged_repos),
"source_url": wiki_url,
},
)
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
async def get_setting(
key: str, _: User = Depends(get_current_active_superuser_async)

View File

@@ -6,31 +6,63 @@ from app.utils.http import RequestUtils
class OcrHelper:
"""
OCR 辅助类,负责获取验证码图片并调用 OCR 服务识别文本。
"""
_ocr_b64_url = f"{settings.OCR_HOST}/captcha/base64"
def get_captcha_text(self, image_url: Optional[str] = None, image_b64: Optional[str] = None,
cookie: Optional[str] = None, ua: Optional[str] = None):
def get_captcha_text(
self,
image_url: Optional[str] = None,
image_b64: Optional[str] = None,
cookie: Optional[str] = None,
ua: Optional[str] = None,
) -> str:
"""
根据图片地址,获取验证码图片,并识别内容
:param image_url: 图片地址
:param image_b64: 图片base64跳过图片地址下载
:param cookie: 下载图片使用的cookie
:param ua: 下载图片使用的ua
:return: 验证码识别结果,失败时返回空字符串
"""
image_b64 = self._normalize_image_base64(image_b64)
if image_url:
ret = RequestUtils(ua=ua,
cookies=cookie).get_res(image_url)
if ret is not None:
image_bin = ret.content
if not image_bin:
return ""
image_b64 = base64.b64encode(image_bin).decode()
data_url_b64 = self._extract_data_url_base64(image_url)
if data_url_b64:
image_b64 = data_url_b64
else:
ret = RequestUtils(ua=ua,
cookies=cookie).get_res(image_url)
if ret is not None:
image_bin = ret.content
if not image_bin:
return ""
image_b64 = base64.b64encode(image_bin).decode()
if not image_b64:
return ""
ret = RequestUtils(content_type="application/json").post_res(
url=self._ocr_b64_url,
json={"base64_img": image_b64})
if ret:
return ret.json().get("result")
return ret.json().get("result") or ""
return ""
@staticmethod
def _normalize_image_base64(image_b64: Optional[str]) -> str:
"""规范化外部传入的图片 base64 内容。"""
if not image_b64:
return ""
return OcrHelper._extract_data_url_base64(image_b64) or image_b64.strip()
@staticmethod
def _extract_data_url_base64(image_url: Optional[str]) -> str:
"""从 data:image/...;base64,... 地址中提取纯 base64 内容。"""
image_url = (image_url or "").strip()
if not image_url.lower().startswith("data:image/"):
return ""
metadata, separator, data = image_url.partition(",")
if not separator or ";base64" not in metadata.lower():
return ""
return data.strip()

View File

@@ -502,18 +502,17 @@ class Jellyfin:
try:
res = RequestUtils(timeout=10).get_res(url, params)
if res:
images = res.json().get("Images")
images = res.json().get("Images") or []
for image in images:
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
return image.get("Url")
# return images[0].get("Url") # 首选无则返回第一张
# TMDB 无匹配时回退本地图片
logger.info(f"未找到 TMDB {image_type},回退本地图片")
else:
logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片")
return self.generate_image_link(item_id, image_type, True)
except Exception as e:
logger.error(f"连接Items/Id/RemoteImages出错" + str(e))
return None
return None
return self.generate_image_link(item_id, image_type, True)
def get_item_path_by_id(self, item_id: str) -> Optional[str]:
"""

View File

@@ -86,6 +86,17 @@ class NotificationSwitchConf(BaseModel):
action: Optional[str] = "all"
class PluginMarketSyncRequest(BaseModel):
"""
插件市场仓库同步请求
"""
# Wiki 插件文档 Markdown 原始文件地址
wiki_url: Optional[str] = Field(
default="https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md",
)
class StorageConf(BaseModel):
"""
存储配置

View File

@@ -114,6 +114,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| :--- | :--- | :--- |
| GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 |
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
### 插件补充接口
@@ -221,6 +222,20 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
`browse_webpage` 使用持久浏览器会话,默认以当前 Agent 会话作为 `session_key``goto``snapshot``click``click_ref``fill``fill_ref``select``select_ref``wait` 等动作会返回页面快照,快照中的 `interactive_elements[].ref` 可用于后续 `*_ref` 操作。支持 `list_tabs``open_tab``focus_tab``close_tab` 管理标签页,支持 `close_session` 释放会话。出于安全考虑,默认拒绝访问 localhost、环回地址、私网地址和链路本地地址确需访问可信内网或本机页面时可显式传入 `allow_private_network: true`
**`recognize_captcha` 图形验证码识别示例**:
```json
{
"tool_name": "recognize_captcha",
"arguments": {
"image_url": "https://example.com/captcha.png",
"cookie": "sid=...",
"user_agent": "Mozilla/5.0 ..."
}
}
```
`recognize_captcha` 用于浏览器自动化登录时识别普通图形验证码。智能体可先通过 `browse_webpage``evaluate` 动作从页面元素中提取 `img.src`,再把图片地址传给该工具;支持 `http/https` 图片地址和 `data:image/...;base64,...`。当验证码图片依赖当前浏览器会话时,可传入 Cookie 与 User-Agent。出于安全考虑默认拒绝访问 localhost、环回地址、私网地址和链路本地地址确需访问可信内网或本机验证码图片时可显式传入 `allow_private_network: true`
### 3. 获取工具详情
**GET** `/api/v1/mcp/tools/{tool_name}`

View File

@@ -8,7 +8,7 @@ description: >-
interaction, such as checking a site page, confirming a JavaScript-rendered
result, testing login state, capturing visible errors, or updating and
validating tracker site cookies.
allowed-tools: browse_webpage search_web query_sites update_site_cookie test_site update_site
allowed-tools: browse_webpage recognize_captcha search_web query_sites update_site_cookie test_site update_site
---
# Browser Use
@@ -41,6 +41,9 @@ dedicated tool can complete the task more directly and safely.
`get_content`, `screenshot`, `click`, `click_ref`, `fill`, `fill_ref`,
`select`, `select_ref`, `evaluate`, `wait`, `list_tabs`, `open_tab`,
`focus_tab`, `close_tab`, `close_session`.
- `recognize_captcha` - Recognize graphic captcha text from an image URL or
`data:image/...;base64,...` value extracted from the page. Pass Cookie and
User-Agent when the image requires the current browser session.
- `search_web` - Find current pages or official references before opening a
target URL. It supports DDGS-backed `search_engine` (`auto`, `duckduckgo`,
`google`, `brave`, etc.) and `site_url` for limiting results to a specified
@@ -174,6 +177,28 @@ update_site_cookie site_identifier=<id> username="..." password="..." two_step_c
Ask for missing username, password, or two-step code only when required for the
operation. Do not expose secrets in the final answer.
### Login Page With A Graphic Captcha
When a user explicitly asks to complete a login flow that contains a normal
graphic captcha:
1. Open the login page and inspect the form with `snapshot`.
2. Extract the captcha image URL with `evaluate`, for example:
```text
browse_webpage action="evaluate" script="() => document.querySelector('img[src*=\"captcha\"], img[alt*=\"验证码\"], img[title*=\"验证码\"]')?.src || ''"
```
3. If the captcha image needs session cookies, extract `document.cookie` and the
current `navigator.userAgent` with `evaluate`.
4. Call `recognize_captcha image_url="<img.src>"` and pass `cookie` /
`user_agent` when needed.
5. Fill the returned `captcha_text`, submit the form, and verify the login
result.
If recognition fails, refresh the captcha once and retry. Stop after a second
failure and tell the user manual input is needed.
### Inspect A Tracker Page
When the user asks what is visible on a site page:
@@ -187,8 +212,9 @@ When the user asks what is visible on a site page:
- Ask before submitting forms that create, delete, purchase, publish, or change
account/security settings.
- Never solve captchas, bypass access controls, or scrape private content beyond
the user's explicit task.
- Solve graphic captchas only for a user-requested login flow. Do not use this
to bypass access controls, defeat anti-bot challenges, or scrape private
content beyond the user's explicit task.
- Do not print passwords, tokens, cookies, two-step secrets, or full session
headers in the response.
- Localhost, loopback, private, and link-local URLs are blocked by default. Set

View File

@@ -1,7 +1,7 @@
---
name: moviepilot-api
version: 1
description: Use this skill when you need to call MoviePilot REST API endpoints directly. Covers all 244 API endpoints across 27 categories including media search, downloads, subscriptions, library management, site management, system administration, plugins, workflows, and more. Use this skill whenever the user asks to interact with MoviePilot via its HTTP API, or when the moviepilot-cli skill cannot cover a specific operation.
description: Use this skill when you need to call MoviePilot REST API endpoints directly. Covers all 245 API endpoints across 27 categories including media search, downloads, subscriptions, library management, site management, system administration, plugins, workflows, and more. Use this skill whenever the user asks to interact with MoviePilot via its HTTP API, or when the moviepilot-cli skill cannot cover a specific operation.
---
# MoviePilot REST API
@@ -320,7 +320,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
### System (23 endpoints)
### System (24 endpoints)
| Method | Path | Description |
|--------|------|-------------|
@@ -330,6 +330,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/system/setting/public/{key}` | Get allowlisted non-sensitive system setting for authenticated users |
| GET | `/api/v1/system/setting/{key}` | Get system setting |
| POST | `/api/v1/system/setting/{key}` | Update system setting |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | Sync plugin market repository URLs from the MoviePilot Wiki and merge with local `PLUGIN_MARKET` |
| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |
| GET | `/api/v1/system/global/user` | User-related settings |
| GET | `/api/v1/system/restart` | Restart system |

View File

@@ -0,0 +1,134 @@
import asyncio
import base64
import json
from unittest.mock import patch
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.helper.ocr import OcrHelper
class _FakeResponse:
"""测试用响应对象,模拟 requests.Response 的最小行为。"""
def __init__(self, content: bytes = b"", payload: dict = None):
"""初始化响应内容与 JSON 载荷。"""
self.content = content
self.payload = payload or {}
def json(self) -> dict:
"""返回测试预设 JSON 内容。"""
return self.payload
def __bool__(self) -> bool:
"""模拟 requests.Response 在成功状态下为真。"""
return True
def test_factory_registers_recognize_captcha_tool():
"""工具工厂应注册图形验证码识别工具。"""
with patch(
"app.agent.tools.factory.PluginManager.get_plugin_agent_tools",
return_value=[],
):
tools = MoviePilotToolFactory.create_tools(
session_id="captcha-session",
user_id="10001",
)
tool_names = {tool.name for tool in tools}
assert "recognize_captcha" in tool_names
def test_mcp_tool_manager_exposes_recognize_captcha_schema():
"""MCP 工具管理器应暴露验证码识别工具参数。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")
with patch(
"app.agent.tools.manager.MoviePilotToolFactory.create_tools",
return_value=[tool],
):
manager = MoviePilotToolsManager(is_admin=True)
tool_definitions = manager.list_tools()
schema = tool_definitions[0].input_schema
assert [item.name for item in tool_definitions] == ["recognize_captcha"]
assert "image_url" in schema["required"]
assert "cookie" in schema["properties"]
assert "user_agent" in schema["properties"]
assert "allow_private_network" in schema["properties"]
def test_ocr_helper_extracts_data_url_base64_without_downloading_image():
"""data:image 地址应直接提取 base64 内容并提交给 OCR 服务。"""
image_b64 = base64.b64encode(b"captcha-image").decode()
image_url = f"data:image/png;base64,{image_b64}"
with patch("app.helper.ocr.RequestUtils") as request_utils:
request_utils.return_value.post_res.return_value = _FakeResponse(
payload={"result": "a8k2"}
)
result = OcrHelper().get_captcha_text(image_url=image_url)
assert result == "a8k2"
request_utils.return_value.get_res.assert_not_called()
request_utils.return_value.post_res.assert_called_once()
assert request_utils.return_value.post_res.call_args.kwargs["json"] == {
"base64_img": image_b64
}
def test_recognize_captcha_tool_returns_captcha_text_from_ocr_helper():
"""验证码工具应返回结构化识别结果,便于 Agent 继续填写表单。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")
async def _run_tool():
"""执行一次带 mock OCR 的工具调用。"""
with patch(
"app.agent.tools.impl.recognize_captcha.OcrHelper.get_captcha_text",
return_value="x7p9",
) as recognize_mock:
result = await tool.run(
image_url="https://example.com/captcha.png",
cookie="sid=abc",
user_agent="MoviePilotTest/1.0",
)
return result, recognize_mock
result, recognize_mock = asyncio.run(_run_tool())
payload = json.loads(result)
assert payload == {
"success": True,
"captcha_text": "x7p9",
"message": "验证码识别成功",
}
recognize_mock.assert_called_once_with(
image_url="https://example.com/captcha.png",
cookie="sid=abc",
ua="MoviePilotTest/1.0",
)
def test_recognize_captcha_tool_blocks_private_network_by_default():
"""验证码工具默认应拒绝本机和私网图片地址。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")
with patch(
"app.agent.tools.impl.recognize_captcha.OcrHelper.get_captcha_text",
return_value="x7p9",
) as recognize_mock:
result = asyncio.run(
tool.run(image_url="http://127.0.0.1/captcha.png")
)
payload = json.loads(result)
assert payload["success"] is False
assert payload["captcha_text"] == ""
assert "默认不允许访问本机或私网地址" in payload["message"]
recognize_mock.assert_not_called()

View File

@@ -45,6 +45,22 @@ class TestAgentRuntimeConfig(unittest.TestCase):
/ "PERSONA.md"
).exists()
)
self.assertTrue(
(
self.agent_root
/ "runtime"
/ "subagents"
/ "general-purpose"
/ "SUBAGENT.md"
).exists()
)
self.assertIn(
"media-researcher",
[
subagent.subagent_id
for subagent in runtime_config.available_subagents
],
)
def test_legacy_root_markdown_is_migrated_to_memory_directory(self):
self.agent_root.mkdir(parents=True, exist_ok=True)
@@ -109,6 +125,8 @@ class TestAgentRuntimeConfig(unittest.TestCase):
self.assertIn("<agent_persona>", sections)
self.assertIn("Active persona: `default`", sections)
self.assertIn("`guide`", sections)
self.assertIn("Available subagents:", sections)
self.assertIn("`media-researcher`", sections)
def test_set_active_persona_supports_id_and_alias(self):
manager = self._manager()

View File

@@ -0,0 +1,172 @@
import textwrap
from pathlib import Path
from app.agent.runtime import AgentRuntimeManager
import app.agent.middleware.subagents as subagent_module
def _write_current_persona(defaults_root: Path) -> None:
"""写入最小可用的人格激活配置。"""
defaults_root.mkdir(parents=True, exist_ok=True)
(defaults_root / "CURRENT_PERSONA.md").write_text(
textwrap.dedent(
"""\
---
version: 3
active_persona: default
extra_context_files: []
deprecated_phrases: []
---
# CURRENT_PERSONA
"""
),
encoding="utf-8",
)
persona_dir = defaults_root / "personas" / "default"
persona_dir.mkdir(parents=True, exist_ok=True)
(persona_dir / "PERSONA.md").write_text(
textwrap.dedent(
"""\
---
version: 1
persona_id: default
label: 默认
description: 默认人格
aliases: []
---
# PERSONA
测试人格。
"""
),
encoding="utf-8",
)
def _write_subagent(
root: Path,
subagent_id: str,
*,
version: int = 1,
description: str = "测试子代理",
body: str = "测试子代理提示。",
) -> Path:
"""写入一个子代理定义文件。"""
subagent_dir = root / "subagents" / subagent_id
subagent_dir.mkdir(parents=True, exist_ok=True)
path = subagent_dir / "SUBAGENT.md"
path.write_text(
textwrap.dedent(
f"""\
---
version: {version}
subagent_id: {subagent_id}
label: 测试
description: {description}
include_tags:
- media
- web
exclude_tags:
- write
- message
---
# SUBAGENT
{body}
"""
),
encoding="utf-8",
)
return path
def test_runtime_syncs_and_parses_subagent_definitions(tmp_path):
"""运行时应同步并解析 defaults/subagents 下的子代理定义。"""
defaults_root = tmp_path / "defaults"
_write_current_persona(defaults_root)
_write_subagent(
defaults_root,
"custom-reader",
description="Custom reader subagent.",
body="Only inspect custom media signals.",
)
manager = AgentRuntimeManager(
agent_root_dir=tmp_path / "agent",
bundled_defaults_dir=defaults_root,
)
runtime_config = manager.load_runtime_config()
copied_path = (
tmp_path
/ "agent"
/ "runtime"
/ "subagents"
/ "custom-reader"
/ "SUBAGENT.md"
)
subagent = runtime_config.available_subagents[0]
assert copied_path.exists()
assert subagent.subagent_id == "custom-reader"
assert subagent.description == "Custom reader subagent."
assert subagent.include_tags == ["media", "web"]
assert subagent.exclude_tags == ["write", "message"]
assert "Only inspect custom media signals." in subagent.text
def test_runtime_updates_bundled_subagent_when_version_increases(tmp_path):
"""内置子代理版本升高时应覆盖用户目录里的旧版副本。"""
defaults_root = tmp_path / "defaults"
_write_current_persona(defaults_root)
_write_subagent(
defaults_root,
"custom-reader",
version=1,
body="version one",
)
manager = AgentRuntimeManager(
agent_root_dir=tmp_path / "agent",
bundled_defaults_dir=defaults_root,
)
manager.ensure_layout()
_write_subagent(
defaults_root,
"custom-reader",
version=2,
body="version two",
)
manager.invalidate_cache()
runtime_config = manager.load_runtime_config()
subagent = runtime_config.available_subagents[0]
assert subagent.version == 2
assert "version two" in subagent.text
def test_middleware_profiles_are_loaded_from_runtime_config(monkeypatch, tmp_path):
"""子代理中间件应从运行时 YAML 定义生成 profile。"""
defaults_root = tmp_path / "defaults"
_write_current_persona(defaults_root)
_write_subagent(
defaults_root,
"custom-reader",
description="Runtime custom reader.",
body="Runtime-only prompt.",
)
manager = AgentRuntimeManager(
agent_root_dir=tmp_path / "agent",
bundled_defaults_dir=defaults_root,
)
monkeypatch.setattr(subagent_module, "agent_runtime_manager", manager)
subagent_module._builtin_subagent_profiles.cache_clear()
subagent_module.builtin_subagent_names.cache_clear()
profiles = subagent_module._builtin_subagent_profiles()
assert [profile.name for profile in profiles] == ["custom-reader"]
assert profiles[0].description == "Runtime custom reader."
assert profiles[0].include_tags == frozenset({"media", "web"})
assert "Runtime-only prompt." in profiles[0].prompt

View File

@@ -1,71 +1,103 @@
import asyncio
from unittest import TestCase
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from app import schemas
from app.api.endpoints.plugin import plugin_history
from app.api.endpoints.system import sync_plugin_market_from_wiki
class PluginEndpointTest(TestCase):
def test_plugin_history_merges_remote_metadata():
"""
已安装插件点击更新说明时,接口会按需合并远端仓库中的更新记录。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
history={"v1.1.0": "- 新增更新说明"},
system_version=">=2.0.0",
system_version_compatible=True,
has_update=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
def test_plugin_history_merges_remote_metadata(self):
"""
已安装插件点击更新说明时,接口会按需合并远端仓库中的更新记录。
"""
try:
from app import schemas
from app.api.endpoints.plugin import plugin_history
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
history={},
)
market_plugin = schemas.Plugin(
id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
history={"v1.1.0": "- 新增更新说明"},
system_version=">=2.0.0",
system_version_compatible=True,
has_update=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
assert result.repo_url == "https://github.com/demo/plugins"
assert result.history == {"v1.1.0": "- 新增更新说明"}
assert result.system_version == ">=2.0.0"
assert result.has_update
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
self.assertEqual("https://github.com/demo/plugins", result.repo_url)
self.assertEqual({"v1.1.0": "- 新增更新说明"}, result.history)
self.assertEqual(">=2.0.0", result.system_version)
self.assertTrue(result.has_update)
def test_plugin_history_returns_installed_plugin_when_remote_missing():
"""
远端仓库不可用时,接口仍返回本地已安装插件信息,前端可继续展示兜底状态。
"""
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
def test_plugin_history_returns_installed_plugin_when_remote_missing(self):
"""
远端仓库不可用时,接口仍返回本地已安装插件信息,前端可继续展示兜底状态。
"""
try:
from app import schemas
from app.api.endpoints.plugin import plugin_history
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_name="Demo Plugin",
plugin_version="1.0.0",
installed=True,
)
plugin_manager = MagicMock()
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.get_local_repo_plugins.return_value = []
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[])
assert result.id == "DemoPlugin"
assert result.history == {}
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(plugin_history("DemoPlugin", None, True))
self.assertEqual("DemoPlugin", result.id)
self.assertEqual({}, result.history)
def test_sync_plugin_market_from_wiki_merges_and_deduplicates_repos():
"""
Wiki 同步会提取标记区域内的 GitHub 仓库地址,并与本地配置合并去重后写入。
"""
markdown = """
<!-- plugin-market-repos:start -->
- https://github.com/local/existing/
- https://github.com/wiki/new-repo/
- https://github.com/wiki/new-repo
<!-- plugin-market-repos:end -->
- https://github.com/wiki/ignored-outside-marker
"""
response = MagicMock(status_code=200, text=markdown)
request_utils = MagicMock()
request_utils.get_res = AsyncMock(return_value=response)
with (
patch("app.api.endpoints.system.AsyncRequestUtils", return_value=request_utils),
patch("app.api.endpoints.system.settings.PLUGIN_MARKET", "https://github.com/local/existing"),
patch(
"app.core.config.Settings.update_setting",
autospec=True,
return_value=(True, ""),
) as update_setting,
patch("app.api.endpoints.system.eventmanager.async_send_event", new=AsyncMock()) as send_event,
):
result = asyncio.run(sync_plugin_market_from_wiki(None, None))
assert result.success
assert result.data["repos"] == [
"https://github.com/local/existing",
"https://github.com/wiki/new-repo",
]
assert result.data["added_count"] == 1
assert result.data["total_count"] == 2
update_setting.assert_called_once_with(
ANY,
"PLUGIN_MARKET",
"https://github.com/local/existing,https://github.com/wiki/new-repo",
)
send_event.assert_awaited_once()

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.13.8-1'
FRONTEND_VERSION = 'v2.13.8'
APP_VERSION = 'v2.13.9'
FRONTEND_VERSION = 'v2.13.9'