mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-14 07:26:50 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93713ba662 | ||
|
|
0f3e9574ab | ||
|
|
25dbe491fe | ||
|
|
d6db0a86f6 | ||
|
|
6e8bce3d04 | ||
|
|
ed1e31d379 | ||
|
|
3a233014de | ||
|
|
13cb1683ff | ||
|
|
ac9132cba6 |
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -53,7 +53,7 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
|
||||
清理错误信息中的敏感字段,避免回显密钥。
|
||||
"""
|
||||
if not message:
|
||||
return "LLM 调用失败"
|
||||
return "LLM 没有返回任何内容"
|
||||
|
||||
sanitized = message
|
||||
if api_key:
|
||||
@@ -68,6 +68,14 @@ def _sanitize_llm_test_error(message: str, api_key: Optional[str] = None) -> str
|
||||
"Authorization: ***",
|
||||
sanitized,
|
||||
)
|
||||
|
||||
normalized_message = sanitized.lower().replace("_", "").replace(" ", "")
|
||||
if "str" in normalized_message and "modeldump" in normalized_message:
|
||||
return (
|
||||
"服务返回内容不是兼容的模型响应,"
|
||||
"请检查基础地址是否填写为 API Base URL,不要填写网页地址或完整的 "
|
||||
"chat/completions 路径"
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ def plugin_dashboard(
|
||||
plugin_id: str,
|
||||
user_agent: Annotated[str | None, Header()] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
) -> schemas.PluginDashboard:
|
||||
) -> Optional[schemas.PluginDashboard]:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union, Annotated
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
@@ -62,6 +66,100 @@ _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:
|
||||
@@ -285,6 +383,98 @@ def _build_nettest_rules() -> list[dict[str, Any]]:
|
||||
return rules
|
||||
|
||||
|
||||
def _collect_named_log_files(name: str) -> list[Path]:
|
||||
"""
|
||||
根据前端传入的日志标识收集可下载日志文件。
|
||||
|
||||
`moviepilot` 固定表示主程序日志,其余标识按插件 ID 处理并映射到
|
||||
`plugins/<plugin_id>.log*`。这里不接收路径或后缀,避免下载入口变成任意
|
||||
日志文件选择器;滚动日志按当前文件优先、备份文件按修改时间倒序补足。
|
||||
"""
|
||||
normalized_name = (name or "").strip().lower()
|
||||
if not normalized_name or not _LOG_DOWNLOAD_NAME_PATTERN.fullmatch(normalized_name):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
log_root = settings.LOG_PATH
|
||||
if normalized_name == "moviepilot":
|
||||
log_dir = log_root
|
||||
log_prefix = "moviepilot.log"
|
||||
else:
|
||||
log_dir = log_root / "plugins"
|
||||
log_prefix = f"{normalized_name}.log"
|
||||
|
||||
if not log_dir.exists() or not log_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
current_log = log_dir / log_prefix
|
||||
backup_logs = [
|
||||
item
|
||||
for item in log_dir.iterdir()
|
||||
if item.is_file() and item.name.startswith(f"{log_prefix}.")
|
||||
]
|
||||
backup_logs.sort(key=lambda item: item.stat().st_mtime, reverse=True)
|
||||
|
||||
log_files = []
|
||||
if current_log.exists() and current_log.is_file():
|
||||
log_files.append(current_log)
|
||||
log_files.extend(backup_logs)
|
||||
return log_files[:_LOG_DOWNLOAD_LIMIT]
|
||||
|
||||
|
||||
def _verify_log_resource_superuser(
|
||||
token_payload: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
) -> schemas.TokenPayload:
|
||||
"""
|
||||
校验日志资源访问权限。
|
||||
|
||||
日志接口通过浏览器新窗口和 EventSource 访问,不能依赖普通 API 请求头;
|
||||
因此这里复用资源 Cookie 完成身份识别,再额外要求管理员身份,避免普通
|
||||
登录用户读取可能包含敏感信息的日志。
|
||||
"""
|
||||
if not token_payload.super_user:
|
||||
raise HTTPException(status_code=403, detail="用户权限不足")
|
||||
return token_payload
|
||||
|
||||
|
||||
async def _build_log_zip_response(name: str) -> StreamingResponse:
|
||||
"""
|
||||
将指定日志标识对应的日志文件打包为 zip 响应。
|
||||
|
||||
打包前逐个校验文件仍位于日志根目录内,避免符号链接或并发文件变更绕过
|
||||
`name` 到固定目录的映射约束。zip 内使用日志根目录相对路径,便于区分
|
||||
主程序日志与插件日志。
|
||||
"""
|
||||
log_files = _collect_named_log_files(name)
|
||||
if not log_files:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
log_root = settings.LOG_PATH
|
||||
async_log_root = AsyncPath(log_root)
|
||||
zip_buffer = io.BytesIO()
|
||||
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
safe_name = (name or "logs").strip().lower() or "logs"
|
||||
zip_stem = f"{safe_name}-logs-{filename_time}"
|
||||
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for log_file in log_files:
|
||||
if not await SecurityUtils.async_is_safe_path(
|
||||
base_path=async_log_root,
|
||||
user_path=AsyncPath(log_file),
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
arcname = f"{zip_stem}/{log_file.name}"
|
||||
archive.write(log_file, arcname)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
headers = {
|
||||
"Content-Disposition": f'attachment; filename="{zip_stem}.zip"'
|
||||
}
|
||||
return StreamingResponse(
|
||||
iter([zip_buffer.getvalue()]),
|
||||
media_type="application/zip",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _validate_nettest_url(url: str) -> Optional[str]:
|
||||
"""
|
||||
对实际请求地址做基础安全校验。
|
||||
@@ -625,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)
|
||||
@@ -705,7 +962,7 @@ async def get_logging(
|
||||
request: Request,
|
||||
length: Optional[int] = 50,
|
||||
logfile: Optional[str] = "moviepilot.log",
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
|
||||
):
|
||||
"""
|
||||
实时获取系统日志
|
||||
@@ -814,6 +1071,17 @@ async def get_logging(
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/logging/download/{name}", summary="下载日志")
|
||||
async def download_logging(
|
||||
name: str,
|
||||
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
|
||||
):
|
||||
"""
|
||||
按日志标识下载主程序或插件滚动日志,返回 zip 文件。
|
||||
"""
|
||||
return await _build_log_zip_response(name)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
|
||||
)
|
||||
|
||||
@@ -1080,7 +1080,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
||||
return dashboard_meta
|
||||
|
||||
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:
|
||||
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> Optional[schemas.PluginDashboard]:
|
||||
"""
|
||||
获取插件仪表盘
|
||||
"""
|
||||
@@ -1113,6 +1113,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||
if dashboard is None:
|
||||
return None
|
||||
if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3:
|
||||
logger.error(f"插件 {pid} 返回的仪表盘数据格式错误")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"插件 {pid} 返回的仪表盘数据格式错误")
|
||||
cols, attrs, elements = dashboard
|
||||
return schemas.PluginDashboard(
|
||||
id=pid,
|
||||
|
||||
@@ -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` 合并去重后写入配置 |
|
||||
|
||||
### 插件补充接口
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
---
|
||||
name: create-moviepilot-plugin
|
||||
version: 1
|
||||
version: 2
|
||||
description: >-
|
||||
Use this skill when the user asks to create, modify, debug, validate, or
|
||||
scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development,
|
||||
_PluginBase implementations, package.v2.json/package.json market metadata,
|
||||
plugins.v2/plugins source layout, PLUGIN_LOCAL_REPO_PATHS local plugin
|
||||
sources, plugin APIs, forms, pages, dashboards, commands, services, workflow
|
||||
actions, agent tools, and local install/reload flows. Also use for Chinese
|
||||
requests mentioning 编写插件、本地插件源、插件开发、V2插件、插件市场、本地安装插件、插件热加载.
|
||||
sources, plugin APIs, Vuetify JSON forms/pages/dashboards, Vue module
|
||||
federation remote components, get_render_mode, get_sidebar_nav, plugin
|
||||
sidebar pages, commands, services, workflow actions, agent tools, and local
|
||||
install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源,
|
||||
插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面.
|
||||
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins
|
||||
---
|
||||
|
||||
@@ -22,21 +24,45 @@ a local plugin source and installed into the running MoviePilot instance.
|
||||
- Host plugin contract: `app/plugins/__init__.py`, especially `_PluginBase`.
|
||||
- Host plugin discovery, local source sync, install, reload: `app/core/plugin.py`
|
||||
and `app/helper/plugin.py`.
|
||||
- Host plugin endpoints, API auth, static files, remotes, and sidebar nav:
|
||||
`app/api/endpoints/plugin.py`.
|
||||
- Local development note: `docs/development-setup.md`.
|
||||
- Plugin repository conventions: `MoviePilot-Plugins` uses `plugins.v2/` with
|
||||
`package.v2.json` for V2 plugins; legacy or cross-generation entries may use
|
||||
`plugins/` with `package.json`.
|
||||
- When working in or from `MoviePilot-Plugins`, read its `README.md`,
|
||||
`docs/Repository_Guide.md`, and `docs/V2_Plugin_Development.md`. For
|
||||
scenario-specific extensions, read the matching `docs/faq/*.md`.
|
||||
- When the plugin uses Vue federation, also read
|
||||
`MoviePilot-Frontend/docs/module-federation-guide.md`,
|
||||
`MoviePilot-Frontend/docs/federation-troubleshooting.md`,
|
||||
`MoviePilot-Frontend/src/utils/federationLoader.ts`, and
|
||||
`MoviePilot-Frontend/src/pages/plugin-app.vue`.
|
||||
- Repository boundaries: `MoviePilot` owns runtime loading, API registration,
|
||||
events, services, data, and permissions; `MoviePilot-Frontend` owns plugin UI
|
||||
rendering, federation loading, and sidebar pages; `MoviePilot-Plugins` owns
|
||||
plugin source, icons, package indexes, and release metadata.
|
||||
|
||||
## Pre-Flight
|
||||
|
||||
1. Understand the user request: plugin purpose, trigger mode, configuration,
|
||||
output UI, whether it needs a scheduler, API, command, workflow action, or
|
||||
agent tool.
|
||||
2. Inspect existing plugins before creating a new one:
|
||||
2. Run the UI Mode Selection Gate before writing any UI code.
|
||||
- If the user already explicitly chose JSON config/Vuetify JSON or Vue
|
||||
federation, follow that choice.
|
||||
- If the plugin has any UI surface and the user has not chosen a mode, ask
|
||||
them to choose between the two modes below and wait for the answer before
|
||||
implementing UI files or schemas.
|
||||
- Do not silently default to either mode just because one seems easier.
|
||||
3. Inspect existing plugins before creating a new one:
|
||||
- Local runtime examples: `app/plugins/<plugin>/__init__.py`
|
||||
- Market/local source candidates: use `query_market_plugins` when the
|
||||
running instance is available.
|
||||
3. Determine the target source path:
|
||||
- For Vue federation examples, prefer current compliant plugins such as
|
||||
`MoviePilot-Plugins/plugins.v2/agenttokens/` and the frontend example
|
||||
`MoviePilot-Frontend/examples/plugin-component/`.
|
||||
4. Determine the target source path:
|
||||
- Query `PLUGIN_LOCAL_REPO_PATHS` with `query_system_settings` when possible.
|
||||
- If exactly one local plugin repository is configured, prefer that path.
|
||||
- If several are configured, choose the one the user named; otherwise ask
|
||||
@@ -47,11 +73,41 @@ a local plugin source and installed into the running MoviePilot instance.
|
||||
plugin source loader. Create that source directory and write the plugin
|
||||
under it; do not write new plugin source directly into `app/plugins/`
|
||||
unless the user explicitly asks for a runtime-only experiment.
|
||||
4. Choose the plugin ID:
|
||||
5. Choose the plugin ID:
|
||||
- Class name is the plugin ID, for example `MyNotifier`.
|
||||
- Directory name is the class name lowercased, for example `mynotifier`.
|
||||
- Avoid collisions with installed or market plugins unless the user is
|
||||
explicitly modifying that plugin.
|
||||
- Do not hardcode the original plugin ID for data/config namespaces when the
|
||||
plugin may support clones; use `self.__class__.__name__`.
|
||||
|
||||
## UI Mode Selection Gate
|
||||
|
||||
MoviePilot plugin UI has exactly two implementation modes. Make the user choose
|
||||
one whenever the request includes configuration, detail pages, dashboards,
|
||||
sidebar pages, or any other plugin UI and the mode is not already explicit.
|
||||
|
||||
Ask a concise question like:
|
||||
|
||||
```text
|
||||
这个插件 UI 用哪种方式实现?
|
||||
1. JSON 配置:后端返回 Vuetify JSON,适合普通配置表单、简单详情页和轻量仪表板。
|
||||
2. 联邦 UI:独立 Vue 远程组件,适合复杂交互、自定义布局、侧栏全页或多页面。
|
||||
```
|
||||
|
||||
Selection rules:
|
||||
|
||||
- **JSON config / Vuetify JSON**: implement `get_form()`, `get_page()`, and
|
||||
`get_dashboard()` with JSON component schemas. No frontend build or
|
||||
`dist/assets/remoteEntry.js` is needed.
|
||||
- **Federation UI / Vue remote component**: implement `get_render_mode()`,
|
||||
expose Vue components through Vite federation, build frontend assets into the
|
||||
plugin directory, and use `get_sidebar_nav()` only when a sidebar page is
|
||||
requested.
|
||||
- If the plugin truly has no user-facing UI, state that no UI mode is needed
|
||||
and implement only the backend extension points the request requires.
|
||||
- Backend-only work may proceed while waiting only if it cannot constrain or
|
||||
preclude either UI mode.
|
||||
|
||||
## Local Source Layout
|
||||
|
||||
@@ -67,6 +123,25 @@ Default to V2 layout for new local plugins:
|
||||
└── ... # helper modules, schemas, static assets
|
||||
```
|
||||
|
||||
For a Vue federation plugin, the runtime requirement is the built remote assets
|
||||
under the plugin directory:
|
||||
|
||||
```text
|
||||
plugins.v2/<plugin_id_lower>/
|
||||
├── __init__.py
|
||||
├── dist/
|
||||
│ └── assets/
|
||||
│ ├── remoteEntry.js
|
||||
│ └── ... # JS/CSS/assets referenced by remoteEntry
|
||||
├── package.json # optional frontend build project metadata
|
||||
├── vite.config.js # optional frontend build config
|
||||
└── src/ # optional source, not required at runtime
|
||||
```
|
||||
|
||||
Do not rely on frontend source files at runtime. If the source is kept in the
|
||||
plugin repository for maintainability, still build and ship the `dist/assets`
|
||||
files required by `remoteEntry.js`.
|
||||
|
||||
Only use the legacy layout when the user explicitly needs it:
|
||||
|
||||
```text
|
||||
@@ -107,21 +182,29 @@ Rules:
|
||||
|
||||
- The package object key must match the plugin class name.
|
||||
- `version` must match `plugin_version`.
|
||||
- `name`, `description`, `icon`, `author`, and `level` should match the plugin
|
||||
class attributes when those attributes exist.
|
||||
- `name`, `description`, `icon`, `author`, `labels`, and `level` should match
|
||||
the plugin class attributes when those attributes exist (`plugin_name`,
|
||||
`plugin_desc`, `plugin_icon`, `plugin_author`, `plugin_label`, `auth_level`).
|
||||
- `history` should record user-readable changes for each published version.
|
||||
- Use `system_version` when the plugin depends on a host capability introduced
|
||||
in a specific MoviePilot version.
|
||||
in a specific MoviePilot version, including new backend APIs, helpers, events,
|
||||
Vue federation behavior, sidebar nav, dashboard behavior, or agent tools.
|
||||
- Use `"release": true` only when the plugin is intentionally distributed by a
|
||||
GitHub Release archive.
|
||||
- New plugin entries should usually be appended to the package index so they
|
||||
appear as newer marketplace items.
|
||||
- Do not add dependencies unless they are actually required. If
|
||||
`requirements.txt` changes, the user must reinstall the plugin; hot reload is
|
||||
not enough to install dependencies.
|
||||
- Plugin dependencies are installed into the shared MoviePilot Python
|
||||
environment. Do not pin or downgrade packages already provided by MoviePilot
|
||||
unless the user has explicitly accepted the compatibility risk.
|
||||
|
||||
## Implementation Skeleton
|
||||
|
||||
Implement all abstract methods from `_PluginBase`. All new public classes,
|
||||
public methods, and public functions need Chinese docstrings.
|
||||
Implement all abstract methods from `_PluginBase`. All new functions and
|
||||
methods need Chinese docstrings; public classes, public methods, and public
|
||||
functions are a hard review gate.
|
||||
|
||||
```python
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -224,7 +307,8 @@ Use only the extension points the requested plugin actually needs:
|
||||
- Notification: use `post_message()` instead of directly calling message
|
||||
modules.
|
||||
- APIs: return route definitions from `get_api()`; default auth is `apikey`
|
||||
when `auth` is omitted.
|
||||
when `auth` is omitted. Vue component APIs should normally use
|
||||
`auth: "bear"` and be called through the `api` prop passed by the frontend.
|
||||
- Commands: return slash-command definitions from `get_command()` and dispatch
|
||||
through MoviePilot events.
|
||||
- Services: return scheduler services from `get_service()` and always clean
|
||||
@@ -239,6 +323,118 @@ Use only the extension points the requested plugin actually needs:
|
||||
satisfy the request. Return `("vue", "<compiled-assets-path>")` and include
|
||||
built frontend assets in the plugin directory.
|
||||
|
||||
## Vue Federation UI
|
||||
|
||||
Use Vue federation only after the Pre-Flight UI decision says JSON schema is not
|
||||
enough. A Vue plugin must align backend methods, built files, and federation
|
||||
exposes.
|
||||
|
||||
Backend requirements:
|
||||
|
||||
```python
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_render_mode() -> Tuple[str, str]:
|
||||
"""声明插件使用 Vue 联邦组件渲染。"""
|
||||
return "vue", "dist/assets"
|
||||
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""Vue 模式下返回默认配置模型。"""
|
||||
return [], self._current_config()
|
||||
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""Vue 模式下详情页由远程 Page 组件渲染。"""
|
||||
return []
|
||||
```
|
||||
|
||||
When the plugin needs a main-layout sidebar page, also implement:
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
"""声明插件在主界面左侧导航栏中的全页入口。"""
|
||||
if not self.get_state():
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "我的插件",
|
||||
"icon": "mdi-puzzle",
|
||||
"section": "system",
|
||||
"permission": "manage",
|
||||
"order": 10,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Sidebar rules:
|
||||
|
||||
- Sidebar entries are only aggregated for enabled plugins whose
|
||||
`get_render_mode()` returns `"vue"`.
|
||||
- `section` must be one of `start`, `discovery`, `subscribe`, `organize`,
|
||||
`system`; invalid values fall back to `system`.
|
||||
- `permission` may be `subscribe`, `discovery`, `search`, `manage`, or `admin`;
|
||||
invalid values are ignored.
|
||||
- `nav_key` defaults to `main` and must not contain `/`, `?`, `#`, or spaces.
|
||||
- Multiple sidebar entries are allowed; each entry needs a stable `nav_key`.
|
||||
|
||||
Frontend federation requirements:
|
||||
|
||||
```js
|
||||
federation({
|
||||
name: 'MyPlugin',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: { requiredVersion: false, generate: false },
|
||||
vuetify: { requiredVersion: false, generate: false, singleton: true },
|
||||
'vuetify/styles': { requiredVersion: false, generate: false, singleton: true },
|
||||
},
|
||||
format: 'esm',
|
||||
})
|
||||
```
|
||||
|
||||
Build requirements:
|
||||
|
||||
- Set Vite `build.target` to `esnext` because federation uses top-level await.
|
||||
- Use `cssCodeSplit: true` and scoped/component-local styles where possible.
|
||||
- Build with the frontend project's documented command, then keep `remoteEntry.js`
|
||||
and every JS/CSS/asset file it references under `dist/assets`.
|
||||
- Do not add frontend runtime dependencies to the plugin Python
|
||||
`requirements.txt`; keep frontend dependencies in the frontend build project.
|
||||
|
||||
Component contracts:
|
||||
|
||||
- `Page` renders the plugin detail dialog and may emit `action`, `switch`, and
|
||||
`close`.
|
||||
- `Config` renders plugin settings, receives `initialConfig` and `api`, and
|
||||
emits `save`, `close`, and `switch`.
|
||||
- `Dashboard` receives `config` and `allowRefresh`.
|
||||
- `AppPage` renders the main-layout sidebar page and receives `api`, `pluginId`,
|
||||
and `navKey`.
|
||||
- For sidebar `nav_key=main`, the frontend loads `./AppPage` then `./Page`.
|
||||
- For any other `nav_key`, the frontend loads `./AppPage{PascalCase(nav_key)}`,
|
||||
then `./AppPage`, then `./Page`. Examples: `settings -> AppPageSettings`,
|
||||
`my_tool -> AppPageMyTool`.
|
||||
- A single `AppPage` may branch on `navKey`, or separate
|
||||
`AppPage{PascalCase}` files may be exposed for specific entries.
|
||||
|
||||
Vue API calls:
|
||||
|
||||
- Define frontend-facing plugin APIs with `auth: "bear"`.
|
||||
- Call them with the injected API object, for example
|
||||
`props.api.get(\`plugin/${props.pluginId}/history\`)`.
|
||||
- Do not pass `settings.API_TOKEN` into Vue components for browser-side calls.
|
||||
|
||||
## Local Install And Reload
|
||||
|
||||
1. After writing files in a configured local plugin repository, call
|
||||
@@ -258,6 +454,17 @@ Use only the extension points the requested plugin actually needs:
|
||||
and package version are consistent.
|
||||
- Confirm every public class, public method, and public function has a Chinese
|
||||
docstring.
|
||||
- Confirm every newly written function or method has a Chinese docstring, even
|
||||
when it is private helper code.
|
||||
- For Vue federation plugins, confirm `get_render_mode()` returns
|
||||
`("vue", "dist/assets")` or the actual built asset path, and that
|
||||
`dist/assets/remoteEntry.js` exists.
|
||||
- For sidebar plugins, confirm the plugin is enabled, `get_state()` returns
|
||||
`True`, `get_sidebar_nav()` returns valid items, and matching `AppPage`
|
||||
exposes exist for all non-main `nav_key` values or a generic `AppPage` handles
|
||||
them.
|
||||
- Confirm frontend-facing API routes use `auth: "bear"` and browser code calls
|
||||
them through the provided `api` prop.
|
||||
- Keep external HTTP calls behind MoviePilot utilities and avoid real network
|
||||
calls in tests.
|
||||
- If the plugin has non-trivial logic, add or update pytest-native tests. Plugin
|
||||
@@ -266,6 +473,27 @@ Use only the extension points the requested plugin actually needs:
|
||||
- Run the narrowest allowed validation for the touched area. In this repository,
|
||||
follow `docs/rules/03-commands.md`; for plugin-only repositories, follow their
|
||||
own documented validation commands.
|
||||
- For plugin repository Python changes, use the host Python environment when
|
||||
possible and run at least syntax compilation for touched plugin files.
|
||||
- For Vue federation changes, run the frontend project's documented typecheck
|
||||
and build commands when available, then verify the built assets were copied to
|
||||
the plugin directory.
|
||||
|
||||
## Vue Federation Troubleshooting
|
||||
|
||||
- `GET /api/v1/plugin/remotes?token=moviepilot` should include the plugin with a
|
||||
URL ending in `/plugin/file/<plugin_id_lower>/<dist_path>/remoteEntry.js`.
|
||||
- `GET /api/v1/plugin/sidebar_nav` should include sidebar entries for enabled
|
||||
Vue plugins with valid `nav_key`, `section`, and `permission`.
|
||||
- If the console says `Module name 'vue' does not resolve to a valid URL`, check
|
||||
the federation `shared` config and use `requiredVersion: false`.
|
||||
- If the console says top-level await is unavailable, set `build.target` to
|
||||
`esnext`.
|
||||
- If dynamic import fails, check the remote file request status, the computed
|
||||
`remoteEntry.js` path, and whether the installed runtime plugin directory
|
||||
actually contains the built assets.
|
||||
- If a sidebar page is blank, check the expose name resolution for the current
|
||||
`nav_key` and fallbacks (`AppPage{PascalCase}` -> `AppPage` -> `Page`).
|
||||
|
||||
## Final Report
|
||||
|
||||
@@ -273,5 +501,7 @@ Report:
|
||||
|
||||
- Plugin ID, source path, and runtime path if installed.
|
||||
- Package file changed (`package.v2.json` or `package.json`).
|
||||
- UI mode used (`vuetify` JSON or `vue` federation), and for Vue plugins the
|
||||
exposed components and built asset path.
|
||||
- Whether the plugin was installed or reloaded.
|
||||
- Validation commands run, or why validation was not run.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
26
tests/test_llm_endpoint_error_messages.py
Normal file
26
tests/test_llm_endpoint_error_messages.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.api.endpoints import llm as llm_endpoint
|
||||
|
||||
|
||||
def test_llm_test_maps_internal_model_dump_error_to_base_url_hint():
|
||||
"""LLM 测试遇到 SDK 内部响应解析错误时应提示检查基础地址。"""
|
||||
with patch.object(llm_endpoint.settings, "AI_AGENT_ENABLE", True), patch.object(
|
||||
llm_endpoint.settings, "LLM_PROVIDER", "openai"
|
||||
), patch.object(llm_endpoint.settings, "LLM_MODEL", "gpt-4o-mini"), patch.object(
|
||||
llm_endpoint.settings, "LLM_API_KEY", "sk-test"
|
||||
), patch.object(
|
||||
llm_endpoint.settings, "LLM_BASE_URL", "https://example.com/not-api"
|
||||
), patch.object(
|
||||
llm_endpoint.LLMHelper,
|
||||
"test_current_settings",
|
||||
AsyncMock(side_effect=RuntimeError("'str' object has no attribute 'model_dump'")),
|
||||
create=True,
|
||||
):
|
||||
resp = asyncio.run(llm_endpoint.llm_test(_="token"))
|
||||
|
||||
assert not resp.success
|
||||
assert "基础地址" in resp.message
|
||||
assert "API Base URL" in resp.message
|
||||
assert "model_dump" not in resp.message
|
||||
65
tests/test_plugin_dashboard.py
Normal file
65
tests/test_plugin_dashboard.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.plugin import PluginManager
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_manager() -> Iterator[PluginManager]:
|
||||
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
|
||||
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||
manager = PluginManager()
|
||||
yield manager
|
||||
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||
|
||||
|
||||
def _plugin_with_dashboard(dashboard: Any) -> SimpleNamespace:
|
||||
"""构造仅包含仪表板接口的插件实例。"""
|
||||
return SimpleNamespace(
|
||||
plugin_name="演示插件",
|
||||
get_render_mode=lambda: ("vue", "dist/assets"),
|
||||
get_dashboard=lambda key=None, user_agent=None: dashboard,
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_dashboard_keeps_vue_elements_none(plugin_manager: PluginManager) -> None:
|
||||
"""Vue 仪表板的 elements=None 应原样返回给前端渲染远程组件。"""
|
||||
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
|
||||
(
|
||||
{"cols": 12},
|
||||
{"title": "演示插件", "border": True},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
dashboard = plugin_manager.get_plugin_dashboard("DemoPlugin", "usage")
|
||||
|
||||
assert dashboard.id == "DemoPlugin"
|
||||
assert dashboard.render_mode == "vue"
|
||||
assert dashboard.cols == {"cols": 12}
|
||||
assert dashboard.attrs == {"title": "演示插件", "border": True}
|
||||
assert dashboard.elements is None
|
||||
|
||||
|
||||
def test_plugin_dashboard_returns_none_when_plugin_has_no_dashboard(plugin_manager: PluginManager) -> None:
|
||||
"""插件声明当前无仪表板时应返回 None,而不是触发解包异常。"""
|
||||
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(None)
|
||||
|
||||
assert plugin_manager.get_plugin_dashboard("DemoPlugin", "missing") is None
|
||||
|
||||
|
||||
def test_plugin_dashboard_rejects_invalid_dashboard_shape(plugin_manager: PluginManager) -> None:
|
||||
"""非空但不符合三元组契约的仪表板数据应返回服务端错误。"""
|
||||
plugin_manager.running_plugins["DemoPlugin"] = _plugin_with_dashboard(
|
||||
{"cols": {}, "attrs": {}, "elements": []}
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
plugin_manager.get_plugin_dashboard("DemoPlugin", "broken")
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "仪表盘数据格式错误" in exc_info.value.detail
|
||||
@@ -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()
|
||||
|
||||
116
tests/test_system_log_download.py
Normal file
116
tests/test_system_log_download.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""系统日志查看与下载接口的权限和打包行为测试。"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def test_logging_routes_use_superuser_dependency():
|
||||
"""日志查看和下载路由都必须绑定管理员依赖,避免普通登录用户读取敏感日志。"""
|
||||
routes = {route.path: route for route in system_endpoint.router.routes}
|
||||
|
||||
logging_dependencies = {dependency.call for dependency in routes["/logging"].dependant.dependencies}
|
||||
download_dependencies = {dependency.call for dependency in routes["/logging/download/{name}"].dependant.dependencies}
|
||||
|
||||
assert system_endpoint._verify_log_resource_superuser in logging_dependencies
|
||||
assert system_endpoint._verify_log_resource_superuser in download_dependencies
|
||||
|
||||
|
||||
def test_log_resource_dependency_rejects_normal_user():
|
||||
"""日志资源依赖必须拒绝非管理员 resource token。"""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
system_endpoint._verify_log_resource_superuser(
|
||||
SimpleNamespace(super_user=False),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.fixture(name="isolated_log_path")
|
||||
def fixture_isolated_log_path(monkeypatch, tmp_path: Path) -> Path:
|
||||
"""将日志目录隔离到临时目录,避免测试读取或打包真实运行日志。"""
|
||||
config_path = tmp_path / "config"
|
||||
log_path = config_path / "logs"
|
||||
log_path.mkdir(parents=True)
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(config_path))
|
||||
return log_path
|
||||
|
||||
|
||||
def test_logging_requires_superuser_dependency(monkeypatch, isolated_log_path):
|
||||
"""实时日志查看接口必须通过管理员依赖,普通资源令牌不能直接读取日志。"""
|
||||
(isolated_log_path / "moviepilot.log").write_text("hello\n", encoding="utf-8")
|
||||
response = asyncio.run(
|
||||
system_endpoint.get_logging(
|
||||
request=SimpleNamespace(is_disconnected=lambda: False),
|
||||
length=-1,
|
||||
logfile="moviepilot.log",
|
||||
_=SimpleNamespace(id=1, name="admin", is_superuser=True),
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(response, Response)
|
||||
|
||||
|
||||
def test_download_moviepilot_logs_packages_latest_ten_log_files(isolated_log_path):
|
||||
"""传入 moviepilot 时下载主程序滚动日志,最多打包 10 个文件。"""
|
||||
for index in range(12):
|
||||
(isolated_log_path / f"moviepilot.log.{index}").write_text(f"old-{index}", encoding="utf-8")
|
||||
(isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8")
|
||||
(isolated_log_path / "moviepilot.txt").write_text("ignored", encoding="utf-8")
|
||||
(isolated_log_path / "plugins").mkdir()
|
||||
(isolated_log_path / "plugins" / "demo.log").write_text("plugin", encoding="utf-8")
|
||||
|
||||
response = asyncio.run(system_endpoint.download_logging(name="moviepilot", _=SimpleNamespace()))
|
||||
body = asyncio.run(_read_streaming_body(response))
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
||||
names = archive.namelist()
|
||||
|
||||
moviepilot_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
|
||||
assert response.media_type == "application/zip"
|
||||
assert 'filename="moviepilot-logs-' in response.headers["Content-Disposition"]
|
||||
assert "moviepilot-moviepilot-logs" not in response.headers["Content-Disposition"]
|
||||
assert len(names) == 10
|
||||
assert f"{moviepilot_zip_root}/moviepilot.log" in names
|
||||
assert "moviepilot.log" not in names
|
||||
assert "plugins/demo.log" not in names
|
||||
assert "moviepilot.txt" not in names
|
||||
|
||||
|
||||
def test_download_plugin_logs_packages_plugin_files_only(isolated_log_path):
|
||||
"""传入插件 ID 时只下载该插件滚动日志,最多打包 10 个文件。"""
|
||||
plugin_dir = isolated_log_path / "plugins"
|
||||
plugin_dir.mkdir()
|
||||
for index in range(11):
|
||||
(plugin_dir / f"demoplugin.log.{index}").write_text(f"plugin-{index}", encoding="utf-8")
|
||||
(plugin_dir / "demoplugin.log").write_text("current", encoding="utf-8")
|
||||
(plugin_dir / "other.log").write_text("other", encoding="utf-8")
|
||||
(isolated_log_path / "moviepilot.log").write_text("main", encoding="utf-8")
|
||||
|
||||
response = asyncio.run(system_endpoint.download_logging(name="DemoPlugin", _=SimpleNamespace()))
|
||||
body = asyncio.run(_read_streaming_body(response))
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(body)) as archive:
|
||||
names = archive.namelist()
|
||||
|
||||
plugin_zip_root = response.headers["Content-Disposition"].split('filename="', 1)[1].removesuffix('.zip"')
|
||||
assert len(names) == 10
|
||||
assert f"{plugin_zip_root}/demoplugin.log" in names
|
||||
assert "demoplugin.log" not in names
|
||||
assert "plugins/demoplugin.log" not in names
|
||||
assert "plugins/other.log" not in names
|
||||
assert "moviepilot.log" not in names
|
||||
|
||||
|
||||
async def _read_streaming_body(response) -> bytes:
|
||||
"""读取 StreamingResponse 内容,便于断言 zip 文件条目。"""
|
||||
return b"".join([chunk async for chunk in response.body_iterator])
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.13.8'
|
||||
APP_VERSION = 'v2.13.8-1'
|
||||
FRONTEND_VERSION = 'v2.13.8'
|
||||
|
||||
Reference in New Issue
Block a user