mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-14 20:21:19 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0dcf6660f | ||
|
|
4e3eddec10 | ||
|
|
93713ba662 | ||
|
|
0f3e9574ab | ||
|
|
25dbe491fe |
@@ -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.
|
||||
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal file
35
app/agent/defaults/subagents/general-purpose/SUBAGENT.md
Normal 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.
|
||||
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/media-researcher/SUBAGENT.md
Normal 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.
|
||||
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal file
19
app/agent/defaults/subagents/moviepilot-explorer/SUBAGENT.md
Normal 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.
|
||||
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal file
18
app/agent/defaults/subagents/resource-searcher/SUBAGENT.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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,
|
||||
|
||||
167
app/agent/tools/impl/recognize_captcha.py
Normal file
167
app/agent/tools/impl/recognize_captcha.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
存储配置
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
134
tests/test_agent_recognize_captcha_tool.py
Normal file
134
tests/test_agent_recognize_captcha_tool.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
172
tests/test_agent_subagent_runtime.py
Normal file
172
tests/test_agent_subagent_runtime.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user