mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 03:27:31 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f32b48d8c | ||
|
|
c6bd396794 | ||
|
|
7985268f10 | ||
|
|
865635c59d | ||
|
|
759b9e47eb | ||
|
|
63e492be7c | ||
|
|
b5eca00ba3 | ||
|
|
b80642f56f | ||
|
|
6d3161f3cb | ||
|
|
ea8d1f8d26 | ||
|
|
5654512d41 | ||
|
|
a52e1fdc1c | ||
|
|
44db45ea28 | ||
|
|
83409c1439 | ||
|
|
cf6c73d85b | ||
|
|
987c1722d7 | ||
|
|
57220c93db | ||
|
|
4abae809c2 | ||
|
|
7fe7be6d71 | ||
|
|
4b1df72a4a | ||
|
|
a23ac6c56d |
@@ -71,6 +71,7 @@ test_*
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
.build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
rust/**/target/
|
||||
|
||||
@@ -2,6 +2,10 @@ name: MoviePilot Builder Beta
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -16,6 +20,25 @@ jobs:
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Wiki Plugin Market
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jxxghp/MoviePilot-Wiki
|
||||
ref: main
|
||||
path: .build/moviepilot-wiki
|
||||
sparse-checkout: plugin.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Plugin Market Default
|
||||
id: plugin_market
|
||||
run: |
|
||||
python3 -m scripts.generate_plugin_market_default \
|
||||
--wiki-file .build/moviepilot-wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
|
||||
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -55,6 +78,8 @@ jobs:
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'version.py'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,6 +27,39 @@ jobs:
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout Wiki Plugin Market
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: jxxghp/MoviePilot-Wiki
|
||||
ref: main
|
||||
path: .build/moviepilot-wiki
|
||||
sparse-checkout: plugin.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate Plugin Market Default
|
||||
id: plugin_market
|
||||
run: |
|
||||
python3 -m scripts.generate_plugin_market_default \
|
||||
--wiki-file .build/moviepilot-wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
wiki_commit=$(git -C .build/moviepilot-wiki rev-parse HEAD)
|
||||
echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release Snapshot
|
||||
id: release_snapshot
|
||||
env:
|
||||
WIKI_COMMIT: ${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add app/core/config.py
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "build(plugin-market): sync default from MoviePilot-Wiki@${WIKI_COMMIT:0:12}"
|
||||
fi
|
||||
echo "release_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
@@ -65,7 +102,10 @@ jobs:
|
||||
linux/arm64/v8
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }}
|
||||
org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }}
|
||||
cache-from: type=gha,scope=moviepilot-docker,version=2
|
||||
cache-to: type=gha,scope=moviepilot-docker,mode=max,version=2
|
||||
|
||||
@@ -78,9 +118,9 @@ jobs:
|
||||
|
||||
# 使用 || 作为分隔符,同时获取 commit 消息和作者 GitHub 用户名
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" HEAD)
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" "${SOURCE_COMMIT}")
|
||||
else
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" ${PREVIOUS_TAG}..HEAD)
|
||||
COMMITS=$(git log --pretty=format:"%s||%an" "${PREVIOUS_TAG}..${SOURCE_COMMIT}")
|
||||
fi
|
||||
|
||||
# 分类收集 commit 消息(使用关联数组去重)
|
||||
@@ -188,6 +228,17 @@ jobs:
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish Release Tag
|
||||
env:
|
||||
RELEASE_COMMIT: ${{ steps.release_snapshot.outputs.release_commit }}
|
||||
run: |
|
||||
tag_name="v${{ env.app_version }}"
|
||||
if git show-ref --verify --quiet "refs/tags/${tag_name}"; then
|
||||
git tag -d "$tag_name"
|
||||
fi
|
||||
git tag "$tag_name" "$RELEASE_COMMIT"
|
||||
git push origin "refs/tags/${tag_name}"
|
||||
|
||||
- name: Generate Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
+101
-5
@@ -129,9 +129,18 @@ class _SessionUsageSnapshot:
|
||||
last_output_tokens: int = 0
|
||||
last_total_tokens: int = 0
|
||||
last_context_usage_ratio: Optional[float] = None
|
||||
last_cache_usage_available: bool = False
|
||||
last_cache_read_input_tokens: int = 0
|
||||
last_cache_write_input_tokens: int = 0
|
||||
last_uncached_input_tokens: int = 0
|
||||
last_cache_hit_ratio: Optional[float] = None
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
total_cache_read_input_tokens: int = 0
|
||||
total_cache_write_input_tokens: int = 0
|
||||
total_uncached_input_tokens: int = 0
|
||||
cache_usage_available: bool = False
|
||||
model_call_count: int = 0
|
||||
last_updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -144,9 +153,23 @@ class _SessionUsageSnapshot:
|
||||
"last_output_tokens": self.last_output_tokens,
|
||||
"last_total_tokens": self.last_total_tokens,
|
||||
"last_context_usage_ratio": self.last_context_usage_ratio,
|
||||
"last_cache_usage_available": self.last_cache_usage_available,
|
||||
"last_cache_read_input_tokens": self.last_cache_read_input_tokens,
|
||||
"last_cache_write_input_tokens": self.last_cache_write_input_tokens,
|
||||
"last_uncached_input_tokens": self.last_uncached_input_tokens,
|
||||
"last_cache_hit_ratio": self.last_cache_hit_ratio,
|
||||
"total_input_tokens": self.total_input_tokens,
|
||||
"total_output_tokens": self.total_output_tokens,
|
||||
"total_tokens": self.total_tokens,
|
||||
"total_cache_read_input_tokens": self.total_cache_read_input_tokens,
|
||||
"total_cache_write_input_tokens": self.total_cache_write_input_tokens,
|
||||
"total_uncached_input_tokens": self.total_uncached_input_tokens,
|
||||
"cache_usage_available": self.cache_usage_available,
|
||||
"total_cache_hit_ratio": (
|
||||
self.total_cache_read_input_tokens / self.total_input_tokens
|
||||
if self.cache_usage_available and self.total_input_tokens
|
||||
else None
|
||||
),
|
||||
"model_call_count": self.model_call_count,
|
||||
"last_updated_at": self.last_updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if self.last_updated_at
|
||||
@@ -318,13 +341,19 @@ class MoviePilotAgent:
|
||||
"""
|
||||
构造可展示的 Agent 会话消息。
|
||||
"""
|
||||
normalized_content = content or ""
|
||||
return {
|
||||
"id": f"{role}-{uuid.uuid4().hex}",
|
||||
"role": role,
|
||||
"content": content or "",
|
||||
"content": normalized_content,
|
||||
"createdAt": cls._current_timestamp_ms(),
|
||||
"status": status,
|
||||
"tools": [],
|
||||
"segments": (
|
||||
[{"type": "text", "content": normalized_content}]
|
||||
if normalized_content
|
||||
else []
|
||||
),
|
||||
"attachments": attachments or [],
|
||||
"choices": [],
|
||||
}
|
||||
@@ -548,9 +577,33 @@ class MoviePilotAgent:
|
||||
self._session_usage.last_output_tokens = output_tokens
|
||||
self._session_usage.last_total_tokens = total_tokens
|
||||
self._session_usage.last_context_usage_ratio = usage.get("context_usage_ratio")
|
||||
cache_usage_available = bool(usage.get("cache_usage_available"))
|
||||
cache_read_input_tokens = self._coerce_int(
|
||||
usage.get("cache_read_input_tokens")
|
||||
) or 0
|
||||
cache_write_input_tokens = self._coerce_int(
|
||||
usage.get("cache_write_input_tokens")
|
||||
) or 0
|
||||
uncached_input_tokens = self._coerce_int(
|
||||
usage.get("uncached_input_tokens")
|
||||
)
|
||||
if uncached_input_tokens is None:
|
||||
uncached_input_tokens = max(
|
||||
input_tokens - cache_read_input_tokens - cache_write_input_tokens,
|
||||
0,
|
||||
)
|
||||
self._session_usage.last_cache_usage_available = cache_usage_available
|
||||
self._session_usage.last_cache_read_input_tokens = cache_read_input_tokens
|
||||
self._session_usage.last_cache_write_input_tokens = cache_write_input_tokens
|
||||
self._session_usage.last_uncached_input_tokens = uncached_input_tokens
|
||||
self._session_usage.last_cache_hit_ratio = usage.get("cache_hit_ratio")
|
||||
self._session_usage.total_input_tokens += input_tokens
|
||||
self._session_usage.total_output_tokens += output_tokens
|
||||
self._session_usage.total_tokens += total_tokens
|
||||
self._session_usage.total_cache_read_input_tokens += cache_read_input_tokens
|
||||
self._session_usage.total_cache_write_input_tokens += cache_write_input_tokens
|
||||
self._session_usage.total_uncached_input_tokens += uncached_input_tokens
|
||||
self._session_usage.cache_usage_available |= cache_usage_available
|
||||
|
||||
def get_session_status(self) -> dict[str, Any]:
|
||||
if not self._session_usage.model:
|
||||
@@ -584,6 +637,17 @@ class MoviePilotAgent:
|
||||
input_tokens=self._session_usage.total_input_tokens,
|
||||
output_tokens=self._session_usage.total_output_tokens,
|
||||
total_tokens=self._session_usage.total_tokens,
|
||||
cache_read_input_tokens=self._session_usage.total_cache_read_input_tokens,
|
||||
cache_write_input_tokens=self._session_usage.total_cache_write_input_tokens,
|
||||
uncached_input_tokens=self._session_usage.total_uncached_input_tokens,
|
||||
cache_hit_ratio=(
|
||||
self._session_usage.total_cache_read_input_tokens
|
||||
/ self._session_usage.total_input_tokens
|
||||
if self._session_usage.cache_usage_available
|
||||
and self._session_usage.total_input_tokens
|
||||
else None
|
||||
),
|
||||
cache_usage_available=self._session_usage.cache_usage_available,
|
||||
model_call_count=self._session_usage.model_call_count,
|
||||
success=success,
|
||||
error=error,
|
||||
@@ -730,6 +794,7 @@ class MoviePilotAgent:
|
||||
use_proxy=settings.LLM_USE_PROXY,
|
||||
thinking_level=settings.LLM_THINKING_LEVEL,
|
||||
api_protocol=settings.LLM_API_PROTOCOL,
|
||||
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
|
||||
)
|
||||
selected_event = await eventmanager.async_send_event(
|
||||
ChainEventType.AgentLLMProvider,
|
||||
@@ -773,6 +838,9 @@ class MoviePilotAgent:
|
||||
api_protocol = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "api_protocol")
|
||||
) or settings.LLM_API_PROTOCOL
|
||||
web_search_mode = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "web_search_mode")
|
||||
) or settings.LLM_WEB_SEARCH_MODE
|
||||
selected_provider_id = self._clean_optional_text(
|
||||
self._get_event_value(resolved_data, "selected_provider_id")
|
||||
)
|
||||
@@ -799,6 +867,7 @@ class MoviePilotAgent:
|
||||
"use_proxy": bool(use_proxy),
|
||||
"thinking_level": thinking_level,
|
||||
"api_protocol": api_protocol,
|
||||
"web_search_mode": web_search_mode,
|
||||
}
|
||||
return self._llm_runtime_config
|
||||
|
||||
@@ -808,7 +877,17 @@ class MoviePilotAgent:
|
||||
:param streaming: 是否启用流式输出
|
||||
"""
|
||||
runtime_config = await self._resolve_llm_runtime_config()
|
||||
return await LLMHelper.get_llm(streaming=streaming, **runtime_config)
|
||||
return await LLMHelper.get_llm(
|
||||
streaming=streaming,
|
||||
prompt_cache_key=self._build_prompt_cache_key(),
|
||||
**runtime_config,
|
||||
)
|
||||
|
||||
def _build_prompt_cache_key(self) -> str:
|
||||
"""生成不暴露用户标识、且在同一会话内稳定的提示词缓存键。"""
|
||||
cache_identity = f"{self.user_id or ''}\x00{self.session_id}"
|
||||
digest = hashlib.sha256(cache_identity.encode("utf-8")).hexdigest()[:32]
|
||||
return f"moviepilot-agent-{digest}"
|
||||
|
||||
@classmethod
|
||||
def _has_image_input_content(cls, content: Any) -> bool:
|
||||
@@ -1006,6 +1085,13 @@ class MoviePilotAgent:
|
||||
allow_message_tools=self.allow_message_tools,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter_local_web_search_tools(tools: List, enabled: bool) -> List:
|
||||
"""按联网搜索策略保留或移除本地 search_web 工具。"""
|
||||
if enabled:
|
||||
return tools
|
||||
return [tool for tool in tools if getattr(tool, "name", None) != "search_web"]
|
||||
|
||||
def _refresh_tool_context(self, values: Dict[str, object]) -> None:
|
||||
"""
|
||||
刷新本轮工具共享上下文。
|
||||
@@ -1035,6 +1121,7 @@ class MoviePilotAgent:
|
||||
bool(runtime_config.get("use_proxy")),
|
||||
runtime_config.get("thinking_level"),
|
||||
runtime_config.get("api_protocol"),
|
||||
runtime_config.get("web_search_mode"),
|
||||
)
|
||||
|
||||
async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]:
|
||||
@@ -1165,6 +1252,8 @@ class MoviePilotAgent:
|
||||
# LLM 模型(用于 agent 执行)
|
||||
agent_model = await self._initialize_llm(streaming=streaming)
|
||||
self._sync_model_profile(agent_model)
|
||||
server_tools = LLMHelper.get_server_tools(agent_model)
|
||||
use_local_web_search = LLMHelper.should_use_local_web_search(agent_model)
|
||||
|
||||
# 为内部模型调用准备非流式 LLM,避免与用户流式回复复用同一实例。
|
||||
non_streaming_model = (
|
||||
@@ -1174,7 +1263,10 @@ class MoviePilotAgent:
|
||||
)
|
||||
|
||||
# 工具列表
|
||||
tools = self._initialize_tools()
|
||||
tools = self._filter_local_web_search_tools(
|
||||
self._initialize_tools(),
|
||||
enabled=use_local_web_search,
|
||||
)
|
||||
tools.extend(await self._initialize_mcp_tools())
|
||||
skills_middleware = SkillsMiddleware(
|
||||
sources=[str(agent_runtime_manager.skills_dir)],
|
||||
@@ -1192,11 +1284,15 @@ class MoviePilotAgent:
|
||||
activity_log_tools = list(
|
||||
getattr(activity_log_middleware, "tools", []) or []
|
||||
)
|
||||
subagent_tools = self._initialize_subagent_tools()
|
||||
subagent_tools = self._filter_local_web_search_tools(
|
||||
self._initialize_subagent_tools(),
|
||||
enabled=use_local_web_search,
|
||||
)
|
||||
subagent_tools.extend(await self._initialize_subagent_mcp_tools())
|
||||
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
|
||||
model=non_streaming_model,
|
||||
tools=subagent_tools,
|
||||
server_tools=server_tools,
|
||||
stream_handler=self.stream_handler,
|
||||
)
|
||||
max_tools = settings.LLM_MAX_TOOLS
|
||||
@@ -1271,7 +1367,7 @@ class MoviePilotAgent:
|
||||
|
||||
agent = create_agent(
|
||||
model=agent_model,
|
||||
tools=[*tools, *skill_tools, *activity_log_tools],
|
||||
tools=[*tools, *skill_tools, *activity_log_tools, *server_tools],
|
||||
system_prompt=system_prompt,
|
||||
middleware=middlewares,
|
||||
checkpointer=InMemorySaver(),
|
||||
|
||||
+317
-109
@@ -5,13 +5,17 @@ import inspect
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.agent.llm.server_tools import ServerToolResolution
|
||||
|
||||
|
||||
class LLMTestError(RuntimeError):
|
||||
"""LLM 测试调用异常,附带请求耗时。"""
|
||||
@@ -224,74 +228,76 @@ def _is_deepseek_thinking_enabled(model_name: str | None, extra_body: Any) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def _patch_deepseek_reasoning_content_support():
|
||||
"""
|
||||
修补 langchain-deepseek 在 tool-call 场景下遗漏 reasoning_content 回传的问题。
|
||||
|
||||
DeepSeek thinking mode 要求:若 assistant 历史消息包含 tool_calls,
|
||||
后续请求中必须带回该条消息的顶层 reasoning_content。
|
||||
某些 langchain-deepseek 版本虽然能从响应中拿到 reasoning_content,
|
||||
但不会在重放消息历史时写回请求载荷,导致 400。
|
||||
"""
|
||||
try:
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
except Exception as err:
|
||||
logger.debug(f"跳过 langchain-deepseek reasoning_content 修补:{err}")
|
||||
def _patch_interleaved_reasoning_request_support(
|
||||
model_cls: Any,
|
||||
*,
|
||||
patch_marker: str,
|
||||
thinking_filter: Any = None,
|
||||
normalize_deepseek_messages: bool = False,
|
||||
inject_missing_as_empty: bool = False,
|
||||
) -> None:
|
||||
"""为兼容模型统一补回工具调用历史中的 reasoning_content。"""
|
||||
if getattr(model_cls, patch_marker, False):
|
||||
return
|
||||
|
||||
if getattr(ChatDeepSeek, "_moviepilot_reasoning_content_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatDeepSeek, "_get_request_payload", None)
|
||||
original_get_request_payload = getattr(model_cls, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-deepseek 缺少 _get_request_payload,无法修补 reasoning_content")
|
||||
logger.warning(
|
||||
f"{model_cls.__name__} 缺少 _get_request_payload,无法修补 reasoning_content"
|
||||
)
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
if "messages" not in payload:
|
||||
return payload
|
||||
|
||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||
if not _is_deepseek_thinking_enabled(
|
||||
extra_body = getattr(self, "extra_body", None)
|
||||
if extra_body is None:
|
||||
extra_body = (getattr(self, "model_kwargs", None) or {}).get("extra_body")
|
||||
if thinking_filter is not None and not thinking_filter(
|
||||
getattr(self, "model_name", None) or getattr(self, "model", None),
|
||||
extra_body,
|
||||
):
|
||||
return payload
|
||||
|
||||
# 从原始 LangChain 消息中取回 reasoning_content。上游 payload 构造器
|
||||
# 不会自动透传这个 DeepSeek 扩展字段。
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
|
||||
for i, message in enumerate(payload["messages"]):
|
||||
if message["role"] == "tool" and isinstance(message["content"], list):
|
||||
message["content"] = json.dumps(message["content"])
|
||||
elif message["role"] == "assistant":
|
||||
if isinstance(message["content"], list):
|
||||
# DeepSeek API 要求 assistant content 为字符串;工具场景下
|
||||
# LangChain 可能保留为内容块列表,这里只拼回可见文本块。
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
for block in message["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
message["content"] = "".join(text_parts) if text_parts else ""
|
||||
|
||||
# DeepSeek thinking mode 要求历史 assistant 消息携带
|
||||
# reasoning_content,即便本地只保存到了 additional_kwargs。
|
||||
if (
|
||||
"reasoning_content" not in message
|
||||
and i < len(messages)
|
||||
and isinstance(messages[i], AIMessage)
|
||||
for index, payload_message in enumerate(payload["messages"]):
|
||||
if normalize_deepseek_messages:
|
||||
if payload_message.get("role") == "tool" and isinstance(
|
||||
payload_message.get("content"), list
|
||||
):
|
||||
message["reasoning_content"] = messages[i].additional_kwargs.get(
|
||||
"reasoning_content", ""
|
||||
payload_message["content"] = json.dumps(payload_message["content"])
|
||||
elif payload_message.get("role") == "assistant" and isinstance(
|
||||
payload_message.get("content"), list
|
||||
):
|
||||
payload_message["content"] = "".join(
|
||||
block.get("text", "")
|
||||
for block in payload_message["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
)
|
||||
|
||||
if (
|
||||
payload_message.get("role") != "assistant"
|
||||
or index >= len(messages)
|
||||
or not isinstance(messages[index], AIMessage)
|
||||
or "reasoning_content" in payload_message
|
||||
):
|
||||
continue
|
||||
|
||||
reasoning_content = messages[index].additional_kwargs.get(
|
||||
"reasoning_content"
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
payload_message["reasoning_content"] = reasoning_content
|
||||
elif inject_missing_as_empty:
|
||||
payload_message["reasoning_content"] = ""
|
||||
|
||||
return payload
|
||||
|
||||
ChatDeepSeek._get_request_payload = _patched_get_request_payload
|
||||
ChatDeepSeek._moviepilot_reasoning_content_patched = True
|
||||
logger.debug("已修补 langchain-deepseek thinking tool-call 的 reasoning_content 回传兼容性")
|
||||
model_cls._get_request_payload = _patched_get_request_payload
|
||||
setattr(model_cls, patch_marker, True)
|
||||
|
||||
|
||||
def _patch_openai_interleaved_reasoning_content_support():
|
||||
@@ -352,42 +358,10 @@ def _patch_openai_interleaved_reasoning_content_support():
|
||||
|
||||
_openai_base._moviepilot_reasoning_response_patched = True
|
||||
|
||||
if getattr(ChatOpenAI, "_moviepilot_interleaved_reasoning_patched", False):
|
||||
return
|
||||
|
||||
original_get_request_payload = getattr(ChatOpenAI, "_get_request_payload", None)
|
||||
if not callable(original_get_request_payload):
|
||||
logger.warning("langchain-openai 缺少 _get_request_payload,无法修补 reasoning_content")
|
||||
return
|
||||
|
||||
@wraps(original_get_request_payload)
|
||||
def _patched_get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = original_get_request_payload(self, input_, stop=stop, **kwargs)
|
||||
if "messages" not in payload:
|
||||
return payload
|
||||
|
||||
messages = self._convert_input(input_).to_messages()
|
||||
for index, payload_message in enumerate(payload["messages"]):
|
||||
if (
|
||||
payload_message.get("role") != "assistant"
|
||||
or index >= len(messages)
|
||||
or not isinstance(messages[index], AIMessage)
|
||||
or "reasoning_content" in payload_message
|
||||
):
|
||||
continue
|
||||
|
||||
reasoning_content = messages[index].additional_kwargs.get(
|
||||
"reasoning_content"
|
||||
)
|
||||
if reasoning_content is not None:
|
||||
# 只回传模型真实返回过的思考字段。普通模型没有该字段时,
|
||||
# payload 保持原样,不额外塞未知参数。
|
||||
payload_message["reasoning_content"] = reasoning_content
|
||||
|
||||
return payload
|
||||
|
||||
ChatOpenAI._get_request_payload = _patched_get_request_payload
|
||||
ChatOpenAI._moviepilot_interleaved_reasoning_patched = True
|
||||
_patch_interleaved_reasoning_request_support(
|
||||
ChatOpenAI,
|
||||
patch_marker="_moviepilot_interleaved_reasoning_patched",
|
||||
)
|
||||
logger.debug("已修补 langchain-openai interleaved reasoning_content 回传兼容性")
|
||||
|
||||
|
||||
@@ -840,6 +814,91 @@ class LLMHelper:
|
||||
headers["User-Agent"] = normalized_user_agent
|
||||
return headers or None
|
||||
|
||||
@staticmethod
|
||||
def _matches_endpoint_host(base_url: str | None, expected_host: str) -> bool:
|
||||
"""严格匹配官方 API 主机,避免向兼容端点发送供应商专属参数。"""
|
||||
try:
|
||||
return (urlsplit(str(base_url or "")).hostname or "").lower() == expected_host
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _build_openai_prompt_cache_options(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
base_url: str | None,
|
||||
use_responses_api: bool | None,
|
||||
prompt_cache_key: str | None,
|
||||
default_headers: dict[str, str] | None,
|
||||
model_kwargs: dict[str, Any],
|
||||
) -> tuple[dict[str, str] | None, dict[str, Any]]:
|
||||
"""为 OpenAI 与 xAI 官方端点构造稳定提示词缓存路由参数。"""
|
||||
cache_key = str(prompt_cache_key or "").strip()
|
||||
headers = dict(default_headers or {})
|
||||
kwargs = dict(model_kwargs)
|
||||
provider_name = str(provider or "").strip().lower()
|
||||
if not cache_key:
|
||||
return headers or None, kwargs
|
||||
|
||||
is_openai = provider_name in {"chatgpt", "openai"} and cls._matches_endpoint_host(
|
||||
base_url,
|
||||
"api.openai.com",
|
||||
)
|
||||
is_xai = provider_name == "xai" and cls._matches_endpoint_host(
|
||||
base_url,
|
||||
"api.x.ai",
|
||||
)
|
||||
if not is_openai and not is_xai:
|
||||
return headers or None, kwargs
|
||||
|
||||
if is_xai and use_responses_api is not True:
|
||||
headers["x-grok-conv-id"] = cache_key
|
||||
return headers, kwargs
|
||||
|
||||
extra_body = dict(kwargs.get("extra_body") or {})
|
||||
extra_body["prompt_cache_key"] = cache_key
|
||||
kwargs["extra_body"] = extra_body
|
||||
return headers or None, kwargs
|
||||
|
||||
@staticmethod
|
||||
def _with_prompt_cache_control(
|
||||
model_cls: type,
|
||||
cache_control: dict[str, str],
|
||||
) -> type:
|
||||
"""创建在最终模型绑定阶段保留缓存控制参数的适配类。"""
|
||||
|
||||
class PromptCachingModel(model_cls):
|
||||
"""在 LangChain 工具绑定后仍保留提示词缓存参数的模型适配器。"""
|
||||
|
||||
def bind(self, **kwargs: Any) -> Any:
|
||||
"""绑定调用参数,并补入当前 Provider 的默认缓存控制。"""
|
||||
kwargs.setdefault("cache_control", dict(cache_control))
|
||||
return super().bind(**kwargs)
|
||||
|
||||
PromptCachingModel.__name__ = f"PromptCaching{model_cls.__name__}"
|
||||
return PromptCachingModel
|
||||
|
||||
@classmethod
|
||||
def _use_anthropic_prompt_cache(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
runtime: dict[str, Any],
|
||||
prompt_cache_key: str | None,
|
||||
) -> bool:
|
||||
"""判断当前运行时是否为可安全启用缓存的 Anthropic 官方端点。"""
|
||||
return (
|
||||
bool(str(prompt_cache_key or "").strip())
|
||||
and str(provider or "").strip().lower() == "anthropic"
|
||||
and str(runtime.get("runtime") or "").strip().lower()
|
||||
== "anthropic_compatible"
|
||||
and cls._matches_endpoint_host(
|
||||
runtime.get("base_url"),
|
||||
"api.anthropic.com",
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _should_use_openai_responses_api(
|
||||
cls,
|
||||
@@ -931,6 +990,36 @@ class LLMHelper:
|
||||
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
|
||||
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
|
||||
|
||||
@staticmethod
|
||||
def _attach_server_tool_metadata(
|
||||
model: Any,
|
||||
resolution: "ServerToolResolution",
|
||||
) -> None:
|
||||
"""把服务端工具解析结果挂到模型实例,供 Agent 组装工具列表。"""
|
||||
metadata = {
|
||||
"mode": resolution.mode,
|
||||
"use_local_web_search": resolution.use_local_web_search,
|
||||
"server_tools": [dict(tool) for tool in resolution.server_tools],
|
||||
"available": resolution.available,
|
||||
"reason": resolution.reason,
|
||||
}
|
||||
try:
|
||||
setattr(model, "_moviepilot_server_tool_metadata", metadata)
|
||||
except Exception:
|
||||
object.__setattr__(model, "_moviepilot_server_tool_metadata", metadata)
|
||||
|
||||
@staticmethod
|
||||
def get_server_tools(model: Any) -> list[dict[str, Any]]:
|
||||
"""读取模型已解析的服务端工具定义。"""
|
||||
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
|
||||
return [dict(tool) for tool in metadata.get("server_tools", [])]
|
||||
|
||||
@staticmethod
|
||||
def should_use_local_web_search(model: Any) -> bool:
|
||||
"""判断当前模型是否应保留 MoviePilot 本地联网搜索工具。"""
|
||||
metadata = getattr(model, "_moviepilot_server_tool_metadata", {}) or {}
|
||||
return bool(metadata.get("use_local_web_search", True))
|
||||
|
||||
@classmethod
|
||||
def _resolve_thinking_level(
|
||||
cls,
|
||||
@@ -979,6 +1068,8 @@ class LLMHelper:
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
prompt_cache_key: str | None = None,
|
||||
):
|
||||
"""
|
||||
获取LLM实例
|
||||
@@ -999,6 +1090,10 @@ class LLMHelper:
|
||||
(auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
|
||||
仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API,
|
||||
``chat_completions`` 强制走 Chat Completions,``auto`` 保持原有自动判断。
|
||||
:param web_search_mode: 联网搜索模式
|
||||
(local/builtin/auto/disabled)。未显式传入时使用配置项
|
||||
``LLM_WEB_SEARCH_MODE``。
|
||||
:param prompt_cache_key: 同一 Agent 会话内稳定且脱敏的提示词缓存路由键。
|
||||
:return: LLM实例
|
||||
"""
|
||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower()
|
||||
@@ -1037,6 +1132,40 @@ class LLMHelper:
|
||||
user_agent=user_agent_value,
|
||||
)
|
||||
model_name = runtime.get("model_id") or model_name
|
||||
from app.agent.llm.server_tools import (
|
||||
ServerToolRegistry,
|
||||
ServerToolUnavailableError,
|
||||
)
|
||||
|
||||
server_tool_resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
mode=(
|
||||
web_search_mode
|
||||
if web_search_mode is not None
|
||||
else getattr(settings, "LLM_WEB_SEARCH_MODE", "local")
|
||||
),
|
||||
api_protocol=(
|
||||
api_protocol
|
||||
if api_protocol is not None
|
||||
else settings.LLM_API_PROTOCOL
|
||||
),
|
||||
base_url=runtime.get("base_url"),
|
||||
)
|
||||
if (
|
||||
server_tool_resolution.mode == "builtin"
|
||||
and not server_tool_resolution.available
|
||||
):
|
||||
raise ServerToolUnavailableError(
|
||||
provider=provider_name,
|
||||
model=str(model_name or ""),
|
||||
tool_id="web_search",
|
||||
)
|
||||
effective_api_protocol = (
|
||||
server_tool_resolution.required_api_protocol
|
||||
if server_tool_resolution.required_api_protocol == "responses"
|
||||
else api_protocol
|
||||
)
|
||||
default_headers = cls._build_openai_default_headers(
|
||||
runtime.get("default_headers"),
|
||||
user_agent=user_agent_value,
|
||||
@@ -1050,7 +1179,15 @@ class LLMHelper:
|
||||
provider=provider_name,
|
||||
model=model_name,
|
||||
runtime=runtime,
|
||||
api_protocol=api_protocol,
|
||||
api_protocol=effective_api_protocol,
|
||||
)
|
||||
default_headers, openai_model_kwargs = cls._build_openai_prompt_cache_options(
|
||||
provider=provider_name,
|
||||
base_url=runtime.get("base_url"),
|
||||
use_responses_api=use_responses_api,
|
||||
prompt_cache_key=prompt_cache_key,
|
||||
default_headers=default_headers,
|
||||
model_kwargs=thinking_kwargs,
|
||||
)
|
||||
llm_proxy = _resolve_llm_proxy(use_proxy)
|
||||
|
||||
@@ -1072,10 +1209,22 @@ class LLMHelper:
|
||||
client_args=_build_google_client_args(llm_proxy),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
elif runtime["runtime"] == "deepseek":
|
||||
elif (
|
||||
runtime["runtime"] == "deepseek"
|
||||
and server_tool_resolution.client_adapter != "openai_responses"
|
||||
and use_responses_api is not True
|
||||
):
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
_patch_deepseek_reasoning_content_support()
|
||||
_patch_interleaved_reasoning_request_support(
|
||||
ChatDeepSeek,
|
||||
patch_marker="_moviepilot_reasoning_content_patched",
|
||||
thinking_filter=lambda model_name, extra_body: (
|
||||
_is_deepseek_thinking_enabled(model_name, extra_body)
|
||||
),
|
||||
normalize_deepseek_messages=True,
|
||||
inject_missing_as_empty=True,
|
||||
)
|
||||
model = ChatDeepSeek(
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
@@ -1093,6 +1242,16 @@ class LLMHelper:
|
||||
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
bedrock_model_cls = ChatBedrockConverse
|
||||
if (
|
||||
str(prompt_cache_key or "").strip()
|
||||
and runtime.get("supports_prompt_cache")
|
||||
):
|
||||
bedrock_model_cls = cls._with_prompt_cache_control(
|
||||
ChatBedrockConverse,
|
||||
{"type": "default"},
|
||||
)
|
||||
|
||||
aws_region = runtime.get("aws_region") or "us-east-1"
|
||||
aws_auth = runtime.get("aws_auth") or {}
|
||||
# Bearer 认证需要跳过 SigV4 签名并注入 Authorization 头,SigV4 认证
|
||||
@@ -1105,7 +1264,7 @@ class LLMHelper:
|
||||
use_proxy=use_proxy,
|
||||
read_timeout=settings.LLM_TOOL_TIMEOUT,
|
||||
)
|
||||
model = ChatBedrockConverse(
|
||||
model = bedrock_model_cls(
|
||||
model_id=model_name,
|
||||
client=bedrock_client,
|
||||
temperature=temperature_value,
|
||||
@@ -1114,7 +1273,18 @@ class LLMHelper:
|
||||
elif runtime["runtime"] in {"anthropic_compatible", "copilot_anthropic"}:
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
model = ChatAnthropic(
|
||||
anthropic_model_cls = ChatAnthropic
|
||||
if cls._use_anthropic_prompt_cache(
|
||||
provider=provider_name,
|
||||
runtime=runtime,
|
||||
prompt_cache_key=prompt_cache_key,
|
||||
):
|
||||
anthropic_model_cls = cls._with_prompt_cache_control(
|
||||
ChatAnthropic,
|
||||
{"type": "ephemeral"},
|
||||
)
|
||||
|
||||
model = anthropic_model_cls(
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
base_url=runtime["base_url"],
|
||||
@@ -1154,7 +1324,8 @@ class LLMHelper:
|
||||
),
|
||||
default_headers=default_headers,
|
||||
use_responses_api=use_responses_api,
|
||||
**thinking_kwargs,
|
||||
output_version=("responses/v1" if use_responses_api else None),
|
||||
**openai_model_kwargs,
|
||||
)
|
||||
|
||||
# 优先使用 provider / models.dev 目录中的上下文上限,减少用户手填成本。
|
||||
@@ -1181,6 +1352,7 @@ class LLMHelper:
|
||||
}
|
||||
|
||||
cls._attach_runtime_metadata(model, runtime)
|
||||
cls._attach_server_tool_metadata(model, server_tool_resolution)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
@@ -1241,12 +1413,14 @@ class LLMHelper:
|
||||
temperature: Optional[float] = None,
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
|
||||
|
||||
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
|
||||
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
|
||||
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
|
||||
"""
|
||||
provider_name = provider if provider is not None else settings.LLM_PROVIDER
|
||||
model_name = model if model is not None else settings.LLM_MODEL
|
||||
@@ -1262,6 +1436,7 @@ class LLMHelper:
|
||||
"user_agent": user_agent,
|
||||
"use_proxy": use_proxy,
|
||||
"api_protocol": api_protocol,
|
||||
"web_search_mode": web_search_mode,
|
||||
}
|
||||
if temperature is not None:
|
||||
llm_kwargs["temperature"] = temperature
|
||||
@@ -1310,7 +1485,7 @@ class LLMHelper:
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
return await LLMProviderManager().list_models(
|
||||
models = await LLMProviderManager().list_models(
|
||||
provider_id=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
@@ -1319,16 +1494,25 @@ class LLMHelper:
|
||||
use_proxy=use_proxy,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
models,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"LLM provider 目录不可用,回退旧模型列表逻辑: {err}")
|
||||
if provider == "google":
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_google_models(
|
||||
api_key or "",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
]
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
[
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_google_models(
|
||||
api_key or "",
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
],
|
||||
base_url=base_url,
|
||||
)
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
@@ -1342,16 +1526,40 @@ class LLMHelper:
|
||||
)
|
||||
except Exception:
|
||||
model_list_base_url = base_url
|
||||
return [
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_openai_compatible_models(
|
||||
provider,
|
||||
api_key or "",
|
||||
model_list_base_url,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
]
|
||||
return self._attach_server_tool_capabilities(
|
||||
provider,
|
||||
[
|
||||
{"id": model_id, "name": model_id}
|
||||
for model_id in await self._get_openai_compatible_models(
|
||||
provider,
|
||||
api_key or "",
|
||||
model_list_base_url,
|
||||
user_agent=user_agent,
|
||||
use_proxy=use_proxy,
|
||||
)
|
||||
],
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _attach_server_tool_capabilities(
|
||||
provider: str,
|
||||
models: List[dict[str, Any]],
|
||||
base_url: Optional[str] = None,
|
||||
) -> List[dict[str, Any]]:
|
||||
"""为模型目录附加通用服务端工具能力元数据。"""
|
||||
from app.agent.llm.server_tools import ServerToolRegistry
|
||||
|
||||
result = []
|
||||
for item in models:
|
||||
model_item = dict(item)
|
||||
model_item["server_tools"] = ServerToolRegistry.list_capabilities(
|
||||
provider=provider,
|
||||
model=str(model_item.get("id") or ""),
|
||||
base_url=base_url,
|
||||
)
|
||||
result.append(model_item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def _get_google_models(api_key: str, use_proxy: bool | None = None) -> List[str]:
|
||||
|
||||
+40
-10
@@ -1665,6 +1665,20 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
await self.get_models_dev_data(use_proxy=use_proxy)
|
||||
).get(models_dev_provider_id, {}) or {}
|
||||
|
||||
@staticmethod
|
||||
def _models_dev_model_candidates(
|
||||
provider_id: str,
|
||||
model_id: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""生成模型目录查询候选,兼容 Provider 添加的透明模型前缀。"""
|
||||
candidates = [model_id]
|
||||
if model_id.startswith("models/"):
|
||||
candidates.append(model_id.removeprefix("models/"))
|
||||
if provider_id == "amazon-bedrock" and "." in model_id:
|
||||
# Cross-region Inference Profile 会增加 us./eu./global. 等前缀。
|
||||
candidates.append(model_id.split(".", 1)[1])
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
|
||||
async def _models_dev_model(
|
||||
self,
|
||||
provider_id: str,
|
||||
@@ -1684,15 +1698,32 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
if not isinstance(models, dict):
|
||||
return None
|
||||
|
||||
candidates = [model_id]
|
||||
if model_id.startswith("models/"):
|
||||
candidates.append(model_id.removeprefix("models/"))
|
||||
|
||||
for candidate in candidates:
|
||||
for candidate in self._models_dev_model_candidates(provider_id, model_id):
|
||||
if candidate in models:
|
||||
return models[candidate]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _metadata_supports_prompt_cache(metadata: Any) -> bool:
|
||||
"""从统一模型元数据中判断是否声明了提示词缓存能力。"""
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
|
||||
explicit_capability = metadata.get("prompt_cache")
|
||||
if isinstance(explicit_capability, bool):
|
||||
return explicit_capability
|
||||
|
||||
capabilities = metadata.get("capabilities")
|
||||
if isinstance(capabilities, dict):
|
||||
explicit_capability = capabilities.get("prompt_cache")
|
||||
if isinstance(explicit_capability, bool):
|
||||
return explicit_capability
|
||||
|
||||
cost = metadata.get("cost")
|
||||
return isinstance(cost, dict) and any(
|
||||
key in cost for key in ("cache_read", "cache_write")
|
||||
)
|
||||
|
||||
def _cached_models_dev_model(
|
||||
self,
|
||||
provider_id: str,
|
||||
@@ -1719,11 +1750,7 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
if not isinstance(models, dict):
|
||||
return None
|
||||
|
||||
candidates = [model_id]
|
||||
if model_id.startswith("models/"):
|
||||
candidates.append(model_id.removeprefix("models/"))
|
||||
|
||||
for candidate in candidates:
|
||||
for candidate in self._models_dev_model_candidates(provider_id, model_id):
|
||||
if candidate in models:
|
||||
return models[candidate]
|
||||
return None
|
||||
@@ -3112,6 +3139,9 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
"model_id": model,
|
||||
"model_record": model_record,
|
||||
"model_metadata": model_metadata,
|
||||
"supports_prompt_cache": self._metadata_supports_prompt_cache(
|
||||
model_metadata
|
||||
),
|
||||
"default_headers": None,
|
||||
"use_responses_api": None,
|
||||
"auth_mode": "api_key",
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""LLM 服务端工具能力注册与解析。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
WEB_SEARCH_MODES = frozenset({"local", "builtin", "auto", "disabled"})
|
||||
|
||||
|
||||
class ServerToolUnavailableError(ValueError):
|
||||
"""表示用户强制选择了当前模型不可用的服务端工具。"""
|
||||
|
||||
def __init__(self, *, provider: str, model: str, tool_id: str) -> None:
|
||||
"""初始化服务端工具不可用异常。"""
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.tool_id = tool_id
|
||||
super().__init__(
|
||||
f"当前模型 {provider}/{model} 或接口地址不支持服务端联网搜索,"
|
||||
"请改用“自动”或“MoviePilot 本地搜索”"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerToolCapability:
|
||||
"""描述一个模型可用的服务端工具能力。"""
|
||||
|
||||
tool_id: str
|
||||
provider_ids: tuple[str, ...]
|
||||
model_patterns: tuple[str, ...]
|
||||
required_api_protocol: str
|
||||
client_adapter: str
|
||||
tool_definition: dict[str, Any]
|
||||
base_url_patterns: tuple[str, ...] = ()
|
||||
match_without_base_url: bool = True
|
||||
|
||||
def matches(self, provider: str, model: str, base_url: Optional[str] = None) -> bool:
|
||||
"""判断给定 provider/model 是否匹配当前能力。"""
|
||||
normalized_provider = str(provider or "").strip().lower()
|
||||
normalized_model = str(model or "").strip().lower().removeprefix("models/")
|
||||
normalized_base_url = str(base_url or "").strip().lower()
|
||||
return (
|
||||
normalized_provider in self.provider_ids
|
||||
and any(fnmatch(normalized_model, pattern) for pattern in self.model_patterns)
|
||||
and (
|
||||
(not normalized_base_url and self.match_without_base_url)
|
||||
or not self.base_url_patterns
|
||||
or any(
|
||||
pattern in normalized_base_url
|
||||
for pattern in self.base_url_patterns
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
"""返回供 API 与前端使用的能力元数据。"""
|
||||
return {
|
||||
"id": self.tool_id,
|
||||
"required_api_protocol": self.required_api_protocol,
|
||||
"client_adapter": self.client_adapter,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerToolResolution:
|
||||
"""记录本次联网搜索模式解析后的执行策略。"""
|
||||
|
||||
mode: str
|
||||
use_local_web_search: bool
|
||||
server_tools: tuple[dict[str, Any], ...] = ()
|
||||
client_adapter: Optional[str] = None
|
||||
required_api_protocol: Optional[str] = None
|
||||
available: bool = False
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class ServerToolRegistry:
|
||||
"""集中注册模型服务端工具,并解析通用执行策略。"""
|
||||
|
||||
_CAPABILITIES = (
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("chatgpt",),
|
||||
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
|
||||
base_url_patterns=("api.openai.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("openai",),
|
||||
model_patterns=("gpt-5*", "gpt-4.1*", "o4-mini*"),
|
||||
base_url_patterns=("api.openai.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
match_without_base_url=False,
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("anthropic",),
|
||||
model_patterns=(
|
||||
"claude-opus-4*",
|
||||
"claude-sonnet-4*",
|
||||
"claude-haiku-4*",
|
||||
"claude-opus-5*",
|
||||
"claude-sonnet-5*",
|
||||
"claude-haiku-5*",
|
||||
"claude-fable-5*",
|
||||
"claude-mythos-5*",
|
||||
),
|
||||
base_url_patterns=("api.anthropic.com",),
|
||||
required_api_protocol="native",
|
||||
client_adapter="anthropic_native",
|
||||
tool_definition={
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("google",),
|
||||
model_patterns=("gemini-3*", "gemini-2.5*", "gemini-2.0-flash*"),
|
||||
required_api_protocol="native",
|
||||
client_adapter="google_native",
|
||||
tool_definition={"google_search": {}},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("xai",),
|
||||
model_patterns=("grok-4.5*",),
|
||||
base_url_patterns=("api.x.ai",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
ServerToolCapability(
|
||||
tool_id="web_search",
|
||||
provider_ids=("deepseek",),
|
||||
model_patterns=("deepseek-v4-flash",),
|
||||
base_url_patterns=("api.deepseek.com",),
|
||||
required_api_protocol="responses",
|
||||
client_adapter="openai_responses",
|
||||
tool_definition={"type": "web_search"},
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def normalize_web_search_mode(cls, mode: Optional[str]) -> str:
|
||||
"""规范化联网搜索模式,未知值回退为本地搜索。"""
|
||||
normalized = str(mode or "local").strip().lower()
|
||||
return normalized if normalized in WEB_SEARCH_MODES else "local"
|
||||
|
||||
@classmethod
|
||||
def get_capability(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: Optional[str] = None,
|
||||
tool_id: str,
|
||||
) -> Optional[ServerToolCapability]:
|
||||
"""查找指定模型的服务端工具能力。"""
|
||||
return next(
|
||||
(
|
||||
capability
|
||||
for capability in cls._CAPABILITIES
|
||||
if capability.tool_id == tool_id
|
||||
and capability.matches(provider, model, base_url)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_capabilities(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""列出指定模型可用的服务端工具能力。"""
|
||||
return [
|
||||
capability.serialize()
|
||||
for capability in cls._CAPABILITIES
|
||||
if capability.matches(provider, model, base_url)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def resolve_web_search(
|
||||
cls,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
mode: Optional[str],
|
||||
api_protocol: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
) -> ServerToolResolution:
|
||||
"""解析联网搜索应使用本地工具还是模型服务端工具。"""
|
||||
normalized_mode = cls.normalize_web_search_mode(mode)
|
||||
normalized_protocol = str(api_protocol or "auto").strip().lower()
|
||||
capability = cls.get_capability(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
tool_id="web_search",
|
||||
)
|
||||
|
||||
if normalized_mode == "disabled":
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=False,
|
||||
reason="web_search_disabled",
|
||||
)
|
||||
if normalized_mode == "local":
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=True,
|
||||
reason="local_web_search_selected",
|
||||
)
|
||||
if capability is None:
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=normalized_mode == "auto",
|
||||
reason="builtin_web_search_unavailable",
|
||||
)
|
||||
if (
|
||||
normalized_mode == "auto"
|
||||
and normalized_protocol == "chat_completions"
|
||||
and capability.required_api_protocol == "responses"
|
||||
):
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=True,
|
||||
available=True,
|
||||
reason="chat_completions_uses_local_fallback",
|
||||
)
|
||||
|
||||
return ServerToolResolution(
|
||||
mode=normalized_mode,
|
||||
use_local_web_search=False,
|
||||
server_tools=(dict(capability.tool_definition),),
|
||||
client_adapter=capability.client_adapter,
|
||||
required_api_protocol=capability.required_api_protocol,
|
||||
available=True,
|
||||
reason="builtin_web_search_selected",
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
|
||||
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
|
||||
并在每次 Agent 启动时注入轻量索引,完整日志由工具按需查询。
|
||||
系统提示词只注入稳定的检索规则,完整日志由工具按需查询。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -447,7 +447,7 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
||||
llm = await LLMHelper.get_llm(streaming=False)
|
||||
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
|
||||
response = await llm.ainvoke(prompt)
|
||||
summary = response.content.strip()
|
||||
summary = LLMHelper.extract_text_content(response.content).strip()
|
||||
# 清理模型可能输出的前缀(如 "摘要:" "总结:")
|
||||
summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary)
|
||||
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
|
||||
@@ -459,12 +459,8 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
||||
|
||||
|
||||
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
|
||||
<activity_log_index>
|
||||
{activity_log_index}
|
||||
</activity_log_index>
|
||||
|
||||
<activity_log_guidelines>
|
||||
The index only shows recent dates and entry counts, not full log contents.
|
||||
Activity log contents and indexes are not included in the default context.
|
||||
Use `query_activity_log` only when the user references previous work, asks to continue a prior task, or recent activity is clearly relevant.
|
||||
Activity logs are read-only and retained for {retention_days} days; use MEMORY.md for durable preferences.
|
||||
</activity_log_guidelines>
|
||||
@@ -473,10 +469,10 @@ ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
|
||||
|
||||
|
||||
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
|
||||
"""自动记录 Agent 活动日志并注入轻量索引的中间件。
|
||||
"""自动记录 Agent 活动日志并注入稳定检索规则的中间件。
|
||||
|
||||
- abefore_agent: 加载近几天的活动日志索引
|
||||
- awrap_model_call: 将活动日志索引和检索规则注入系统提示词
|
||||
- awrap_model_call: 将固定的活动日志检索规则注入系统提示词
|
||||
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
|
||||
|
||||
参数:
|
||||
@@ -516,31 +512,9 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
|
||||
"""获取指定日期的日志文件路径。"""
|
||||
return AsyncPath(self.activity_dir) / f"{date_str}.md"
|
||||
|
||||
def _format_activity_log(self, contents: dict[str, str]) -> str:
|
||||
"""格式化活动日志索引用于系统提示词注入。"""
|
||||
if not contents:
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
# 按日期排序(最近的在前)
|
||||
sorted_dates = sorted(contents.keys(), reverse=True)
|
||||
sections = []
|
||||
for date_str in sorted_dates:
|
||||
content = contents[date_str].strip()
|
||||
if content:
|
||||
sections.append(f"### {date_str}\n{content}")
|
||||
|
||||
if not sections:
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log_index="(近期暂无活动日志索引。需要历史上下文时可调用 query_activity_log。)",
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
log_body = "\n".join(sections)
|
||||
def _format_activity_log(self, _contents: dict[str, str]) -> str:
|
||||
"""生成不受活动日志内容变化影响的系统提示词。"""
|
||||
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
|
||||
activity_log_index=log_body,
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,8 +27,10 @@ from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.log import logger
|
||||
|
||||
# 安全提示: SKILL.md 文件最大限制为 10MB,防止 DoS 攻击
|
||||
MAX_SKILL_FILE_SIZE = 10 * 1024 * 1024
|
||||
# 磁盘读取上限与模型返回上限分离,避免异常大的 Skill 文件撑爆内存或上下文。
|
||||
MAX_SKILL_FILE_SIZE = 1 * 1024 * 1024
|
||||
MAX_SKILL_RESULT_CHARS = 64 * 1024
|
||||
SKILL_CONTENT_TRUNCATION_SUFFIX = "\n...(Skill 内容已截断)"
|
||||
|
||||
# Agent Skills 规范约束 (https://agentskills.io/specification)
|
||||
MAX_SKILL_NAME_LENGTH = 64
|
||||
@@ -248,7 +250,17 @@ async def _alist_skills(source_path: AsyncPath) -> list[SkillMetadata]:
|
||||
for skill_path in skill_dirs:
|
||||
skill_md_path = skill_path / "SKILL.md"
|
||||
|
||||
skill_content = await skill_md_path.read_text(encoding="utf-8", errors="replace")
|
||||
stat = await skill_md_path.stat()
|
||||
if stat.st_size > MAX_SKILL_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Skipping %s: file too large (%d bytes)",
|
||||
skill_md_path,
|
||||
stat.st_size,
|
||||
)
|
||||
continue
|
||||
skill_content = (await skill_md_path.read_bytes()).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
|
||||
# 解析元数据
|
||||
skill_metadata = _parse_skill_metadata(
|
||||
@@ -280,7 +292,16 @@ def _list_skills(source_path: Path) -> list[SkillMetadata]:
|
||||
skills: list[SkillMetadata] = []
|
||||
for skill_path in skill_dirs:
|
||||
skill_md_path = skill_path / "SKILL.md"
|
||||
skill_content = skill_md_path.read_text(encoding="utf-8", errors="replace")
|
||||
if skill_md_path.stat().st_size > MAX_SKILL_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Skipping %s: file too large (%d bytes)",
|
||||
skill_md_path,
|
||||
skill_md_path.stat().st_size,
|
||||
)
|
||||
continue
|
||||
skill_content = skill_md_path.read_bytes().decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
skill_metadata = _parse_skill_metadata(
|
||||
content=skill_content,
|
||||
skill_path=str(skill_md_path),
|
||||
@@ -456,6 +477,46 @@ class _SkillToolProvider:
|
||||
raw_content = await handle.read(MAX_SKILL_FILE_SIZE)
|
||||
return raw_content.decode("utf-8", errors="replace"), truncated
|
||||
|
||||
@staticmethod
|
||||
def _serialize_skill_payload(payload: dict[str, Any]) -> str:
|
||||
"""序列化 Skill 返回值,并严格限制最终进入模型的字符数。"""
|
||||
serialized = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if len(serialized) <= MAX_SKILL_RESULT_CHARS:
|
||||
return serialized
|
||||
|
||||
original_content = str(payload.get("content") or "")
|
||||
truncated_payload = dict(payload)
|
||||
truncated_payload["truncated"] = True
|
||||
low = 0
|
||||
high = len(original_content)
|
||||
best_result = json.dumps(
|
||||
{
|
||||
**truncated_payload,
|
||||
"content": SKILL_CONTENT_TRUNCATION_SUFFIX.strip(),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
while low <= high:
|
||||
middle = (low + high) // 2
|
||||
candidate = json.dumps(
|
||||
{
|
||||
**truncated_payload,
|
||||
"content": (
|
||||
original_content[:middle]
|
||||
+ SKILL_CONTENT_TRUNCATION_SUFFIX
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
if len(candidate) <= MAX_SKILL_RESULT_CHARS:
|
||||
best_result = candidate
|
||||
low = middle + 1
|
||||
else:
|
||||
high = middle - 1
|
||||
return best_result
|
||||
|
||||
async def load_skill(self, name: str) -> str:
|
||||
"""加载指定 Skill 的完整说明并返回 JSON 字符串。"""
|
||||
logger.info(f"加载 Skill: name={name}")
|
||||
@@ -471,7 +532,7 @@ class _SkillToolProvider:
|
||||
)
|
||||
|
||||
content, truncated = await self._read_skill_content(skill["path"])
|
||||
return json.dumps(
|
||||
return self._serialize_skill_payload(
|
||||
{
|
||||
"success": True,
|
||||
"skill": {
|
||||
@@ -483,9 +544,7 @@ class _SkillToolProvider:
|
||||
},
|
||||
"content": content,
|
||||
"truncated": truncated,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"加载 Skill 失败: {err}", exc_info=True)
|
||||
|
||||
@@ -377,11 +377,13 @@ class _SubAgentAgentProvider:
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""初始化子代理执行器。"""
|
||||
self._model = model
|
||||
self._profiles = {profile.name: profile for profile in profiles}
|
||||
self._tools = tools
|
||||
self._server_tools = server_tools or []
|
||||
self._agents = {}
|
||||
self._default_agent_name = "general-purpose"
|
||||
|
||||
@@ -404,7 +406,7 @@ class _SubAgentAgentProvider:
|
||||
)
|
||||
agent = create_agent(
|
||||
model=self._model,
|
||||
tools=subagent_tools,
|
||||
tools=[*subagent_tools, *self._server_tools],
|
||||
system_prompt=profile.prompt,
|
||||
name=profile.name,
|
||||
)
|
||||
@@ -462,16 +464,19 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
system_prompt: str = SUBAGENT_PARENT_PROMPT,
|
||||
task_description: str = SUBAGENT_TASK_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
"""初始化同步子代理中间件。"""
|
||||
self.system_prompt = system_prompt
|
||||
self.stream_handler = stream_handler
|
||||
self._provider = _SubAgentAgentProvider(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools,
|
||||
)
|
||||
self.tools = [
|
||||
StructuredTool.from_function(
|
||||
@@ -549,6 +554,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
model: BaseChatModel,
|
||||
profiles: tuple[_SubAgentProfile, ...],
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
|
||||
stream_handler: Any = None,
|
||||
) -> None:
|
||||
@@ -558,6 +564,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools,
|
||||
)
|
||||
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
|
||||
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
|
||||
@@ -1111,6 +1118,7 @@ def create_subagent_middlewares(
|
||||
*,
|
||||
model: BaseChatModel,
|
||||
tools: list[BaseTool],
|
||||
server_tools: Optional[list[dict[str, Any]]] = None,
|
||||
stream_handler: Any = None,
|
||||
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
|
||||
"""创建子代理中间件列表和任务工具列表。"""
|
||||
@@ -1120,12 +1128,14 @@ def create_subagent_middlewares(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools or [],
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
control_middleware = SubAgentTaskControlMiddleware(
|
||||
model=model,
|
||||
profiles=profiles,
|
||||
tools=tools,
|
||||
server_tools=server_tools or [],
|
||||
stream_handler=stream_handler,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,18 @@ class UsageMiddleware(AgentMiddleware):
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _first_int(
|
||||
cls,
|
||||
candidates: tuple[tuple[Any, tuple[str, ...]], ...],
|
||||
) -> int | None:
|
||||
"""按优先级返回首个可用的 usage 整数值。"""
|
||||
for container, keys in candidates:
|
||||
value = cls._lookup_int(container, *keys)
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_model_name(cls, model: Any) -> str | None:
|
||||
return (
|
||||
@@ -82,6 +94,131 @@ class UsageMiddleware(AgentMiddleware):
|
||||
or {}
|
||||
)
|
||||
|
||||
input_token_details = None
|
||||
if usage_metadata:
|
||||
getter = getattr(usage_metadata, "get", None)
|
||||
input_token_details = (
|
||||
getter("input_token_details")
|
||||
if callable(getter)
|
||||
else getattr(usage_metadata, "input_token_details", None)
|
||||
)
|
||||
|
||||
cache_read_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
input_token_details,
|
||||
(
|
||||
"cache_read",
|
||||
"cached_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
token_usage,
|
||||
(
|
||||
"prompt_cache_hit_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
response_metadata,
|
||||
(
|
||||
"prompt_cache_hit_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
"cached_tokens",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if cache_read_tokens is None:
|
||||
cache_read_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
token_usage.get("prompt_tokens_details", {}),
|
||||
("cached_tokens", "cache_read"),
|
||||
),
|
||||
(
|
||||
token_usage.get("input_tokens_details", {}),
|
||||
("cached_tokens", "cache_read"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
cache_write_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
input_token_details,
|
||||
(
|
||||
"cache_creation",
|
||||
"cache_write",
|
||||
"cache_write_tokens",
|
||||
"cache_write_input_tokens",
|
||||
"cacheWriteInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
token_usage,
|
||||
(
|
||||
"cache_creation_input_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_input_tokens",
|
||||
"cacheWriteInputTokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
response_metadata,
|
||||
(
|
||||
"cache_creation_input_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_input_tokens",
|
||||
"cacheWriteInputTokens",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if cache_write_tokens is None:
|
||||
cache_write_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
token_usage.get("prompt_tokens_details", {}),
|
||||
("cache_write_tokens", "cache_creation"),
|
||||
),
|
||||
(
|
||||
token_usage.get("input_tokens_details", {}),
|
||||
("cache_write_tokens", "cache_creation"),
|
||||
),
|
||||
)
|
||||
)
|
||||
cache_write_ttl_tokens = sum(
|
||||
cls._lookup_int(
|
||||
input_token_details,
|
||||
ttl_key,
|
||||
)
|
||||
or 0
|
||||
for ttl_key in (
|
||||
"ephemeral_5m_input_tokens",
|
||||
"ephemeral_1h_input_tokens",
|
||||
)
|
||||
)
|
||||
if cache_write_ttl_tokens:
|
||||
cache_write_tokens = cache_write_ttl_tokens
|
||||
|
||||
cache_miss_tokens = cls._first_int(
|
||||
(
|
||||
(
|
||||
token_usage,
|
||||
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
|
||||
),
|
||||
(
|
||||
response_metadata,
|
||||
("prompt_cache_miss_tokens", "cache_miss_input_tokens"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if input_tokens is None:
|
||||
input_tokens = cls._lookup_int(
|
||||
token_usage,
|
||||
@@ -94,6 +231,27 @@ class UsageMiddleware(AgentMiddleware):
|
||||
"prompt_token_count",
|
||||
"input_tokens",
|
||||
)
|
||||
if input_tokens is None:
|
||||
bedrock_input_tokens = cls._lookup_int(token_usage, "inputTokens")
|
||||
if bedrock_input_tokens is not None:
|
||||
input_tokens = (
|
||||
bedrock_input_tokens
|
||||
+ (cache_read_tokens or 0)
|
||||
+ (cache_write_tokens or 0)
|
||||
)
|
||||
if input_tokens is None and any(
|
||||
value is not None
|
||||
for value in (
|
||||
cache_read_tokens,
|
||||
cache_write_tokens,
|
||||
cache_miss_tokens,
|
||||
)
|
||||
):
|
||||
input_tokens = (
|
||||
(cache_read_tokens or 0)
|
||||
+ (cache_write_tokens or 0)
|
||||
+ (cache_miss_tokens or 0)
|
||||
)
|
||||
|
||||
if output_tokens is None:
|
||||
output_tokens = cls._lookup_int(
|
||||
@@ -113,8 +271,24 @@ class UsageMiddleware(AgentMiddleware):
|
||||
if total_tokens is None:
|
||||
total_tokens = cls._lookup_int(response_metadata, "total_token_count")
|
||||
|
||||
has_cache_usage = any(
|
||||
value is not None
|
||||
for value in (
|
||||
cache_read_tokens,
|
||||
cache_write_tokens,
|
||||
cache_miss_tokens,
|
||||
)
|
||||
)
|
||||
has_usage = any(
|
||||
value is not None for value in (input_tokens, output_tokens, total_tokens)
|
||||
value is not None
|
||||
for value in (
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
cache_read_tokens,
|
||||
cache_write_tokens,
|
||||
cache_miss_tokens,
|
||||
)
|
||||
)
|
||||
resolved_input = input_tokens or 0
|
||||
resolved_output = output_tokens or 0
|
||||
@@ -123,12 +297,32 @@ class UsageMiddleware(AgentMiddleware):
|
||||
if total_tokens is not None
|
||||
else resolved_input + resolved_output
|
||||
)
|
||||
resolved_cache_read = cache_read_tokens or 0
|
||||
resolved_cache_write = cache_write_tokens or 0
|
||||
uncached_input_tokens = (
|
||||
cache_miss_tokens
|
||||
if cache_miss_tokens is not None
|
||||
else max(
|
||||
resolved_input - resolved_cache_read - resolved_cache_write,
|
||||
0,
|
||||
)
|
||||
)
|
||||
cache_hit_ratio = (
|
||||
resolved_cache_read / resolved_input
|
||||
if has_cache_usage and resolved_input
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"has_usage": has_usage,
|
||||
"cache_usage_available": has_cache_usage,
|
||||
"input_tokens": resolved_input,
|
||||
"output_tokens": resolved_output,
|
||||
"total_tokens": resolved_total,
|
||||
"cache_read_input_tokens": resolved_cache_read,
|
||||
"cache_write_input_tokens": resolved_cache_write,
|
||||
"uncached_input_tokens": uncached_input_tokens,
|
||||
"cache_hit_ratio": cache_hit_ratio,
|
||||
}
|
||||
|
||||
async def awrap_model_call(
|
||||
@@ -157,9 +351,14 @@ class UsageMiddleware(AgentMiddleware):
|
||||
if ai_message
|
||||
else {
|
||||
"has_usage": False,
|
||||
"cache_usage_available": False,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_write_input_tokens": 0,
|
||||
"uncached_input_tokens": 0,
|
||||
"cache_hit_ratio": None,
|
||||
}
|
||||
)
|
||||
context_window_tokens = self._extract_context_window_tokens(request.model)
|
||||
|
||||
@@ -65,7 +65,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
|
||||
- If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable.
|
||||
- Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily.
|
||||
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths. Use `list_directory` to inspect one known directory or a supported remote storage backend, and use `read_file` when the exact local file is known.
|
||||
- For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended.
|
||||
- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters.
|
||||
- When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API.
|
||||
- Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial.
|
||||
@@ -85,7 +85,14 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
</agent_core>
|
||||
|
||||
<communication_runtime>
|
||||
{verbose_spec}
|
||||
<progress_updates>
|
||||
- Base progress updates on meaningful changes in understanding or execution, not on elapsed time or the number of tool calls.
|
||||
- Do not send a progress update merely because work is starting or because one or two tools have finished. Work through a coherent batch of investigation first.
|
||||
- Send an intermediate update when you have a useful preliminary conclusion, complete or validate a meaningful stage, discover evidence that materially changes the working direction, or encounter a sustained blocker the user should know about.
|
||||
- Explain the result or new direction with enough context to be useful, including the key evidence and what you will do next. An update may use several sentences when the finding needs explanation; brevity is not a goal by itself.
|
||||
- Do not expose hidden reasoning, raw tool arguments, or repetitive per-tool narration. Do not repeat an unchanged status.
|
||||
- Continue working after each update. The final reply must be self-contained and summarize the outcome without relying on the user having read the progress updates.
|
||||
</progress_updates>
|
||||
|
||||
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
|
||||
{button_choice_spec}
|
||||
|
||||
@@ -24,6 +24,7 @@ SYSTEM_TASKS_FILE = "System Tasks.yaml"
|
||||
SYSTEM_TASKS_SCHEMA_VERSION = 2
|
||||
COMMON_SHELL_COMMANDS = (
|
||||
"ssh",
|
||||
"sshpass",
|
||||
"scp",
|
||||
"sftp",
|
||||
"git",
|
||||
@@ -139,19 +140,6 @@ class PromptManager:
|
||||
markdown_spec = self._generate_formatting_instructions(caps)
|
||||
button_choice_spec = self._generate_button_choice_instructions(msg_channel)
|
||||
|
||||
# 啰嗦模式
|
||||
verbose_spec = ""
|
||||
if not settings.AI_AGENT_VERBOSE:
|
||||
verbose_spec = (
|
||||
"\n\n[Important Instruction] STRICTLY ENFORCED: "
|
||||
"If tools are needed, DO NOT output any conversational text, explanations, progress updates, "
|
||||
"or acknowledgements before the first tool call or between tool calls. "
|
||||
"Call tools directly without any transitional phrases. "
|
||||
"You MUST remain completely silent until all required tools have finished and you have the final result. "
|
||||
"Only then may you send one final user-facing reply. "
|
||||
"DO NOT output any intermediate content whatsoever."
|
||||
)
|
||||
|
||||
# MoviePilot系统信息
|
||||
moviepilot_info = self._get_moviepilot_info()
|
||||
voice_reply_spec = self._generate_voice_reply_instructions()
|
||||
@@ -159,7 +147,6 @@ class PromptManager:
|
||||
# 始终替换占位符,避免后续 .format() 时因残留花括号报 KeyError
|
||||
base_prompt = base_prompt.format(
|
||||
markdown_spec=markdown_spec,
|
||||
verbose_spec=verbose_spec,
|
||||
moviepilot_info=moviepilot_info,
|
||||
voice_reply_spec=voice_reply_spec,
|
||||
button_choice_spec=button_choice_spec,
|
||||
|
||||
+29
-15
@@ -28,7 +28,6 @@ class ToolChain(ChainBase):
|
||||
# 单个工具结果的兜底上限。各工具仍应优先在自身逻辑中分页或摘要化;
|
||||
# 这里用于拦截遗漏路径,避免超大结果直接进入模型上下文。
|
||||
DEFAULT_TOOL_RESULT_MAX_CHARS = 64 * 1024
|
||||
MIN_TOOL_RESULT_PREVIEW_CHARS = 512
|
||||
|
||||
|
||||
def serialize_tool_result_for_agent(result: Any) -> str:
|
||||
@@ -59,20 +58,35 @@ def format_tool_result_for_agent(
|
||||
if not max_chars or max_chars <= 0 or len(formatted_result) <= max_chars:
|
||||
return formatted_result
|
||||
|
||||
preview_limit = max(MIN_TOOL_RESULT_PREVIEW_CHARS, max_chars)
|
||||
preview = formatted_result[:preview_limit]
|
||||
payload = {
|
||||
"tool_result_truncated": True,
|
||||
"tool_name": tool_name,
|
||||
"total_chars": len(formatted_result),
|
||||
"returned_chars": len(preview),
|
||||
"content_preview": preview,
|
||||
"message": (
|
||||
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
|
||||
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
|
||||
),
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
def _dump_preview(preview: str) -> str:
|
||||
"""序列化截断结果,并让 returned_chars 与实际预览保持一致。"""
|
||||
payload = {
|
||||
"tool_result_truncated": True,
|
||||
"tool_name": tool_name,
|
||||
"total_chars": len(formatted_result),
|
||||
"returned_chars": len(preview),
|
||||
"content_preview": preview,
|
||||
"message": (
|
||||
f"工具返回内容超过 {max_chars} 字符,已截断为预览;"
|
||||
"请使用更精确的筛选条件、分页参数或专用查询参数继续获取。"
|
||||
),
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
# JSON 会转义换行、引号和反斜杠,预览本身等于上限时,最终返回值仍可能
|
||||
# 明显超限。通过二分查找预留包装开销,确保进入模型的最终字符串是硬上限。
|
||||
low = 0
|
||||
high = min(len(formatted_result), max_chars)
|
||||
best_result = _dump_preview("")
|
||||
while low <= high:
|
||||
middle = (low + high) // 2
|
||||
candidate = _dump_preview(formatted_result[:middle])
|
||||
if len(candidate) <= max_chars:
|
||||
best_result = candidate
|
||||
low = middle + 1
|
||||
else:
|
||||
high = middle - 1
|
||||
return best_result
|
||||
|
||||
|
||||
# 将常见的阻塞调用按能力域拆分到独立线程池,避免外部慢 IO 抢占同一批 worker。
|
||||
|
||||
@@ -12,8 +12,8 @@ from app.agent.tools.tags import ToolTag
|
||||
from app.helper.browser import BrowserSessionHelper
|
||||
from app.log import logger
|
||||
|
||||
# 页面内容最大长度
|
||||
MAX_CONTENT_LENGTH = 8000
|
||||
# 页面内容最大长度;保留在全局工具结果兜底上限以内。
|
||||
MAX_CONTENT_LENGTH = 12_000
|
||||
# 默认超时时间(秒)
|
||||
DEFAULT_TIMEOUT = 30
|
||||
# 截图最大宽度
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Literal, Optional, TextIO, Type
|
||||
@@ -27,7 +28,9 @@ from app.log import logger
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 60
|
||||
MAX_TIMEOUT_SECONDS = 300
|
||||
MAX_OUTPUT_PREVIEW_BYTES = 10 * 1024
|
||||
MAX_OUTPUT_PREVIEW_BYTES = 32 * 1024
|
||||
MAX_OUTPUT_HEAD_BYTES = 16 * 1024
|
||||
MAX_OUTPUT_TAIL_BYTES = 16 * 1024
|
||||
READ_CHUNK_SIZE = 4096
|
||||
KILL_GRACE_SECONDS = 3
|
||||
COMMAND_CONCURRENCY_LIMIT = 2
|
||||
@@ -36,11 +39,13 @@ _command_semaphore = asyncio.Semaphore(COMMAND_CONCURRENCY_LIMIT)
|
||||
|
||||
@dataclass
|
||||
class _CommandOutput:
|
||||
"""保存前 10KB 预览,并在超限时将完整输出写入临时文件。"""
|
||||
"""保存命令头尾预览,并在超限时将完整输出写入临时文件。"""
|
||||
|
||||
preview_limit_bytes: int
|
||||
preview_entries: list[tuple[str, str]] = field(default_factory=list)
|
||||
tail_entries: deque[tuple[str, str]] = field(default_factory=deque)
|
||||
captured_bytes: int = 0
|
||||
tail_bytes: int = 0
|
||||
preview_truncated: bool = False
|
||||
temp_file_path: Optional[str] = None
|
||||
temp_file_handle: Optional[TextIO] = None
|
||||
@@ -93,10 +98,12 @@ class _CommandOutput:
|
||||
self.temp_file_handle = None
|
||||
|
||||
def append(self, stream_name: str, text: str) -> None:
|
||||
"""追加一段输出,超出预览上限后只保留完整日志文件。"""
|
||||
"""追加一段输出,超出预览上限后保留头尾预览和完整日志文件。"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
self._append_tail(stream_name, text)
|
||||
|
||||
if self.temp_file_handle:
|
||||
self._write_chunk(stream_name, text)
|
||||
return
|
||||
@@ -117,6 +124,60 @@ class _CommandOutput:
|
||||
self.preview_entries.append((stream_name, preview))
|
||||
self.captured_bytes += len(preview.encode("utf-8"))
|
||||
|
||||
def _append_tail(self, stream_name: str, text: str) -> None:
|
||||
"""维护固定字节大小的尾部输出,方便定位测试和构建失败信息。"""
|
||||
self.tail_entries.append((stream_name, text))
|
||||
self.tail_bytes += len(text.encode("utf-8"))
|
||||
while self.tail_bytes > MAX_OUTPUT_TAIL_BYTES and self.tail_entries:
|
||||
old_stream, old_text = self.tail_entries.popleft()
|
||||
old_bytes = len(old_text.encode("utf-8"))
|
||||
overflow = self.tail_bytes - MAX_OUTPUT_TAIL_BYTES
|
||||
if old_bytes <= overflow:
|
||||
self.tail_bytes -= old_bytes
|
||||
continue
|
||||
kept_text = old_text.encode("utf-8")[overflow:].decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
kept_bytes = len(kept_text.encode("utf-8"))
|
||||
self.tail_bytes -= old_bytes
|
||||
if kept_text:
|
||||
self.tail_entries.appendleft((old_stream, kept_text))
|
||||
self.tail_bytes += kept_bytes
|
||||
|
||||
@staticmethod
|
||||
def _format_entries(entries: list[tuple[str, str]]) -> str:
|
||||
"""按 stdout/stderr 切换插入可读的输出分段标题。"""
|
||||
parts: list[str] = []
|
||||
last_stream: Optional[str] = None
|
||||
for stream_name, text in entries:
|
||||
if stream_name != last_stream:
|
||||
title = "标准输出" if stream_name == "stdout" else "错误输出"
|
||||
parts.append(f"\n[{title}]\n")
|
||||
last_stream = stream_name
|
||||
parts.append(text)
|
||||
return "".join(parts).strip()
|
||||
|
||||
@property
|
||||
def combined_preview(self) -> str:
|
||||
"""返回完整输出或头尾组合预览。"""
|
||||
if not self.preview_truncated:
|
||||
return self._format_entries(self.preview_entries)
|
||||
|
||||
head_entries: list[tuple[str, str]] = []
|
||||
remaining = MAX_OUTPUT_HEAD_BYTES
|
||||
for stream_name, text in self.preview_entries:
|
||||
if remaining <= 0:
|
||||
break
|
||||
clipped = self._clip_text_to_bytes(text, remaining)
|
||||
if clipped:
|
||||
head_entries.append((stream_name, clipped))
|
||||
remaining -= len(clipped.encode("utf-8"))
|
||||
head = self._format_entries(head_entries)
|
||||
tail = self._format_entries(list(self.tail_entries))
|
||||
return (
|
||||
f"{head}\n\n...(中间输出已省略,完整内容在临时文件中)...\n\n{tail}"
|
||||
).strip()
|
||||
|
||||
@property
|
||||
def stdout(self) -> str:
|
||||
"""返回当前保留的 stdout 预览。"""
|
||||
@@ -295,7 +356,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
stream_name: str,
|
||||
output: _CommandOutput,
|
||||
) -> None:
|
||||
"""按块读取一次性命令输出,只把前 10KB 保留在返回结果中。"""
|
||||
"""按块读取一次性命令输出,保留 32KB 头尾预览。"""
|
||||
while True:
|
||||
chunk = await stream.read(READ_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
@@ -379,17 +440,16 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
file_note = "截至命令终止前的完整输出" if timed_out else "完整输出"
|
||||
result += (
|
||||
"\n\n提示:\n"
|
||||
f"命令输出超过 10KB,仅返回前 {MAX_OUTPUT_PREVIEW_BYTES} 字节内容。\n"
|
||||
f"命令输出超过 {MAX_OUTPUT_PREVIEW_BYTES // 1024}KB,"
|
||||
f"仅返回前后各 {MAX_OUTPUT_HEAD_BYTES // 1024}KB 预览。\n"
|
||||
f"{file_note}已写入临时文件: {output.temp_file_path}\n"
|
||||
"如需完整内容,请继续读取该文件。"
|
||||
)
|
||||
if output.stdout:
|
||||
result += f"\n\n标准输出:\n{output.stdout}"
|
||||
if output.stderr:
|
||||
result += f"\n\n错误输出:\n{output.stderr}"
|
||||
if output.combined_preview:
|
||||
result += f"\n\n命令输出预览:\n{output.combined_preview}"
|
||||
if output.preview_truncated:
|
||||
result += "\n\n...(仅展示前 10KB 内容)"
|
||||
if not output.stdout and not output.stderr:
|
||||
result += "\n\n...(仅展示前后各 16KB 内容)"
|
||||
if not output.combined_preview:
|
||||
result += "\n\n(无输出内容)"
|
||||
return result
|
||||
|
||||
|
||||
@@ -15,21 +15,47 @@ from app.schemas.file import FileItem
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
DEFAULT_DIRECTORY_PAGE_SIZE = 50
|
||||
MAX_DIRECTORY_PAGE_SIZE = 200
|
||||
|
||||
|
||||
class ListDirectoryInput(BaseModel):
|
||||
"""查询文件系统目录内容工具的输入参数模型"""
|
||||
path: str = Field(..., description="Directory path to list contents (e.g., '/home/user/downloads' or 'C:/Downloads')")
|
||||
storage: Optional[str] = Field("local", description="Storage type (default: 'local' for local file system, can be 'smb', 'alist', etc.)")
|
||||
sort_by: Optional[str] = Field("name", description="Sort order: 'name' for alphabetical sorting, 'time' for modification time sorting (default: 'name')")
|
||||
limit: Optional[int] = Field(
|
||||
DEFAULT_DIRECTORY_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=MAX_DIRECTORY_PAGE_SIZE,
|
||||
description=(
|
||||
f"Maximum items to return in this page (default: {DEFAULT_DIRECTORY_PAGE_SIZE}, "
|
||||
f"maximum: {MAX_DIRECTORY_PAGE_SIZE})"
|
||||
),
|
||||
)
|
||||
offset: Optional[int] = Field(
|
||||
0,
|
||||
ge=0,
|
||||
description="Number of sorted directory items to skip before this page",
|
||||
)
|
||||
|
||||
|
||||
class ListDirectoryTool(MoviePilotTool):
|
||||
"""分页查询本地或远程存储目录中的文件和子目录。"""
|
||||
|
||||
name: str = "list_directory"
|
||||
tags: list[str] = [
|
||||
ToolTag.Read,
|
||||
ToolTag.Directory,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = "List actual files and folders in a file system directory (NOT configuration). Shows files and subdirectories with their names, types, sizes, and modification times. Returns up to 20 items and the total count if there are more items. Use 'query_directory_settings' to query directory configuration settings."
|
||||
description: str = (
|
||||
"List actual files and folders in a file system directory (NOT configuration). "
|
||||
"Shows files and subdirectories with their names, types, sizes, and modification "
|
||||
f"times. Returns a page of up to {DEFAULT_DIRECTORY_PAGE_SIZE} items with total "
|
||||
f"count and next offset; limit is capped at {MAX_DIRECTORY_PAGE_SIZE}. "
|
||||
"Use 'query_directory_settings' to query directory configuration settings."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ListDirectoryInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -45,10 +71,14 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
|
||||
@staticmethod
|
||||
def _list_directory_sync(
|
||||
path: str, storage: Optional[str] = "local", sort_by: Optional[str] = "name"
|
||||
path: str,
|
||||
storage: Optional[str] = "local",
|
||||
sort_by: Optional[str] = "name",
|
||||
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
|
||||
offset: Optional[int] = 0,
|
||||
) -> str:
|
||||
"""
|
||||
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行。
|
||||
目录遍历可能触发本地磁盘或远程存储请求,统一放到线程池中执行并分页返回。
|
||||
"""
|
||||
if not path:
|
||||
return "错误:路径不能为空"
|
||||
@@ -64,9 +94,6 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
|
||||
if file_list is None:
|
||||
return f"无法访问目录:{path},请检查路径是否正确或存储是否可用"
|
||||
if not file_list:
|
||||
return f"目录 {path} 为空"
|
||||
|
||||
if sort_by == "time":
|
||||
file_list.sort(key=lambda x: x.modify_time or 0, reverse=True)
|
||||
else:
|
||||
@@ -78,7 +105,14 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
total_count = len(file_list)
|
||||
limited_list = file_list[:20]
|
||||
normalized_limit = max(
|
||||
1,
|
||||
min(int(limit or DEFAULT_DIRECTORY_PAGE_SIZE), MAX_DIRECTORY_PAGE_SIZE),
|
||||
)
|
||||
normalized_offset = max(0, int(offset or 0))
|
||||
limited_list = file_list[
|
||||
normalized_offset : normalized_offset + normalized_limit
|
||||
]
|
||||
simplified_items = []
|
||||
for item in limited_list:
|
||||
size_str = StringUtils.str_filesize(item.size) if item.size else None
|
||||
@@ -102,16 +136,39 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
simplified["extension"] = item.extension
|
||||
simplified_items.append(simplified)
|
||||
|
||||
result_json = json.dumps(simplified_items, ensure_ascii=False, indent=2)
|
||||
if total_count > 20:
|
||||
return (
|
||||
f"注意:目录中共有 {total_count} 个项目,为节省上下文空间,仅显示前 20 个项目。\n\n"
|
||||
f"{result_json}"
|
||||
)
|
||||
return result_json
|
||||
returned_count = len(simplified_items)
|
||||
has_more = normalized_offset + returned_count < total_count
|
||||
return json.dumps(
|
||||
{
|
||||
"items": simplified_items,
|
||||
"total_count": total_count,
|
||||
"returned_count": returned_count,
|
||||
"limit": normalized_limit,
|
||||
"offset": normalized_offset,
|
||||
"has_more": has_more,
|
||||
"next_offset": (
|
||||
normalized_offset + returned_count if has_more else None
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def run(self, path: str, storage: Optional[str] = "local",
|
||||
sort_by: Optional[str] = "name", **kwargs) -> str:
|
||||
sort_by: Optional[str] = "name",
|
||||
limit: Optional[int] = DEFAULT_DIRECTORY_PAGE_SIZE,
|
||||
offset: Optional[int] = 0,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
分页查询指定目录的文件和子目录。
|
||||
|
||||
:param path: 要查询的目录路径
|
||||
:param storage: 存储类型,默认为本地存储
|
||||
:param sort_by: 排序方式,支持名称或修改时间
|
||||
:param limit: 当前页最大条数,最高不超过工具上限
|
||||
:param offset: 当前页起始偏移量
|
||||
:return: 包含项目列表和分页元数据的 JSON 字符串
|
||||
"""
|
||||
logger.info(f"执行工具: {self.name}, 参数: path={path}, storage={storage}, sort_by={sort_by}")
|
||||
|
||||
try:
|
||||
@@ -123,7 +180,13 @@ class ListDirectoryTool(MoviePilotTool):
|
||||
if resolved_path:
|
||||
path = str(resolved_path)
|
||||
return await self.run_blocking(
|
||||
"storage", self._list_directory_sync, path, storage, sort_by
|
||||
"storage",
|
||||
self._list_directory_sync,
|
||||
path,
|
||||
storage,
|
||||
sort_by,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"查询目录内容失败: {e}", exc_info=True)
|
||||
|
||||
@@ -14,6 +14,10 @@ from app.log import logger
|
||||
|
||||
# 最大读取大小 50KB
|
||||
MAX_READ_SIZE = 50 * 1024
|
||||
READ_FILE_TRUNCATION_MESSAGE = (
|
||||
"文件内容超过50KB,本次结果已截断。"
|
||||
"请使用 start_line 和 end_line 参数指定行号范围分段读取。"
|
||||
)
|
||||
|
||||
|
||||
class ReadFileInput(BaseModel):
|
||||
@@ -39,7 +43,11 @@ class ReadFileTool(MoviePilotTool):
|
||||
ToolTag.Read,
|
||||
ToolTag.File,
|
||||
]
|
||||
description: str = "Read the content of a text file. Supports reading by line range. Each read is limited to 50KB; content exceeding this limit will be truncated."
|
||||
description: str = (
|
||||
"Read the content of a text file. Supports reading by line range. Each "
|
||||
"read is limited to 50KB; when content is truncated, continue with "
|
||||
"smaller start_line and end_line ranges."
|
||||
)
|
||||
args_schema: Type[BaseModel] = ReadFileInput
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
@@ -99,22 +107,21 @@ class ReadFileTool(MoviePilotTool):
|
||||
truncated = True
|
||||
|
||||
if include_metadata:
|
||||
return json.dumps(
|
||||
{
|
||||
"file_path": str(resolved_path),
|
||||
"sha256": hashlib.sha256(raw_content).hexdigest(),
|
||||
"size_bytes": len(raw_content),
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
"truncated": truncated,
|
||||
"content": content,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
payload = {
|
||||
"file_path": str(resolved_path),
|
||||
"sha256": hashlib.sha256(raw_content).hexdigest(),
|
||||
"size_bytes": len(raw_content),
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
"truncated": truncated,
|
||||
}
|
||||
if truncated:
|
||||
payload["truncation_message"] = READ_FILE_TRUNCATION_MESSAGE
|
||||
payload["content"] = content
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
if truncated:
|
||||
return f"{content}\n\n[警告:文件内容已超过50KB限制,以上内容已被截断。请使用 start_line/end_line 参数分段读取。]"
|
||||
return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]"
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.apiv1 import api_router
|
||||
|
||||
|
||||
api_router_v2 = APIRouter()
|
||||
api_router_v2.include_router(api_router)
|
||||
@@ -0,0 +1,224 @@
|
||||
import json
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from app.schemas.response import Response
|
||||
|
||||
|
||||
API_V2_STR = "/api/v2"
|
||||
OPENAPI_V2_PATH = f"{API_V2_STR}/openapi.json"
|
||||
_PROTOCOL_PREFIXES = ("/openai", "/anthropic", "/mcp")
|
||||
_JSON_CONTENT_TYPES = ("application/json", "+json")
|
||||
|
||||
|
||||
def _is_protocol_path(path: str) -> bool:
|
||||
"""判断路径是否属于需要保留原始协议响应的接口。"""
|
||||
relative_path = path.removeprefix(API_V2_STR)
|
||||
return any(
|
||||
relative_path == prefix or relative_path.startswith(f"{prefix}/")
|
||||
for prefix in _PROTOCOL_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
def _is_json_response(response: StarletteResponse) -> bool:
|
||||
"""判断响应是否为可安全解析的 JSON 响应。"""
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0]
|
||||
return any(
|
||||
content_type == accepted_type or content_type.endswith(accepted_type)
|
||||
for accepted_type in _JSON_CONTENT_TYPES
|
||||
)
|
||||
|
||||
|
||||
def _is_response_payload(payload: Any) -> bool:
|
||||
"""判断响应内容是否已经符合通用 Response 结构。"""
|
||||
return isinstance(payload, dict) and {
|
||||
"success",
|
||||
"message",
|
||||
"data",
|
||||
}.issubset(payload)
|
||||
|
||||
|
||||
def _get_error_message(payload: Any) -> str:
|
||||
"""从旧版错误响应中提取统一的错误消息。"""
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("detail")
|
||||
if isinstance(detail, str) and detail:
|
||||
return detail
|
||||
if isinstance(detail, list):
|
||||
messages = [
|
||||
item.get("msg")
|
||||
for item in detail
|
||||
if isinstance(item, dict) and isinstance(item.get("msg"), str)
|
||||
]
|
||||
if messages:
|
||||
return "; ".join(messages)
|
||||
if detail is not None:
|
||||
return json.dumps(detail, ensure_ascii=False)
|
||||
message = payload.get("message")
|
||||
if isinstance(message, str) and message:
|
||||
return message
|
||||
if isinstance(payload, str) and payload:
|
||||
return payload
|
||||
return "请求失败"
|
||||
|
||||
|
||||
def _copy_response_headers(source: StarletteResponse, target: StarletteResponse) -> None:
|
||||
"""复制适配前响应中仍然有效的头信息。"""
|
||||
for key, value in source.raw_headers:
|
||||
if key.lower() not in {b"content-length", b"content-type"}:
|
||||
target.raw_headers.append((key, value))
|
||||
|
||||
|
||||
def _restore_response_body(
|
||||
source: StarletteResponse,
|
||||
body: bytes,
|
||||
) -> StarletteResponse:
|
||||
"""在检查响应体后恢复原始响应内容和头信息。"""
|
||||
restored_response = StarletteResponse(
|
||||
content=body,
|
||||
status_code=source.status_code,
|
||||
background=source.background,
|
||||
)
|
||||
restored_response.raw_headers = list(source.raw_headers)
|
||||
return restored_response
|
||||
|
||||
|
||||
class V2ResponseMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
为 v2 REST 接口适配统一的 Response 响应结构。
|
||||
|
||||
已经返回项目 Response 模型的成功响应保持原样,避免改变既有接口语义;
|
||||
OpenAI、Anthropic 和 MCP 协议接口也保持原始协议响应。
|
||||
"""
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
||||
) -> StarletteResponse:
|
||||
"""处理 v2 请求并在必要时封装 JSON 响应。"""
|
||||
response = await call_next(request)
|
||||
if not request.url.path.startswith(f"{API_V2_STR}/"):
|
||||
return response
|
||||
if request.url.path == OPENAPI_V2_PATH:
|
||||
return response
|
||||
if _is_protocol_path(request.url.path):
|
||||
return response
|
||||
if response.status_code in {204, 304} or not _is_json_response(response):
|
||||
return response
|
||||
if response.headers.get("content-encoding"):
|
||||
return response
|
||||
|
||||
route = request.scope.get("route")
|
||||
route_response_model = getattr(route, "response_model", None)
|
||||
if response.status_code < 400 and route_response_model is Response:
|
||||
return response
|
||||
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
if not body:
|
||||
return _restore_response_body(response, body)
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (TypeError, ValueError):
|
||||
return _restore_response_body(response, body)
|
||||
|
||||
if _is_response_payload(payload):
|
||||
return _restore_response_body(response, body)
|
||||
|
||||
if response.status_code >= 400:
|
||||
content = {
|
||||
"success": False,
|
||||
"message": _get_error_message(payload),
|
||||
"data": {},
|
||||
}
|
||||
if isinstance(payload, dict) and isinstance(payload.get("detail_i18n"), str):
|
||||
content["message_i18n"] = payload["detail_i18n"]
|
||||
else:
|
||||
content = {
|
||||
"success": True,
|
||||
"message": "",
|
||||
"data": payload,
|
||||
}
|
||||
|
||||
wrapped_response = JSONResponse(
|
||||
content=content,
|
||||
status_code=response.status_code,
|
||||
background=response.background,
|
||||
)
|
||||
_copy_response_headers(response, wrapped_response)
|
||||
return wrapped_response
|
||||
|
||||
|
||||
def configure_v2_openapi(app: FastAPI) -> None:
|
||||
"""
|
||||
将 v2 普通 JSON 接口的 OpenAPI 响应模型改为通用 Response。
|
||||
|
||||
:param app: 已完成 v1/v2 路由注册的 FastAPI 应用
|
||||
"""
|
||||
if getattr(app, "_v2_openapi_configured", False):
|
||||
return
|
||||
|
||||
original_openapi = app.openapi
|
||||
|
||||
def custom_openapi() -> dict[str, Any]:
|
||||
"""生成包含 v2 通用响应模型的 OpenAPI 文档。"""
|
||||
schema = original_openapi()
|
||||
components = schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
components["Response"] = Response.model_json_schema(
|
||||
ref_template="#/components/schemas/{model}"
|
||||
)
|
||||
|
||||
route_map = {
|
||||
(route.path, method.lower()): route
|
||||
for route in app.routes
|
||||
if isinstance(route, APIRoute)
|
||||
for method in route.methods
|
||||
}
|
||||
response_ref = {"$ref": "#/components/schemas/Response"}
|
||||
for path, path_item in schema.get("paths", {}).items():
|
||||
if not path.startswith(f"{API_V2_STR}/"):
|
||||
continue
|
||||
for method, operation in path_item.items():
|
||||
if method not in {
|
||||
"get",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
"delete",
|
||||
"options",
|
||||
"head",
|
||||
}:
|
||||
continue
|
||||
route = route_map.get((path, method))
|
||||
if (
|
||||
route is None
|
||||
or route.response_model is None
|
||||
or route.response_model is Any
|
||||
or route.response_model is Response
|
||||
or _is_protocol_path(path)
|
||||
):
|
||||
continue
|
||||
if route.status_code in {204, 304}:
|
||||
continue
|
||||
content_type = getattr(route.response_class, "media_type", None)
|
||||
if content_type and not (
|
||||
content_type == "application/json" or content_type.endswith("+json")
|
||||
):
|
||||
continue
|
||||
status_code = str(route.status_code or 200)
|
||||
response = operation.get("responses", {}).get(status_code)
|
||||
if response and "content" in response:
|
||||
json_content = response["content"].get("application/json")
|
||||
if json_content is not None:
|
||||
json_content["schema"] = response_ref
|
||||
|
||||
app.openapi_schema = schema
|
||||
return schema
|
||||
|
||||
app.openapi = custom_openapi
|
||||
app._v2_openapi_configured = True
|
||||
+258
-38
@@ -7,6 +7,7 @@ import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from queue import Empty, Queue
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
@@ -50,6 +51,10 @@ WEB_AGENT_UPLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
WEB_AGENT_BROWSER_AUDIO_SUFFIXES = {".aac", ".m4a", ".mp3", ".mp4", ".wav", ".wave"}
|
||||
WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS = 2.0
|
||||
WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS = 60.0
|
||||
WEB_AGENT_STREAM_COALESCE_SECONDS = 0.03
|
||||
WEB_AGENT_STREAM_COALESCE_MAX_CHARS = 256
|
||||
WEB_AGENT_STREAM_HEARTBEAT_SECONDS = 15.0
|
||||
WEB_AGENT_STREAM_QUEUE_MAX_SIZE = 64
|
||||
_WEB_AGENT_FILE_REGISTRY: dict[str, dict[str, Any]] = {}
|
||||
_WEB_AGENT_NOTICE_QUEUES: dict[str, list[Queue[schemas.Notification]]] = {}
|
||||
_WEB_AGENT_NOTICE_LOCK = Lock()
|
||||
@@ -57,6 +62,107 @@ _WEB_AGENT_NOTICE_LISTENER_REGISTERED = False
|
||||
_WEB_AGENT_BACKGROUND_TASKS: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
class _WebAgentEventPublisher:
|
||||
"""合并 WebAgent 文本增量,并通过有界队列向 SSE 消费者提供事件。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: asyncio.Queue[dict] = asyncio.Queue(
|
||||
maxsize=WEB_AGENT_STREAM_QUEUE_MAX_SIZE
|
||||
)
|
||||
self._pending_events: deque[dict] = deque()
|
||||
self._pending_signal = asyncio.Event()
|
||||
self._pending_delta = ""
|
||||
self._delta_timer: Optional[asyncio.TimerHandle] = None
|
||||
self._disposed = False
|
||||
self._max_depth = 0
|
||||
self._last_logged_depth = 0
|
||||
self._pump_task = asyncio.create_task(self._pump())
|
||||
|
||||
@property
|
||||
def max_depth(self) -> int:
|
||||
"""返回本轮发布器观测到的最大积压深度。"""
|
||||
return self._max_depth
|
||||
|
||||
def publish(self, event: dict) -> None:
|
||||
"""发布事件;相邻文本会按时间或长度边界合并。"""
|
||||
if self._disposed:
|
||||
return
|
||||
if event.get("type") == "delta":
|
||||
self._pending_delta += str(event.get("content") or "")
|
||||
if len(self._pending_delta) >= WEB_AGENT_STREAM_COALESCE_MAX_CHARS:
|
||||
self._flush_delta()
|
||||
elif self._delta_timer is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._delta_timer = loop.call_later(
|
||||
WEB_AGENT_STREAM_COALESCE_SECONDS,
|
||||
self._flush_delta,
|
||||
)
|
||||
return
|
||||
|
||||
self._flush_delta()
|
||||
self._append_event(event)
|
||||
|
||||
async def get(self) -> dict:
|
||||
"""等待并返回下一条已排序事件。"""
|
||||
return await self._queue.get()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""停止发布器并释放等待中的泵任务。"""
|
||||
if self._disposed:
|
||||
return
|
||||
self._disposed = True
|
||||
self._cancel_delta_timer()
|
||||
self._pending_delta = ""
|
||||
self._pending_events.clear()
|
||||
self._pump_task.cancel()
|
||||
try:
|
||||
await self._pump_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _cancel_delta_timer(self) -> None:
|
||||
"""取消尚未触发的文本合并计时器。"""
|
||||
if self._delta_timer is None:
|
||||
return
|
||||
self._delta_timer.cancel()
|
||||
self._delta_timer = None
|
||||
|
||||
def _flush_delta(self) -> None:
|
||||
"""把当前文本缓冲转换成一条增量事件。"""
|
||||
self._cancel_delta_timer()
|
||||
if not self._pending_delta or self._disposed:
|
||||
return
|
||||
content = self._pending_delta
|
||||
self._pending_delta = ""
|
||||
self._append_event({"type": "delta", "content": content})
|
||||
|
||||
def _append_event(self, event: dict) -> None:
|
||||
"""追加待发布事件,相邻文本在出口阻塞时继续合并。"""
|
||||
if (
|
||||
event.get("type") == "delta"
|
||||
and self._pending_events
|
||||
and self._pending_events[-1].get("type") == "delta"
|
||||
):
|
||||
self._pending_events[-1]["content"] += str(event.get("content") or "")
|
||||
else:
|
||||
self._pending_events.append(event)
|
||||
self._pending_signal.set()
|
||||
depth = self._queue.qsize() + len(self._pending_events)
|
||||
self._max_depth = max(self._max_depth, depth)
|
||||
if depth >= WEB_AGENT_STREAM_QUEUE_MAX_SIZE // 2 and depth > self._last_logged_depth:
|
||||
self._last_logged_depth = depth
|
||||
logger.debug(f"WebAgent SSE事件积压深度: {depth}")
|
||||
|
||||
async def _pump(self) -> None:
|
||||
"""按发布顺序把本地合并结果写入有界出口队列。"""
|
||||
while True:
|
||||
await self._pending_signal.wait()
|
||||
while self._pending_events:
|
||||
event = self._pending_events.popleft()
|
||||
await self._queue.put(event)
|
||||
self._pending_signal.clear()
|
||||
|
||||
|
||||
def _ensure_superuser(user: User) -> None:
|
||||
"""校验当前用户是否为超级管理员。"""
|
||||
if not getattr(user, "is_superuser", False):
|
||||
@@ -146,6 +252,20 @@ class _WebAgentStreamingHandler(StreamingHandler):
|
||||
"""
|
||||
self._on_emit = on_emit
|
||||
|
||||
def record_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_message: Optional[str] = None,
|
||||
tool_kwargs: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""记录并立即输出 Web 工具事件,避免汇总延迟到正文结束后。"""
|
||||
super().record_tool_call(
|
||||
tool_name=tool_name,
|
||||
tool_message=tool_message,
|
||||
tool_kwargs=tool_kwargs,
|
||||
)
|
||||
self.flush_pending_tool_summary()
|
||||
|
||||
def emit(self, token: str) -> str:
|
||||
"""追加 token 并同步通知 SSE 生产者。"""
|
||||
emitted = super().emit(token)
|
||||
@@ -268,6 +388,18 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
|
||||
"""文本输出交由 Web 流式处理器统一回调,避免重复增量。"""
|
||||
self.stream_handler.emit(text)
|
||||
|
||||
def _emit_output(self, text: str) -> None:
|
||||
"""保留完整输出状态,同时只把本次增量交给 Web SSE 回调。"""
|
||||
if not text:
|
||||
return
|
||||
self._streamed_output += text
|
||||
if not callable(self.output_callback):
|
||||
return
|
||||
try:
|
||||
self.output_callback(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"Web智能体输出回调失败: {e}")
|
||||
|
||||
|
||||
def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str:
|
||||
"""
|
||||
@@ -318,16 +450,53 @@ async def _get_accessible_agent_chat(
|
||||
return chat
|
||||
|
||||
|
||||
def _append_web_agent_text_segment(assistant_message: dict, content: str) -> None:
|
||||
"""
|
||||
将文本增量追加到展示消息,并仅合并相邻文本片段。
|
||||
|
||||
:param assistant_message: 当前助手展示消息
|
||||
:param content: 新增文本
|
||||
"""
|
||||
if not content:
|
||||
return
|
||||
assistant_message["content"] = str(assistant_message.get("content") or "") + content
|
||||
segments = assistant_message.setdefault("segments", [])
|
||||
if segments and segments[-1].get("type") == "text":
|
||||
segments[-1]["content"] = str(segments[-1].get("content") or "") + content
|
||||
else:
|
||||
segments.append({"type": "text", "content": content})
|
||||
|
||||
|
||||
def _build_legacy_web_agent_segments(content: str, tools: list[dict]) -> list[dict]:
|
||||
"""
|
||||
为未携带有序片段的旧展示消息生成兼容布局。
|
||||
|
||||
:param content: 聚合后的助手文本
|
||||
:param tools: 工具提示列表
|
||||
:return: 按旧版工具在前、文本在后的顺序生成的片段
|
||||
"""
|
||||
segments = [
|
||||
{"type": "tool", "toolIndex": index}
|
||||
for index in range(len(tools))
|
||||
]
|
||||
if content:
|
||||
segments.append({"type": "text", "content": content})
|
||||
return segments
|
||||
|
||||
|
||||
def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None:
|
||||
"""
|
||||
将 WebAgent SSE 事件同步应用到服务端展示消息快照。
|
||||
"""
|
||||
event_type = event.get("type")
|
||||
if event_type == "delta":
|
||||
assistant_message["content"] += event.get("content") or ""
|
||||
_append_web_agent_text_segment(
|
||||
assistant_message, event.get("content") or ""
|
||||
)
|
||||
elif event_type == "tool":
|
||||
for tool in assistant_message["tools"]:
|
||||
tool["status"] = "done"
|
||||
tool_index = len(assistant_message["tools"])
|
||||
assistant_message["tools"].append(
|
||||
{
|
||||
"id": f"tool-{uuid.uuid4().hex}",
|
||||
@@ -335,6 +504,9 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None
|
||||
"status": "running",
|
||||
}
|
||||
)
|
||||
assistant_message.setdefault("segments", []).append(
|
||||
{"type": "tool", "toolIndex": tool_index}
|
||||
)
|
||||
elif event_type == "attachment" and event.get("attachment"):
|
||||
assistant_message["attachments"].append(event["attachment"])
|
||||
elif event_type == "choice" and event.get("choice"):
|
||||
@@ -346,14 +518,22 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None
|
||||
assistant_message["attachments"] = target_message.get("attachments") or []
|
||||
assistant_message["choices"] = target_message.get("choices") or []
|
||||
assistant_message["tools"] = target_message.get("tools") or []
|
||||
target_segments = target_message.get("segments")
|
||||
assistant_message["segments"] = (
|
||||
target_segments
|
||||
if isinstance(target_segments, list)
|
||||
else _build_legacy_web_agent_segments(
|
||||
assistant_message["content"], assistant_message["tools"]
|
||||
)
|
||||
)
|
||||
assistant_message["status"] = target_message.get("status") or "done"
|
||||
elif event_type == "error":
|
||||
assistant_message["status"] = "error"
|
||||
assistant_message["content"] = (
|
||||
assistant_message["content"]
|
||||
or event.get("message")
|
||||
or "智能助手响应失败"
|
||||
)
|
||||
if not assistant_message["content"]:
|
||||
_append_web_agent_text_segment(
|
||||
assistant_message,
|
||||
event.get("message") or "智能助手响应失败",
|
||||
)
|
||||
for tool in assistant_message["tools"]:
|
||||
tool["status"] = "done"
|
||||
elif event_type == "done":
|
||||
@@ -1743,14 +1923,52 @@ async def web_agent_stream(
|
||||
{"session_id": session_id},
|
||||
locale=locale,
|
||||
)
|
||||
events = await _collect_web_agent_traditional_events(
|
||||
text=prompt,
|
||||
current_user=current_user,
|
||||
original_message_id=payload.original_message_id,
|
||||
original_chat_id=payload.original_chat_id,
|
||||
collection_task = asyncio.create_task(
|
||||
_collect_web_agent_traditional_events(
|
||||
text=prompt,
|
||||
current_user=current_user,
|
||||
original_message_id=payload.original_message_id,
|
||||
original_chat_id=payload.original_chat_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
events = await asyncio.wait_for(
|
||||
asyncio.shield(collection_task),
|
||||
timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS,
|
||||
)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
if await request.is_disconnected():
|
||||
collection_task.cancel()
|
||||
return
|
||||
yield ": heartbeat\n\n"
|
||||
except asyncio.CancelledError:
|
||||
if not collection_task.done():
|
||||
collection_task.cancel()
|
||||
return
|
||||
|
||||
assistant_message = _build_web_agent_display_message_from_events(events)
|
||||
display_messages.append(assistant_message)
|
||||
|
||||
async def save_display_snapshot() -> None:
|
||||
"""后台保存传统消息展示快照,不阻塞 SSE 终态。"""
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_save_web_agent_display_snapshot,
|
||||
session_id=session_id,
|
||||
current_user=current_user,
|
||||
messages=display_messages,
|
||||
client_session_id=payload.session_id or session_id,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"保存WebAgent传统消息快照失败: {str(err)}")
|
||||
|
||||
snapshot_task = asyncio.create_task(save_display_snapshot())
|
||||
_WEB_AGENT_BACKGROUND_TASKS.add(snapshot_task)
|
||||
snapshot_task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
|
||||
await asyncio.sleep(0)
|
||||
for event in events:
|
||||
event_payload = copy.deepcopy(event)
|
||||
yield _build_web_agent_sse(
|
||||
@@ -1759,21 +1977,14 @@ async def web_agent_stream(
|
||||
locale=locale,
|
||||
)
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
await run_in_threadpool(
|
||||
_save_web_agent_display_snapshot,
|
||||
session_id=session_id,
|
||||
current_user=current_user,
|
||||
messages=display_messages,
|
||||
client_session_id=payload.session_id or session_id,
|
||||
)
|
||||
return
|
||||
yield _build_web_agent_sse("done", {}, locale=locale)
|
||||
|
||||
return StreamingResponse(
|
||||
traditional_event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
@@ -1820,8 +2031,7 @@ async def web_agent_stream(
|
||||
|
||||
session_id = _build_web_agent_session_id(current_user, payload.session_id)
|
||||
MessageChain().bind_user_session(str(current_user.id), session_id)
|
||||
event_queue: asyncio.Queue = asyncio.Queue()
|
||||
last_output = ""
|
||||
event_publisher = _WebAgentEventPublisher()
|
||||
user_attachments = _build_web_agent_input_attachments(
|
||||
images=payload.images or [],
|
||||
files=[
|
||||
@@ -1846,16 +2056,13 @@ async def web_agent_stream(
|
||||
)
|
||||
display_messages.append(assistant_display_message)
|
||||
|
||||
def output_callback(output: str) -> None:
|
||||
def output_callback(delta: str) -> None:
|
||||
"""
|
||||
接收 Agent 累积输出并转成增量事件。
|
||||
接收 Agent 文本增量并转换成 SSE 事件。
|
||||
"""
|
||||
nonlocal last_output
|
||||
delta = output[len(last_output):] if output.startswith(last_output) else output
|
||||
last_output = output
|
||||
for item in _split_web_agent_output(delta):
|
||||
_apply_web_agent_display_event(item, assistant_display_message)
|
||||
event_queue.put_nowait(item)
|
||||
event_publisher.publish(item)
|
||||
|
||||
def notification_callback(notification: schemas.Notification) -> None:
|
||||
"""
|
||||
@@ -1863,7 +2070,7 @@ async def web_agent_stream(
|
||||
"""
|
||||
for item in _build_web_agent_notification_events(notification):
|
||||
_apply_web_agent_display_event(item, assistant_display_message)
|
||||
event_queue.put_nowait(item)
|
||||
event_publisher.publish(item)
|
||||
|
||||
async def event_generator() -> AsyncIterator[str]:
|
||||
"""
|
||||
@@ -1905,10 +2112,12 @@ async def web_agent_stream(
|
||||
"message": f"智能助手执行失败: {str(err)}",
|
||||
}
|
||||
_apply_web_agent_display_event(error_event, assistant_display_message)
|
||||
await event_queue.put(error_event)
|
||||
event_publisher.publish(error_event)
|
||||
finally:
|
||||
done_event = {"type": "done"}
|
||||
_apply_web_agent_display_event(done_event, assistant_display_message)
|
||||
# 终态先进入 SSE 队列,避免展示快照落库延迟前端结束动画。
|
||||
event_publisher.publish(done_event)
|
||||
await run_in_threadpool(
|
||||
_save_web_agent_display_snapshot,
|
||||
session_id=session_id,
|
||||
@@ -1916,40 +2125,51 @@ async def web_agent_stream(
|
||||
messages=display_messages,
|
||||
client_session_id=payload.session_id or session_id,
|
||||
)
|
||||
await event_queue.put(done_event)
|
||||
|
||||
task = asyncio.create_task(run_agent())
|
||||
_WEB_AGENT_BACKGROUND_TASKS.add(task)
|
||||
task.add_done_callback(_WEB_AGENT_BACKGROUND_TASKS.discard)
|
||||
disconnected = False
|
||||
terminal_sent = False
|
||||
try:
|
||||
yield _build_web_agent_sse(
|
||||
"start",
|
||||
{"session_id": session_id},
|
||||
locale=locale,
|
||||
)
|
||||
disconnected = False
|
||||
while not global_vars.is_system_stopped:
|
||||
if await request.is_disconnected():
|
||||
disconnected = True
|
||||
break
|
||||
event = await event_queue.get()
|
||||
try:
|
||||
event = await asyncio.wait_for(
|
||||
event_publisher.get(),
|
||||
timeout=WEB_AGENT_STREAM_HEARTBEAT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
event_type = str(event.get("type") or "")
|
||||
if event_type == "done":
|
||||
terminal_sent = True
|
||||
yield _build_web_agent_sse(
|
||||
event.pop("type"),
|
||||
event,
|
||||
event_type,
|
||||
{key: value for key, value in event.items() if key != "type"},
|
||||
locale=locale,
|
||||
)
|
||||
if task.done() and event_queue.empty():
|
||||
if event_type == "done":
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
disconnected = True
|
||||
return
|
||||
finally:
|
||||
if not task.done() and not disconnected:
|
||||
if not task.done() and not disconnected and not terminal_sent:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await event_publisher.aclose()
|
||||
# 客户端退到后台导致 SSE 断开时,保留后台 Agent 继续执行;完成后会保存展示快照,
|
||||
# 前端恢复可见时可通过会话详情接口拉取最终状态。
|
||||
|
||||
@@ -1957,7 +2177,7 @@ async def web_agent_stream(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
|
||||
@@ -4,70 +4,13 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db.models.user import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser_async
|
||||
from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cache", summary="查询豆瓣识别缓存", response_model=schemas.Response
|
||||
)
|
||||
async def douban_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""查询可管理的豆瓣识别缓存。"""
|
||||
cache_items = DoubanCache().list_items()
|
||||
recognized_count = sum(1 for item in cache_items if item["douban_id"])
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data={
|
||||
"count": len(cache_items),
|
||||
"recognized": recognized_count,
|
||||
"unrecognized": len(cache_items) - recognized_count,
|
||||
"shared_recognized": SystemConfigOper().get(
|
||||
SystemConfigKey.MediaRecognizeShareCount
|
||||
) or 0,
|
||||
"shared_recognize_enabled": settings.MEDIA_RECOGNIZE_SHARE,
|
||||
"data": cache_items,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/cache/{cache_key:path}",
|
||||
summary="删除指定豆瓣识别缓存",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
async def delete_douban_recognition_cache(
|
||||
cache_key: str,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""按缓存键删除单条豆瓣识别缓存。"""
|
||||
deleted_item = DoubanCache().delete(cache_key)
|
||||
if not deleted_item:
|
||||
return schemas.Response(success=False, message="豆瓣识别缓存不存在")
|
||||
return schemas.Response(success=True, message="豆瓣识别缓存删除成功")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/cache", summary="清空豆瓣识别缓存", response_model=schemas.Response
|
||||
)
|
||||
async def clear_douban_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""清空全部豆瓣识别缓存。"""
|
||||
DoubanCache().clear()
|
||||
return schemas.Response(success=True, message="豆瓣识别缓存清理完成")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ class LlmTestRequest(BaseModel):
|
||||
temperature: Optional[float] = None
|
||||
use_proxy: Optional[bool] = None
|
||||
api_protocol: Optional[str] = None
|
||||
web_search_mode: Optional[str] = None
|
||||
|
||||
|
||||
class LlmProviderAuthStartRequest(BaseModel):
|
||||
@@ -271,6 +272,7 @@ async def llm_test(
|
||||
user_agent=settings.LLM_USER_AGENT,
|
||||
use_proxy=settings.LLM_USE_PROXY,
|
||||
api_protocol=settings.LLM_API_PROTOCOL,
|
||||
web_search_mode=settings.LLM_WEB_SEARCH_MODE,
|
||||
)
|
||||
|
||||
if not payload.provider:
|
||||
@@ -305,6 +307,7 @@ async def llm_test(
|
||||
"user_agent": payload.user_agent,
|
||||
"use_proxy": payload.use_proxy,
|
||||
"api_protocol": payload.api_protocol,
|
||||
"web_search_mode": payload.web_search_mode,
|
||||
}
|
||||
if payload.temperature is not None:
|
||||
test_kwargs["temperature"] = payload.temperature
|
||||
|
||||
@@ -90,7 +90,7 @@ def wallpaper() -> Any:
|
||||
"""
|
||||
url = WallpaperHelper().get_wallpaper()
|
||||
if url:
|
||||
return schemas.Response(success=True, message=url)
|
||||
return schemas.Response(success=True, data=url)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.event import eventmanager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.db.models import User
|
||||
@@ -22,6 +23,29 @@ router = APIRouter()
|
||||
MediaSource = str
|
||||
|
||||
|
||||
def _build_recognize_metainfo(
|
||||
title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
custom_words: Optional[str] = None,
|
||||
) -> MetaBase:
|
||||
"""构造标题识别元数据,并兼容第三方客户端传入媒体文件路径。"""
|
||||
custom_word_list = custom_words.split("\n") if custom_words else None
|
||||
normalized_title = title.replace("\\", "/")
|
||||
title_path = Path(normalized_title)
|
||||
if (
|
||||
("/" in title or "\\" in title)
|
||||
and "://" not in title
|
||||
and title_path.suffix.lower() in settings.RMT_MEDIAEXT
|
||||
):
|
||||
metainfo = MetaInfoPath(
|
||||
title_path,
|
||||
custom_words=custom_word_list,
|
||||
)
|
||||
metainfo.title = title
|
||||
return metainfo
|
||||
return MetaInfo(title, subtitle, custom_words=custom_word_list)
|
||||
|
||||
|
||||
def _build_media_seasons(
|
||||
mediainfo: Any, season: Optional[int] = None,
|
||||
) -> List[schemas.MediaSeason]:
|
||||
@@ -84,9 +108,7 @@ async def recognize(
|
||||
:param _:
|
||||
"""
|
||||
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
|
||||
metainfo = MetaInfo(
|
||||
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
|
||||
)
|
||||
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
metainfo,
|
||||
source=source,
|
||||
|
||||
@@ -11,6 +11,7 @@ from starlette import status
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.api.apiv2_utils import API_V2_STR, OPENAPI_V2_PATH
|
||||
from app.command import Command
|
||||
from app.core.cache import async_fresh
|
||||
from app.core.config import settings
|
||||
@@ -36,8 +37,15 @@ from app.scheduler import Scheduler
|
||||
from app.schemas.event import PluginDataResetEventData
|
||||
from app.schemas.types import ChainEventType, SystemConfigKey
|
||||
|
||||
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||||
PROTECTED_ROUTES = {
|
||||
"/api/v1/openapi.json",
|
||||
OPENAPI_V2_PATH,
|
||||
"/docs",
|
||||
"/docs/oauth2-redirect",
|
||||
"/redoc",
|
||||
}
|
||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||
PLUGIN_V2_PREFIX = f"{API_V2_STR}/plugin"
|
||||
|
||||
router = APIRouter()
|
||||
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
|
||||
@@ -158,8 +166,11 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||
elif Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
app.add_api_route(**api, tags=["plugin"])
|
||||
v2_api = api.copy()
|
||||
v2_api["path"] = api_path.replace(PLUGIN_PREFIX, PLUGIN_V2_PREFIX, 1)
|
||||
app.add_api_route(**v2_api, tags=["plugin"])
|
||||
is_modified = True
|
||||
logger.debug(f"Added plugin route: {api_path}")
|
||||
logger.debug(f"Added plugin routes: {api_path}, {v2_api['path']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
|
||||
|
||||
@@ -177,8 +188,13 @@ def _remove_routes(plugin_id: str) -> bool:
|
||||
"""
|
||||
if not plugin_id:
|
||||
return False
|
||||
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
|
||||
routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)]
|
||||
prefixes = {
|
||||
f"{PLUGIN_PREFIX}/{plugin_id}/",
|
||||
f"{PLUGIN_V2_PREFIX}/{plugin_id}/",
|
||||
}
|
||||
routes_to_remove = [
|
||||
route for route in app.routes if any(route.path.startswith(prefix) for prefix in prefixes)
|
||||
]
|
||||
removed = False
|
||||
for route in routes_to_remove:
|
||||
try:
|
||||
|
||||
+51
-75
@@ -36,6 +36,12 @@ from app.db.user_oper import (
|
||||
)
|
||||
from app.helper.image import ImageHelper
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.helper.market import (
|
||||
PLUGIN_MARKET_WIKI_URL,
|
||||
extract_plugin_market_repos_from_wiki,
|
||||
merge_plugin_market_repos,
|
||||
split_plugin_market_repo_urls,
|
||||
)
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.rule import RuleHelper
|
||||
@@ -70,32 +76,47 @@ _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:
|
||||
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
|
||||
"""校验强制服务端联网搜索配置,返回用户可读错误信息。"""
|
||||
from app.agent.llm.server_tools import (
|
||||
ServerToolRegistry,
|
||||
ServerToolUnavailableError,
|
||||
)
|
||||
|
||||
mode = ServerToolRegistry.normalize_web_search_mode(
|
||||
env.get(
|
||||
"LLM_WEB_SEARCH_MODE",
|
||||
getattr(settings, "LLM_WEB_SEARCH_MODE", "local"),
|
||||
)
|
||||
)
|
||||
if mode != "builtin":
|
||||
return None
|
||||
repo_url = repo_url.removesuffix(".git")
|
||||
parsed_url = urlparse(repo_url)
|
||||
if parsed_url.scheme not in {"http", "https"}:
|
||||
|
||||
provider = str(
|
||||
env.get("LLM_PROVIDER", getattr(settings, "LLM_PROVIDER", "")) or ""
|
||||
).strip()
|
||||
model = str(
|
||||
env.get("LLM_MODEL", getattr(settings, "LLM_MODEL", "")) or ""
|
||||
).strip()
|
||||
base_url = env.get("LLM_BASE_URL", getattr(settings, "LLM_BASE_URL", None))
|
||||
capability = ServerToolRegistry.get_capability(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=str(base_url or "").strip() or None,
|
||||
tool_id="web_search",
|
||||
)
|
||||
if capability:
|
||||
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]}"
|
||||
|
||||
return str(
|
||||
ServerToolUnavailableError(
|
||||
provider=provider,
|
||||
model=model,
|
||||
tool_id="web_search",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
|
||||
@@ -115,55 +136,6 @@ def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
判断目标URL是否仍然落在允许的协议、主机、端口和路径前缀内。
|
||||
@@ -763,6 +735,10 @@ async def set_env_setting(
|
||||
"""
|
||||
更新系统环境变量(仅管理员)
|
||||
"""
|
||||
validation_error = _validate_llm_server_tool_config(env)
|
||||
if validation_error:
|
||||
return schemas.Response(success=False, message=validation_error)
|
||||
|
||||
result = settings.update_settings(env=env)
|
||||
# 统计成功和失败的结果
|
||||
success_updates = {k: v for k, v in result.items() if v[0]}
|
||||
@@ -844,7 +820,7 @@ async def sync_plugin_market_from_wiki(
|
||||
"""
|
||||
从 Wiki 插件文档同步插件市场仓库地址。
|
||||
"""
|
||||
wiki_url = (request.wiki_url if request else None) or _PLUGIN_MARKET_WIKI_URL
|
||||
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 同步地址")
|
||||
@@ -864,14 +840,14 @@ async def sync_plugin_market_from_wiki(
|
||||
message=f"访问 Wiki 插件仓库清单失败,状态码:{res.status_code}",
|
||||
)
|
||||
|
||||
wiki_repos = _extract_plugin_market_repos_from_wiki(res.text)
|
||||
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_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_repos = merge_plugin_market_repos(local_repos, wiki_repos)
|
||||
merged_value = ",".join(merged_repos)
|
||||
|
||||
success, message = settings.update_setting("PLUGIN_MARKET", merged_value)
|
||||
|
||||
@@ -119,7 +119,7 @@ async def upload_avatar(
|
||||
if not user:
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
await user.async_update(db, {"avatar": f"data:image/ico;base64,{file_base64}"})
|
||||
return schemas.Response(success=True, message=file.filename)
|
||||
return schemas.Response(success=True, data={"filename": file.filename})
|
||||
|
||||
|
||||
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
|
||||
|
||||
@@ -1349,6 +1349,33 @@ class MessageChain(ChainBase):
|
||||
f"排队消息数: {status.get('pending_messages', 0)}",
|
||||
f"最后更新: {status.get('last_updated_at') or '暂无'}",
|
||||
]
|
||||
if status.get("cache_usage_available"):
|
||||
last_cache_ratio = status.get("last_cache_hit_ratio")
|
||||
total_cache_ratio = status.get("total_cache_hit_ratio")
|
||||
lines.insert(
|
||||
6,
|
||||
"最近一次缓存: "
|
||||
f"命中 {cls._format_token_count(status.get('last_cache_read_input_tokens'))} / "
|
||||
f"写入 {cls._format_token_count(status.get('last_cache_write_input_tokens'))} / "
|
||||
f"未命中 {cls._format_token_count(status.get('last_uncached_input_tokens'))}"
|
||||
+ (
|
||||
f" ({last_cache_ratio * 100:.2f}%)"
|
||||
if last_cache_ratio is not None
|
||||
else ""
|
||||
),
|
||||
)
|
||||
lines.insert(
|
||||
8,
|
||||
"当前会话累计缓存: "
|
||||
f"命中 {cls._format_token_count(status.get('total_cache_read_input_tokens'))} / "
|
||||
f"写入 {cls._format_token_count(status.get('total_cache_write_input_tokens'))} / "
|
||||
f"未命中 {cls._format_token_count(status.get('total_uncached_input_tokens'))}"
|
||||
+ (
|
||||
f" ({total_cache_ratio * 100:.2f}%)"
|
||||
if total_cache_ratio is not None
|
||||
else ""
|
||||
),
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def remote_session_status(
|
||||
|
||||
+171
-11
@@ -6,6 +6,7 @@ import traceback
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
from typing import List, Optional, Tuple, Union, Dict, Callable, Any
|
||||
|
||||
from app import schemas
|
||||
@@ -122,11 +123,17 @@ class JobManager:
|
||||
_season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 记录从 meta 作业迁移到 media 作业的关系,用于清理提前失败后残留的 media 作业
|
||||
_meta_to_media_ids: Dict[Tuple, set[Tuple]] = {}
|
||||
# 记录任务最近一次状态心跳,供外部异步接管任务的失活检测使用
|
||||
_task_state_changed_at: Dict[Tuple[str, str], float] = {}
|
||||
# 记录仍由主程序整理线程直接执行的任务,避免把阻塞中的本地任务误判为失活
|
||||
_active_executions: set[Tuple[str, str]] = set()
|
||||
|
||||
def __init__(self):
|
||||
self._job_view = {}
|
||||
self._season_episodes = {}
|
||||
self._meta_to_media_ids = {}
|
||||
self._task_state_changed_at = {}
|
||||
self._active_executions = set()
|
||||
|
||||
@staticmethod
|
||||
def __get_meta_id(meta: MetaBase = None, season: Optional[int] = None) -> Tuple:
|
||||
@@ -248,6 +255,7 @@ class JobManager:
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
self._task_state_changed_at[file_key] = monotonic()
|
||||
# 添加季集信息
|
||||
if self._season_episodes.get(__mediaid__):
|
||||
self._season_episodes[__mediaid__].extend(task.meta.episode_list)
|
||||
@@ -262,7 +270,9 @@ class JobManager:
|
||||
"""
|
||||
将任务从 meta 作业迁移到 media 作业
|
||||
"""
|
||||
curr_task, source_job_id = self.__remove_task_with_job_id(task.fileitem)
|
||||
curr_task, source_job_id = self.__remove_task_with_job_id(
|
||||
task.fileitem, preserve_execution=True
|
||||
)
|
||||
if not self.add_task(task, state=curr_task.state if curr_task else "waiting"):
|
||||
return False
|
||||
if curr_task and task.mediainfo:
|
||||
@@ -290,14 +300,116 @@ class JobManager:
|
||||
"""
|
||||
移除指定作业和对应季集缓存
|
||||
"""
|
||||
if job_id in self._season_episodes:
|
||||
self._season_episodes.pop(job_id)
|
||||
if job_id in self._job_view:
|
||||
self._job_view.pop(job_id)
|
||||
job = self._job_view.pop(job_id, None)
|
||||
self._season_episodes.pop(job_id, None)
|
||||
if not job:
|
||||
return
|
||||
for task in job.tasks:
|
||||
file_key = self.__get_file_key(task.fileitem)
|
||||
if file_key:
|
||||
self._task_state_changed_at.pop(file_key, None)
|
||||
self._active_executions.discard(file_key)
|
||||
|
||||
def __remove_done_job_groups(self, job_ids: set[Tuple]):
|
||||
"""
|
||||
清理已进入终态的独立作业或关联作业组。
|
||||
"""
|
||||
candidates = set(job_ids)
|
||||
for metaid, mediaids in list(self._meta_to_media_ids.items()):
|
||||
related_ids = {metaid, *mediaids}
|
||||
if not related_ids.intersection(candidates):
|
||||
continue
|
||||
if all(self.__is_job_done(job_id) for job_id in related_ids):
|
||||
for job_id in related_ids:
|
||||
self.__pop_job(job_id)
|
||||
self._meta_to_media_ids.pop(metaid, None)
|
||||
candidates.difference_update(related_ids)
|
||||
|
||||
referenced_ids = {
|
||||
job_id
|
||||
for metaid, mediaids in self._meta_to_media_ids.items()
|
||||
for job_id in {metaid, *mediaids}
|
||||
}
|
||||
for job_id in candidates - referenced_ids:
|
||||
if self.__is_job_done(job_id):
|
||||
self.__pop_job(job_id)
|
||||
|
||||
def start_execution(self, task: TransferTask):
|
||||
"""
|
||||
标记任务仍由主程序整理线程直接执行。
|
||||
|
||||
:param task: 整理任务
|
||||
"""
|
||||
if not task or not task.fileitem:
|
||||
return
|
||||
file_key = self.__get_file_key(task.fileitem)
|
||||
if not file_key:
|
||||
return
|
||||
with job_lock:
|
||||
self._active_executions.add(file_key)
|
||||
|
||||
def finish_execution(self, task: TransferTask):
|
||||
"""
|
||||
结束主程序整理线程对任务的直接执行标记。
|
||||
|
||||
:param task: 整理任务
|
||||
"""
|
||||
if not task or not task.fileitem:
|
||||
return
|
||||
file_key = self.__get_file_key(task.fileitem)
|
||||
if not file_key:
|
||||
return
|
||||
with job_lock:
|
||||
self._active_executions.discard(file_key)
|
||||
|
||||
def expire_stale_running_tasks(
|
||||
self, timeout_seconds: int
|
||||
) -> List[Tuple[FileItem, int]]:
|
||||
"""
|
||||
将外部接管后长期无心跳的运行中任务标记失败并清理作业视图。
|
||||
|
||||
主程序整理线程仍在直接执行的任务不会被清理,以免把阻塞中的真实任务
|
||||
误报为已终止。外部接管方可重复调用 ``running_task`` 刷新状态心跳。
|
||||
|
||||
:param timeout_seconds: 失活超时秒数,小于等于 0 时禁用
|
||||
:return: 已失活任务及其无心跳秒数
|
||||
"""
|
||||
if timeout_seconds <= 0:
|
||||
return []
|
||||
|
||||
current_time = monotonic()
|
||||
expired: List[Tuple[FileItem, int]] = []
|
||||
affected_job_ids: set[Tuple] = set()
|
||||
with job_lock:
|
||||
for mediaid, job in self._job_view.items():
|
||||
for task in job.tasks:
|
||||
file_key = self.__get_file_key(task.fileitem)
|
||||
if (
|
||||
not file_key
|
||||
or task.state != "running"
|
||||
or file_key in self._active_executions
|
||||
):
|
||||
continue
|
||||
updated_at = self._task_state_changed_at.get(file_key, current_time)
|
||||
inactive_seconds = current_time - updated_at
|
||||
if inactive_seconds < timeout_seconds:
|
||||
continue
|
||||
task.state = "failed"
|
||||
self._task_state_changed_at[file_key] = current_time
|
||||
episodes = getattr(task.meta, "episode_list", None) or []
|
||||
if mediaid in self._season_episodes:
|
||||
self._season_episodes[mediaid] = list(
|
||||
set(self._season_episodes[mediaid]) - set(episodes)
|
||||
)
|
||||
expired.append((task.fileitem, int(inactive_seconds)))
|
||||
affected_job_ids.add(mediaid)
|
||||
|
||||
self.__remove_done_job_groups(affected_job_ids)
|
||||
return expired
|
||||
|
||||
def running_task(self, task: TransferTask):
|
||||
"""
|
||||
设置任务为运行中
|
||||
设置任务为运行中,并刷新外部异步任务的状态心跳。
|
||||
"""
|
||||
with job_lock:
|
||||
__mediaid__ = self.__get_id(task)
|
||||
@@ -307,6 +419,9 @@ class JobManager:
|
||||
for t in self._job_view[__mediaid__].tasks:
|
||||
if t.fileitem == task.fileitem:
|
||||
t.state = "running"
|
||||
file_key = self.__get_file_key(t.fileitem)
|
||||
if file_key:
|
||||
self._task_state_changed_at[file_key] = monotonic()
|
||||
break
|
||||
|
||||
def finish_task(self, task: TransferTask):
|
||||
@@ -321,6 +436,9 @@ class JobManager:
|
||||
for t in self._job_view[__mediaid__].tasks:
|
||||
if t.fileitem == task.fileitem:
|
||||
t.state = "completed"
|
||||
file_key = self.__get_file_key(t.fileitem)
|
||||
if file_key:
|
||||
self._task_state_changed_at[file_key] = monotonic()
|
||||
break
|
||||
|
||||
def fail_task(self, task: TransferTask):
|
||||
@@ -335,6 +453,9 @@ class JobManager:
|
||||
for t in self._job_view[__mediaid__].tasks:
|
||||
if t.fileitem == task.fileitem:
|
||||
t.state = "failed"
|
||||
file_key = self.__get_file_key(t.fileitem)
|
||||
if file_key:
|
||||
self._task_state_changed_at[file_key] = monotonic()
|
||||
break
|
||||
# 移除剧集信息
|
||||
if __mediaid__ in self._season_episodes:
|
||||
@@ -359,6 +480,7 @@ class JobManager:
|
||||
continue
|
||||
if job_task.state not in ["completed", "failed"]:
|
||||
job_task.state = "failed"
|
||||
self._task_state_changed_at[file_key] = monotonic()
|
||||
if mediaid in self._season_episodes:
|
||||
self._season_episodes[mediaid] = list(
|
||||
set(self._season_episodes[mediaid])
|
||||
@@ -374,7 +496,9 @@ class JobManager:
|
||||
return task
|
||||
|
||||
def __remove_task_with_job_id(
|
||||
self, fileitem: FileItem
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
preserve_execution: bool = False,
|
||||
) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]:
|
||||
"""
|
||||
根据文件项移除任务,并返回任务所在的作业ID
|
||||
@@ -388,6 +512,9 @@ class JobManager:
|
||||
for task in job.tasks:
|
||||
if self.__get_file_key(task.fileitem) == file_key:
|
||||
job.tasks.remove(task)
|
||||
self._task_state_changed_at.pop(file_key, None)
|
||||
if not preserve_execution:
|
||||
self._active_executions.discard(file_key)
|
||||
# 如果没有作业了,则移除作业
|
||||
if not job.tasks:
|
||||
self._job_view.pop(mediaid)
|
||||
@@ -407,10 +534,9 @@ class JobManager:
|
||||
with job_lock:
|
||||
__mediaid__ = self.__get_id(task)
|
||||
if __mediaid__ in self._job_view:
|
||||
# 移除季集信息
|
||||
if __mediaid__ in self._season_episodes:
|
||||
self._season_episodes.pop(__mediaid__)
|
||||
return self._job_view.pop(__mediaid__)
|
||||
job = self._job_view[__mediaid__]
|
||||
self.__pop_job(__mediaid__)
|
||||
return job
|
||||
return None
|
||||
|
||||
def try_remove_job(self, task: TransferTask):
|
||||
@@ -1509,6 +1635,33 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
return
|
||||
self.jobview.remove_task(fileitem)
|
||||
|
||||
def __start_job_execution(self, task: TransferTask):
|
||||
"""在作业视图支持执行租约时标记主程序任务开始执行。"""
|
||||
marker = getattr(self.jobview, "start_execution", None)
|
||||
if marker:
|
||||
marker(task)
|
||||
|
||||
def __finish_job_execution(self, task: TransferTask):
|
||||
"""在作业视图支持执行租约时标记主程序任务结束执行。"""
|
||||
marker = getattr(self.jobview, "finish_execution", None)
|
||||
if marker:
|
||||
marker(task)
|
||||
|
||||
def __expire_stale_transfer_tasks(self):
|
||||
"""清理外部接管后失去状态心跳的运行中整理任务。"""
|
||||
timeout_minutes = max(int(settings.TRANSFER_TASK_TIMEOUT), 0)
|
||||
expire_tasks = getattr(self.jobview, "expire_stale_running_tasks", None)
|
||||
expired_tasks = (
|
||||
expire_tasks(timeout_seconds=timeout_minutes * 60)
|
||||
if expire_tasks
|
||||
else []
|
||||
)
|
||||
for fileitem, inactive_seconds in expired_tasks:
|
||||
logger.error(
|
||||
f"整理任务 {fileitem.path} 已连续 {inactive_seconds // 60} 分钟无状态心跳,"
|
||||
"已标记失败并从整理队列视图清理"
|
||||
)
|
||||
|
||||
def __fail_transfer_task(self, task: TransferTask):
|
||||
"""
|
||||
标记异常整理任务失败并清理作业视图
|
||||
@@ -1560,6 +1713,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self._active_tasks += 1
|
||||
|
||||
try:
|
||||
self.__start_job_execution(task)
|
||||
# 更新进度
|
||||
__process_msg = f"正在整理 {fileitem.name} ..."
|
||||
logger.info(__process_msg)
|
||||
@@ -1598,6 +1752,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self._processed_num += 1
|
||||
self._fail_num += 1
|
||||
finally:
|
||||
self.__finish_job_execution(task)
|
||||
self._queue.task_done()
|
||||
with task_lock:
|
||||
# 减少运行中的任务数
|
||||
@@ -1618,6 +1773,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
except queue.Empty:
|
||||
# 即使队列空了,如果还有任务在运行,也不应该结束进度
|
||||
# 这部分逻辑已经在 finally 的 active_tasks == 0 中处理了
|
||||
self.__expire_stale_transfer_tasks()
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"整理队列处理出现错误:{e} - {traceback.format_exc()}")
|
||||
@@ -1914,6 +2070,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
获取整理任务列表
|
||||
"""
|
||||
self.__expire_stale_transfer_tasks()
|
||||
return self.jobview.list_jobs()
|
||||
|
||||
def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]:
|
||||
@@ -3446,6 +3603,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
},
|
||||
)
|
||||
try:
|
||||
self.__start_job_execution(transfer_task)
|
||||
state, err_msg = self.__handle_transfer(
|
||||
task=transfer_task,
|
||||
callback=_preview_callback if preview else self.__default_callback,
|
||||
@@ -3458,6 +3616,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if not preview:
|
||||
self.__fail_transfer_task(transfer_task)
|
||||
state, err_msg = False, str(e)
|
||||
finally:
|
||||
self.__finish_job_execution(transfer_task)
|
||||
if not state:
|
||||
all_success = False
|
||||
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
|
||||
+66
-15
@@ -178,7 +178,7 @@ class ConfigModel(BaseModel):
|
||||
PACKAGE_CACHE_DAYS: int = 90
|
||||
# pip/uv 包下载缓存根目录,留空时使用配置目录下的 .cache
|
||||
PACKAGE_CACHE_ROOT: Optional[str] = None
|
||||
# 元数据识别缓存过期时间(小时),0为自动
|
||||
# 单条元数据识别缓存有效期(小时),0为自动
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
|
||||
# ==================== 网络代理配置 ====================
|
||||
@@ -391,6 +391,8 @@ class ConfigModel(BaseModel):
|
||||
# ==================== 整理配置 ====================
|
||||
# 文件整理线程数
|
||||
TRANSFER_THREADS: int = 1
|
||||
# 外部接管的运行中整理任务无状态心跳超时(分钟),0 表示禁用
|
||||
TRANSFER_TASK_TIMEOUT: int = 120
|
||||
# 电影重命名格式
|
||||
MOVIE_RENAME_FORMAT: str = (
|
||||
"{{title}}{% if year %} ({{year}}){% endif %}"
|
||||
@@ -434,22 +436,69 @@ class ConfigModel(BaseModel):
|
||||
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||
"https://github.com/honue/MoviePilot-Plugins,"
|
||||
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
|
||||
"https://github.com/DDSRem-Dev/MoviePilot-Plugins,"
|
||||
"https://github.com/madrays/MoviePilot-Plugins,"
|
||||
"https://github.com/justzerock/MoviePilot-Plugins,"
|
||||
"https://github.com/KoWming/MoviePilot-Plugins,"
|
||||
"https://github.com/wikrin/MoviePilot-Plugins,"
|
||||
"https://github.com/dandkong/MoviePilot-Plugins,"
|
||||
"https://github.com/Aqr-K/MoviePilot-Plugins,"
|
||||
"https://github.com/AnjoyLi/MoviePilot-Plugins,"
|
||||
"https://github.com/WithdewHua/MoviePilot-Plugins,"
|
||||
"https://github.com/HankunYu/MoviePilot-Plugins,"
|
||||
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
|
||||
"https://github.com/Aqr-K/MoviePilot-Plugins,"
|
||||
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/almus2zhang/MoviePilot-Plugins,"
|
||||
"https://github.com/Pixel-LH/MoviePilot-Plugins,"
|
||||
"https://github.com/lightolly/MoviePilot-Plugins,"
|
||||
"https://github.com/suraxiuxiu/MoviePilot-Plugins,"
|
||||
"https://github.com/gxterry/MoviePilot-Plugins,"
|
||||
"https://github.com/DzAvril/MoviePilot-Plugins,"
|
||||
"https://github.com/mrtian2016/MoviePilot-Plugins,"
|
||||
"https://github.com/Hqyel/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/xijin285/MoviePilot-Plugins,"
|
||||
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/boeto/MoviePilot-Plugins,"
|
||||
"https://github.com/xiangt920/MoviePilot-Plugins,"
|
||||
"https://github.com/yubanmeiqin9048/MoviePilot-Plugins,"
|
||||
"https://github.com/loongcheung/MoviePilot-Plugins,"
|
||||
"https://github.com/xcehnz/MoviePilot-Plugins,"
|
||||
"https://github.com/imaliang/MoviePilot-Plugins,"
|
||||
"https://github.com/wikrin/MoviePilot-Plugins,"
|
||||
"https://github.com/DDSRem-Dev/MoviePilot-Plugins,"
|
||||
"https://github.com/KoWming/MoviePilot-Plugins,"
|
||||
"https://github.com/madrays/MoviePilot-Plugins,"
|
||||
"https://github.com/aClarkChen/MoviePilot-Plugins,"
|
||||
"https://github.com/justzerock/MoviePilot-Plugins,"
|
||||
"https://github.com/Seed680/MoviePilot-Plugins,"
|
||||
"https://github.com/imaliang/MoviePilot-Plugins"
|
||||
"https://github.com/DzAvril/MoviePilot-Plugins,"
|
||||
"https://github.com/xijin285/MoviePilot-Plugins,"
|
||||
"https://github.com/cqubioyj/MoviePilot-Plugins,"
|
||||
"https://github.com/Hqyel/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/xiaoQQya/MoviePilot-Plugins,"
|
||||
"https://github.com/mrtian2016/MoviePilot-Plugins,"
|
||||
"https://github.com/andyxu8023/MoviePilot-Plugins,"
|
||||
"https://github.com/Sinterdial/MoviePilot-Plugins,"
|
||||
"https://github.com/4Nest/MoviePilot-Plugins,"
|
||||
"https://github.com/baranwang/MoviePilot-Plugins,"
|
||||
"https://github.com/luanyi143/MoviePilot-Plugins,"
|
||||
"https://github.com/jianji112/MoviePilot-Plugins,"
|
||||
"https://github.com/AWdress/MoviePilot-Plugins,"
|
||||
"https://github.com/cyt-666/MoviePilot-Plugins,"
|
||||
"https://github.com/jiangbkvir/MoviePilot-Plugins,"
|
||||
"https://github.com/wio-ki/MoviePilot-Plugins,"
|
||||
"https://github.com/bfjy2024/MoviePilot-Plugins,"
|
||||
"https://github.com/ui-beam-9/MoviePilot-Plugins,"
|
||||
"https://github.com/Mister-album/MoviePilot-Plugins,"
|
||||
"https://github.com/ifsherlock/MoviePilot-Plugins,"
|
||||
"https://github.com/clone-fan/MoviePilot-Plugins,"
|
||||
"https://github.com/jun9100/moviepilot-multiratingsrecommend,"
|
||||
"https://github.com/AshBearlt/MoviePilot-Plugins,"
|
||||
"https://github.com/z2561221/MoviePilot-Plugins,"
|
||||
"https://github.com/jinyuhao-886/MoviePilot-Plugins,"
|
||||
"https://github.com/Fuck996/MoviePilot-Plugins,"
|
||||
"https://github.com/shyblacktea/MoviePilot-Plugins,"
|
||||
"https://github.com/milikii/MoviePilot-ProwlarrExtend,"
|
||||
"https://github.com/cudamin/MoviePilot-Plugins,"
|
||||
"https://github.com/singleton-altman/MoviePilot-Plugins,"
|
||||
"https://github.com/morningstar-ski/moviepilot-zhuiju-helper,"
|
||||
"https://github.com/jtcymc/MoviePilot-PluginsV2,"
|
||||
"https://github.com/EWEDLCM/MoviePilot-Plugins,"
|
||||
"https://github.com/mitlearn/MoviePilot-PluginsV2,"
|
||||
"https://github.com/BrettDean/MoviePilot-Plugins,"
|
||||
"https://github.com/odomu/MoviePilot-Plugins,"
|
||||
"https://github.com/zyt0339/MoviePilot-Plugins,"
|
||||
"https://github.com/TorrenKt/MoviePilot-Plugins-TSLM"
|
||||
)
|
||||
# 插件安装数据共享
|
||||
PLUGIN_STATISTIC_SHARE: bool = True
|
||||
@@ -571,6 +620,8 @@ class ConfigModel(BaseModel):
|
||||
LLM_THINKING_LEVEL: Optional[str] = "off"
|
||||
# OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses
|
||||
LLM_API_PROTOCOL: str = "auto"
|
||||
# 联网搜索模式:local(本地)/ builtin(模型服务端)/ auto(自动)/ disabled(关闭)
|
||||
LLM_WEB_SEARCH_MODE: str = "local"
|
||||
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
|
||||
LLM_SUPPORT_IMAGE_INPUT: bool = True
|
||||
# 是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent
|
||||
@@ -585,8 +636,8 @@ class ConfigModel(BaseModel):
|
||||
LLM_USE_PROXY: bool = True
|
||||
# LLM Base URL 预设标识,用于区分同一 Base URL 下的不同模型目录
|
||||
LLM_BASE_URL_PRESET: Optional[str] = None
|
||||
# LLM最大上下文Token数量(K)
|
||||
LLM_MAX_CONTEXT_TOKENS: int = 128
|
||||
# LLM最大上下文Token数量(K),仅在模型目录未提供规格时作为回退值
|
||||
LLM_MAX_CONTEXT_TOKENS: int = 256
|
||||
# LLM OpenAI兼容接口请求User-Agent
|
||||
LLM_USER_AGENT: Optional[str] = None
|
||||
# LLM温度参数
|
||||
|
||||
+126
-33
@@ -9,6 +9,7 @@ import socket
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
@@ -48,6 +49,11 @@ LOG_ERROR_PATTERNS = (
|
||||
LOG_RECORD_PATTERN = re.compile(
|
||||
r"(?:【(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)】|(?:DEBUG|INFO|WARNING|ERROR|CRITICAL):)"
|
||||
)
|
||||
LOG_TIMESTAMP_PATTERN = re.compile(
|
||||
r"(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})"
|
||||
)
|
||||
LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
LOG_LOOKBACK_HOURS = 24
|
||||
CONSOLE_LOGGER_PATTERN = re.compile(r"\[([^\]]+)]")
|
||||
PLUGIN_ERROR_PATTERNS = (
|
||||
re.compile(r"(?:^|\s-\s)plugin\.py\s+-\s", re.IGNORECASE),
|
||||
@@ -295,6 +301,78 @@ def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -
|
||||
return list(deque((_mask_text(line) for line in text.splitlines()), maxlen=max_lines))
|
||||
|
||||
|
||||
def _parse_log_timestamp(line: str) -> Optional[datetime]:
|
||||
"""解析日志行中的时间戳。"""
|
||||
match = LOG_TIMESTAMP_PATTERN.search(line[:96])
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(match.group(1), LOG_TIMESTAMP_FORMAT)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _recent_log_lines(
|
||||
lines: list[str],
|
||||
now: Optional[datetime] = None,
|
||||
) -> list[str]:
|
||||
"""按日志记录边界保留诊断时间窗内的日志。"""
|
||||
if not lines:
|
||||
return []
|
||||
timestamps = [_parse_log_timestamp(line) for line in lines]
|
||||
if not any(timestamps):
|
||||
return lines
|
||||
|
||||
cutoff = (now or datetime.now()) - timedelta(hours=LOG_LOOKBACK_HOURS)
|
||||
recent: list[str] = []
|
||||
include_record = False
|
||||
for line, timestamp in zip(lines, timestamps):
|
||||
if timestamp is not None:
|
||||
include_record = timestamp >= cutoff
|
||||
if include_record:
|
||||
recent.append(line)
|
||||
return recent
|
||||
|
||||
|
||||
def _error_fingerprint(line: str) -> str:
|
||||
"""生成跨主日志、控制台镜像和插件独立日志可比较的错误指纹。"""
|
||||
normalized = LOG_TIMESTAMP_PATTERN.sub("<time>", line.strip())
|
||||
if " - " in normalized:
|
||||
normalized = normalized.rsplit(" - ", 1)[-1]
|
||||
normalized = re.sub(
|
||||
r"^(?:【(?:ERROR|CRITICAL|WARNING)】|(?:ERROR|CRITICAL|WARNING):)\s*",
|
||||
"",
|
||||
normalized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return re.sub(r"\s+", " ", normalized).strip().lower()
|
||||
|
||||
|
||||
def _aggregate_log_entries(
|
||||
entries: list[tuple[Path, str]],
|
||||
max_matches: int = 12,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""合并跨日志的重复错误,并返回详情行和来源文件。"""
|
||||
unique_entries: dict[str, dict[str, Any]] = {}
|
||||
log_files: list[str] = []
|
||||
for path, line in entries:
|
||||
path_text = str(path)
|
||||
if path_text not in log_files:
|
||||
log_files.append(path_text)
|
||||
fingerprint = _error_fingerprint(line)
|
||||
if fingerprint not in unique_entries:
|
||||
unique_entries[fingerprint] = {"line": line, "sources": []}
|
||||
sources = unique_entries[fingerprint]["sources"]
|
||||
if path.name not in sources:
|
||||
sources.append(path.name)
|
||||
|
||||
details = [
|
||||
f"[{', '.join(item['sources'])}] {item['line']}"
|
||||
for item in list(unique_entries.values())[-max_matches:]
|
||||
]
|
||||
return details, log_files
|
||||
|
||||
|
||||
def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
|
||||
"""从近期日志中提取错误关键词命中的行。"""
|
||||
matches: list[str] = []
|
||||
@@ -430,13 +508,13 @@ def _check_config(runner: DoctorRunnerProtocol) -> None:
|
||||
)
|
||||
|
||||
proxy_host = (settings.PROXY_HOST or "").strip()
|
||||
if proxy_host and not re.match(r"^https?://", proxy_host, re.IGNORECASE):
|
||||
if proxy_host and not re.match(r"^(https?|socks5h?)://", proxy_host, re.IGNORECASE):
|
||||
runner.add(
|
||||
finding_id="config.proxy_format",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="代理地址格式可能不完整",
|
||||
detail=f"PROXY_HOST={proxy_host} 未包含 http:// 或 https:// 前缀。",
|
||||
detail=f"PROXY_HOST={proxy_host} 未包含 http:// 或 https:// 或 socks5:// 或 socks5h:// 前缀。",
|
||||
recommendation="如果外部访问异常,请把 PROXY_HOST 调整为完整 URL。",
|
||||
)
|
||||
|
||||
@@ -756,11 +834,15 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
|
||||
log_files.extend(plugin_log_files[:20])
|
||||
|
||||
found_any = False
|
||||
entries: dict[str, list[tuple[Path, str]]] = {
|
||||
"core": [],
|
||||
"plugin": [],
|
||||
}
|
||||
for path in log_files:
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
found_any = True
|
||||
lines = _tail_lines(path)
|
||||
lines = _recent_log_lines(_tail_lines(path))
|
||||
is_plugin_log = plugin_log_dir in path.parents
|
||||
if is_plugin_log:
|
||||
scoped_errors = [(True, _find_error_lines(lines))]
|
||||
@@ -770,35 +852,9 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
|
||||
plugin_logger_names,
|
||||
)
|
||||
scoped_errors = [(False, core_errors), (True, plugin_errors)]
|
||||
if not any(errors for _, errors in scoped_errors):
|
||||
continue
|
||||
has_core_errors = bool(scoped_errors[0][1]) if not is_plugin_log else False
|
||||
for is_plugin_error, errors in scoped_errors:
|
||||
if not errors:
|
||||
continue
|
||||
finding_suffix = (
|
||||
"plugin_errors"
|
||||
if is_plugin_error and has_core_errors
|
||||
else "recent_errors"
|
||||
)
|
||||
runner.add(
|
||||
finding_id=f"logs.{path.stem}.{finding_suffix}",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="最近日志存在插件异常" if is_plugin_error else "最近日志存在错误线索",
|
||||
detail="\n".join(errors),
|
||||
recommendation=(
|
||||
"可使用安全模式启动后检查插件配置。"
|
||||
if is_plugin_error
|
||||
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
|
||||
),
|
||||
affects_report_status=not is_plugin_error,
|
||||
context={
|
||||
"log_file": str(path),
|
||||
"matches": len(errors),
|
||||
"component": "plugin" if is_plugin_error else "core",
|
||||
},
|
||||
)
|
||||
component = "plugin" if is_plugin_error else "core"
|
||||
entries[component].extend((path, error) for error in errors)
|
||||
|
||||
if not found_any:
|
||||
runner.add(
|
||||
@@ -811,13 +867,50 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
if not any(finding.id.startswith("logs.") and finding.id.endswith("recent_errors") for finding in runner.report.findings):
|
||||
core_first_path = entries["core"][0][0] if entries["core"] else None
|
||||
for component in ("core", "plugin"):
|
||||
component_entries = entries[component]
|
||||
if not component_entries:
|
||||
continue
|
||||
first_path = component_entries[0][0]
|
||||
finding_suffix = (
|
||||
"plugin_errors"
|
||||
if component == "plugin" and first_path == core_first_path
|
||||
else "recent_errors"
|
||||
)
|
||||
detail_lines, source_files = _aggregate_log_entries(component_entries)
|
||||
runner.add(
|
||||
finding_id=f"logs.{first_path.stem}.{finding_suffix}",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="最近日志存在插件异常" if component == "plugin" else "最近日志存在错误线索",
|
||||
detail="\n".join(detail_lines),
|
||||
recommendation=(
|
||||
"可使用安全模式启动后检查插件配置。"
|
||||
if component == "plugin"
|
||||
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
|
||||
),
|
||||
affects_report_status=component == "core",
|
||||
context={
|
||||
"log_file": str(first_path),
|
||||
"log_files": source_files,
|
||||
"matches": len(component_entries),
|
||||
"unique_matches": len(detail_lines),
|
||||
"component": component,
|
||||
"lookback_hours": LOG_LOOKBACK_HOURS,
|
||||
},
|
||||
)
|
||||
|
||||
if not entries["core"]:
|
||||
runner.add(
|
||||
finding_id="logs.recent",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="最近日志未发现明显错误关键词",
|
||||
detail=f"已扫描 {settings.LOG_PATH} 下的主日志、启动日志和插件日志。",
|
||||
detail=(
|
||||
f"已扫描 {settings.LOG_PATH} 下最近 {LOG_LOOKBACK_HOURS} 小时的主日志、"
|
||||
"启动日志和插件日志;插件扩展告警不参与核心健康状态。"
|
||||
),
|
||||
recommendation="如果问题仍存在,请结合具体操作时间扩大日志范围排查。",
|
||||
)
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ def format_text_report(report: DoctorReport) -> str:
|
||||
summary = report.summary
|
||||
lines.extend([
|
||||
"",
|
||||
f"汇总: total={summary['total']} error={summary['error']} warn={summary['warn']} fixed={summary['fixed']}",
|
||||
f"汇总: total={summary['total']} error={summary['error']} "
|
||||
f"warn={summary['warn']} advisory={summary['advisory']} "
|
||||
f"fixed={summary['fixed']}",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -114,11 +114,14 @@ class DoctorReport:
|
||||
"warn": 0,
|
||||
"error": 0,
|
||||
"fixed": 0,
|
||||
"advisory": 0,
|
||||
}
|
||||
for finding in self.findings:
|
||||
counts[finding.severity.value] += 1
|
||||
if finding.fixed:
|
||||
counts["fixed"] += 1
|
||||
elif not finding.affects_report_status:
|
||||
counts["advisory"] += 1
|
||||
return counts
|
||||
|
||||
def exit_code(self) -> int:
|
||||
|
||||
+32
-8
@@ -1,12 +1,27 @@
|
||||
from typing import Awaitable, Callable
|
||||
import json
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.apiv2_utils import OPENAPI_V2_PATH, V2ResponseMiddleware
|
||||
from app.core.config import settings
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.startup.lifecycle import lifespan
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
def _get_http_exception_message(detail: Any) -> str:
|
||||
"""将 HTTPException 的 detail 转换为统一消息文本。"""
|
||||
if isinstance(detail, str) and detail:
|
||||
return detail
|
||||
if detail is None:
|
||||
return "请求失败"
|
||||
try:
|
||||
return json.dumps(detail, ensure_ascii=False)
|
||||
except TypeError:
|
||||
return str(detail)
|
||||
|
||||
|
||||
async def localized_http_exception_handler(
|
||||
@@ -14,18 +29,20 @@ async def localized_http_exception_handler(
|
||||
exc: HTTPException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
为 HTTPException 响应补充多语言错误详情。
|
||||
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
|
||||
|
||||
:param _request: 当前 HTTP 请求
|
||||
:param exc: FastAPI HTTP 异常
|
||||
:return: 带 detail_i18n 的 JSON 错误响应
|
||||
:return: 统一 JSON 错误响应
|
||||
"""
|
||||
content = {"detail": exc.detail}
|
||||
if isinstance(exc.detail, str):
|
||||
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
|
||||
message = _get_http_exception_message(exc.detail)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=content,
|
||||
content={
|
||||
"success": False,
|
||||
"message": message,
|
||||
"data": {},
|
||||
},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
@@ -36,10 +53,16 @@ def create_app() -> FastAPI:
|
||||
"""
|
||||
_app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
version=APP_VERSION,
|
||||
openapi_url=OPENAPI_V2_PATH,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@_app.get(f"{settings.API_V1_STR}/openapi.json", include_in_schema=False)
|
||||
def get_v1_openapi_schema() -> dict[str, Any]:
|
||||
"""保留旧版 OpenAPI 地址并返回当前完整接口文档。"""
|
||||
return _app.openapi()
|
||||
|
||||
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||
|
||||
# 配置 CORS 中间件
|
||||
@@ -50,6 +73,7 @@ def create_app() -> FastAPI:
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
_app.add_middleware(V2ResponseMiddleware)
|
||||
|
||||
@_app.middleware("http")
|
||||
async def locale_context_middleware(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
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 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, require_markers: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
从 Wiki 插件文档中提取插件仓库地址。
|
||||
|
||||
:param markdown: Wiki 插件文档 Markdown 内容
|
||||
:param require_markers: 是否要求文档包含唯一且有序的清单边界标记
|
||||
:return: 规范化并按文档顺序去重的插件仓库地址
|
||||
"""
|
||||
content = markdown or ""
|
||||
start_count = content.count(PLUGIN_MARKET_WIKI_START)
|
||||
end_count = content.count(PLUGIN_MARKET_WIKI_END)
|
||||
start_index = content.find(PLUGIN_MARKET_WIKI_START)
|
||||
end_index = content.find(PLUGIN_MARKET_WIKI_END)
|
||||
if start_count == 1 and end_count == 1 and start_index < end_index:
|
||||
content = content[
|
||||
start_index + len(PLUGIN_MARKET_WIKI_START):end_index
|
||||
]
|
||||
elif require_markers:
|
||||
raise ValueError("Wiki 插件仓库清单必须包含唯一且有序的开始和结束标记")
|
||||
|
||||
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
|
||||
@@ -183,9 +183,6 @@
|
||||
"TheMovieDb 识别缓存不存在": "TheMovieDb recognition cache does not exist",
|
||||
"TheMovieDb 识别缓存删除成功": "TheMovieDb recognition cache deleted successfully",
|
||||
"TheMovieDb 识别缓存清理完成": "TheMovieDb recognition cache cleanup completed",
|
||||
"豆瓣识别缓存不存在": "Douban recognition cache does not exist",
|
||||
"豆瓣识别缓存删除成功": "Douban recognition cache deleted successfully",
|
||||
"豆瓣识别缓存清理完成": "Douban recognition cache cleanup completed",
|
||||
"重新识别完成": "Re-recognition completed",
|
||||
"未识别到新名称": "Unable to recognize new name",
|
||||
"缺少参数": "Missing parameters",
|
||||
|
||||
@@ -110,10 +110,7 @@
|
||||
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置",
|
||||
"TheMovieDb 识别缓存不存在": "TheMovieDb 识别缓存不存在",
|
||||
"TheMovieDb 识别缓存删除成功": "TheMovieDb 识别缓存删除成功",
|
||||
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成",
|
||||
"豆瓣识别缓存不存在": "豆瓣识别缓存不存在",
|
||||
"豆瓣识别缓存删除成功": "豆瓣识别缓存删除成功",
|
||||
"豆瓣识别缓存清理完成": "豆瓣识别缓存清理完成"
|
||||
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成"
|
||||
},
|
||||
"message_patterns": [
|
||||
{
|
||||
|
||||
@@ -183,9 +183,6 @@
|
||||
"TheMovieDb 识别缓存不存在": "TheMovieDb 識別快取不存在",
|
||||
"TheMovieDb 识别缓存删除成功": "TheMovieDb 識別快取刪除成功",
|
||||
"TheMovieDb 识别缓存清理完成": "TheMovieDb 識別快取清理完成",
|
||||
"豆瓣识别缓存不存在": "豆瓣識別快取不存在",
|
||||
"豆瓣识别缓存删除成功": "豆瓣識別快取刪除成功",
|
||||
"豆瓣识别缓存清理完成": "豆瓣識別快取清理完成",
|
||||
"重新识别完成": "重新識別完成",
|
||||
"未识别到新名称": "未識別到新名稱",
|
||||
"缺少参数": "缺少參數",
|
||||
|
||||
+48
-147
@@ -11,7 +11,6 @@ from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas import MediaPerson, APIRateLimitException
|
||||
from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
|
||||
@@ -24,12 +23,10 @@ from app.utils.zhconv import convert as zhconv_convert
|
||||
class DoubanModule(_ModuleBase):
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
cache: DoubanCache = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
self.doubanapi = DoubanApi()
|
||||
self.scraper = DoubanScraper()
|
||||
self.cache = DoubanCache()
|
||||
|
||||
def stop(self):
|
||||
self.doubanapi.close()
|
||||
@@ -110,7 +107,6 @@ class DoubanModule(_ModuleBase):
|
||||
def _recognize_media_core(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: Optional[str] = None,
|
||||
cache: Optional[bool] = True,
|
||||
douban_info_func=None,
|
||||
match_doubaninfo_func=None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
@@ -119,7 +115,6 @@ class DoubanModule(_ModuleBase):
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param cache: 是否使用缓存
|
||||
:param douban_info_func: 获取豆瓣信息的函数
|
||||
:param match_doubaninfo_func: 匹配豆瓣信息的函数
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
@@ -134,69 +129,39 @@ class DoubanModule(_ModuleBase):
|
||||
):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
# 未提供元数据时,直接查询豆瓣信息,不使用缓存
|
||||
cache_info = {}
|
||||
if doubanid:
|
||||
info = douban_info_func(
|
||||
doubanid=doubanid,
|
||||
mtype=mtype or (meta.type if meta else None),
|
||||
)
|
||||
elif not meta.name:
|
||||
logger.error("识别媒体信息时未提供元数据名称")
|
||||
return None
|
||||
else:
|
||||
# 读取缓存
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if doubanid:
|
||||
meta.doubanid = doubanid
|
||||
cache_info = self.cache.get(meta) if cache else {}
|
||||
cache_hit = False
|
||||
|
||||
# 识别豆瓣信息
|
||||
if not cache_info or not cache:
|
||||
# 缓存没有或者强制不使用缓存
|
||||
if doubanid:
|
||||
# 直接查询详情
|
||||
info = douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)
|
||||
elif meta:
|
||||
info = {}
|
||||
for name in self._prepare_search_names(meta):
|
||||
if meta.begin_season is not None:
|
||||
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {name} ...")
|
||||
# 匹配豆瓣信息
|
||||
match_info = match_doubaninfo_func(name=name,
|
||||
mtype=mtype or meta.type,
|
||||
year=meta.year,
|
||||
season=meta.begin_season)
|
||||
if match_info:
|
||||
# 匹配到豆瓣信息
|
||||
info = douban_info_func(
|
||||
doubanid=match_info.get("id"),
|
||||
mtype=mtype or meta.type
|
||||
)
|
||||
if info:
|
||||
break
|
||||
else:
|
||||
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
|
||||
return None
|
||||
|
||||
# 保存到缓存
|
||||
if meta and cache:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
cache_hit = True
|
||||
if cache_info.get("title"):
|
||||
logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}")
|
||||
info = douban_info_func(mtype=cache_info.get("type"),
|
||||
doubanid=cache_info.get("id"))
|
||||
else:
|
||||
logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别")
|
||||
info = None
|
||||
info = {}
|
||||
for name in self._prepare_search_names(meta):
|
||||
if meta.begin_season is not None:
|
||||
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {name} ...")
|
||||
match_info = match_doubaninfo_func(
|
||||
name=name,
|
||||
mtype=mtype or meta.type,
|
||||
year=meta.year,
|
||||
season=meta.begin_season,
|
||||
)
|
||||
if match_info:
|
||||
info = douban_info_func(
|
||||
doubanid=match_info.get("id"),
|
||||
mtype=mtype or meta.type,
|
||||
)
|
||||
if info:
|
||||
break
|
||||
|
||||
if info:
|
||||
# 赋值TMDB信息并返回
|
||||
mediainfo = MediaInfo(douban_info=info)
|
||||
mediainfo.recognize_cache_hit = cache_hit
|
||||
if meta:
|
||||
logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year} "
|
||||
@@ -213,7 +178,6 @@ class DoubanModule(_ModuleBase):
|
||||
async def _async_recognize_media_core(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: Optional[str] = None,
|
||||
cache: Optional[bool] = True,
|
||||
async_douban_info_func=None,
|
||||
async_match_doubaninfo_func=None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
@@ -222,7 +186,6 @@ class DoubanModule(_ModuleBase):
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param cache: 是否使用缓存
|
||||
:param async_douban_info_func: 获取豆瓣信息的异步函数
|
||||
:param async_match_doubaninfo_func: 匹配豆瓣信息的异步函数
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
@@ -237,69 +200,39 @@ class DoubanModule(_ModuleBase):
|
||||
):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
# 未提供元数据时,直接查询豆瓣信息,不使用缓存
|
||||
cache_info = {}
|
||||
if doubanid:
|
||||
info = await async_douban_info_func(
|
||||
doubanid=doubanid,
|
||||
mtype=mtype or (meta.type if meta else None),
|
||||
)
|
||||
elif not meta.name:
|
||||
logger.error("识别媒体信息时未提供元数据名称")
|
||||
return None
|
||||
else:
|
||||
# 读取缓存
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if doubanid:
|
||||
meta.doubanid = doubanid
|
||||
cache_info = self.cache.get(meta) if cache else {}
|
||||
cache_hit = False
|
||||
|
||||
# 识别豆瓣信息
|
||||
if not cache_info or not cache:
|
||||
# 缓存没有或者强制不使用缓存
|
||||
if doubanid:
|
||||
# 直接查询详情
|
||||
info = await async_douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)
|
||||
elif meta:
|
||||
info = {}
|
||||
for name in self._prepare_search_names(meta):
|
||||
if meta.begin_season is not None:
|
||||
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {name} ...")
|
||||
# 匹配豆瓣信息
|
||||
match_info = await async_match_doubaninfo_func(name=name,
|
||||
mtype=mtype or meta.type,
|
||||
year=meta.year,
|
||||
season=meta.begin_season)
|
||||
if match_info:
|
||||
# 匹配到豆瓣信息
|
||||
info = await async_douban_info_func(
|
||||
doubanid=match_info.get("id"),
|
||||
mtype=mtype or meta.type
|
||||
)
|
||||
if info:
|
||||
break
|
||||
else:
|
||||
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
|
||||
return None
|
||||
|
||||
# 保存到缓存
|
||||
if meta and cache:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
cache_hit = True
|
||||
if cache_info.get("title"):
|
||||
logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}")
|
||||
info = await async_douban_info_func(mtype=cache_info.get("type"),
|
||||
doubanid=cache_info.get("id"))
|
||||
else:
|
||||
logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别")
|
||||
info = None
|
||||
info = {}
|
||||
for name in self._prepare_search_names(meta):
|
||||
if meta.begin_season is not None:
|
||||
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {name} ...")
|
||||
match_info = await async_match_doubaninfo_func(
|
||||
name=name,
|
||||
mtype=mtype or meta.type,
|
||||
year=meta.year,
|
||||
season=meta.begin_season,
|
||||
)
|
||||
if match_info:
|
||||
info = await async_douban_info_func(
|
||||
doubanid=match_info.get("id"),
|
||||
mtype=mtype or meta.type,
|
||||
)
|
||||
if info:
|
||||
break
|
||||
|
||||
if info:
|
||||
# 赋值TMDB信息并返回
|
||||
mediainfo = MediaInfo(douban_info=info)
|
||||
mediainfo.recognize_cache_hit = cache_hit
|
||||
if meta:
|
||||
logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year} "
|
||||
@@ -316,21 +249,18 @@ class DoubanModule(_ModuleBase):
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: Optional[str] = None,
|
||||
cache: Optional[bool] = True,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
return self._recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
doubanid=doubanid,
|
||||
cache=cache,
|
||||
douban_info_func=self.douban_info,
|
||||
match_doubaninfo_func=self.match_doubaninfo,
|
||||
**kwargs
|
||||
@@ -339,51 +269,23 @@ class DoubanModule(_ModuleBase):
|
||||
async def async_recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: Optional[str] = None,
|
||||
cache: Optional[bool] = True,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
return await self._async_recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
doubanid=doubanid,
|
||||
cache=cache,
|
||||
async_douban_info_func=self.async_douban_info,
|
||||
async_match_doubaninfo_func=self.async_match_doubaninfo,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def update_recognize_cache(
|
||||
self,
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
回填豆瓣本地识别缓存,覆盖名称负缓存,避免共享识别后重复回查。
|
||||
"""
|
||||
if not meta or not mediainfo:
|
||||
return None
|
||||
if mediainfo.source != "douban" or not mediainfo.douban_info:
|
||||
return None
|
||||
self.cache.update(meta, mediainfo.douban_info)
|
||||
return True
|
||||
|
||||
async def async_update_recognize_cache(
|
||||
self,
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
异步回填豆瓣本地识别缓存。
|
||||
"""
|
||||
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
|
||||
|
||||
@rate_limit_exponential(source="douban_info")
|
||||
def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]:
|
||||
"""
|
||||
@@ -1272,7 +1174,6 @@ class DoubanModule(_ModuleBase):
|
||||
"""
|
||||
logger.info("开始清除豆瓣缓存 ...")
|
||||
self.doubanapi.clear_cache()
|
||||
self.cache.clear()
|
||||
logger.info("豆瓣缓存清除完成")
|
||||
|
||||
def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]:
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import pickle
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Optional
|
||||
|
||||
from app.core.cache import TTLCache
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.singleton import WeakSingleton
|
||||
|
||||
lock = RLock()
|
||||
|
||||
|
||||
class DoubanCache(metaclass=WeakSingleton):
|
||||
"""
|
||||
豆瓣缓存数据
|
||||
{
|
||||
"id": '',
|
||||
"title": '',
|
||||
"year": '',
|
||||
"type": MediaType
|
||||
}
|
||||
"""
|
||||
# 豆瓣缓存过期
|
||||
_douban_cache_expire: bool = True
|
||||
|
||||
def __init__(self):
|
||||
"""初始化豆瓣识别缓存并恢复本地持久化数据。"""
|
||||
self.maxsize = settings.CONF.douban
|
||||
self.ttl = settings.CONF.meta
|
||||
self.region = "__douban_cache__"
|
||||
self._meta_filepath = settings.TEMP_PATH / self.region
|
||||
# 初始化缓存
|
||||
self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)
|
||||
# 非Redis加载本地缓存数据
|
||||
if not self._cache.is_redis():
|
||||
for key, value in self.__load(self._meta_filepath).items():
|
||||
self._cache.set(key, value)
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
清空所有豆瓣缓存
|
||||
"""
|
||||
with lock:
|
||||
self._cache.clear()
|
||||
self.save(force=True)
|
||||
|
||||
def list_items(self) -> list[dict]:
|
||||
"""返回可供管理界面展示的豆瓣识别缓存列表。"""
|
||||
with lock:
|
||||
cache_items = []
|
||||
for key, value in self._cache.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
media_type = value.get("type")
|
||||
if not isinstance(media_type, MediaType):
|
||||
try:
|
||||
media_type = MediaType(media_type)
|
||||
except (TypeError, ValueError):
|
||||
media_type = None
|
||||
cache_items.append({
|
||||
"key": key,
|
||||
"douban_id": value.get("id") or 0,
|
||||
"title": value.get("title") or "",
|
||||
"year": value.get("year") or "",
|
||||
"media_type": media_type.to_agent() if media_type else "unknown",
|
||||
"poster_path": value.get("poster_path") or "",
|
||||
})
|
||||
return sorted(cache_items, key=lambda item: item["key"])
|
||||
|
||||
@staticmethod
|
||||
def __get_key(meta: MetaBase) -> str:
|
||||
"""
|
||||
获取缓存KEY
|
||||
"""
|
||||
return f"[{meta.type.value if meta.type else '未知'}]" \
|
||||
f"{meta.doubanid or meta.name}-{meta.year}-{meta.begin_season}"
|
||||
|
||||
def get(self, meta: MetaBase):
|
||||
"""
|
||||
根据KEY值获取缓存值
|
||||
"""
|
||||
key = self.__get_key(meta)
|
||||
with lock:
|
||||
return self._cache.get(key) or {}
|
||||
|
||||
def delete(self, key: str) -> dict:
|
||||
"""
|
||||
删除缓存信息
|
||||
@param key: 缓存key
|
||||
@return: 被删除的缓存内容
|
||||
"""
|
||||
with lock:
|
||||
redis_data = self._cache.get(key)
|
||||
if redis_data:
|
||||
self._cache.delete(key)
|
||||
self.save(force=True)
|
||||
return redis_data
|
||||
return {}
|
||||
|
||||
def modify(self, key: str, title: str) -> dict:
|
||||
"""
|
||||
修改缓存信息
|
||||
@param key: 缓存key
|
||||
@param title: 标题
|
||||
@return: 被修改后缓存内容
|
||||
"""
|
||||
with lock:
|
||||
redis_data = self._cache.get(key)
|
||||
if redis_data:
|
||||
redis_data["title"] = title
|
||||
self._cache.set(key, redis_data)
|
||||
return redis_data
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def __load(path: Path) -> dict:
|
||||
"""
|
||||
从文件中加载缓存
|
||||
"""
|
||||
try:
|
||||
if path.exists():
|
||||
with open(path, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"加载缓存失败: {str(e)} - {traceback.format_exc()}")
|
||||
return {}
|
||||
|
||||
def update(self, meta: MetaBase, info: dict) -> None:
|
||||
"""
|
||||
新增或更新缓存条目
|
||||
"""
|
||||
if info:
|
||||
# 缓存标题
|
||||
cache_title = info.get("title")
|
||||
# 缓存年份
|
||||
cache_year = info.get('year')
|
||||
# 类型
|
||||
if isinstance(info.get('media_type'), MediaType):
|
||||
mtype = info.get('media_type')
|
||||
elif info.get("type"):
|
||||
mtype = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
||||
else:
|
||||
meta = MetaInfo(cache_title)
|
||||
if meta.begin_season is not None:
|
||||
mtype = MediaType.TV
|
||||
else:
|
||||
mtype = MediaType.MOVIE
|
||||
# 海报
|
||||
poster_path = info.get("pic", {}).get("large")
|
||||
if not poster_path and info.get("cover_url"):
|
||||
poster_path = info.get("cover_url")
|
||||
if not poster_path and info.get("cover"):
|
||||
poster_path = info.get("cover").get("url")
|
||||
|
||||
with lock:
|
||||
self._cache.set(self.__get_key(meta), {
|
||||
"id": info.get("id"),
|
||||
"type": mtype,
|
||||
"year": cache_year,
|
||||
"title": cache_title,
|
||||
"poster_path": poster_path
|
||||
})
|
||||
|
||||
elif info is not None:
|
||||
# None时不缓存,此时代表网络错误,允许重复请求
|
||||
with lock:
|
||||
self._cache.set(self.__get_key(meta), {
|
||||
"id": 0
|
||||
})
|
||||
|
||||
def save(self, force: Optional[bool] = False) -> None:
|
||||
"""
|
||||
保存缓存数据到文件
|
||||
"""
|
||||
# Redis不需要保存到本地文件
|
||||
if self._cache.is_redis():
|
||||
return
|
||||
|
||||
# 本地文件
|
||||
meta_data = self.__load(self._meta_filepath)
|
||||
# 当前缓存数据(去除无法识别)
|
||||
new_meta_data = {k: v for k, v in self._cache.items() if v.get("id")}
|
||||
|
||||
if not force \
|
||||
and meta_data.keys() == new_meta_data.keys():
|
||||
return
|
||||
# 写入本地
|
||||
with open(self._meta_filepath, 'wb') as f:
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
|
||||
|
||||
def __del__(self):
|
||||
"""实例释放前保存非 Redis 缓存。"""
|
||||
self.save()
|
||||
@@ -1,9 +1,10 @@
|
||||
import pickle
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from math import ceil
|
||||
from threading import RLock
|
||||
from time import time
|
||||
|
||||
from app.core.cache import TTLCache
|
||||
from app.core.cache import FileCache, TTLCache
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
@@ -11,6 +12,9 @@ from app.schemas.types import MediaType
|
||||
from app.utils.singleton import WeakSingleton
|
||||
|
||||
lock = RLock()
|
||||
PERSISTENCE_VERSION = 1
|
||||
PERSISTENCE_REGION = "recognize"
|
||||
PERSISTENCE_KEY = "tmdb"
|
||||
|
||||
|
||||
class TmdbCache(metaclass=WeakSingleton):
|
||||
@@ -23,21 +27,78 @@ class TmdbCache(metaclass=WeakSingleton):
|
||||
"type": MediaType
|
||||
}
|
||||
"""
|
||||
# TMDB缓存过期
|
||||
_tmdb_cache_expire: bool = True
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 TMDB 识别缓存并恢复本地持久化数据。"""
|
||||
self.maxsize = settings.CONF.douban
|
||||
"""初始化 TMDB 识别缓存并恢复未过期的持久化数据。"""
|
||||
self.maxsize = settings.CONF.tmdb
|
||||
self.ttl = settings.CONF.meta
|
||||
self.region = "__tmdb_cache__"
|
||||
self._meta_filepath = settings.TEMP_PATH / self.region
|
||||
# 初始化缓存
|
||||
self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)
|
||||
# 非Redis加载本地缓存数据
|
||||
self._expires_at: dict[str, float] = {}
|
||||
self._dirty = False
|
||||
self._file_cache = None
|
||||
self._legacy_file_cache = None
|
||||
self._legacy_cache_found = False
|
||||
if not self._cache.is_redis():
|
||||
for key, value in self.__load(self._meta_filepath).items():
|
||||
self._cache.set(key, value)
|
||||
self._file_cache = FileCache(base=settings.CACHE_PATH, ttl=self.ttl)
|
||||
self._legacy_file_cache = FileCache(base=settings.TEMP_PATH.parent, ttl=self.ttl)
|
||||
self._restore()
|
||||
|
||||
def _restore(self) -> None:
|
||||
"""从统一文件缓存恢复仍在有效期内的 TMDB 识别数据。"""
|
||||
try:
|
||||
content = self._file_cache.get(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
|
||||
if not content:
|
||||
content = self._legacy_file_cache.get(
|
||||
self.region,
|
||||
region=settings.TEMP_PATH.name,
|
||||
)
|
||||
if content:
|
||||
self._legacy_cache_found = True
|
||||
self._dirty = True
|
||||
if not content:
|
||||
return
|
||||
payload = pickle.loads(content)
|
||||
now = time()
|
||||
if (
|
||||
isinstance(payload, dict)
|
||||
and payload.get("version") == PERSISTENCE_VERSION
|
||||
and isinstance(payload.get("items"), dict)
|
||||
):
|
||||
items = payload["items"]
|
||||
elif isinstance(payload, dict):
|
||||
# 旧版缓存没有保存过期时间,迁移时从当前时刻重新计算一次有效期。
|
||||
items = {
|
||||
key: {"value": value, "expires_at": now + self.ttl}
|
||||
for key, value in payload.items()
|
||||
}
|
||||
self._dirty = True
|
||||
else:
|
||||
return
|
||||
|
||||
for key, item in items.items():
|
||||
if not isinstance(item, dict):
|
||||
self._dirty = True
|
||||
continue
|
||||
value = item.get("value")
|
||||
expires_at = item.get("expires_at")
|
||||
if not isinstance(value, dict) or not isinstance(expires_at, (int, float)):
|
||||
self._dirty = True
|
||||
continue
|
||||
remaining_ttl = expires_at - now
|
||||
if remaining_ttl <= 0:
|
||||
self._dirty = True
|
||||
continue
|
||||
self._cache.set(key, value, ttl=ceil(remaining_ttl))
|
||||
self._expires_at[key] = expires_at
|
||||
except Exception as err:
|
||||
logger.error(f"加载TMDB识别缓存失败:{str(err)} - {traceback.format_exc()}")
|
||||
|
||||
def _set(self, key: str, value: dict) -> None:
|
||||
"""写入单条 TMDB 识别缓存并记录其独立过期时间。"""
|
||||
self._cache.set(key, value)
|
||||
if not self._cache.is_redis():
|
||||
self._expires_at[key] = time() + self.ttl
|
||||
self._dirty = True
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
@@ -45,6 +106,8 @@ class TmdbCache(metaclass=WeakSingleton):
|
||||
"""
|
||||
with lock:
|
||||
self._cache.clear()
|
||||
self._expires_at.clear()
|
||||
self._dirty = True
|
||||
self.save(force=True)
|
||||
|
||||
def list_items(self) -> list[dict]:
|
||||
@@ -87,7 +150,10 @@ class TmdbCache(metaclass=WeakSingleton):
|
||||
key = self.__get_key(meta)
|
||||
|
||||
with lock:
|
||||
return self._cache.get(key) or {}
|
||||
cache_data = self._cache.get(key)
|
||||
if not cache_data and self._expires_at.pop(key, None) is not None:
|
||||
self._dirty = True
|
||||
return cache_data or {}
|
||||
|
||||
def delete(self, key: str) -> dict:
|
||||
"""
|
||||
@@ -99,6 +165,8 @@ class TmdbCache(metaclass=WeakSingleton):
|
||||
redis_data = self._cache.get(key)
|
||||
if redis_data:
|
||||
self._cache.delete(key)
|
||||
self._expires_at.pop(key, None)
|
||||
self._dirty = True
|
||||
self.save(force=True)
|
||||
return redis_data
|
||||
return {}
|
||||
@@ -114,24 +182,10 @@ class TmdbCache(metaclass=WeakSingleton):
|
||||
redis_data = self._cache.get(key)
|
||||
if redis_data:
|
||||
redis_data['title'] = title
|
||||
self._cache.set(key, redis_data)
|
||||
self._set(key, redis_data)
|
||||
return redis_data
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def __load(path: Path) -> dict:
|
||||
"""
|
||||
从文件中加载缓存
|
||||
"""
|
||||
try:
|
||||
if path.exists():
|
||||
with open(path, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f'加载缓存失败:{str(e)} - {traceback.format_exc()}')
|
||||
return {}
|
||||
|
||||
def update(self, meta: MetaBase, info: dict) -> None:
|
||||
"""
|
||||
新增或更新缓存条目
|
||||
@@ -157,32 +211,68 @@ class TmdbCache(metaclass=WeakSingleton):
|
||||
"poster_path": info.get("poster_path"),
|
||||
"backdrop_path": info.get("backdrop_path")
|
||||
}
|
||||
self._cache.set(key, cache_data)
|
||||
self._set(key, cache_data)
|
||||
|
||||
elif info is not None:
|
||||
# None时不缓存,此时代表网络错误,允许重复请求
|
||||
with lock:
|
||||
self._cache.set(key, {"id": 0})
|
||||
self._set(key, {"id": 0})
|
||||
|
||||
def save(self, force: bool = False) -> None:
|
||||
"""
|
||||
保存缓存数据到文件
|
||||
使用统一文件缓存保存未过期的 TMDB 识别数据。
|
||||
"""
|
||||
# Redis不需要保存到本地文件
|
||||
if self._cache.is_redis():
|
||||
return
|
||||
with lock:
|
||||
now = time()
|
||||
cache_items = dict(self._cache.items())
|
||||
active_keys = set(cache_items)
|
||||
stale_keys = set(self._expires_at) - active_keys
|
||||
if stale_keys:
|
||||
for key in stale_keys:
|
||||
self._expires_at.pop(key, None)
|
||||
self._dirty = True
|
||||
|
||||
# Redis不可用时,保存到本地文件
|
||||
meta_data = self.__load(self._meta_filepath)
|
||||
# 当前缓存,去除无法识别
|
||||
new_meta_data = {k: v for k, v in self._cache.items() if v.get("id")}
|
||||
persisted_items = {}
|
||||
for key, value in cache_items.items():
|
||||
expires_at = self._expires_at.get(key)
|
||||
if expires_at is None:
|
||||
expires_at = now + self.ttl
|
||||
self._expires_at[key] = expires_at
|
||||
self._dirty = True
|
||||
if expires_at <= now or not value.get("id"):
|
||||
continue
|
||||
persisted_items[key] = {
|
||||
"value": value,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
if not force \
|
||||
and meta_data.keys() == new_meta_data.keys():
|
||||
return
|
||||
if not force and not self._dirty:
|
||||
return
|
||||
|
||||
with open(self._meta_filepath, 'wb') as f:
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # type: ignore
|
||||
try:
|
||||
if persisted_items:
|
||||
payload = {
|
||||
"version": PERSISTENCE_VERSION,
|
||||
"items": persisted_items,
|
||||
}
|
||||
self._file_cache.set(
|
||||
PERSISTENCE_KEY,
|
||||
pickle.dumps(payload, pickle.HIGHEST_PROTOCOL),
|
||||
region=PERSISTENCE_REGION,
|
||||
)
|
||||
else:
|
||||
self._file_cache.delete(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
|
||||
if self._legacy_cache_found:
|
||||
self._legacy_file_cache.delete(
|
||||
self.region,
|
||||
region=settings.TEMP_PATH.name,
|
||||
)
|
||||
self._legacy_cache_found = False
|
||||
self._dirty = False
|
||||
except Exception as err:
|
||||
logger.error(f"保存TMDB识别缓存失败:{str(err)} - {traceback.format_exc()}")
|
||||
|
||||
def __del__(self):
|
||||
"""实例释放前保存非 Redis 缓存。"""
|
||||
|
||||
@@ -660,16 +660,6 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
||||
kwargs={"job_id": "scheduler_job"},
|
||||
)
|
||||
|
||||
# 缓存清理服务,每隔24小时
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="clear_cache",
|
||||
name="缓存清理",
|
||||
hours=settings.CONF.meta / 3600,
|
||||
kwargs={"job_id": "clear_cache"},
|
||||
)
|
||||
|
||||
# 数据表清理服务,每天凌晨执行一次
|
||||
if settings.DATA_CLEANUP_ENABLE:
|
||||
self._scheduler.add_job(
|
||||
|
||||
@@ -133,6 +133,16 @@ class AgentChatToolCall(BaseModel):
|
||||
status: str = Field(default="done", description="工具状态")
|
||||
|
||||
|
||||
class AgentChatMessageSegment(BaseModel):
|
||||
"""
|
||||
Agent 会话消息中的有序展示片段。
|
||||
"""
|
||||
|
||||
type: str = Field(..., description="片段类型")
|
||||
content: str = Field(default="", description="文本片段内容")
|
||||
toolIndex: Optional[int] = Field(None, description="工具提示索引")
|
||||
|
||||
|
||||
class AgentChatChoiceButton(BaseModel):
|
||||
"""
|
||||
Agent 会话选择按钮。
|
||||
@@ -185,6 +195,7 @@ class AgentChatMessage(BaseModel):
|
||||
createdAt: Union[int, float] = Field(..., description="创建时间戳")
|
||||
status: str = Field(default="done", description="消息状态")
|
||||
tools: list[AgentChatToolCall] = Field(default_factory=list, description="工具提示列表")
|
||||
segments: list[AgentChatMessageSegment] = Field(default_factory=list, description="有序展示片段")
|
||||
attachments: list[AgentChatAttachment] = Field(default_factory=list, description="附件列表")
|
||||
choices: list[AgentChatChoiceCard] = Field(default_factory=list, description="选择卡片列表")
|
||||
choice_selection: Optional[AgentChatChoiceSelection] = Field(None, description="用户选择项快照")
|
||||
|
||||
@@ -95,6 +95,7 @@ class AgentLLMProviderEventData(ChainEventData):
|
||||
use_proxy: Optional[bool] = Field(default=None, description="是否使用系统代理")
|
||||
thinking_level: Optional[str] = Field(default=None, description="思考模式级别")
|
||||
api_protocol: Optional[str] = Field(default=None, description="OpenAI兼容接口API协议:auto/chat_completions/responses")
|
||||
web_search_mode: Optional[str] = Field(default=None, description="联网搜索模式:local/builtin/auto/disabled")
|
||||
selected_provider_id: Optional[str] = Field(default=None, description="插件侧供应商ID")
|
||||
selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称")
|
||||
source: Optional[str] = Field(default=None, description="选择来源")
|
||||
@@ -118,6 +119,11 @@ class AgentTokensUsageEventData(BaseEventData):
|
||||
input_tokens: int = Field(default=0, description="输入 tokens")
|
||||
output_tokens: int = Field(default=0, description="输出 tokens")
|
||||
total_tokens: int = Field(default=0, description="总 tokens")
|
||||
cache_read_input_tokens: int = Field(default=0, description="从提示词缓存读取的输入 tokens")
|
||||
cache_write_input_tokens: int = Field(default=0, description="写入提示词缓存的输入 tokens")
|
||||
uncached_input_tokens: int = Field(default=0, description="未命中缓存的输入 tokens")
|
||||
cache_hit_ratio: Optional[float] = Field(default=None, description="提示词缓存命中率")
|
||||
cache_usage_available: bool = Field(default=False, description="供应商是否返回缓存用量明细")
|
||||
model_call_count: int = Field(default=0, description="模型调用次数")
|
||||
success: bool = Field(default=False, description="Agent 执行是否成功")
|
||||
error: Optional[str] = Field(default=None, description="失败原因")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
@@ -15,7 +15,7 @@ class Response(BaseModel):
|
||||
# 多语言消息文本
|
||||
message_i18n: Optional[str] = None
|
||||
# 数据
|
||||
data: Optional[Union[dict, list]] = Field(default_factory=dict)
|
||||
data: Optional[Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_message_i18n(self) -> "Response":
|
||||
|
||||
@@ -8,10 +8,15 @@ def init_routers(app: FastAPI):
|
||||
初始化路由
|
||||
"""
|
||||
from app.api.apiv1 import api_router
|
||||
from app.api.apiv2 import api_router_v2
|
||||
from app.api.apiv2_utils import API_V2_STR, configure_v2_openapi
|
||||
from app.api.servarr import arr_router
|
||||
from app.api.servcookie import cookie_router
|
||||
# API路由
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
# v2 API复用v1路由,仅在响应出口统一封装
|
||||
app.include_router(api_router_v2, prefix=API_V2_STR)
|
||||
configure_v2_openapi(app)
|
||||
# Radarr、Sonarr路由
|
||||
app.include_router(arr_router, prefix="/api/v3")
|
||||
# CookieCloud路由
|
||||
|
||||
@@ -39,6 +39,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fuse3 \
|
||||
rsync \
|
||||
openssh-client \
|
||||
sshpass \
|
||||
iproute2 \
|
||||
netcat-openbsd \
|
||||
lsof \
|
||||
|
||||
@@ -31,15 +31,16 @@ location /cookiecloud {
|
||||
}
|
||||
|
||||
# SSE特殊配置
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
|
||||
error_log /dev/null crit;
|
||||
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
# 禁用缓存
|
||||
add_header Cache-Control no-cache;
|
||||
add_header Cache-Control "no-cache, no-transform";
|
||||
add_header X-Accel-Buffering no;
|
||||
gzip off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
|
||||
+9
-1
@@ -238,6 +238,10 @@ moviepilot setup --config-dir /path/to/moviepilot-config
|
||||
- 默认下载目录与媒体库目录
|
||||
- AI Agent
|
||||
可按需启用,并配置 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`
|
||||
与 `LLM_WEB_SEARCH_MODE`。联网搜索支持 MoviePilot 本地搜索、模型服务端搜索、
|
||||
服务端优先自动回退与完全关闭;服务端模式仅在当前模型目录声明支持时生效。
|
||||
当前可识别 OpenAI、Anthropic Claude、Google Gemini、xAI Grok 与 DeepSeek
|
||||
官方端点已公布的服务端联网搜索能力,第三方兼容端点不会被自动误判。
|
||||
- 用户站点认证
|
||||
可按需选择认证站点,并按站点要求填写用户名、UID、Passkey 等参数
|
||||
- 开机自启
|
||||
@@ -374,6 +378,7 @@ moviepilot version
|
||||
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
||||
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
||||
- 后端默认监听 `PORT`,默认值 `3001`
|
||||
- `TRANSFER_TASK_TIMEOUT` 控制外部异步接管的运行中整理任务失活超时,单位为分钟,默认 `120`,设为 `0` 可禁用;主程序整理线程仍在直接执行的任务不受此项清理
|
||||
- 前端通过 `service.js` 代理 `/api` 与 `/cookiecloud` 到后端
|
||||
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
|
||||
|
||||
@@ -392,7 +397,8 @@ moviepilot doctor --deep
|
||||
- `--json` 输出稳定 JSON,可供 Agent、脚本或 Issue 流程收集
|
||||
- `--fix` 只执行白名单安全修复,例如清理过期 runtime 文件或补齐不合法的 `API_TOKEN`
|
||||
- `--deep` 执行可能较慢的深度探测,例如 PostgreSQL TCP 连通性检查
|
||||
- 插件日志异常会保留为诊断告警并标记 `affects_report_status=false`,但不会单独降低系统整体状态;核心错误仍正常参与状态聚合
|
||||
- Doctor 只分析最近 24 小时日志,并跨主日志、控制台镜像和插件独立日志聚合相同错误
|
||||
- 插件日志异常会保留为诊断告警并标记 `affects_report_status=false`,但不会单独降低系统整体状态;`summary.advisory` 单独统计这类建议项,核心错误仍正常参与状态聚合
|
||||
- Docker 环境可使用 `docker exec <container> moviepilot doctor`;如果容器已退出,也可用镜像挂载同一配置目录运行 `python -m app.cli doctor`
|
||||
|
||||
日志:
|
||||
@@ -484,6 +490,8 @@ moviepilot tool run search_torrents media_type=movie tmdb_id=12345
|
||||
- `read_file`、`write_file`、`edit_file` 和 `execute_command`
|
||||
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
|
||||
由 Agent 按当前用户权限直接调用这些工具。
|
||||
- `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
|
||||
`start_line`、`end_line` 指定更小的行号范围继续读取。
|
||||
|
||||
## Scheduler 命令
|
||||
|
||||
|
||||
@@ -110,6 +110,25 @@ chmod +x scripts/start-local.sh
|
||||
|
||||
如果资源文件没有放到 `app/helper/`,站点索引、规则和内置资源相关能力可能无法按本地开发预期工作;如果插件没有放到 `app/plugins/`,主程序也不会在本地运行时发现该插件。
|
||||
|
||||
### 4.1 GitHub 发版时生成插件市场默认值
|
||||
|
||||
源码分支中的 `ConfigModel.PLUGIN_MARKET` 只保留官方插件仓库作为离线兜底。GitHub 的正式版与 Beta 镜像构建会检出 `MoviePilot-Wiki` 的 `main` 分支,并由 `scripts/generate_plugin_market_default.py` 读取 `plugin.md` 中 `plugin-market-repos:start/end` 标记区域,将规范化、去重后的公开仓库清单写入构建工作区。
|
||||
|
||||
生成过程遵循以下约束:
|
||||
|
||||
- 标记必须唯一、顺序正确,清单不能为空且必须包含 `jxxghp/MoviePilot-Plugins`;不满足时直接终止构建。
|
||||
- 生成脚本只替换 `ConfigModel` 中的 `PLUGIN_MARKET` 默认值,不写入运行时环境变量,因此用户仍可通过系统环境变量或 `/config/app.env` 覆盖。
|
||||
- 正式版工作流会创建仅由 Release Tag 引用的本地快照提交,Docker 镜像和 Tag 源码归档均来自该快照;Actions 不会将生成结果回写到 `v2` 分支。
|
||||
- Release Tag 快照提交信息和镜像标签会记录本次使用的 MoviePilot Wiki Commit,便于追溯清单来源。
|
||||
|
||||
本地验证生成结果时,先激活项目虚拟环境,再执行:
|
||||
|
||||
```bash
|
||||
python -m scripts.generate_plugin_market_default \
|
||||
--wiki-file /path/to/MoviePilot-Wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
```
|
||||
|
||||
### 5. 运行安全检查
|
||||
|
||||
我们使用 `safety` 工具检查依赖项中是否存在已知安全漏洞。更新运行时依赖后,应至少检查运行时入口;更新开发测试依赖时,也应覆盖开发入口。
|
||||
|
||||
+4
-2
@@ -38,7 +38,7 @@ Doctor 默认执行只读检查:
|
||||
- 运行路径:程序目录、配置目录、日志目录、Python 解释器
|
||||
- 关键配置:`API_TOKEN`、`PORT`、`NGINX_PORT`、代理格式、安全模式
|
||||
- 进程与端口:后端、前端端口监听状态,runtime 文件是否过期
|
||||
- 日志线索:后端日志、启动日志、前端日志和插件日志中的近期错误
|
||||
- 日志线索:后端日志、启动日志、前端日志和插件日志最近 24 小时内的错误
|
||||
- 核心依赖:FastAPI、Pydantic、SQLAlchemy、Uvicorn、CloakBrowser 等是否可导入
|
||||
- 数据库:SQLite 只读打开和完整性检查;PostgreSQL 默认做配置检查
|
||||
- 前端资源:`version.txt`、`service.js` 或核心静态文件是否存在
|
||||
@@ -48,6 +48,8 @@ Doctor 默认执行只读检查:
|
||||
|
||||
整体状态只聚合会影响 MoviePilot 核心运行的诊断项。插件独立日志以及主日志中可明确识别的插件子系统异常仍会作为 `warn/degraded` 诊断项保留,但其 `affects_report_status` 为 `false`,不会单独把整体状态从 `healthy` 降为 `degraded`;同一日志中若还存在核心错误,核心错误仍会参与状态聚合。
|
||||
|
||||
Doctor 会按核心与插件两个组件聚合日志发现,并对主日志、控制台镜像和插件独立日志中的相同错误去重。JSON 汇总中的 `warn` 保留全部警告数量,`advisory` 单独统计不影响整体状态的建议项;日志发现的 `context.log_files`、`matches` 和 `unique_matches` 分别说明来源、原始命中数和去重后命中数。
|
||||
|
||||
## 自救能力
|
||||
|
||||
`moviepilot doctor --fix` 只做白名单安全修复:
|
||||
@@ -87,4 +89,4 @@ Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否
|
||||
|
||||
## Issue 反馈集成
|
||||
|
||||
`feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。
|
||||
`feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。连续重复的同类日志模板会保留首条、末条和重复次数,避免轮询或等待日志挤掉真正的错误上下文。
|
||||
|
||||
+20
-7
@@ -112,9 +112,23 @@ MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器
|
||||
|
||||
MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`。
|
||||
|
||||
标准 REST 响应包含 `success`、`message`、`message_i18n`、`data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
|
||||
#### REST API 版本
|
||||
|
||||
FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail`。
|
||||
- `/api/v1` 默认保持原有响应结构,已有客户端无需迁移;登录壁纸接口的 URL 已统一放入 `data`。
|
||||
- `/api/v2` 复用 `/api/v1` 的同一套路由、请求参数、鉴权依赖和业务实现,只统一普通 JSON 响应结构。
|
||||
- v1 中已经使用通用 `Response` 的接口在 v2 中保持原样;其他成功 JSON 响应转换为 `{"success": true, "message": "", "data": <原响应>}`。
|
||||
- HTTP 错误保留原状态码,并统一返回 `{"success": false, "message": <错误详情>, "data": {}}`;非业务异常不做多语言翻译。
|
||||
- SSE、文件、图片、空响应,以及 OpenAI、Anthropic、MCP 等标准协议接口保持原始响应格式,不进行通用封装。
|
||||
|
||||
因此,普通 REST 接口可将文档中的 `/api/v1/...` 路径直接替换为 `/api/v2/...`。例如 `/api/v1/download/` 对应 `/api/v2/download/`。
|
||||
|
||||
通用 REST 响应包含 `success`、`message`、`message_i18n`、`data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
|
||||
|
||||
`GET /api/v1/login/wallpaper` 及对应的 v2 路径会将壁纸 URL 放在 `data` 字段中。`POST /api/v1/user/avatar/{user_id}` 及对应的 v2 路径会以 `data.filename` 返回原始文件名。上述接口的 `message` 均不再承载业务数据。
|
||||
|
||||
FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`。
|
||||
|
||||
交互式接口文档 `/docs` 默认读取 `/api/v2/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。旧地址 `/api/v1/openapi.json` 继续保留并返回同一份完整接口文档。
|
||||
|
||||
#### 媒体识别 / 整理
|
||||
|
||||
@@ -125,7 +139,7 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| GET | `/api/v1/media/search` | 按标题搜索媒体、合集或人物,参数:`title`、`type`、`page`、`count`,可选 `source`;`media` 支持 `themoviedb`、`douban`、`bangumi`、`anilist`,`collection` 支持 `themoviedb`,`person` 支持 `themoviedb`、`douban` |
|
||||
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source` |
|
||||
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 |
|
||||
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
|
||||
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 及插件自定义来源前缀 |
|
||||
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧)可指定本次刮削媒体 |
|
||||
@@ -213,11 +227,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
||||
| GET | `/api/v1/tmdb/cache` | 查询 TheMovieDb 识别缓存统计、共享识别累计成功命中次数及开关状态 |
|
||||
| DELETE | `/api/v1/tmdb/cache/{cache_key}` | 按缓存键删除单条 TheMovieDb 识别缓存,缓存键需要进行 URL 编码 |
|
||||
| DELETE | `/api/v1/tmdb/cache` | 清空全部 TheMovieDb 识别缓存 |
|
||||
| GET | `/api/v1/douban/cache` | 查询豆瓣识别缓存统计、共享识别累计成功命中次数及开关状态 |
|
||||
| DELETE | `/api/v1/douban/cache/{cache_key}` | 按缓存键删除单条豆瓣识别缓存,缓存键需要进行 URL 编码 |
|
||||
| DELETE | `/api/v1/douban/cache` | 清空全部豆瓣识别缓存 |
|
||||
|
||||
缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`、`data`,以及共享识别统计字段
|
||||
TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`、`data`,以及共享识别统计字段
|
||||
`shared_recognized` 和开关字段 `shared_recognize_enabled`。共享命中次数仅在共享结果驱动的二次媒体识别成功后累计。
|
||||
|
||||
### 插件补充接口
|
||||
@@ -249,6 +260,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
||||
内置 Agent 的本地文件与命令工具 `read_file`、`write_file`、`edit_file`、
|
||||
`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的
|
||||
用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。
|
||||
其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
|
||||
`start_line`、`end_line` 指定更小的行号范围继续读取。
|
||||
|
||||
媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
|
||||
|
||||
|
||||
@@ -285,4 +285,20 @@ bash scripts/collect-site-adapter.sh
|
||||
- Never put a Cookie or other credential in command arguments or shell history.
|
||||
- Feature Request attachments are public. Review all four files in the generated ZIP before attaching it, and never attach raw HTML, HAR, or browser network archives.
|
||||
|
||||
*Last Updated: 2026-07-12*
|
||||
---
|
||||
|
||||
## Plugin Market Release Default
|
||||
|
||||
```bash
|
||||
# Run after activating the project virtual environment
|
||||
python -m scripts.generate_plugin_market_default \
|
||||
--wiki-file /path/to/MoviePilot-Wiki/plugin.md \
|
||||
--config-file app/core/config.py
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- The Wiki document must contain exactly one `plugin-market-repos:start/end` marker pair.
|
||||
- The marked list must be nonempty and include `jxxghp/MoviePilot-Plugins`.
|
||||
- This command rewrites only `ConfigModel.PLUGIN_MARKET`; inspect the resulting diff before committing or packaging.
|
||||
|
||||
*Last Updated: 2026-08-06*
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
根据 MoviePilot Wiki 清单生成发版快照中的插件市场默认值。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.helper.market import extract_plugin_market_repos_from_wiki
|
||||
|
||||
|
||||
OFFICIAL_PLUGIN_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
|
||||
|
||||
def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="生成插件市场发版默认值")
|
||||
parser.add_argument("--wiki-file", type=Path, required=True)
|
||||
parser.add_argument("--config-file", type=Path, required=True)
|
||||
return parser.parse_args(args)
|
||||
|
||||
|
||||
def _find_plugin_market_assignment(source: str) -> tuple[int, int, str]:
|
||||
tree = ast.parse(source)
|
||||
source_lines = source.splitlines(keepends=True)
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef) or node.name != "ConfigModel":
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, ast.AnnAssign):
|
||||
continue
|
||||
if not isinstance(item.target, ast.Name):
|
||||
continue
|
||||
if item.target.id != "PLUGIN_MARKET" or item.end_lineno is None:
|
||||
continue
|
||||
start = item.lineno - 1
|
||||
indent_size = len(source_lines[start]) - len(source_lines[start].lstrip())
|
||||
indent = source_lines[start][:indent_size]
|
||||
return start, item.end_lineno, indent
|
||||
raise ValueError("未在 ConfigModel 中找到 PLUGIN_MARKET 默认值")
|
||||
|
||||
|
||||
def _format_plugin_market_assignment(repos: list[str], indent: str) -> str:
|
||||
lines = [f"{indent}PLUGIN_MARKET: str = (\n"]
|
||||
for index, repo in enumerate(repos):
|
||||
suffix = "," if index < len(repos) - 1 else ""
|
||||
lines.append(f'{indent} "{repo}{suffix}"\n')
|
||||
lines.append(f"{indent})\n")
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def _generate_plugin_market_default(wiki_file: Path, config_file: Path) -> list[str]:
|
||||
markdown = wiki_file.read_text(encoding="utf-8")
|
||||
repos = extract_plugin_market_repos_from_wiki(markdown, require_markers=True)
|
||||
if not repos:
|
||||
raise ValueError("Wiki 插件仓库清单为空")
|
||||
if OFFICIAL_PLUGIN_MARKET not in repos:
|
||||
raise ValueError("Wiki 插件仓库清单缺少 MoviePilot 官方插件仓库")
|
||||
|
||||
source = config_file.read_text(encoding="utf-8")
|
||||
start, end, indent = _find_plugin_market_assignment(source)
|
||||
source_lines = source.splitlines(keepends=True)
|
||||
replacement = _format_plugin_market_assignment(repos, indent)
|
||||
updated_source = (
|
||||
"".join(source_lines[:start])
|
||||
+ replacement
|
||||
+ "".join(source_lines[end:])
|
||||
)
|
||||
config_file.write_text(updated_source, encoding="utf-8")
|
||||
return repos
|
||||
|
||||
|
||||
def main(args: Optional[list[str]] = None) -> int:
|
||||
"""
|
||||
读取 Wiki 清单并更新指定配置文件中的插件市场默认值。
|
||||
"""
|
||||
options = _parse_args(args)
|
||||
repos = _generate_plugin_market_default(options.wiki_file, options.config_file)
|
||||
print(f"已生成 PLUGIN_MARKET 默认值,共 {len(repos)} 个仓库")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2093,6 +2093,21 @@ def _collect_agent_config(
|
||||
runtime_python=runtime_python,
|
||||
)
|
||||
config["LLM_MODEL"] = _prompt_model_choice(models, default=current_model)
|
||||
config["LLM_WEB_SEARCH_MODE"] = _prompt_choice(
|
||||
"LLM 联网搜索模式",
|
||||
choices={
|
||||
"local": "MoviePilot 本地搜索",
|
||||
"builtin": "模型服务端搜索",
|
||||
"auto": "自动(服务端优先,不支持时回退本地)",
|
||||
"disabled": "关闭联网搜索",
|
||||
},
|
||||
default=(
|
||||
_env_default("LLM_WEB_SEARCH_MODE", "local")
|
||||
if _env_default("LLM_WEB_SEARCH_MODE", "local")
|
||||
in {"local", "builtin", "auto", "disabled"}
|
||||
else "local"
|
||||
),
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ a local plugin source and installed into the running MoviePilot instance.
|
||||
`list_directory` only when inspecting one known folder or a configured remote
|
||||
storage backend.
|
||||
- Read the relevant implementation and adjacent example before editing.
|
||||
- If `read_file` reports truncation, continue with smaller `start_line` and
|
||||
`end_line` ranges until all relevant sections have been inspected.
|
||||
- Before using a Python or Node.js dependency API, determine the exact installed
|
||||
or locked version from requirements, package manifests, lockfiles, local
|
||||
package source, and `.pyi`/`.d.ts` declarations. If those are insufficient,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: feedback-issue
|
||||
version: 7
|
||||
version: 8
|
||||
description: >-
|
||||
Use this skill ONLY when the user EXPLICITLY requests filing an
|
||||
upstream issue for MoviePilot core, frontend, or an installed plugin,
|
||||
@@ -92,6 +92,9 @@ Log relevance rules:
|
||||
then applies a recent time window, removes Agent/tool dispatch noise,
|
||||
and keeps only timestamped log blocks whose first line contains a
|
||||
normalized keyword.
|
||||
- Consecutive log records with the same template are compacted to the
|
||||
first record, a repetition count, and the last record. Verify the
|
||||
retained boundary records before treating the excerpt as evidence.
|
||||
- If no specific keyword survives normalization, the script records the
|
||||
doctor report and log-selection metadata but does not include recent
|
||||
log lines. This avoids attaching unrelated noise.
|
||||
|
||||
@@ -33,6 +33,10 @@ _LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_MODULE_RE = re.compile(
|
||||
r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+-\s+([^\s][^\-]*?)\s+-\s+"
|
||||
)
|
||||
_LOG_DYNAMIC_VALUE_RE = re.compile(
|
||||
r"(?<![A-Za-z])(?:[0-9a-f]{8,}|\d+(?:\.\d+)?)(?![A-Za-z])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_META_NOISE_MODULES = frozenset({
|
||||
"collect_feedback_diagnostics.py",
|
||||
@@ -238,6 +242,43 @@ def is_meta_noise(line: str) -> bool:
|
||||
return match.group(1).strip() in _META_NOISE_MODULES
|
||||
|
||||
|
||||
def _repetition_fingerprint(line: str) -> str:
|
||||
"""生成用于识别连续重复日志模板的指纹。"""
|
||||
if parse_line_timestamp(line) is None:
|
||||
return line
|
||||
normalized = _LOG_TIMESTAMP_RE.sub("<time>", line)
|
||||
normalized = _LOG_DYNAMIC_VALUE_RE.sub("<value>", normalized)
|
||||
return re.sub(r"\s+", " ", normalized).strip().lower()
|
||||
|
||||
|
||||
def _compact_repeated_lines(lines: list[str]) -> list[str]:
|
||||
"""压缩连续重复日志模板,同时保留首条、末条和重复次数。"""
|
||||
compacted: list[str] = []
|
||||
group: list[str] = []
|
||||
fingerprint: Optional[str] = None
|
||||
|
||||
def flush_group() -> None:
|
||||
if len(group) < 4:
|
||||
compacted.extend(group)
|
||||
return
|
||||
compacted.append(group[0])
|
||||
compacted.append(
|
||||
f"... 同类日志连续重复 {len(group)} 次,已省略 {len(group) - 2} 行 ..."
|
||||
)
|
||||
compacted.append(group[-1])
|
||||
|
||||
for line in lines:
|
||||
current_fingerprint = _repetition_fingerprint(line)
|
||||
if group and current_fingerprint != fingerprint:
|
||||
flush_group()
|
||||
group = []
|
||||
group.append(line)
|
||||
fingerprint = current_fingerprint
|
||||
if group:
|
||||
flush_group()
|
||||
return compacted
|
||||
|
||||
|
||||
def filter_lines(
|
||||
text: str,
|
||||
keywords: list[str],
|
||||
@@ -285,7 +326,8 @@ def filter_lines(
|
||||
elif keep_block:
|
||||
matched.append(line)
|
||||
if matched:
|
||||
return matched[-max_lines:], sorted(matched_keywords)
|
||||
compacted = _compact_repeated_lines(matched)
|
||||
return compacted[-max_lines:], sorted(matched_keywords)
|
||||
return [], []
|
||||
|
||||
|
||||
|
||||
@@ -371,6 +371,14 @@ def build_prefill_url(
|
||||
return f"{issue_new_url(repo)}?{encoded}"
|
||||
|
||||
|
||||
def _safe_count(value: Any) -> int:
|
||||
"""把不可信的诊断计数字段转换为非负整数。"""
|
||||
try:
|
||||
return max(int(value or 0), 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
|
||||
"""把 doctor JSON 报告压缩成适合 Issue 和预览展示的摘要。"""
|
||||
if not isinstance(doctor, dict):
|
||||
@@ -390,28 +398,75 @@ def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
|
||||
runtime = environment.get("runtime")
|
||||
if runtime:
|
||||
lines.append(f"运行环境:{runtime}")
|
||||
findings = report.get("findings") or []
|
||||
summary = report.get("summary") or {}
|
||||
if isinstance(summary, dict):
|
||||
advisory_count = summary.get("advisory")
|
||||
if advisory_count is None and isinstance(findings, list):
|
||||
advisory_count = sum(
|
||||
1
|
||||
for item in findings
|
||||
if isinstance(item, dict)
|
||||
and item.get("affects_report_status") is False
|
||||
and not item.get("fixed")
|
||||
)
|
||||
lines.append(
|
||||
"汇总:"
|
||||
f"total={summary.get('total', 0)} "
|
||||
f"error={summary.get('error', 0)} "
|
||||
f"warn={summary.get('warn', 0)} "
|
||||
f"advisory={advisory_count or 0} "
|
||||
f"fixed={summary.get('fixed', 0)}"
|
||||
)
|
||||
|
||||
findings = report.get("findings") or []
|
||||
if isinstance(findings, list):
|
||||
important = [
|
||||
item for item in findings
|
||||
if isinstance(item, dict) and item.get("severity") in {"error", "warn"}
|
||||
][:8]
|
||||
grouped: dict[tuple[str, str, str, bool], dict[str, Any]] = {}
|
||||
for item in findings:
|
||||
if not isinstance(item, dict) or item.get("severity") not in {"error", "warn"}:
|
||||
continue
|
||||
title = str(item.get("title") or item.get("id") or "未知诊断项")
|
||||
recommendation = str(item.get("recommendation") or "").strip()
|
||||
advisory = item.get("affects_report_status") is False
|
||||
key = (str(item.get("severity")), title, recommendation, advisory)
|
||||
group = grouped.setdefault(
|
||||
key,
|
||||
{
|
||||
"count": 0,
|
||||
"matches": 0,
|
||||
"unique_matches": 0,
|
||||
"sources": [],
|
||||
},
|
||||
)
|
||||
group["count"] += 1
|
||||
context = item.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
continue
|
||||
group["matches"] += _safe_count(context.get("matches"))
|
||||
group["unique_matches"] += _safe_count(
|
||||
context.get("unique_matches") or context.get("matches") or 0
|
||||
)
|
||||
source_files = context.get("log_files") or [context.get("log_file")]
|
||||
for source_file in source_files:
|
||||
if not source_file:
|
||||
continue
|
||||
source_name = Path(str(source_file)).name
|
||||
if source_name not in group["sources"]:
|
||||
group["sources"].append(source_name)
|
||||
|
||||
important = list(grouped.items())[:8]
|
||||
if important:
|
||||
lines.append("关键发现:")
|
||||
for item in important:
|
||||
title = str(item.get("title") or item.get("id") or "未知诊断项")
|
||||
recommendation = str(item.get("recommendation") or "").strip()
|
||||
line = f"- [{item.get('severity')}] {title}"
|
||||
for (severity, title, recommendation, advisory), group in important:
|
||||
marker = f"{severity}/advisory" if advisory else severity
|
||||
line = f"- [{marker}] {title}"
|
||||
if group["count"] > 1:
|
||||
line = f"{line}(合并 {group['count']} 项)"
|
||||
if group["sources"]:
|
||||
line = f"{line};来源:{', '.join(group['sources'])}"
|
||||
if group["matches"]:
|
||||
line = f"{line};命中:{group['matches']} 条"
|
||||
if group["unique_matches"] < group["matches"]:
|
||||
line = f"{line},去重后 {group['unique_matches']} 条"
|
||||
if recommendation:
|
||||
line = f"{line};建议:{recommendation}"
|
||||
lines.append(line)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: moviepilot-api
|
||||
version: 7
|
||||
version: 9
|
||||
description: >-
|
||||
Use this skill when you need to call MoviePilot REST API endpoints directly
|
||||
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
||||
@@ -65,6 +65,25 @@ python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
|
||||
- Both methods validate against the same `API_TOKEN` value.
|
||||
- Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available.
|
||||
|
||||
### API versions and response envelopes
|
||||
|
||||
- `/api/v1` preserves the existing endpoint-specific response shapes by
|
||||
default; the login wallpaper URL is now returned in `data`.
|
||||
- `/api/v2` reuses the same routes, parameters, authentication dependencies,
|
||||
and business handlers, but wraps ordinary JSON responses in the shared
|
||||
`Response` envelope.
|
||||
- A successful raw v1 payload becomes
|
||||
`{"success":true,"message":"","data":<original payload>}` in v2.
|
||||
- Existing `Response` payloads are not wrapped again. HTTP errors on both v1
|
||||
and v2 keep their original status code and expose the error text in
|
||||
`message` with `data={}`. Non-business HTTP exceptions are not translated.
|
||||
- SSE, files, images, empty responses, and OpenAI, Anthropic, or MCP protocol
|
||||
endpoints keep their protocol-native response body.
|
||||
|
||||
Use `/api/v2` for app clients that require one JSON envelope. Any ordinary
|
||||
REST path listed below can switch from `/api/v1/...` to `/api/v2/...` without
|
||||
changing its method, parameters, request body, or authentication.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
@@ -79,6 +98,9 @@ python scripts/mp-api.py DELETE /api/v1/subscribe/123
|
||||
|
||||
# Endpoints that require ?token= auth
|
||||
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
|
||||
|
||||
# Uniform v2 JSON response envelope
|
||||
python scripts/mp-api.py GET /api/v2/dashboard/cpu
|
||||
```
|
||||
|
||||
## Complete API Reference
|
||||
@@ -92,8 +114,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/media/search` | Search media, collections, or people by title. Params: `title` (required), `type`, `page`, `count`, optional `source`. Supported sources: `media` = `themoviedb`, `douban`, `bangumi`, `anilist`; `collection` = `themoviedb`; `person` = `themoviedb`, `douban` |
|
||||
| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle`, `custom_words`, optional `source` |
|
||||
| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source` |
|
||||
| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata such as title and year |
|
||||
| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata |
|
||||
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` |
|
||||
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` |
|
||||
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: `media_source`, `media_id`, `type_name` (`电影`/`电视剧`) |
|
||||
@@ -444,9 +466,9 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
|
||||
| POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache |
|
||||
| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Params: `tmdbid`, `doubanid` |
|
||||
|
||||
### Recognition Cache (6 endpoints)
|
||||
### Recognition Cache (3 endpoints)
|
||||
|
||||
The two list endpoints return local cache totals plus `shared_recognized` and
|
||||
The list endpoint returns local cache totals plus `shared_recognized` and
|
||||
`shared_recognize_enabled` for the persisted successful shared-recognition count.
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -454,9 +476,6 @@ The two list endpoints return local cache totals plus `shared_recognized` and
|
||||
| GET | `/api/v1/tmdb/cache` | Get TheMovieDb recognition cache statistics |
|
||||
| DELETE | `/api/v1/tmdb/cache/{cache_key}` | Delete one URL-encoded TheMovieDb recognition cache key |
|
||||
| DELETE | `/api/v1/tmdb/cache` | Clear TheMovieDb recognition cache |
|
||||
| GET | `/api/v1/douban/cache` | Get Douban recognition cache statistics |
|
||||
| DELETE | `/api/v1/douban/cache/{cache_key}` | Delete one URL-encoded Douban recognition cache key |
|
||||
| DELETE | `/api/v1/douban/cache` | Clear Douban recognition cache |
|
||||
|
||||
### Message (8 endpoints)
|
||||
|
||||
@@ -482,7 +501,7 @@ The two list endpoints return local cache totals plus `shared_recognized` and
|
||||
| GET | `/api/v1/user/{username}` | User detail |
|
||||
| DELETE | `/api/v1/user/id/{user_id}` | Delete user by ID |
|
||||
| DELETE | `/api/v1/user/name/{user_name}` | Delete user by username |
|
||||
| POST | `/api/v1/user/avatar/{user_id}` | Upload avatar. Body: multipart/form-data |
|
||||
| POST | `/api/v1/user/avatar/{user_id}` | Upload avatar. Body: multipart/form-data; original filename is returned in `data.filename` |
|
||||
| GET | `/api/v1/user/config/{key}` | Get user config |
|
||||
| POST | `/api/v1/user/config/{key}` | Update user config |
|
||||
|
||||
@@ -491,7 +510,7 @@ The two list endpoints return local cache totals plus `shared_recognized` and
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
|
||||
| GET | `/api/v1/login/wallpaper` | Login page wallpaper |
|
||||
| GET | `/api/v1/login/wallpaper` | Login page wallpaper; URL is returned in `data` |
|
||||
| GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
|
||||
|
||||
### MCP Tools (6 endpoints)
|
||||
|
||||
@@ -53,8 +53,8 @@ def test_activity_log_index_counts_entries_without_body(tmp_path):
|
||||
assert "整理了电影文件" not in json.dumps(index, ensure_ascii=False)
|
||||
|
||||
|
||||
def test_activity_log_prompt_injects_index_not_full_log(tmp_path):
|
||||
"""ActivityLogMiddleware 注入系统提示词时不应携带完整活动日志正文。"""
|
||||
def test_activity_log_prompt_is_stable_and_excludes_log_index(tmp_path):
|
||||
"""ActivityLogMiddleware 的系统提示词不应随活动日志索引变化。"""
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
_write_activity_log(
|
||||
tmp_path,
|
||||
@@ -74,10 +74,15 @@ def test_activity_log_prompt_injects_index_not_full_log(tmp_path):
|
||||
|
||||
modified = middleware.modify_request(request)
|
||||
system_text = str(modified.system_message.content)
|
||||
stable_prompt = middleware._format_activity_log(
|
||||
{"2099-12-31": "999 条活动记录"}
|
||||
)
|
||||
|
||||
assert "1 条活动记录" in system_text
|
||||
assert "1 条活动记录" not in system_text
|
||||
assert date_str not in system_text
|
||||
assert "这是一条不应默认进入上下文的活动正文" not in system_text
|
||||
assert "query_activity_log" in system_text
|
||||
assert middleware._format_activity_log(state_update["activity_log_contents"]) == stable_prompt
|
||||
|
||||
|
||||
def test_activity_log_abefore_agent_refreshes_existing_state(tmp_path):
|
||||
@@ -146,6 +151,29 @@ def test_summarize_with_llm_ignores_skip_marker():
|
||||
llm.ainvoke.assert_awaited_once()
|
||||
|
||||
|
||||
def test_summarize_with_llm_extracts_text_blocks():
|
||||
"""活动摘要应兼容 LLM 返回的结构化文本块。"""
|
||||
llm = SimpleNamespace(
|
||||
ainvoke=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
content=[
|
||||
{"type": "reasoning", "text": "内部推理"},
|
||||
{"type": "text", "text": "摘要:用户完成了文件工具排查。"},
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.agent.llm.LLMHelper.get_llm",
|
||||
new=AsyncMock(return_value=llm),
|
||||
):
|
||||
summary = asyncio.run(_summarize_with_llm("用户: 排查文件工具"))
|
||||
|
||||
assert summary == "用户完成了文件工具排查。"
|
||||
llm.ainvoke.assert_awaited_once()
|
||||
|
||||
|
||||
def test_activity_log_records_detailed_summary(tmp_path):
|
||||
"""有实际工具动作的交互应写入较完整的活动摘要。"""
|
||||
summary = (
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.agent.tools.impl.edit_file import EditFileTool
|
||||
from app.agent.tools.impl.read_file import ReadFileTool
|
||||
from app.agent.tools.impl.list_directory import ListDirectoryTool
|
||||
from app.agent.tools.impl.read_file import MAX_READ_SIZE, ReadFileTool
|
||||
from app.agent.tools.impl.write_file import WriteFileTool
|
||||
from app.chain.storage import StorageChain
|
||||
|
||||
|
||||
def _make_admin_tool(tool_class):
|
||||
@@ -129,3 +133,58 @@ def test_read_file_can_return_sha256_metadata(tmp_path):
|
||||
"插件内容".encode("utf-8")
|
||||
).hexdigest()
|
||||
assert payload["truncated"] is False
|
||||
|
||||
|
||||
def test_read_file_returns_line_range_hint_when_truncated(tmp_path):
|
||||
"""超过50KB时应保留前段内容并提示按行号范围继续读取。"""
|
||||
file_path = tmp_path / "large.py"
|
||||
exact_content = "a" * MAX_READ_SIZE
|
||||
file_path.write_text(exact_content, encoding="utf-8")
|
||||
tool = _make_admin_tool(ReadFileTool)
|
||||
|
||||
exact_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
|
||||
file_path.write_text(f"{exact_content}b", encoding="utf-8")
|
||||
truncated_result = asyncio.run(tool.ainvoke({"file_path": str(file_path)}))
|
||||
metadata_result = asyncio.run(
|
||||
tool.run(str(file_path), include_metadata=True)
|
||||
)
|
||||
metadata = json.loads(metadata_result)
|
||||
|
||||
assert exact_result == exact_content
|
||||
assert truncated_result.startswith(exact_content)
|
||||
assert "50KB" in truncated_result
|
||||
assert "start_line" in truncated_result
|
||||
assert "end_line" in truncated_result
|
||||
assert "tool_result_truncated" not in truncated_result
|
||||
assert metadata["truncated"] is True
|
||||
assert "行号范围" in metadata["truncation_message"]
|
||||
|
||||
|
||||
def test_list_directory_returns_paged_items_with_next_offset(tmp_path):
|
||||
"""目录工具应返回可继续查询的分页元数据。"""
|
||||
items = [
|
||||
SimpleNamespace(
|
||||
name=f"file-{index:03d}.txt",
|
||||
type="file",
|
||||
path=str(tmp_path / f"file-{index:03d}.txt"),
|
||||
size=100,
|
||||
modify_time=None,
|
||||
extension=".txt",
|
||||
)
|
||||
for index in range(120)
|
||||
]
|
||||
tool = _make_admin_tool(ListDirectoryTool)
|
||||
|
||||
with patch.object(StorageChain, "list_files", return_value=items):
|
||||
result = asyncio.run(
|
||||
tool.run(str(tmp_path), limit=50, offset=50)
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["total_count"] == 120
|
||||
assert payload["returned_count"] == 50
|
||||
assert payload["offset"] == 50
|
||||
assert payload["limit"] == 50
|
||||
assert payload["has_more"] is True
|
||||
assert payload["next_offset"] == 100
|
||||
assert payload["items"][0]["name"] == "file-050.txt"
|
||||
|
||||
@@ -84,3 +84,22 @@ def test_resolve_llm_runtime_config_prefers_plugin_api_protocol(monkeypatch) ->
|
||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||
|
||||
assert runtime_config["api_protocol"] == "chat_completions"
|
||||
|
||||
|
||||
def test_resolve_llm_runtime_config_prefers_plugin_web_search_mode(monkeypatch) -> None:
|
||||
"""插件显式覆盖联网搜索模式时应优先使用插件值。"""
|
||||
monkeypatch.setattr(settings, "LLM_WEB_SEARCH_MODE", "local")
|
||||
agent = MoviePilotAgent(session_id="web-search-plugin", user_id="user-1")
|
||||
|
||||
async def override_web_search_mode(_event_type, event_data):
|
||||
"""模拟插件覆盖联网搜索模式。"""
|
||||
event_data.web_search_mode = "builtin"
|
||||
return SimpleNamespace(event_data=event_data)
|
||||
|
||||
with patch(
|
||||
"app.agent.eventmanager.async_send_event",
|
||||
new=AsyncMock(side_effect=override_web_search_mode),
|
||||
):
|
||||
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
|
||||
|
||||
assert runtime_config["web_search_mode"] == "builtin"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.agent.prompt import prompt_manager
|
||||
from app.core.config import settings
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
def test_progress_prompt_is_independent_from_tool_display_mode() -> None:
|
||||
"""进度沟通规则不应随工具逐条或汇总展示模式变化。"""
|
||||
with patch.object(settings, "AI_AGENT_VERBOSE", False):
|
||||
summary_mode_prompt = prompt_manager.get_agent_prompt(
|
||||
channel=MessageChannel.WebAgent.value
|
||||
)
|
||||
with patch.object(settings, "AI_AGENT_VERBOSE", True):
|
||||
verbose_mode_prompt = prompt_manager.get_agent_prompt(
|
||||
channel=MessageChannel.WebAgent.value
|
||||
)
|
||||
|
||||
assert summary_mode_prompt == verbose_mode_prompt
|
||||
assert "meaningful changes in understanding or execution" in summary_mode_prompt
|
||||
assert "not on elapsed time or the number of tool calls" in summary_mode_prompt
|
||||
assert "one or two tools have finished" in summary_mode_prompt
|
||||
assert "useful preliminary conclusion" in summary_mode_prompt
|
||||
assert "materially changes the working direction" in summary_mode_prompt
|
||||
assert "sustained blocker the user should know about" in summary_mode_prompt
|
||||
assert "including the key evidence and what you will do next" in summary_mode_prompt
|
||||
assert "brevity is not a goal by itself" in summary_mode_prompt
|
||||
assert "do not repeat an unchanged status" in summary_mode_prompt.lower()
|
||||
assert "Continue working after each update" in summary_mode_prompt
|
||||
assert "The final reply must be self-contained" in summary_mode_prompt
|
||||
assert "before the first tool call" not in summary_mode_prompt
|
||||
assert "approximately every 30 to 60 seconds" not in summary_mode_prompt
|
||||
assert "one or two short sentences" not in summary_mode_prompt
|
||||
assert "remain completely silent" not in summary_mode_prompt
|
||||
assert "DO NOT output any intermediate content" not in summary_mode_prompt
|
||||
@@ -0,0 +1,225 @@
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
from app.agent import MoviePilotAgent
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
from app.agent.middleware.usage import UsageMiddleware
|
||||
from app.chain.message import MessageChain
|
||||
|
||||
|
||||
def test_usage_extracts_normalized_cache_details() -> None:
|
||||
"""标准 usage_metadata 应解析缓存读取、写入和未命中 tokens。"""
|
||||
usage = UsageMiddleware._extract_usage(
|
||||
AIMessage(
|
||||
content="ok",
|
||||
usage_metadata={
|
||||
"input_tokens": 1200,
|
||||
"output_tokens": 100,
|
||||
"total_tokens": 1300,
|
||||
"input_token_details": {
|
||||
"cache_read": 700,
|
||||
"cache_creation": 0,
|
||||
"ephemeral_5m_input_tokens": 300,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert usage["cache_usage_available"]
|
||||
assert usage["cache_read_input_tokens"] == 700
|
||||
assert usage["cache_write_input_tokens"] == 300
|
||||
assert usage["uncached_input_tokens"] == 200
|
||||
assert usage["cache_hit_ratio"] == pytest.approx(700 / 1200)
|
||||
|
||||
|
||||
def test_usage_extracts_deepseek_cache_hit_and_miss_tokens() -> None:
|
||||
"""DeepSeek 原始 usage 应保留其显式缓存命中与未命中字段。"""
|
||||
usage = UsageMiddleware._extract_usage(
|
||||
AIMessage(
|
||||
content="ok",
|
||||
response_metadata={
|
||||
"token_usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 1050,
|
||||
"prompt_cache_hit_tokens": 800,
|
||||
"prompt_cache_miss_tokens": 200,
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert usage["cache_usage_available"]
|
||||
assert usage["cache_read_input_tokens"] == 800
|
||||
assert usage["cache_write_input_tokens"] == 0
|
||||
assert usage["uncached_input_tokens"] == 200
|
||||
assert usage["cache_hit_ratio"] == pytest.approx(0.8)
|
||||
|
||||
|
||||
def test_session_usage_aggregates_and_formats_cache_statistics() -> None:
|
||||
"""会话状态应聚合缓存统计并在状态文本中展示。"""
|
||||
agent = MoviePilotAgent(session_id="cache-session", user_id="user-1")
|
||||
agent._record_usage(
|
||||
{
|
||||
"has_usage": True,
|
||||
"cache_usage_available": True,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 10,
|
||||
"total_tokens": 110,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_write_input_tokens": 20,
|
||||
"uncached_input_tokens": 20,
|
||||
"cache_hit_ratio": 0.6,
|
||||
}
|
||||
)
|
||||
agent._record_usage(
|
||||
{
|
||||
"has_usage": True,
|
||||
"cache_usage_available": True,
|
||||
"input_tokens": 50,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 55,
|
||||
"cache_read_input_tokens": 20,
|
||||
"cache_write_input_tokens": 0,
|
||||
"uncached_input_tokens": 30,
|
||||
"cache_hit_ratio": 0.4,
|
||||
}
|
||||
)
|
||||
|
||||
status = agent.get_session_status()
|
||||
status.update({"is_processing": False, "pending_messages": 0})
|
||||
status_text = MessageChain._format_session_status_text(status)
|
||||
|
||||
assert status["total_cache_read_input_tokens"] == 80
|
||||
assert status["total_cache_write_input_tokens"] == 20
|
||||
assert status["total_uncached_input_tokens"] == 50
|
||||
assert status["total_cache_hit_ratio"] == pytest.approx(80 / 150)
|
||||
assert "当前会话累计缓存: 命中 80 / 写入 20 / 未命中 50 (53.33%)" in status_text
|
||||
|
||||
|
||||
def test_prompt_cache_key_is_stable_and_private() -> None:
|
||||
"""提示词缓存键应在同一会话内稳定且不暴露原始标识。"""
|
||||
first = MoviePilotAgent(session_id="private-session", user_id="private-user")
|
||||
second = MoviePilotAgent(session_id="private-session", user_id="private-user")
|
||||
other = MoviePilotAgent(session_id="other-session", user_id="private-user")
|
||||
|
||||
cache_key = first._build_prompt_cache_key()
|
||||
|
||||
assert cache_key == second._build_prompt_cache_key()
|
||||
assert cache_key != other._build_prompt_cache_key()
|
||||
assert "private-session" not in cache_key
|
||||
assert "private-user" not in cache_key
|
||||
|
||||
|
||||
def test_openai_prompt_cache_options_only_target_official_endpoints() -> None:
|
||||
"""OpenAI 专属缓存参数不得泄露到第三方兼容端点。"""
|
||||
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
|
||||
provider="openai",
|
||||
base_url="https://api.openai.com/v1",
|
||||
use_responses_api=False,
|
||||
prompt_cache_key="cache-key",
|
||||
default_headers={"User-Agent": "MoviePilot"},
|
||||
model_kwargs={"extra_body": {"existing": True}},
|
||||
)
|
||||
_, compatible_kwargs = LLMHelper._build_openai_prompt_cache_options(
|
||||
provider="openai",
|
||||
base_url="https://api.openai.com.example/v1",
|
||||
use_responses_api=False,
|
||||
prompt_cache_key="cache-key",
|
||||
default_headers=None,
|
||||
model_kwargs={},
|
||||
)
|
||||
|
||||
assert headers == {"User-Agent": "MoviePilot"}
|
||||
assert kwargs["extra_body"] == {
|
||||
"existing": True,
|
||||
"prompt_cache_key": "cache-key",
|
||||
}
|
||||
assert compatible_kwargs == {}
|
||||
|
||||
|
||||
def test_xai_chat_completions_uses_stable_conversation_header() -> None:
|
||||
"""xAI Chat Completions 应使用官方会话缓存路由请求头。"""
|
||||
headers, kwargs = LLMHelper._build_openai_prompt_cache_options(
|
||||
provider="xai",
|
||||
base_url="https://api.x.ai/v1",
|
||||
use_responses_api=None,
|
||||
prompt_cache_key="cache-key",
|
||||
default_headers=None,
|
||||
model_kwargs={},
|
||||
)
|
||||
|
||||
assert headers == {"x-grok-conv-id": "cache-key"}
|
||||
assert kwargs == {}
|
||||
|
||||
|
||||
def test_prompt_cache_adapter_preserves_control_after_tool_binding() -> None:
|
||||
"""Provider 缓存参数应在 Agent 最终绑定工具时仍然存在。"""
|
||||
|
||||
class FakeModel:
|
||||
"""模拟通过 bind_tools 再调用 bind 的 LangChain 模型。"""
|
||||
|
||||
def bind(self, **kwargs):
|
||||
"""返回最终绑定参数。"""
|
||||
return kwargs
|
||||
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
"""模拟模型的工具绑定流程。"""
|
||||
return self.bind(tools=tools, **kwargs)
|
||||
|
||||
cached_model_cls = LLMHelper._with_prompt_cache_control(
|
||||
FakeModel,
|
||||
{"type": "default"},
|
||||
)
|
||||
|
||||
result = cached_model_cls().bind_tools([{"name": "tool"}])
|
||||
|
||||
assert result["tools"] == [{"name": "tool"}]
|
||||
assert result["cache_control"] == {"type": "default"}
|
||||
|
||||
|
||||
def test_anthropic_cache_control_only_targets_official_endpoint() -> None:
|
||||
"""Anthropic 原生缓存控制不得发送到第三方兼容端点。"""
|
||||
official = LLMHelper._use_anthropic_prompt_cache(
|
||||
provider="anthropic",
|
||||
runtime={
|
||||
"runtime": "anthropic_compatible",
|
||||
"base_url": "https://api.anthropic.com/v1",
|
||||
},
|
||||
prompt_cache_key="cache-key",
|
||||
)
|
||||
compatible = LLMHelper._use_anthropic_prompt_cache(
|
||||
provider="minimax",
|
||||
runtime={
|
||||
"runtime": "anthropic_compatible",
|
||||
"base_url": "https://api.minimax.io/anthropic/v1",
|
||||
},
|
||||
prompt_cache_key="cache-key",
|
||||
)
|
||||
|
||||
assert official
|
||||
assert not compatible
|
||||
|
||||
|
||||
def test_provider_metadata_declares_prompt_cache_without_model_allowlist() -> None:
|
||||
"""Provider 应通过模型能力元数据判断缓存支持,不依赖模型 ID 白名单。"""
|
||||
assert LLMProviderManager._metadata_supports_prompt_cache(
|
||||
{"cost": {"input": 1, "cache_read": 0.1}}
|
||||
)
|
||||
assert LLMProviderManager._metadata_supports_prompt_cache(
|
||||
{"capabilities": {"prompt_cache": True}}
|
||||
)
|
||||
assert not LLMProviderManager._metadata_supports_prompt_cache(
|
||||
{"cost": {"input": 1, "output": 2}}
|
||||
)
|
||||
|
||||
|
||||
def test_bedrock_model_metadata_candidates_remove_region_prefix() -> None:
|
||||
"""Bedrock 跨区域模型应自动回落到基础模型的能力元数据。"""
|
||||
candidates = LLMProviderManager._models_dev_model_candidates(
|
||||
"amazon-bedrock",
|
||||
"us.vendor.model-version",
|
||||
)
|
||||
|
||||
assert candidates == ("us.vendor.model-version", "vendor.model-version")
|
||||
@@ -7,6 +7,7 @@ from langchain.agents.middleware.types import ModelRequest
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from app.agent.middleware.skills import (
|
||||
MAX_SKILL_RESULT_CHARS,
|
||||
SKILL_TOOL_NAME,
|
||||
SkillsMiddleware,
|
||||
_alist_skills,
|
||||
@@ -80,6 +81,24 @@ async def test_skill_tool_loads_skill_by_id_and_name(tmp_path):
|
||||
assert by_name["skill"]["name"] == "MoviePilot CLI"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_skill_tool_caps_large_result_before_model_context(tmp_path):
|
||||
"""超大 Skill 内容应在工具返回前限制到模型上下文上限。"""
|
||||
_write_skill(tmp_path, "large-skill")
|
||||
skill_path = tmp_path / "large-skill" / "SKILL.md"
|
||||
with skill_path.open("a", encoding="utf-8") as file_handle:
|
||||
file_handle.write("\n" + ("large-line\n" * 30000))
|
||||
|
||||
middleware = SkillsMiddleware(sources=[str(tmp_path)])
|
||||
result = await middleware.tools[0].ainvoke({"name": "large-skill"})
|
||||
payload = json.loads(result)
|
||||
|
||||
assert len(result) <= MAX_SKILL_RESULT_CHARS
|
||||
assert payload["success"] is True
|
||||
assert payload["truncated"] is True
|
||||
assert "Skill 内容已截断" in payload["content"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_skill_tool_returns_not_found_for_unknown_skill(tmp_path):
|
||||
"""skill 工具找不到技能时应返回结构化失败信息。"""
|
||||
|
||||
@@ -88,6 +88,8 @@ def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None:
|
||||
use_proxy=True,
|
||||
thinking_level="xhigh",
|
||||
api_protocol="auto",
|
||||
web_search_mode="local",
|
||||
prompt_cache_key=agent._build_prompt_cache_key(),
|
||||
)
|
||||
assert agent._llm_provider_selection["selected_provider_id"] == "provider-1"
|
||||
|
||||
@@ -118,6 +120,11 @@ def test_execute_agent_broadcasts_usage_on_success() -> None:
|
||||
"input_tokens": 12,
|
||||
"output_tokens": 8,
|
||||
"total_tokens": 20,
|
||||
"cache_usage_available": True,
|
||||
"cache_read_input_tokens": 8,
|
||||
"cache_write_input_tokens": 2,
|
||||
"uncached_input_tokens": 2,
|
||||
"cache_hit_ratio": 8 / 12,
|
||||
}
|
||||
)
|
||||
return _FakeAgent([AIMessage(content="ok")])
|
||||
@@ -137,6 +144,11 @@ def test_execute_agent_broadcasts_usage_on_success() -> None:
|
||||
assert usage.input_tokens == 12
|
||||
assert usage.output_tokens == 8
|
||||
assert usage.total_tokens == 20
|
||||
assert usage.cache_read_input_tokens == 8
|
||||
assert usage.cache_write_input_tokens == 2
|
||||
assert usage.uncached_input_tokens == 2
|
||||
assert usage.cache_hit_ratio == 8 / 12
|
||||
assert usage.cache_usage_available
|
||||
|
||||
|
||||
def test_execute_agent_broadcasts_usage_on_failure() -> None:
|
||||
|
||||
@@ -26,9 +26,23 @@ class TestAgentToolResultLimits(unittest.TestCase):
|
||||
|
||||
self.assertTrue(payload["tool_result_truncated"])
|
||||
self.assertEqual(payload["tool_name"], "oversized_result_tool")
|
||||
self.assertEqual(payload["returned_chars"], DEFAULT_TOOL_RESULT_MAX_CHARS)
|
||||
self.assertLessEqual(len(result), DEFAULT_TOOL_RESULT_MAX_CHARS)
|
||||
self.assertEqual(payload["returned_chars"], len(payload["content_preview"]))
|
||||
self.assertLess(payload["returned_chars"], DEFAULT_TOOL_RESULT_MAX_CHARS)
|
||||
self.assertGreater(payload["total_chars"], payload["returned_chars"])
|
||||
|
||||
def test_formatter_keeps_escaped_preview_within_hard_limit(self):
|
||||
"""大量换行转义后,最终工具结果仍不得超过配置的字符上限。"""
|
||||
result = format_tool_result_for_agent(
|
||||
"line\n" * DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
tool_name="escaped_result_tool",
|
||||
)
|
||||
payload = json.loads(result)
|
||||
|
||||
self.assertLessEqual(len(result), DEFAULT_TOOL_RESULT_MAX_CHARS)
|
||||
self.assertTrue(payload["tool_result_truncated"])
|
||||
self.assertEqual(payload["returned_chars"], len(payload["content_preview"]))
|
||||
|
||||
def test_formatter_preserves_sensitive_json_fields_for_agent_use(self):
|
||||
result = format_tool_result_for_agent(
|
||||
{
|
||||
|
||||
@@ -284,3 +284,46 @@ def test_upload_avatar_rejects_other_user_for_non_superuser():
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "用户权限不足"
|
||||
|
||||
|
||||
def test_upload_avatar_returns_filename_in_data(monkeypatch):
|
||||
"""头像上传成功时应通过 data 返回文件名,message 只保留消息文本。"""
|
||||
|
||||
class FakeUser:
|
||||
"""记录头像更新内容的用户桩。"""
|
||||
|
||||
def __init__(self):
|
||||
self.values = None
|
||||
|
||||
async def async_update(self, db: object, values: dict[str, str]) -> None:
|
||||
"""记录待写入的头像数据。"""
|
||||
self.values = values
|
||||
|
||||
class FakeUserModel:
|
||||
"""返回固定用户的模型桩。"""
|
||||
|
||||
@classmethod
|
||||
async def async_get(cls, db: object, user_id: int) -> FakeUser:
|
||||
"""按用户 ID 返回测试用户。"""
|
||||
assert user_id == 1
|
||||
return fake_user
|
||||
|
||||
fake_user = FakeUser()
|
||||
current_user = SimpleNamespace(id=1, is_superuser=False)
|
||||
upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png")
|
||||
monkeypatch.setattr(user_endpoint, "User", FakeUserModel)
|
||||
|
||||
response = asyncio.run(
|
||||
user_endpoint.upload_avatar(
|
||||
user_id=1,
|
||||
db=object(),
|
||||
file=upload_file,
|
||||
current_user=current_user,
|
||||
)
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert response.data == {"filename": "avatar.png"}
|
||||
assert response.message is None
|
||||
assert response.message_i18n is None
|
||||
assert fake_user.values == {"avatar": "data:image/ico;base64,b'YXZhdGFy'"}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.apiv2_utils import (
|
||||
OPENAPI_V2_PATH,
|
||||
V2ResponseMiddleware,
|
||||
configure_v2_openapi,
|
||||
)
|
||||
from app.schemas.response import Response
|
||||
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def anyio_backend():
|
||||
"""使用 asyncio 运行异步接口测试。"""
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api_app() -> FastAPI:
|
||||
"""构造同时包含 v1 和 v2 示例接口的测试应用。"""
|
||||
app = FastAPI()
|
||||
app.add_middleware(V2ResponseMiddleware)
|
||||
|
||||
@app.get("/api/v1/items")
|
||||
async def get_v1_items() -> list[dict]:
|
||||
"""返回未封装的 v1 列表。"""
|
||||
return [{"id": 1}]
|
||||
|
||||
@app.get("/api/v2/items")
|
||||
async def get_v2_items() -> list[dict]:
|
||||
"""返回供 v2 适配器封装的列表。"""
|
||||
return [{"id": 1}]
|
||||
|
||||
@app.get("/api/v2/wrapped", response_model=Response)
|
||||
async def get_wrapped_response() -> Response:
|
||||
"""返回已经使用通用结构封装的响应。"""
|
||||
return Response(success=True, message="操作成功", data={"id": 1})
|
||||
|
||||
@app.get("/api/v2/error")
|
||||
async def get_error() -> None:
|
||||
"""返回供 v2 适配器转换的 HTTP 错误。"""
|
||||
raise HTTPException(status_code=400, detail="请求参数错误")
|
||||
|
||||
@app.get("/api/v2/validated/{item_id}")
|
||||
async def get_validated_item(item_id: int) -> dict:
|
||||
"""返回带路径参数校验的示例数据。"""
|
||||
return {"id": item_id}
|
||||
|
||||
@app.get("/api/v2/openai/v1/models")
|
||||
async def get_openai_models() -> dict:
|
||||
"""返回需要保持原始协议结构的 OpenAI 模型列表。"""
|
||||
return {"object": "list", "data": []}
|
||||
|
||||
@app.get("/api/v2/events")
|
||||
async def get_events() -> None:
|
||||
"""返回不应封装的 SSE 流。"""
|
||||
async def event_source():
|
||||
"""生成一条测试事件。"""
|
||||
yield "data: ok\n\n"
|
||||
|
||||
return StreamingResponse(event_source(), media_type="text/event-stream")
|
||||
|
||||
@app.get(OPENAPI_V2_PATH, include_in_schema=False)
|
||||
async def get_v2_openapi_schema() -> dict:
|
||||
"""返回不应被 v2 中间件封装的 OpenAPI 文档。"""
|
||||
return {"openapi": "3.1.0", "info": {"title": "Test", "version": "1.0.0"}}
|
||||
|
||||
configure_v2_openapi(app)
|
||||
return app
|
||||
|
||||
|
||||
def make_client(app: FastAPI) -> httpx.AsyncClient:
|
||||
"""创建不访问真实网络的 ASGI 测试客户端。"""
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def test_v2_wraps_raw_json_without_changing_v1(api_app: FastAPI):
|
||||
"""v2 应封装原始 JSON 数据,同时保持 v1 返回结构不变。"""
|
||||
async with make_client(api_app) as client:
|
||||
v1_response = await client.get("/api/v1/items")
|
||||
v2_response = await client.get("/api/v2/items")
|
||||
|
||||
assert v1_response.json() == [{"id": 1}]
|
||||
assert v2_response.json() == {
|
||||
"success": True,
|
||||
"message": "",
|
||||
"data": [{"id": 1}],
|
||||
}
|
||||
|
||||
|
||||
async def test_v2_keeps_existing_response_payload(api_app: FastAPI):
|
||||
"""已经使用 Response 的接口不应被重复封装。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/wrapped")
|
||||
|
||||
payload = response.json()
|
||||
assert payload["success"] is True
|
||||
assert payload["message"] == "操作成功"
|
||||
assert payload["data"] == {"id": 1}
|
||||
assert "success" not in payload["data"]
|
||||
|
||||
|
||||
async def test_v2_moves_http_error_detail_to_message(api_app: FastAPI):
|
||||
"""v2 HTTP 错误应保留状态码并把 detail 转换到 message。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/error")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"success": False,
|
||||
"message": "请求参数错误",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
|
||||
async def test_v2_moves_validation_error_to_message(api_app: FastAPI):
|
||||
"""v2 参数校验错误也应返回可直接展示的 message。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/validated/not-an-integer")
|
||||
|
||||
payload = response.json()
|
||||
assert response.status_code == 422
|
||||
assert payload["success"] is False
|
||||
assert payload["message"]
|
||||
assert payload["data"] == {}
|
||||
|
||||
|
||||
async def test_v2_keeps_protocol_response_unwrapped(api_app: FastAPI):
|
||||
"""OpenAI 等标准协议接口应保持原始响应结构。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/openai/v1/models")
|
||||
|
||||
assert response.json() == {"object": "list", "data": []}
|
||||
|
||||
|
||||
async def test_v2_keeps_streaming_response_unwrapped(api_app: FastAPI):
|
||||
"""v2 SSE 等非 JSON 响应应保持原始内容。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get("/api/v2/events")
|
||||
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
assert response.text == "data: ok\n\n"
|
||||
|
||||
|
||||
async def test_v2_keeps_openapi_schema_unwrapped(api_app: FastAPI):
|
||||
"""v2 OpenAPI 文档应保持 Swagger UI 可读取的原始结构。"""
|
||||
async with make_client(api_app) as client:
|
||||
response = await client.get(OPENAPI_V2_PATH)
|
||||
|
||||
assert response.json() == {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Test", "version": "1.0.0"},
|
||||
}
|
||||
|
||||
|
||||
def test_v2_openapi_uses_response_schema(api_app: FastAPI):
|
||||
"""v2 普通 JSON 接口的 OpenAPI 应声明通用 Response 模型。"""
|
||||
schema = api_app.openapi()
|
||||
|
||||
raw_schema = schema["paths"]["/api/v2/items"]["get"]["responses"]["200"]
|
||||
protocol_schema = schema["paths"]["/api/v2/openai/v1/models"]["get"]
|
||||
stream_schema = schema["paths"]["/api/v2/events"]["get"]
|
||||
|
||||
assert raw_schema["content"]["application/json"]["schema"] == {
|
||||
"$ref": "#/components/schemas/Response"
|
||||
}
|
||||
assert protocol_schema["responses"]["200"]["content"]["application/json"][
|
||||
"schema"
|
||||
] != {"$ref": "#/components/schemas/Response"}
|
||||
assert stream_schema["responses"]["200"]["content"]["application/json"].get(
|
||||
"schema"
|
||||
) != {"$ref": "#/components/schemas/Response"}
|
||||
data_schema = schema["components"]["schemas"]["Response"]["properties"]["data"]
|
||||
assert data_schema["title"] == "Data"
|
||||
assert {} in data_schema["anyOf"]
|
||||
|
||||
|
||||
def test_response_accepts_scalar_data():
|
||||
"""通用 Response 的 data 应支持标量接口数据。"""
|
||||
response = Response(success=True, data=1)
|
||||
|
||||
assert response.data == 1
|
||||
|
||||
|
||||
def test_v2_router_reuses_v1_route_endpoints():
|
||||
"""v2 路由应直接复用 v1 的端点函数,避免复制业务实现。"""
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from app.api.apiv1 import api_router
|
||||
from app.api.apiv2 import api_router_v2
|
||||
|
||||
v1_routes = [route for route in api_router.routes if isinstance(route, APIRoute)]
|
||||
v2_routes = [route for route in api_router_v2.routes if isinstance(route, APIRoute)]
|
||||
|
||||
assert len(v2_routes) == len(v1_routes)
|
||||
assert all(
|
||||
v2_route.path == v1_route.path
|
||||
and v2_route.methods == v1_route.methods
|
||||
and v2_route.endpoint is v1_route.endpoint
|
||||
for v1_route, v2_route in zip(v1_routes, v2_routes)
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_routes_are_mirrored_to_v2(monkeypatch):
|
||||
"""插件动态路由注册和移除时应同步维护 v2 路径。"""
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
|
||||
class FakeApp:
|
||||
"""记录动态注册路径的应用桩。"""
|
||||
|
||||
def __init__(self):
|
||||
self.routes = []
|
||||
self.openapi_schema = None
|
||||
|
||||
def add_api_route(self, **kwargs):
|
||||
"""记录新增的路由路径。"""
|
||||
self.routes.append(SimpleNamespace(path=kwargs["path"]))
|
||||
|
||||
def setup(self):
|
||||
"""模拟 FastAPI 路由重建。"""
|
||||
|
||||
class FakePluginManager:
|
||||
"""返回单个测试插件 API 的管理器桩。"""
|
||||
|
||||
def get_plugin_apis(self, plugin_id):
|
||||
"""返回测试插件 API。"""
|
||||
assert plugin_id == "DemoPlugin"
|
||||
return [
|
||||
{
|
||||
"path": "/DemoPlugin/health",
|
||||
"endpoint": lambda: {"ok": True},
|
||||
"methods": ["GET"],
|
||||
}
|
||||
]
|
||||
|
||||
fake_app = FakeApp()
|
||||
monkeypatch.setattr(plugin_endpoint, "app", fake_app)
|
||||
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
|
||||
|
||||
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add")
|
||||
|
||||
assert [route.path for route in fake_app.routes] == [
|
||||
"/api/v1/plugin/DemoPlugin/health",
|
||||
"/api/v2/plugin/DemoPlugin/health",
|
||||
]
|
||||
|
||||
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
|
||||
|
||||
assert fake_app.routes == []
|
||||
@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
||||
expected_versions = {
|
||||
"database-operation": "3",
|
||||
"moviepilot-api": "7",
|
||||
"moviepilot-api": "9",
|
||||
"moviepilot-cli": "6",
|
||||
"moviepilot-update": "3",
|
||||
"transfer-failed-retry": "2",
|
||||
|
||||
+65
-7
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -9,6 +10,11 @@ from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
|
||||
from app.doctor.runner import DoctorRunner
|
||||
|
||||
|
||||
def _current_log_timestamp() -> str:
|
||||
"""返回 Doctor 近期日志测试使用的当前时间戳。"""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
|
||||
"""doctor JSON 报告应包含稳定状态、环境、汇总和发现列表。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
@@ -95,7 +101,7 @@ def test_doctor_plugin_log_error_does_not_degrade_report(tmp_path, monkeypatch):
|
||||
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
|
||||
plugin_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
plugin_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - demo.py - 插件任务执行异常\n",
|
||||
f"【ERROR】{_current_log_timestamp()} - demo.py - 插件任务执行异常\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -121,7 +127,7 @@ def test_doctor_plugin_load_error_in_main_log_does_not_degrade_report(
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
app_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
|
||||
f"【ERROR】{_current_log_timestamp()} - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
|
||||
"Exception: boom\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -146,12 +152,12 @@ def test_doctor_plugin_error_mirrored_to_stdio_does_not_degrade_report(
|
||||
plugin_log = settings.LOG_PATH / "plugins" / "DemoPlugin.log"
|
||||
plugin_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
plugin_log.write_text(
|
||||
"【INFO】2026-07-20 08:00:00 - demo.py - 插件已启动\n",
|
||||
f"【INFO】{_current_log_timestamp()} - demo.py - 插件已启动\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdio_log = settings.LOG_PATH / "moviepilot.stdout.log"
|
||||
stdio_log.write_text(
|
||||
"ERROR: [demoplugin] 2026-07-20 08:01:00 demo.py - task exception\n",
|
||||
f"ERROR: [demoplugin] {_current_log_timestamp()} demo.py - task exception\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -171,7 +177,7 @@ def test_doctor_core_log_error_still_degrades_report(tmp_path, monkeypatch):
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
app_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
|
||||
f"【ERROR】{_current_log_timestamp()} - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
|
||||
"RuntimeError: boom\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -195,9 +201,9 @@ def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status(
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
app_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
|
||||
f"【ERROR】{_current_log_timestamp()} - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
|
||||
"Exception: plugin boom\n"
|
||||
"【ERROR】2026-07-20 08:01:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
|
||||
f"【ERROR】{_current_log_timestamp()} - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
|
||||
"Exception: core boom\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -214,3 +220,55 @@ def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status(
|
||||
assert "core boom" in core_finding.detail
|
||||
assert "plugin boom" in plugin_finding.detail
|
||||
assert runner.report.status.value == "degraded"
|
||||
|
||||
|
||||
def test_doctor_deduplicates_mirrored_plugin_errors(tmp_path, monkeypatch):
|
||||
"""同一插件错误出现在主日志和插件日志时应只生成一条聚合告警。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
|
||||
plugin_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
f"【ERROR】{timestamp} - plugin.py - 插件任务执行异常\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
plugin_log.write_text(
|
||||
f"【ERROR】{timestamp} - demo.py - 插件任务执行异常\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_logs(runner)
|
||||
|
||||
plugin_findings = [
|
||||
finding
|
||||
for finding in runner.report.findings
|
||||
if finding.title == "最近日志存在插件异常"
|
||||
]
|
||||
assert len(plugin_findings) == 1
|
||||
finding = plugin_findings[0]
|
||||
assert finding.context["matches"] == 2
|
||||
assert finding.context["unique_matches"] == 1
|
||||
assert len(finding.context["log_files"]) == 2
|
||||
assert runner.report.summary["advisory"] == 1
|
||||
assert runner.report.find("logs.recent") is not None
|
||||
|
||||
|
||||
def test_doctor_ignores_errors_outside_log_window(tmp_path, monkeypatch):
|
||||
"""超出日志诊断时间窗的历史错误不应污染当前 Doctor 结果。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
timestamp = (datetime.now() - timedelta(hours=25)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
app_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
f"【ERROR】{timestamp} - rss.py - 历史解析错误\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_logs(runner)
|
||||
|
||||
assert runner.report.find("logs.moviepilot.recent_errors") is None
|
||||
assert runner.report.find("logs.recent") is not None
|
||||
assert runner.report.status.value == "healthy"
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.api.endpoints import douban as douban_endpoint
|
||||
from app.db.user_oper import get_current_active_superuser_async
|
||||
from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
|
||||
|
||||
class _MemoryCacheStub:
|
||||
"""提供豆瓣缓存管理测试所需的最小内存后端。"""
|
||||
|
||||
def __init__(self, data: dict):
|
||||
"""使用给定字典初始化测试缓存。"""
|
||||
self.data = data
|
||||
|
||||
def items(self):
|
||||
"""返回全部缓存条目。"""
|
||||
return self.data.items()
|
||||
|
||||
def get(self, key: str):
|
||||
"""读取指定缓存条目。"""
|
||||
return self.data.get(key)
|
||||
|
||||
def delete(self, key: str):
|
||||
"""删除指定缓存条目。"""
|
||||
self.data.pop(key, None)
|
||||
|
||||
def set(self, key: str, value):
|
||||
"""写入指定缓存条目。"""
|
||||
self.data[key] = value
|
||||
|
||||
def clear(self):
|
||||
"""清空全部缓存条目。"""
|
||||
self.data.clear()
|
||||
|
||||
|
||||
def _build_douban_cache(data: dict) -> DoubanCache:
|
||||
"""构造绕过单例初始化的豆瓣缓存测试实例。"""
|
||||
cache = object.__new__(DoubanCache)
|
||||
cache._cache = _MemoryCacheStub(data)
|
||||
cache.save = lambda force=False: None
|
||||
return cache
|
||||
|
||||
|
||||
def test_douban_cache_management_endpoints_require_superuser():
|
||||
"""豆瓣识别缓存管理接口必须仅允许超级管理员访问。"""
|
||||
endpoints = [
|
||||
douban_endpoint.douban_recognition_cache,
|
||||
douban_endpoint.delete_douban_recognition_cache,
|
||||
douban_endpoint.clear_douban_recognition_cache,
|
||||
]
|
||||
|
||||
for endpoint in endpoints:
|
||||
dependency = inspect.signature(endpoint).parameters["_"].default.dependency
|
||||
assert dependency is get_current_active_superuser_async
|
||||
|
||||
|
||||
def test_douban_cache_list_items_normalizes_media_type_and_sorting():
|
||||
"""豆瓣管理列表应输出稳定顺序和前端可识别的媒体类型。"""
|
||||
cache = _build_douban_cache({
|
||||
"[电视剧]Zulu-2024-1": {
|
||||
"id": "2",
|
||||
"title": "Zulu",
|
||||
"type": MediaType.TV,
|
||||
"year": "2024",
|
||||
},
|
||||
"[电影]Alpha-2023-None": {
|
||||
"id": "1",
|
||||
"title": "Alpha",
|
||||
"type": "电影",
|
||||
"year": "2023",
|
||||
"poster_path": "https://example.com/alpha.jpg",
|
||||
},
|
||||
"[电影]Missing-2022-None": {"id": 0},
|
||||
})
|
||||
|
||||
items = cache.list_items()
|
||||
|
||||
assert [item["title"] for item in items] == ["Alpha", "", "Zulu"]
|
||||
assert [item["media_type"] for item in items] == ["movie", "unknown", "tv"]
|
||||
assert items[0]["poster_path"] == "https://example.com/alpha.jpg"
|
||||
assert items[1]["douban_id"] == 0
|
||||
|
||||
|
||||
def test_douban_cache_infers_special_season_title_as_tv():
|
||||
"""缺少显式类型时,S00 标题仍应按电视剧写入缓存。"""
|
||||
cache = _build_douban_cache({})
|
||||
|
||||
cache.update(
|
||||
meta=None,
|
||||
info={"id": "special", "title": "测试剧 S00", "year": "2024"},
|
||||
)
|
||||
|
||||
cached = next(iter(cache._cache.data.values()))
|
||||
assert cached["type"] == MediaType.TV
|
||||
|
||||
|
||||
def test_douban_cache_delete_and_clear_persist_immediately(monkeypatch):
|
||||
"""豆瓣管理操作应修改运行时缓存并立即触发本地持久化。"""
|
||||
cache = _build_douban_cache({"first": {"id": "1"}, "second": {"id": "2"}})
|
||||
saved_forces = []
|
||||
monkeypatch.setattr(cache, "save", lambda force=False: saved_forces.append(force))
|
||||
|
||||
assert cache.delete("first") == {"id": "1"}
|
||||
assert cache.delete("missing") == {}
|
||||
cache.clear()
|
||||
|
||||
assert cache.list_items() == []
|
||||
assert saved_forces == [True, True]
|
||||
|
||||
|
||||
def test_douban_cache_endpoint_returns_management_statistics(monkeypatch):
|
||||
"""豆瓣查询接口应返回识别成功和失败条目的统计。"""
|
||||
cache = _build_douban_cache({
|
||||
"recognized": {"id": "1", "title": "Alpha", "type": MediaType.MOVIE},
|
||||
"unrecognized": {"id": 0},
|
||||
})
|
||||
get_system_config = Mock(return_value=None)
|
||||
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
|
||||
monkeypatch.setattr(
|
||||
douban_endpoint,
|
||||
"SystemConfigOper",
|
||||
lambda: type("SystemConfigStub", (), {"get": get_system_config})(),
|
||||
)
|
||||
monkeypatch.setattr(douban_endpoint.settings, "MEDIA_RECOGNIZE_SHARE", False)
|
||||
|
||||
response = asyncio.run(douban_endpoint.douban_recognition_cache(None))
|
||||
|
||||
assert response.success is True
|
||||
assert response.data["count"] == 2
|
||||
assert response.data["recognized"] == 1
|
||||
assert response.data["unrecognized"] == 1
|
||||
assert response.data["shared_recognized"] == 0
|
||||
assert response.data["shared_recognize_enabled"] is False
|
||||
get_system_config.assert_called_once_with(
|
||||
SystemConfigKey.MediaRecognizeShareCount
|
||||
)
|
||||
|
||||
|
||||
def test_douban_cache_delete_endpoint_reports_missing_item(monkeypatch):
|
||||
"""豆瓣删除接口应区分成功删除与缓存不存在。"""
|
||||
cache = _build_douban_cache({"existing": {"id": "1"}})
|
||||
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
|
||||
|
||||
deleted_response = asyncio.run(
|
||||
douban_endpoint.delete_douban_recognition_cache("existing", None)
|
||||
)
|
||||
missing_response = asyncio.run(
|
||||
douban_endpoint.delete_douban_recognition_cache("missing", None)
|
||||
)
|
||||
|
||||
assert deleted_response.success is True
|
||||
assert missing_response.success is False
|
||||
|
||||
|
||||
def test_douban_cache_clear_endpoint_removes_all_items(monkeypatch):
|
||||
"""豆瓣清空接口应删除全部识别缓存。"""
|
||||
cache = _build_douban_cache({"existing": {"id": "1"}})
|
||||
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
|
||||
|
||||
response = asyncio.run(douban_endpoint.clear_douban_recognition_cache(None))
|
||||
|
||||
assert response.success is True
|
||||
assert cache.list_items() == []
|
||||
@@ -0,0 +1,80 @@
|
||||
import asyncio
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.core.meta import MetaBase
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def test_douban_recognize_does_not_keep_dedicated_mapping_cache():
|
||||
"""豆瓣识别应每次执行匹配,不再保留专用标题映射缓存。"""
|
||||
module = DoubanModule()
|
||||
meta = MetaBase("测试电影")
|
||||
meta.name = "测试电影"
|
||||
meta.type = MediaType.MOVIE
|
||||
meta.year = "2024"
|
||||
match_doubaninfo = Mock(return_value={"id": "200"})
|
||||
douban_info = Mock(return_value={
|
||||
"id": "200",
|
||||
"title": "测试电影",
|
||||
"type": "movie",
|
||||
"year": "2024",
|
||||
})
|
||||
|
||||
first_result = module._recognize_media_core(
|
||||
meta=meta,
|
||||
source="douban",
|
||||
match_doubaninfo_func=match_doubaninfo,
|
||||
douban_info_func=douban_info,
|
||||
)
|
||||
second_result = module._recognize_media_core(
|
||||
meta=meta,
|
||||
source="douban",
|
||||
match_doubaninfo_func=match_doubaninfo,
|
||||
douban_info_func=douban_info,
|
||||
)
|
||||
|
||||
assert first_result.douban_id == "200"
|
||||
assert second_result.douban_id == "200"
|
||||
assert match_doubaninfo.call_count == 2
|
||||
assert douban_info.call_count == 2
|
||||
|
||||
|
||||
def test_async_douban_recognize_does_not_keep_dedicated_mapping_cache():
|
||||
"""异步豆瓣识别也应每次执行匹配,不使用专用标题映射缓存。"""
|
||||
module = DoubanModule()
|
||||
meta = MetaBase("测试剧集")
|
||||
meta.name = "测试剧集"
|
||||
meta.type = MediaType.TV
|
||||
meta.year = "2024"
|
||||
match_doubaninfo = AsyncMock(return_value={"id": "201"})
|
||||
douban_info = AsyncMock(return_value={
|
||||
"id": "201",
|
||||
"title": "测试剧集",
|
||||
"type": "tv",
|
||||
"year": "2024",
|
||||
})
|
||||
|
||||
async def recognize_twice():
|
||||
"""连续执行两次异步豆瓣识别。"""
|
||||
first_result = await module._async_recognize_media_core(
|
||||
meta=meta,
|
||||
source="douban",
|
||||
async_match_doubaninfo_func=match_doubaninfo,
|
||||
async_douban_info_func=douban_info,
|
||||
)
|
||||
second_result = await module._async_recognize_media_core(
|
||||
meta=meta,
|
||||
source="douban",
|
||||
async_match_doubaninfo_func=match_doubaninfo,
|
||||
async_douban_info_func=douban_info,
|
||||
)
|
||||
return first_result, second_result
|
||||
|
||||
first_result, second_result = asyncio.run(recognize_twice())
|
||||
|
||||
assert first_result.douban_id == "201"
|
||||
assert second_result.douban_id == "201"
|
||||
assert match_doubaninfo.await_count == 2
|
||||
assert douban_info.await_count == 2
|
||||
@@ -36,24 +36,28 @@ class TestExecuteCommandTool(unittest.TestCase):
|
||||
return asyncio.run(tool.run(action="run", command=command, timeout=timeout))
|
||||
|
||||
def test_large_output_is_truncated_before_returning_to_agent(self):
|
||||
"""大输出一次性命令只把预览返回给 Agent,并把完整内容写到临时文件。"""
|
||||
"""大输出一次性命令返回头尾预览,并把完整内容写到临时文件。"""
|
||||
command = _python_command(
|
||||
"import sys; sys.stdout.write('x' * 200000); sys.stdout.flush()"
|
||||
"import sys; sys.stdout.write('HEAD-' + 'x' * 200000 + '-TAIL'); sys.stdout.flush()"
|
||||
)
|
||||
|
||||
result = self._run_command(command)
|
||||
temp_file_path = self._temp_file_path_from_result(result)
|
||||
|
||||
self.addCleanup(lambda: os.path.exists(temp_file_path) and os.unlink(temp_file_path))
|
||||
self.assertIn("命令输出超过 10KB", result)
|
||||
self.assertIn("仅展示前 10KB 内容", result)
|
||||
self.assertIn("命令输出超过 32KB", result)
|
||||
self.assertIn("仅展示前后各 16KB 内容", result)
|
||||
self.assertIn("如需完整内容,请继续读取该文件", result)
|
||||
self.assertLess(len(result), MAX_OUTPUT_PREVIEW_BYTES + 600)
|
||||
self.assertIn("HEAD-", result)
|
||||
self.assertIn("-TAIL", result)
|
||||
self.assertLess(len(result), MAX_OUTPUT_PREVIEW_BYTES + 1200)
|
||||
|
||||
with open(temp_file_path, encoding="utf-8") as file_handle:
|
||||
file_content = file_handle.read()
|
||||
|
||||
self.assertIn("[标准输出]", file_content)
|
||||
self.assertIn("HEAD-", file_content)
|
||||
self.assertIn("-TAIL", file_content)
|
||||
self.assertGreater(len(file_content), 100000)
|
||||
|
||||
def test_timeout_returns_partial_output_promptly(self):
|
||||
@@ -106,7 +110,7 @@ class TestExecuteCommandTool(unittest.TestCase):
|
||||
def test_timeout_with_large_output_writes_partial_full_log_to_temp_file(self):
|
||||
"""超时且输出较大时,终止前完整输出应写入临时文件。"""
|
||||
command = _python_command(
|
||||
"import sys, time; sys.stdout.write('x' * 20000); sys.stdout.flush(); time.sleep(5)"
|
||||
"import sys, time; sys.stdout.write('x' * 60000); sys.stdout.flush(); time.sleep(5)"
|
||||
)
|
||||
|
||||
result = self._run_command(command, timeout=1)
|
||||
@@ -120,7 +124,7 @@ class TestExecuteCommandTool(unittest.TestCase):
|
||||
file_content = file_handle.read()
|
||||
|
||||
self.assertIn("[标准输出]", file_content)
|
||||
self.assertGreaterEqual(file_content.count("x"), 20000)
|
||||
self.assertGreaterEqual(file_content.count("x"), 60000)
|
||||
|
||||
def test_timeout_is_capped(self):
|
||||
"""一次性执行的 timeout 参数超过上限时应自动限幅。"""
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Feedback Issue 日志压缩和 Doctor 摘要聚合测试。"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parents[1] / "skills" / "feedback-issue" / "scripts"
|
||||
|
||||
|
||||
def _load_module(name: str, path: Path):
|
||||
"""从脚本路径加载测试模块。"""
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def feedback_modules():
|
||||
"""加载 feedback 脚本,并在测试后恢复进程模块和搜索路径。"""
|
||||
old_path = list(sys.path)
|
||||
module_names = ["feedback_issue_common", "feedback_issue_collect_quality_test"]
|
||||
old_modules = {name: sys.modules.get(name) for name in module_names}
|
||||
try:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
common = _load_module(
|
||||
"feedback_issue_common",
|
||||
SCRIPT_DIR / "feedback_issue_common.py",
|
||||
)
|
||||
collect = _load_module(
|
||||
"feedback_issue_collect_quality_test",
|
||||
SCRIPT_DIR / "collect_feedback_diagnostics.py",
|
||||
)
|
||||
yield common, collect
|
||||
finally:
|
||||
sys.path[:] = old_path
|
||||
for name, module in old_modules.items():
|
||||
if module is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = module
|
||||
|
||||
|
||||
def test_filter_lines_compacts_consecutive_repeated_templates(feedback_modules):
|
||||
"""关键词命中的连续轮询日志应压缩为首条、计数和末条。"""
|
||||
_, collect = feedback_modules
|
||||
now = datetime.now()
|
||||
lines = [
|
||||
(
|
||||
f"【INFO】{(now - timedelta(seconds=20 - index)).strftime('%Y-%m-%d %H:%M:%S')},000 "
|
||||
f"- transfer.py - 等待转存任务完成:{index}/20"
|
||||
)
|
||||
for index in range(1, 11)
|
||||
]
|
||||
|
||||
filtered, matched_keywords = collect.filter_lines(
|
||||
"\n".join(lines),
|
||||
keywords=["等待转存"],
|
||||
max_lines=80,
|
||||
window_start=now - timedelta(minutes=5),
|
||||
)
|
||||
|
||||
assert len(filtered) == 3
|
||||
assert "1/20" in filtered[0]
|
||||
assert "连续重复 10 次" in filtered[1]
|
||||
assert "10/20" in filtered[2]
|
||||
assert matched_keywords == ["等待转存"]
|
||||
|
||||
|
||||
def test_doctor_summary_groups_legacy_duplicate_advisories(feedback_modules):
|
||||
"""旧版 Doctor 的重复插件发现也应在反馈摘要中合并展示。"""
|
||||
common, _ = feedback_modules
|
||||
findings = [
|
||||
{
|
||||
"severity": "warn",
|
||||
"title": "最近日志存在插件异常",
|
||||
"recommendation": "检查插件配置。",
|
||||
"affects_report_status": False,
|
||||
"context": {
|
||||
"log_file": f"/config/logs/plugins/plugin-{index}.log",
|
||||
"matches": 2,
|
||||
},
|
||||
}
|
||||
for index in range(5)
|
||||
]
|
||||
summary = common.format_doctor_summary({
|
||||
"success": True,
|
||||
"report": {
|
||||
"status": "healthy",
|
||||
"summary": {
|
||||
"total": 5,
|
||||
"error": 0,
|
||||
"warn": 5,
|
||||
"fixed": 0,
|
||||
},
|
||||
"findings": findings,
|
||||
},
|
||||
})
|
||||
|
||||
assert summary.count("最近日志存在插件异常") == 1
|
||||
assert "advisory=5" in summary
|
||||
assert "warn/advisory" in summary
|
||||
assert "合并 5 项" in summary
|
||||
assert "plugin-0.log" in summary
|
||||
assert "命中:10 条" in summary
|
||||
@@ -1,5 +1,4 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
@@ -54,12 +53,15 @@ class DeepSeekCompatPatchTest(unittest.TestCase):
|
||||
_FakeChatDeepSeek._get_request_payload = _ORIGINAL_GET_REQUEST_PAYLOAD
|
||||
if hasattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched"):
|
||||
delattr(_FakeChatDeepSeek, "_moviepilot_reasoning_content_patched")
|
||||
# helper 的修补函数内部 `from langchain_deepseek import ChatDeepSeek`,
|
||||
# 这里临时把该名指向假类,使修补作用到 _FakeChatDeepSeek;patch 在用例结束自动还原。
|
||||
patcher = patch("langchain_deepseek.ChatDeepSeek", _FakeChatDeepSeek)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
llm_module._patch_deepseek_reasoning_content_support()
|
||||
llm_module._patch_interleaved_reasoning_request_support(
|
||||
_FakeChatDeepSeek,
|
||||
patch_marker="_moviepilot_reasoning_content_patched",
|
||||
thinking_filter=lambda model_name, extra_body: (
|
||||
llm_module._is_deepseek_thinking_enabled(model_name, extra_body)
|
||||
),
|
||||
normalize_deepseek_messages=True,
|
||||
inject_missing_as_empty=True,
|
||||
)
|
||||
|
||||
def test_injects_reasoning_content_for_assistant_tool_calls(self):
|
||||
llm = _FakeChatDeepSeek("deepseek-v4-pro")
|
||||
|
||||
@@ -245,6 +245,7 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
||||
user_agent=None,
|
||||
use_proxy=None,
|
||||
api_protocol=None,
|
||||
web_search_mode=None,
|
||||
)
|
||||
self.assertEqual(result["provider"], "deepseek")
|
||||
self.assertEqual(result["model"], "deepseek-chat")
|
||||
@@ -439,8 +440,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
||||
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
|
||||
), patch.object(
|
||||
llm_module,
|
||||
"_patch_deepseek_reasoning_content_support",
|
||||
side_effect=lambda: patch_calls.append(True),
|
||||
"_patch_interleaved_reasoning_request_support",
|
||||
side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)),
|
||||
):
|
||||
asyncio.run(
|
||||
llm_module.LLMHelper.get_llm(
|
||||
@@ -457,7 +458,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
||||
calls[0].get("extra_body"),
|
||||
{"thinking": {"type": "enabled"}},
|
||||
)
|
||||
self.assertEqual(patch_calls, [True])
|
||||
self.assertEqual(patch_calls[0][0][0], _FakeChatDeepSeek)
|
||||
self.assertTrue(patch_calls[0][1]["normalize_deepseek_messages"])
|
||||
self.assertEqual(calls[0].get("reasoning_effort"), "max")
|
||||
self.assertEqual(calls[0].get("api_base"), "https://api.deepseek.com")
|
||||
|
||||
@@ -476,8 +478,8 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
||||
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
|
||||
), patch.object(
|
||||
llm_module,
|
||||
"_patch_deepseek_reasoning_content_support",
|
||||
side_effect=lambda: patch_calls.append(True),
|
||||
"_patch_interleaved_reasoning_request_support",
|
||||
side_effect=lambda *args, **kwargs: patch_calls.append((args, kwargs)),
|
||||
):
|
||||
asyncio.run(
|
||||
llm_module.LLMHelper.get_llm(
|
||||
@@ -494,10 +496,65 @@ class LlmHelperTestCallTest(unittest.TestCase):
|
||||
calls[0].get("extra_body"),
|
||||
{"thinking": {"type": "disabled"}},
|
||||
)
|
||||
self.assertEqual(patch_calls, [True])
|
||||
self.assertEqual(patch_calls[0][0][0], _FakeChatDeepSeek)
|
||||
self.assertTrue(patch_calls[0][1]["normalize_deepseek_messages"])
|
||||
self.assertIsNone(calls[0].get("reasoning_effort"))
|
||||
self.assertEqual(calls[0].get("api_base"), "https://proxy.example.com")
|
||||
|
||||
def test_get_llm_uses_common_responses_adapter_for_deepseek_web_search(self):
|
||||
"""DeepSeek 服务端搜索应走通用 ChatOpenAI Responses 适配器。"""
|
||||
calls = []
|
||||
|
||||
class _FakeChatOpenAI:
|
||||
def __init__(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
self.model = kwargs["model"]
|
||||
self.profile = None
|
||||
|
||||
openai_module = ModuleType("langchain_openai")
|
||||
openai_module.ChatOpenAI = _FakeChatOpenAI
|
||||
|
||||
with patch.dict(sys.modules, {"langchain_openai": openai_module}), patch.object(
|
||||
llm_module,
|
||||
"_patch_openai_responses_instructions_support",
|
||||
):
|
||||
model = asyncio.run(
|
||||
llm_module.LLMHelper.get_llm(
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
thinking_level="off",
|
||||
api_key="sk-test",
|
||||
base_url="https://api.deepseek.com",
|
||||
api_protocol="auto",
|
||||
web_search_mode="builtin",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0]["base_url"], "https://api.deepseek.com")
|
||||
self.assertTrue(calls[0]["use_responses_api"])
|
||||
self.assertEqual(calls[0]["output_version"], "responses/v1")
|
||||
self.assertEqual(
|
||||
llm_module.LLMHelper.get_server_tools(model),
|
||||
[{"type": "web_search"}],
|
||||
)
|
||||
self.assertFalse(llm_module.LLMHelper.should_use_local_web_search(model))
|
||||
|
||||
def test_get_llm_rejects_unsupported_builtin_web_search(self):
|
||||
"""强制服务端搜索不可用时应在构造模型前显式失败。"""
|
||||
with self.assertRaisesRegex(ValueError, "不支持服务端联网搜索"):
|
||||
asyncio.run(
|
||||
llm_module.LLMHelper.get_llm(
|
||||
provider="deepseek",
|
||||
model="deepseek-chat",
|
||||
thinking_level="off",
|
||||
api_key="sk-test",
|
||||
base_url="https://api.deepseek.com",
|
||||
api_protocol="auto",
|
||||
web_search_mode="builtin",
|
||||
)
|
||||
)
|
||||
|
||||
def test_get_llm_uses_openai_reasoning_effort_none_for_off(self):
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""LLM 服务端工具能力解析测试。"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.llm import LLMHelper
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
from app.agent.llm.server_tools import (
|
||||
ServerToolRegistry,
|
||||
ServerToolUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
def test_deepseek_v4_flash_exposes_builtin_web_search() -> None:
|
||||
"""DeepSeek V4 Flash 应声明 Responses 服务端联网搜索能力。"""
|
||||
capabilities = ServerToolRegistry.list_capabilities(
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
)
|
||||
|
||||
assert capabilities == [
|
||||
{
|
||||
"id": "web_search",
|
||||
"required_api_protocol": "responses",
|
||||
"client_adapter": "openai_responses",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"provider",
|
||||
"model",
|
||||
"base_url",
|
||||
"expected_tool",
|
||||
"required_api_protocol",
|
||||
"client_adapter",
|
||||
),
|
||||
[
|
||||
(
|
||||
"chatgpt",
|
||||
"gpt-5.6-sol",
|
||||
"https://api.openai.com/v1",
|
||||
{"type": "web_search"},
|
||||
"responses",
|
||||
"openai_responses",
|
||||
),
|
||||
(
|
||||
"openai",
|
||||
"gpt-4.1-mini",
|
||||
"https://api.openai.com/v1",
|
||||
{"type": "web_search"},
|
||||
"responses",
|
||||
"openai_responses",
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-5",
|
||||
"https://api.anthropic.com/v1",
|
||||
{"type": "web_search_20250305", "name": "web_search"},
|
||||
"native",
|
||||
"anthropic_native",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"models/gemini-3.6-flash-preview",
|
||||
None,
|
||||
{"google_search": {}},
|
||||
"native",
|
||||
"google_native",
|
||||
),
|
||||
(
|
||||
"xai",
|
||||
"grok-4.5",
|
||||
"https://api.x.ai/v1",
|
||||
{"type": "web_search"},
|
||||
"responses",
|
||||
"openai_responses",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_official_provider_models_expose_builtin_web_search(
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: str | None,
|
||||
expected_tool: dict,
|
||||
required_api_protocol: str,
|
||||
client_adapter: str,
|
||||
) -> None:
|
||||
"""官方文档声明支持的模型应返回各自原生服务端搜索工具。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider=provider,
|
||||
model=model,
|
||||
mode="builtin",
|
||||
api_protocol="auto",
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
assert resolution.server_tools == (expected_tool,)
|
||||
assert resolution.required_api_protocol == required_api_protocol
|
||||
assert resolution.client_adapter == client_adapter
|
||||
assert resolution.use_local_web_search is False
|
||||
assert resolution.available is True
|
||||
|
||||
|
||||
def test_builtin_web_search_selects_responses_adapter() -> None:
|
||||
"""服务端搜索应切换到通用 Responses 适配器并关闭本地搜索。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
mode="builtin",
|
||||
api_protocol="auto",
|
||||
)
|
||||
|
||||
assert resolution.server_tools == ({"type": "web_search"},)
|
||||
assert resolution.client_adapter == "openai_responses"
|
||||
assert resolution.required_api_protocol == "responses"
|
||||
assert resolution.use_local_web_search is False
|
||||
|
||||
|
||||
def test_auto_web_search_falls_back_to_local_for_unsupported_model() -> None:
|
||||
"""自动模式在模型不支持服务端搜索时应保留本地搜索。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider="deepseek",
|
||||
model="deepseek-chat",
|
||||
mode="auto",
|
||||
api_protocol="auto",
|
||||
)
|
||||
|
||||
assert resolution.server_tools == ()
|
||||
assert resolution.use_local_web_search is True
|
||||
assert resolution.reason == "builtin_web_search_unavailable"
|
||||
|
||||
|
||||
def test_auto_web_search_respects_chat_completions_selection() -> None:
|
||||
"""显式 Chat Completions 下自动模式应回退本地搜索。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
mode="auto",
|
||||
api_protocol="chat_completions",
|
||||
)
|
||||
|
||||
assert resolution.server_tools == ()
|
||||
assert resolution.use_local_web_search is True
|
||||
assert resolution.available is True
|
||||
|
||||
|
||||
def test_native_web_search_ignores_openai_chat_completions_selection() -> None:
|
||||
"""原生 Gemini 服务端搜索不应被 OpenAI 协议选项误伤回退。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider="google",
|
||||
model="gemini-3.6-flash-preview",
|
||||
mode="auto",
|
||||
api_protocol="chat_completions",
|
||||
)
|
||||
|
||||
assert resolution.server_tools == ({"google_search": {}},)
|
||||
assert resolution.use_local_web_search is False
|
||||
assert resolution.available is True
|
||||
|
||||
|
||||
def test_builtin_web_search_does_not_silently_fall_back() -> None:
|
||||
"""强制服务端模式在模型不支持时不应静默启用本地搜索。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-pro",
|
||||
mode="builtin",
|
||||
api_protocol="auto",
|
||||
)
|
||||
|
||||
assert resolution.server_tools == ()
|
||||
assert resolution.use_local_web_search is False
|
||||
assert resolution.available is False
|
||||
|
||||
|
||||
def test_deepseek_builtin_web_search_is_limited_to_official_endpoint() -> None:
|
||||
"""自定义 DeepSeek 兼容端点不应被误判为官方托管搜索。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
mode="auto",
|
||||
api_protocol="auto",
|
||||
base_url="https://deepseek-proxy.example.com/v1",
|
||||
)
|
||||
|
||||
assert resolution.server_tools == ()
|
||||
assert resolution.use_local_web_search is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "model", "base_url"),
|
||||
[
|
||||
("openai", "gpt-5.6-sol", "https://openai-proxy.example.com/v1"),
|
||||
("anthropic", "claude-opus-5", "https://anthropic-proxy.example.com/v1"),
|
||||
("xai", "grok-4.5", "https://xai-proxy.example.com/v1"),
|
||||
],
|
||||
)
|
||||
def test_provider_web_search_is_limited_to_official_endpoints(
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: str,
|
||||
) -> None:
|
||||
"""第三方兼容端点不应被误判为厂商官方托管搜索。"""
|
||||
resolution = ServerToolRegistry.resolve_web_search(
|
||||
provider=provider,
|
||||
model=model,
|
||||
mode="auto",
|
||||
api_protocol="auto",
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
assert resolution.server_tools == ()
|
||||
assert resolution.use_local_web_search is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "model", "runtime_name", "base_url", "expected_tool"),
|
||||
[
|
||||
(
|
||||
"chatgpt",
|
||||
"gpt-5.6-sol",
|
||||
"openai_compatible",
|
||||
"https://api.openai.com/v1",
|
||||
{"type": "web_search"},
|
||||
),
|
||||
(
|
||||
"anthropic",
|
||||
"claude-opus-5",
|
||||
"anthropic_compatible",
|
||||
"https://api.anthropic.com/v1",
|
||||
{"type": "web_search_20250305", "name": "web_search"},
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-3.6-flash-preview",
|
||||
"google",
|
||||
None,
|
||||
{"google_search": {}},
|
||||
),
|
||||
(
|
||||
"xai",
|
||||
"grok-4.5",
|
||||
"openai_compatible",
|
||||
"https://api.x.ai/v1",
|
||||
{"type": "web_search"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_llm_helper_binds_each_native_server_search_tool_offline(
|
||||
provider: str,
|
||||
model: str,
|
||||
runtime_name: str,
|
||||
base_url: str | None,
|
||||
expected_tool: dict,
|
||||
) -> None:
|
||||
"""LLM Helper 应能离线构造并绑定各厂商的原生搜索工具。"""
|
||||
runtime = {
|
||||
"provider_id": provider,
|
||||
"runtime": runtime_name,
|
||||
"model_id": model,
|
||||
"api_key": "test-key",
|
||||
"base_url": base_url,
|
||||
"default_headers": None,
|
||||
"use_responses_api": None,
|
||||
"model_record": None,
|
||||
"model_metadata": None,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
LLMProviderManager,
|
||||
"resolve_runtime",
|
||||
new=AsyncMock(return_value=runtime),
|
||||
):
|
||||
llm = asyncio.run(
|
||||
LLMHelper.get_llm(
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_key="test-key",
|
||||
base_url=base_url,
|
||||
web_search_mode="builtin",
|
||||
)
|
||||
)
|
||||
|
||||
tools = LLMHelper.get_server_tools(llm)
|
||||
assert tools == [expected_tool]
|
||||
assert llm.bind_tools(tools) is not None
|
||||
|
||||
|
||||
def test_unavailable_server_tool_error_guides_user_to_safe_modes() -> None:
|
||||
"""服务端搜索不可用时应明确告知用户可选的回退模式。"""
|
||||
error = ServerToolUnavailableError(
|
||||
provider="deepseek",
|
||||
model="deepseek-chat",
|
||||
tool_id="web_search",
|
||||
)
|
||||
|
||||
assert error.provider == "deepseek"
|
||||
assert error.model == "deepseek-chat"
|
||||
assert error.tool_id == "web_search"
|
||||
assert "不支持服务端联网搜索" in str(error)
|
||||
assert "自动" in str(error)
|
||||
assert "MoviePilot 本地搜索" in str(error)
|
||||
@@ -303,7 +303,7 @@ class LocalSetupLlmProviderPromptTests(unittest.TestCase):
|
||||
), patch.object(
|
||||
module, "_env_llm_thinking_level_default", return_value="auto"
|
||||
), patch.object(
|
||||
module, "_prompt_choice", side_effect=["auto", "minimax-cn-coding"]
|
||||
module, "_prompt_choice", side_effect=["auto", "minimax-cn-coding", "local"]
|
||||
):
|
||||
config = module._collect_agent_config()
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.factory import localized_http_exception_handler
|
||||
from app.factory import create_app, localized_http_exception_handler
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
|
||||
from app.schemas.response import Response
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
def _has_chinese(text: str) -> bool:
|
||||
@@ -247,8 +248,8 @@ def test_response_auto_fills_message_i18n_from_locale_context():
|
||||
assert response.message_i18n == "Module does not support testing"
|
||||
|
||||
|
||||
def test_http_exception_handler_adds_detail_i18n_from_locale_context():
|
||||
"""HTTPException 响应应补充多语言 detail 字段。"""
|
||||
def test_http_exception_handler_returns_untranslated_response_envelope():
|
||||
"""HTTPException 响应应统一封装并保留原始错误文本。"""
|
||||
token = LocaleHelper.set_current_locale("en-US")
|
||||
try:
|
||||
response = asyncio.run(
|
||||
@@ -260,9 +261,22 @@ def test_http_exception_handler_adds_detail_i18n_from_locale_context():
|
||||
finally:
|
||||
LocaleHelper.reset_current_locale(token)
|
||||
|
||||
assert response.status_code == 401
|
||||
payload = json.loads(response.body)
|
||||
assert payload["detail"] == "用户名或密码错误"
|
||||
assert payload["detail_i18n"] == "Incorrect username or password"
|
||||
assert payload == {
|
||||
"success": False,
|
||||
"message": "用户名或密码错误",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
|
||||
def test_application_docs_use_v2_openapi_and_backend_version():
|
||||
"""默认接口文档应展示 v2 地址并使用真实后端版本号。"""
|
||||
app = create_app()
|
||||
|
||||
assert app.openapi_url == "/api/v2/openapi.json"
|
||||
assert app.version == APP_VERSION
|
||||
assert "/api/v1/openapi.json" in {route.path for route in app.routes}
|
||||
|
||||
|
||||
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
|
||||
|
||||
@@ -95,3 +95,23 @@ def test_login_invalid_password_does_not_expose_mfa_methods(monkeypatch):
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "用户名或密码错误"
|
||||
assert "X-MFA-Required" not in (exc_info.value.headers or {})
|
||||
|
||||
|
||||
def test_wallpaper_returns_url_in_data(monkeypatch):
|
||||
"""登录壁纸地址应放入 data,message 只保留消息文本。"""
|
||||
|
||||
class FakeWallpaperHelper:
|
||||
"""返回固定登录壁纸地址。"""
|
||||
|
||||
def get_wallpaper(self):
|
||||
"""返回测试壁纸地址。"""
|
||||
return "https://images.example/wallpaper.jpg"
|
||||
|
||||
monkeypatch.setattr(login_endpoint, "WallpaperHelper", FakeWallpaperHelper)
|
||||
|
||||
response = login_endpoint.wallpaper()
|
||||
|
||||
assert response.success is True
|
||||
assert response.data == "https://images.example/wallpaper.jpg"
|
||||
assert response.message is None
|
||||
assert response.message_i18n is None
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints.media import recognize
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"title",
|
||||
[
|
||||
"武神主宰 (2020)/武神主宰.S01E680.mp4",
|
||||
r"D:\武神主宰 (2020)\武神主宰.S01E680.mp4",
|
||||
],
|
||||
)
|
||||
def test_recognize_uses_parent_metadata_for_media_file_path(title: str) -> None:
|
||||
"""标题参数为媒体文件路径时应合并父目录中的名称和年份。"""
|
||||
chain = Mock()
|
||||
chain.async_recognize_by_meta = AsyncMock(
|
||||
return_value=MediaInfo(
|
||||
title="武神主宰",
|
||||
year="2020",
|
||||
type=MediaType.TV,
|
||||
)
|
||||
)
|
||||
|
||||
with patch("app.api.endpoints.media.MediaChain", return_value=chain):
|
||||
asyncio.run(recognize(title=title, _=Mock()))
|
||||
|
||||
metainfo = chain.async_recognize_by_meta.await_args.args[0]
|
||||
assert metainfo.name == "武神主宰"
|
||||
assert metainfo.year == "2020"
|
||||
assert metainfo.begin_season == 1
|
||||
assert metainfo.begin_episode == 680
|
||||
assert metainfo.title == title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"title",
|
||||
[
|
||||
"Fate/stay night",
|
||||
"https://example.com/武神主宰.S01E680.mp4",
|
||||
],
|
||||
)
|
||||
def test_recognize_does_not_treat_non_path_title_as_file_path(title: str) -> None:
|
||||
"""普通片名和网络地址不应误走文件路径解析。"""
|
||||
chain = Mock()
|
||||
chain.async_recognize_by_meta = AsyncMock(
|
||||
return_value=MediaInfo(title="Fate/stay night", type=MediaType.TV)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.api.endpoints.media.MediaChain", return_value=chain),
|
||||
patch("app.api.endpoints.media.MetaInfoPath") as meta_info_path,
|
||||
):
|
||||
asyncio.run(recognize(title=title, _=Mock()))
|
||||
|
||||
meta_info_path.assert_not_called()
|
||||
@@ -0,0 +1,129 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.helper.market import extract_plugin_market_repos_from_wiki
|
||||
from scripts.generate_plugin_market_default import (
|
||||
OFFICIAL_PLUGIN_MARKET,
|
||||
_generate_plugin_market_default,
|
||||
)
|
||||
|
||||
|
||||
def _read_plugin_market_default(config_file: Path) -> str:
|
||||
tree = ast.parse(config_file.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef) or node.name != "ConfigModel":
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, ast.AnnAssign):
|
||||
continue
|
||||
if isinstance(item.target, ast.Name) and item.target.id == "PLUGIN_MARKET":
|
||||
return ast.literal_eval(item.value)
|
||||
raise AssertionError("未找到 PLUGIN_MARKET 默认值")
|
||||
|
||||
|
||||
def test_extract_plugin_market_repos_uses_marked_section_and_deduplicates() -> None:
|
||||
"""
|
||||
Wiki 清单解析只读取标记区域,并规范化、去重仓库地址。
|
||||
"""
|
||||
markdown = """
|
||||
- https://github.com/outside/ignored
|
||||
<!-- plugin-market-repos:start -->
|
||||
- https://github.com/jxxghp/MoviePilot-Plugins/
|
||||
- https://github.com/demo/Market.git
|
||||
- https://github.com/demo/Market
|
||||
<!-- plugin-market-repos:end -->
|
||||
- https://github.com/outside/ignored-again
|
||||
"""
|
||||
|
||||
assert extract_plugin_market_repos_from_wiki(
|
||||
markdown, require_markers=True
|
||||
) == [
|
||||
OFFICIAL_PLUGIN_MARKET,
|
||||
"https://github.com/demo/Market",
|
||||
]
|
||||
|
||||
|
||||
def test_extract_plugin_market_repos_requires_unique_markers_for_build() -> None:
|
||||
"""
|
||||
构建模式拒绝缺失或重复边界标记的 Wiki 文档。
|
||||
"""
|
||||
with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"):
|
||||
extract_plugin_market_repos_from_wiki(
|
||||
f"- {OFFICIAL_PLUGIN_MARKET}", require_markers=True
|
||||
)
|
||||
|
||||
markdown = f"""
|
||||
<!-- plugin-market-repos:start -->
|
||||
<!-- plugin-market-repos:start -->
|
||||
- {OFFICIAL_PLUGIN_MARKET}
|
||||
<!-- plugin-market-repos:end -->
|
||||
"""
|
||||
with pytest.raises(ValueError, match="唯一且有序的开始和结束标记"):
|
||||
extract_plugin_market_repos_from_wiki(markdown, require_markers=True)
|
||||
|
||||
|
||||
def test_generate_plugin_market_default_updates_assignment_idempotently(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
生成脚本只替换 ConfigModel 默认值,并保持重复执行结果一致。
|
||||
"""
|
||||
wiki_file = tmp_path / "plugin.md"
|
||||
wiki_file.write_text(
|
||||
f"""
|
||||
<!-- plugin-market-repos:start -->
|
||||
- {OFFICIAL_PLUGIN_MARKET}
|
||||
- https://github.com/demo/MoviePilot-Plugins
|
||||
<!-- plugin-market-repos:end -->
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_file = tmp_path / "config.py"
|
||||
config_file.write_text(
|
||||
"""class ConfigModel(BaseModel):
|
||||
PLUGIN_MARKET: str = "https://github.com/old/Market"
|
||||
OTHER_SETTING: bool = True
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
repos = _generate_plugin_market_default(wiki_file, config_file)
|
||||
first_result = config_file.read_text(encoding="utf-8")
|
||||
_generate_plugin_market_default(wiki_file, config_file)
|
||||
|
||||
assert repos == [
|
||||
OFFICIAL_PLUGIN_MARKET,
|
||||
"https://github.com/demo/MoviePilot-Plugins",
|
||||
]
|
||||
assert _read_plugin_market_default(config_file) == ",".join(repos)
|
||||
assert "OTHER_SETTING: bool = True" in first_result
|
||||
assert config_file.read_text(encoding="utf-8") == first_result
|
||||
|
||||
|
||||
def test_generate_plugin_market_default_requires_official_repo(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
发版默认清单缺少官方仓库时终止生成。
|
||||
"""
|
||||
wiki_file = tmp_path / "plugin.md"
|
||||
wiki_file.write_text(
|
||||
"""
|
||||
<!-- plugin-market-repos:start -->
|
||||
- https://github.com/demo/MoviePilot-Plugins
|
||||
<!-- plugin-market-repos:end -->
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_file = tmp_path / "config.py"
|
||||
config_file.write_text(
|
||||
"""class ConfigModel(BaseModel):
|
||||
PLUGIN_MARKET: str = "https://github.com/old/Market"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="缺少 MoviePilot 官方插件仓库"):
|
||||
_generate_plugin_market_default(wiki_file, config_file)
|
||||
@@ -0,0 +1,79 @@
|
||||
import threading
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app import scheduler as scheduler_module
|
||||
from app.scheduler import Scheduler
|
||||
|
||||
|
||||
class _BackgroundSchedulerStub:
|
||||
"""记录系统定时任务注册结果的调度器替身。"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化任务记录。"""
|
||||
self.jobs = []
|
||||
self.started = False
|
||||
|
||||
def add_job(self, func, trigger, **kwargs):
|
||||
"""记录一次任务注册。"""
|
||||
self.jobs.append({"func": func, "trigger": trigger, **kwargs})
|
||||
|
||||
def start(self):
|
||||
"""记录调度器已启动。"""
|
||||
self.started = True
|
||||
|
||||
|
||||
def test_meta_cache_expire_does_not_schedule_bulk_cache_clear(monkeypatch):
|
||||
"""单条缓存 TTL 不应再被用于注册整批缓存清理任务。"""
|
||||
background_scheduler = _BackgroundSchedulerStub()
|
||||
generic_chain = Mock()
|
||||
for name in [
|
||||
"MediaServerChain",
|
||||
"RecommendChain",
|
||||
"SchedulerChain",
|
||||
"SiteChain",
|
||||
"SubscribeChain",
|
||||
"TransferChain",
|
||||
"WallpaperHelper",
|
||||
"WorkflowChain",
|
||||
"PluginManager",
|
||||
]:
|
||||
monkeypatch.setattr(scheduler_module, name, lambda: generic_chain)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module.ServiceConfigHelper,
|
||||
"get_mediaserver_configs",
|
||||
lambda: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scheduler_module,
|
||||
"BackgroundScheduler",
|
||||
lambda **kwargs: background_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(Scheduler, "stop", lambda self: None)
|
||||
monkeypatch.setattr(Scheduler, "init_workflow_jobs", lambda self: None)
|
||||
monkeypatch.setattr(Scheduler, "init_agent_task_jobs", lambda self: None)
|
||||
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
|
||||
monkeypatch.setattr(scheduler_module.settings, "DEV", False)
|
||||
monkeypatch.setattr(scheduler_module.settings, "COOKIECLOUD_INTERVAL", 0)
|
||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_SEARCH", False)
|
||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_MODE", "rss")
|
||||
monkeypatch.setattr(scheduler_module.settings, "SUBSCRIBE_RSS_INTERVAL", 30)
|
||||
monkeypatch.setattr(scheduler_module.settings, "SITEDATA_REFRESH_INTERVAL", 0)
|
||||
monkeypatch.setattr(scheduler_module.settings, "MEMORY_GC_INTERVAL", 0)
|
||||
monkeypatch.setattr(scheduler_module.settings, "AI_AGENT_ENABLE", False)
|
||||
monkeypatch.setattr(scheduler_module.settings, "DATA_CLEANUP_ENABLE", False)
|
||||
monkeypatch.setattr(scheduler_module.settings, "USAGE_STATISTIC_SHARE", False)
|
||||
|
||||
scheduler = object.__new__(Scheduler)
|
||||
scheduler._scheduler = None
|
||||
scheduler._event = threading.Event()
|
||||
scheduler._lock = threading.RLock()
|
||||
scheduler._jobs = {}
|
||||
scheduler._auth_count = 0
|
||||
scheduler._auth_message = False
|
||||
|
||||
scheduler.init()
|
||||
|
||||
scheduled_job_ids = {job["id"] for job in background_scheduler.jobs}
|
||||
assert "clear_cache" not in scheduled_job_ids
|
||||
assert "clear_cache" in scheduler._jobs
|
||||
assert background_scheduler.started is True
|
||||
@@ -138,6 +138,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
||||
user_agent="MoviePilot-Test/1.0",
|
||||
use_proxy=True,
|
||||
api_protocol="responses",
|
||||
web_search_mode="local",
|
||||
)
|
||||
self.assertTrue(resp.success)
|
||||
self.assertEqual(resp.data["provider"], "deepseek")
|
||||
@@ -190,6 +191,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
||||
user_agent="MoviePilot-Custom/1.0",
|
||||
use_proxy=False,
|
||||
api_protocol=None,
|
||||
web_search_mode=None,
|
||||
)
|
||||
self.assertTrue(resp.success)
|
||||
self.assertEqual(resp.data["provider"], "openai")
|
||||
@@ -233,6 +235,7 @@ class LlmTestEndpointTest(unittest.TestCase):
|
||||
user_agent=None,
|
||||
use_proxy=None,
|
||||
api_protocol=None,
|
||||
web_search_mode=None,
|
||||
)
|
||||
self.assertTrue(resp.success)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""系统 LLM 服务端联网搜索配置测试。"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
|
||||
|
||||
def test_set_env_rejects_unsupported_builtin_web_search() -> None:
|
||||
"""强制不可用的服务端搜索时应拒绝保存且不部分写入配置。"""
|
||||
env = {
|
||||
"LLM_PROVIDER": "deepseek",
|
||||
"LLM_MODEL": "deepseek-chat",
|
||||
"LLM_BASE_URL": "https://api.deepseek.com",
|
||||
"LLM_WEB_SEARCH_MODE": "builtin",
|
||||
}
|
||||
|
||||
with patch.object(type(system_endpoint.settings), "update_settings") as update_settings:
|
||||
response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object()))
|
||||
|
||||
assert response.success is False
|
||||
assert "不支持服务端联网搜索" in response.message
|
||||
update_settings.assert_not_called()
|
||||
|
||||
|
||||
def test_set_env_accepts_supported_deepseek_builtin_web_search() -> None:
|
||||
"""DeepSeek V4 Flash 官方端点应允许保存强制服务端搜索。"""
|
||||
env = {
|
||||
"LLM_PROVIDER": "deepseek",
|
||||
"LLM_MODEL": "deepseek-v4-flash",
|
||||
"LLM_BASE_URL": "https://api.deepseek.com",
|
||||
"LLM_WEB_SEARCH_MODE": "builtin",
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
type(system_endpoint.settings),
|
||||
"update_settings",
|
||||
return_value={key: (True, None) for key in env},
|
||||
) as update_settings, patch.object(
|
||||
system_endpoint.eventmanager,
|
||||
"async_send_event",
|
||||
):
|
||||
response = asyncio.run(system_endpoint.set_env_setting(env=env, _=object()))
|
||||
|
||||
assert response.success is True
|
||||
update_settings.assert_called_once_with(env=env)
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import pickle
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.api.endpoints import tmdb as tmdb_endpoint
|
||||
from app.db.user_oper import get_current_active_superuser_async
|
||||
from app.modules.themoviedb import tmdb_cache as tmdb_cache_module
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
|
||||
@@ -27,19 +29,83 @@ class _MemoryCacheStub:
|
||||
"""删除指定缓存条目。"""
|
||||
self.data.pop(key, None)
|
||||
|
||||
def set(self, key: str, value, ttl=None):
|
||||
"""写入指定缓存条目。"""
|
||||
self.data[key] = value
|
||||
|
||||
def clear(self):
|
||||
"""清空全部缓存条目。"""
|
||||
self.data.clear()
|
||||
|
||||
|
||||
class _FileCacheStub:
|
||||
"""提供 TMDB 持久化测试所需的统一文件缓存替身。"""
|
||||
|
||||
def __init__(self, content: bytes = None):
|
||||
"""使用预置序列化内容初始化文件缓存。"""
|
||||
self.content = content
|
||||
self.set_calls = []
|
||||
self.delete_calls = []
|
||||
|
||||
def get(self, key: str, region: str):
|
||||
"""读取预置缓存内容。"""
|
||||
return self.content
|
||||
|
||||
def set(self, key: str, value: bytes, region: str):
|
||||
"""记录统一文件缓存写入。"""
|
||||
self.content = value
|
||||
self.set_calls.append((key, region))
|
||||
|
||||
def delete(self, key: str, region: str):
|
||||
"""记录统一文件缓存删除。"""
|
||||
self.content = None
|
||||
self.delete_calls.append((key, region))
|
||||
|
||||
|
||||
class _TTLCacheStub(_MemoryCacheStub):
|
||||
"""记录每条数据恢复时剩余 TTL 的内存缓存替身。"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化空缓存和 TTL 记录。"""
|
||||
super().__init__({})
|
||||
self.ttls = {}
|
||||
|
||||
@staticmethod
|
||||
def is_redis() -> bool:
|
||||
"""测试替身固定使用非 Redis 后端。"""
|
||||
return False
|
||||
|
||||
def set(self, key: str, value, ttl=None):
|
||||
"""写入缓存并记录本次设置的 TTL。"""
|
||||
super().set(key, value, ttl=ttl)
|
||||
self.ttls[key] = ttl
|
||||
|
||||
|
||||
def _build_tmdb_cache(data: dict) -> TmdbCache:
|
||||
"""构造绕过单例初始化的 TMDB 缓存测试实例。"""
|
||||
cache = object.__new__(TmdbCache)
|
||||
cache._cache = _MemoryCacheStub(data)
|
||||
cache._expires_at = {key: float("inf") for key in data}
|
||||
cache._dirty = False
|
||||
cache._file_cache = None
|
||||
cache._legacy_file_cache = None
|
||||
cache._legacy_cache_found = False
|
||||
cache.save = lambda force=False: None
|
||||
return cache
|
||||
|
||||
|
||||
def _build_initialized_tmdb_cache(monkeypatch, file_cache: _FileCacheStub,
|
||||
runtime_cache: _TTLCacheStub,
|
||||
now: float = 1000) -> TmdbCache:
|
||||
"""使用可控时间和缓存替身初始化完整 TMDB 缓存实例。"""
|
||||
monkeypatch.setattr(tmdb_cache_module, "time", lambda: now)
|
||||
monkeypatch.setattr(tmdb_cache_module, "TTLCache", lambda **kwargs: runtime_cache)
|
||||
monkeypatch.setattr(tmdb_cache_module, "FileCache", lambda **kwargs: file_cache)
|
||||
cache = object.__new__(TmdbCache)
|
||||
cache.__init__()
|
||||
return cache
|
||||
|
||||
|
||||
def test_tmdb_cache_management_endpoints_require_superuser():
|
||||
"""识别缓存管理接口必须仅允许超级管理员访问。"""
|
||||
endpoints = [
|
||||
@@ -92,6 +158,115 @@ def test_tmdb_cache_delete_and_clear_persist_immediately(monkeypatch):
|
||||
assert saved_forces == [True, True]
|
||||
|
||||
|
||||
def test_tmdb_cache_restores_only_unexpired_persisted_items(monkeypatch):
|
||||
"""TMDB 持久化恢复应保留每条数据原有期限并跳过已过期条目。"""
|
||||
payload = {
|
||||
"version": tmdb_cache_module.PERSISTENCE_VERSION,
|
||||
"items": {
|
||||
"fresh": {
|
||||
"value": {"id": 1, "title": "有效"},
|
||||
"expires_at": 1030,
|
||||
},
|
||||
"expired": {
|
||||
"value": {"id": 2, "title": "过期"},
|
||||
"expires_at": 999,
|
||||
},
|
||||
},
|
||||
}
|
||||
file_cache = _FileCacheStub(pickle.dumps(payload))
|
||||
runtime_cache = _TTLCacheStub()
|
||||
|
||||
cache = _build_initialized_tmdb_cache(
|
||||
monkeypatch=monkeypatch,
|
||||
file_cache=file_cache,
|
||||
runtime_cache=runtime_cache,
|
||||
)
|
||||
|
||||
assert runtime_cache.data == {"fresh": {"id": 1, "title": "有效"}}
|
||||
assert runtime_cache.ttls == {"fresh": 30}
|
||||
assert cache._expires_at == {"fresh": 1030}
|
||||
assert cache._dirty is True
|
||||
|
||||
|
||||
def test_tmdb_cache_persists_individual_expiration_with_file_cache(monkeypatch):
|
||||
"""TMDB 持久化应通过统一文件缓存保存每条数据的独立过期时间。"""
|
||||
file_cache = _FileCacheStub()
|
||||
runtime_cache = _TTLCacheStub()
|
||||
cache = _build_initialized_tmdb_cache(
|
||||
monkeypatch=monkeypatch,
|
||||
file_cache=file_cache,
|
||||
runtime_cache=runtime_cache,
|
||||
)
|
||||
runtime_cache.data = {
|
||||
"recognized": {"id": 1, "title": "有效"},
|
||||
"unrecognized": {"id": 0},
|
||||
}
|
||||
cache._expires_at = {
|
||||
"recognized": 1060,
|
||||
"unrecognized": 1070,
|
||||
}
|
||||
cache._dirty = True
|
||||
|
||||
cache.save()
|
||||
|
||||
payload = pickle.loads(file_cache.content)
|
||||
assert file_cache.set_calls == [(
|
||||
tmdb_cache_module.PERSISTENCE_KEY,
|
||||
tmdb_cache_module.PERSISTENCE_REGION,
|
||||
)]
|
||||
assert payload == {
|
||||
"version": tmdb_cache_module.PERSISTENCE_VERSION,
|
||||
"items": {
|
||||
"recognized": {
|
||||
"value": {"id": 1, "title": "有效"},
|
||||
"expires_at": 1060,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_tmdb_cache_migrates_legacy_file_to_global_file_cache(monkeypatch):
|
||||
"""旧 TMDB 缓存应迁移到全局文件缓存并删除旧文件。"""
|
||||
primary_cache = _FileCacheStub()
|
||||
legacy_cache = _FileCacheStub(pickle.dumps({
|
||||
"legacy": {"id": 1, "title": "旧缓存"},
|
||||
}))
|
||||
file_caches = iter([primary_cache, legacy_cache])
|
||||
file_cache_calls = []
|
||||
|
||||
def build_file_cache(**kwargs):
|
||||
"""记录全局文件缓存构造参数并返回对应替身。"""
|
||||
file_cache_calls.append(kwargs)
|
||||
return next(file_caches)
|
||||
|
||||
runtime_cache = _TTLCacheStub()
|
||||
monkeypatch.setattr(tmdb_cache_module, "time", lambda: 1000)
|
||||
monkeypatch.setattr(
|
||||
tmdb_cache_module,
|
||||
"TTLCache",
|
||||
lambda **kwargs: runtime_cache,
|
||||
)
|
||||
monkeypatch.setattr(tmdb_cache_module, "FileCache", build_file_cache)
|
||||
|
||||
cache = object.__new__(TmdbCache)
|
||||
cache.__init__()
|
||||
cache.save()
|
||||
|
||||
assert file_cache_calls == [
|
||||
{"base": tmdb_cache_module.settings.CACHE_PATH, "ttl": cache.ttl},
|
||||
{"base": tmdb_cache_module.settings.TEMP_PATH.parent, "ttl": cache.ttl},
|
||||
]
|
||||
assert runtime_cache.data == {"legacy": {"id": 1, "title": "旧缓存"}}
|
||||
assert primary_cache.set_calls == [(
|
||||
tmdb_cache_module.PERSISTENCE_KEY,
|
||||
tmdb_cache_module.PERSISTENCE_REGION,
|
||||
)]
|
||||
assert legacy_cache.delete_calls == [(
|
||||
cache.region,
|
||||
tmdb_cache_module.settings.TEMP_PATH.name,
|
||||
)]
|
||||
|
||||
|
||||
def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
|
||||
"""查询接口应返回识别成功和失败条目的统计。"""
|
||||
cache = _build_tmdb_cache({
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""整理任务失活收敛行为测试。"""
|
||||
|
||||
from app.chain import transfer
|
||||
from app.chain.transfer import JobManager
|
||||
from app.schemas import FileItem, TransferTask
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
class _FakeMeta:
|
||||
"""提供整理任务分组需要的最小元数据。"""
|
||||
|
||||
def __init__(self):
|
||||
self.name = "Test Show"
|
||||
self.title = "Test Show S01E01"
|
||||
self.year = "2026"
|
||||
self.type = MediaType.TV
|
||||
self.begin_season = 1
|
||||
self.end_season = None
|
||||
self.total_season = 1
|
||||
self.begin_episode = 1
|
||||
self.end_episode = None
|
||||
self.total_episode = 1
|
||||
self.episode_list = [1]
|
||||
self.season_episode = "S01E01"
|
||||
self.part = None
|
||||
|
||||
def to_dict(self):
|
||||
"""返回 TransferJobTask 所需的元数据字典。"""
|
||||
return {
|
||||
"title": self.title,
|
||||
"name": self.name,
|
||||
"year": self.year,
|
||||
"type": self.type.value,
|
||||
"begin_season": self.begin_season,
|
||||
"end_season": self.end_season,
|
||||
"total_season": self.total_season,
|
||||
"begin_episode": self.begin_episode,
|
||||
"end_episode": self.end_episode,
|
||||
"total_episode": self.total_episode,
|
||||
"season_episode": self.season_episode,
|
||||
"episode_list": self.episode_list,
|
||||
"part": self.part,
|
||||
}
|
||||
|
||||
|
||||
def _make_task(name: str = "Test.Show.S01E01.mkv") -> TransferTask:
|
||||
"""创建失活检测使用的整理任务。"""
|
||||
return TransferTask(
|
||||
fileitem=FileItem(
|
||||
storage="local",
|
||||
path=f"/downloads/{name}",
|
||||
type="file",
|
||||
name=name,
|
||||
basename=name.removesuffix(".mkv"),
|
||||
extension="mkv",
|
||||
size=1024,
|
||||
),
|
||||
meta=_FakeMeta(),
|
||||
)
|
||||
|
||||
|
||||
def test_external_running_task_expires_without_heartbeat(monkeypatch):
|
||||
"""外部接管的运行中任务超过心跳期限后应被标记失败并清理。"""
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(transfer, "monotonic", lambda: clock[0])
|
||||
manager = JobManager()
|
||||
task = _make_task()
|
||||
assert manager.add_task(task)
|
||||
manager.running_task(task)
|
||||
|
||||
clock[0] = 221.0
|
||||
expired = manager.expire_stale_running_tasks(timeout_seconds=120)
|
||||
|
||||
assert expired == [(task.fileitem, 121)]
|
||||
assert manager.list_jobs() == []
|
||||
|
||||
|
||||
def test_main_thread_execution_is_not_expired(monkeypatch):
|
||||
"""主程序整理线程仍在执行的任务不应被失活检测伪清理。"""
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(transfer, "monotonic", lambda: clock[0])
|
||||
manager = JobManager()
|
||||
task = _make_task()
|
||||
assert manager.add_task(task)
|
||||
manager.start_execution(task)
|
||||
manager.running_task(task)
|
||||
|
||||
clock[0] = 500.0
|
||||
assert manager.expire_stale_running_tasks(timeout_seconds=120) == []
|
||||
assert manager.total() == 1
|
||||
|
||||
manager.finish_execution(task)
|
||||
expired = manager.expire_stale_running_tasks(timeout_seconds=120)
|
||||
assert expired == [(task.fileitem, 400)]
|
||||
assert manager.list_jobs() == []
|
||||
|
||||
|
||||
def test_waiting_task_and_refreshed_heartbeat_do_not_expire(monkeypatch):
|
||||
"""等待中任务不受失活期限影响,重复运行状态更新可刷新外部心跳。"""
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr(transfer, "monotonic", lambda: clock[0])
|
||||
manager = JobManager()
|
||||
waiting_task = _make_task("Test.Show.S01E01.waiting.mkv")
|
||||
running_task = _make_task("Test.Show.S01E02.running.mkv")
|
||||
running_task.meta.begin_episode = 2
|
||||
running_task.meta.episode_list = [2]
|
||||
assert manager.add_task(waiting_task)
|
||||
assert manager.add_task(running_task)
|
||||
manager.running_task(running_task)
|
||||
|
||||
clock[0] = 180.0
|
||||
manager.running_task(running_task)
|
||||
clock[0] = 250.0
|
||||
|
||||
assert manager.expire_stale_running_tasks(timeout_seconds=120) == []
|
||||
assert manager.total() == 2
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
from queue import Queue
|
||||
from threading import Event as ThreadEvent
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -8,6 +9,7 @@ from app import schemas
|
||||
from app.agent import ReplyMode, agent_manager
|
||||
from app.api.endpoints.agent import (
|
||||
_WebAgentMoviePilotAgent,
|
||||
_WebAgentEventPublisher,
|
||||
_WEB_AGENT_FILE_REGISTRY,
|
||||
_WEB_AGENT_NOTICE_QUEUES,
|
||||
_apply_web_agent_display_event,
|
||||
@@ -76,6 +78,32 @@ def test_split_web_agent_output_preserves_standalone_newline_delta():
|
||||
assert content == "可以这样操作:\n- **搜索资源**:搜索电影\n- **下载管理**:添加任务"
|
||||
|
||||
|
||||
def test_web_agent_event_publisher_coalesces_text_before_semantic_events():
|
||||
"""连续文本应合并,且工具事件前的文本顺序不能改变。"""
|
||||
|
||||
async def scenario():
|
||||
publisher = _WebAgentEventPublisher()
|
||||
try:
|
||||
for index in range(100):
|
||||
publisher.publish({"type": "delta", "content": str(index % 10)})
|
||||
publisher.publish({"type": "tool", "message": "查询完成"})
|
||||
|
||||
first = await asyncio.wait_for(publisher.get(), timeout=1)
|
||||
second = await asyncio.wait_for(publisher.get(), timeout=1)
|
||||
return first, second, publisher.max_depth
|
||||
finally:
|
||||
await publisher.aclose()
|
||||
|
||||
first, second, max_depth = asyncio.run(scenario())
|
||||
|
||||
assert first == {
|
||||
"type": "delta",
|
||||
"content": "".join(str(index % 10) for index in range(100)),
|
||||
}
|
||||
assert second == {"type": "tool", "message": "查询完成"}
|
||||
assert max_depth == 2
|
||||
|
||||
|
||||
def test_build_web_agent_session_id_is_stable_per_user_and_seed():
|
||||
"""同一用户和前端会话标识应生成稳定的服务端会话 ID。"""
|
||||
user = SimpleNamespace(id=1, name="admin")
|
||||
@@ -106,7 +134,7 @@ def test_build_web_agent_session_id_reuses_accessible_history():
|
||||
|
||||
|
||||
def test_apply_web_agent_display_event_updates_snapshot():
|
||||
"""WebAgent SSE 事件应可聚合为服务端展示快照。"""
|
||||
"""WebAgent SSE 事件应按到达顺序聚合为服务端展示快照。"""
|
||||
message = {
|
||||
"id": "assistant-1",
|
||||
"role": "assistant",
|
||||
@@ -114,12 +142,14 @@ def test_apply_web_agent_display_event_updates_snapshot():
|
||||
"createdAt": 1,
|
||||
"status": "streaming",
|
||||
"tools": [],
|
||||
"segments": [],
|
||||
"attachments": [],
|
||||
"choices": [],
|
||||
}
|
||||
|
||||
_apply_web_agent_display_event({"type": "delta", "content": "你好"}, message)
|
||||
_apply_web_agent_display_event({"type": "tool", "message": "查询订阅"}, message)
|
||||
_apply_web_agent_display_event({"type": "delta", "content": ",查询完成"}, message)
|
||||
_apply_web_agent_display_event(
|
||||
{
|
||||
"type": "attachment",
|
||||
@@ -129,14 +159,48 @@ def test_apply_web_agent_display_event_updates_snapshot():
|
||||
)
|
||||
_apply_web_agent_display_event({"type": "done"}, message)
|
||||
|
||||
assert message["content"] == "你好"
|
||||
assert message["content"] == "你好,查询完成"
|
||||
assert message["status"] == "done"
|
||||
assert len(message["tools"]) == 1
|
||||
assert message["tools"][0]["message"] == "查询订阅"
|
||||
assert message["tools"][0]["status"] == "done"
|
||||
assert message["segments"] == [
|
||||
{"type": "text", "content": "你好"},
|
||||
{"type": "tool", "toolIndex": 0},
|
||||
{"type": "text", "content": ",查询完成"},
|
||||
]
|
||||
assert message["attachments"] == [{"kind": "file", "url": "message/agent/file/a"}]
|
||||
|
||||
|
||||
def test_agent_chat_display_schema_preserves_ordered_segments():
|
||||
"""前端回传会话快照时应保留文字和工具的有序片段。"""
|
||||
payload = schemas.AgentChatDisplaySaveRequest(
|
||||
messages=[
|
||||
{
|
||||
"id": "assistant-1",
|
||||
"role": "assistant",
|
||||
"content": "先检查检查完成",
|
||||
"createdAt": 1,
|
||||
"status": "done",
|
||||
"tools": [
|
||||
{"id": "tool-1", "message": "执行检查", "status": "done"}
|
||||
],
|
||||
"segments": [
|
||||
{"type": "text", "content": "先检查"},
|
||||
{"type": "tool", "toolIndex": 0},
|
||||
{"type": "text", "content": "检查完成"},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert payload.messages[0].model_dump()["segments"] == [
|
||||
{"type": "text", "content": "先检查", "toolIndex": None},
|
||||
{"type": "tool", "content": "", "toolIndex": 0},
|
||||
{"type": "text", "content": "检查完成", "toolIndex": None},
|
||||
]
|
||||
|
||||
|
||||
def test_build_web_agent_input_attachments_marks_kinds():
|
||||
"""WebAgent 用户输入附件应转换为可展示的附件记录。"""
|
||||
attachments = _build_web_agent_input_attachments(
|
||||
@@ -311,6 +375,45 @@ def test_web_agent_reused_for_background_task_disables_streaming():
|
||||
assert agent._should_stream() is False
|
||||
|
||||
|
||||
def test_web_agent_output_callback_receives_only_new_text():
|
||||
"""WebAgent 外部回调应接收增量,同时内部仍保留完整输出。"""
|
||||
outputs = []
|
||||
agent = _WebAgentMoviePilotAgent(
|
||||
session_id="web-agent:incremental-output",
|
||||
user_id="7",
|
||||
channel=MessageChannel.WebAgent.value,
|
||||
source="web-agent",
|
||||
username="admin",
|
||||
replay_mode=ReplyMode.CAPTURE_ONLY,
|
||||
output_callback=outputs.append,
|
||||
)
|
||||
|
||||
agent._handle_stream_text("你")
|
||||
agent._handle_stream_text("好")
|
||||
|
||||
assert outputs == ["你", "好"]
|
||||
assert agent._streamed_output == "你好"
|
||||
|
||||
|
||||
def test_web_agent_tool_summary_is_emitted_before_following_text():
|
||||
"""Web 工具状态应在调用发生时输出,不能拖到正文结束后。"""
|
||||
outputs = []
|
||||
agent = _WebAgentMoviePilotAgent(
|
||||
session_id="web-agent:tool-order",
|
||||
user_id="7",
|
||||
channel=MessageChannel.WebAgent.value,
|
||||
source="web-agent",
|
||||
username="admin",
|
||||
replay_mode=ReplyMode.CAPTURE_ONLY,
|
||||
output_callback=outputs.append,
|
||||
)
|
||||
|
||||
agent.stream_handler.record_tool_call("query_download_tasks")
|
||||
agent._handle_stream_text("查询完成。")
|
||||
|
||||
assert outputs == ["(查询了 1 次数据)\n\n", "查询完成。"]
|
||||
|
||||
|
||||
def test_web_agent_channel_supports_streaming_and_attachments():
|
||||
"""WebAgent 渠道应声明流式、多媒体和文件发送能力。"""
|
||||
assert ChannelCapabilityManager.supports_capability(
|
||||
@@ -587,13 +690,16 @@ def test_web_agent_stream_binds_session_to_agent_manager():
|
||||
if worker:
|
||||
worker.cancel()
|
||||
|
||||
async def scenario():
|
||||
response = await web_agent_stream(payload, request, user)
|
||||
return "".join(await _collect_streaming_response(response))
|
||||
|
||||
try:
|
||||
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
|
||||
"app.api.endpoints.agent._WebAgentMoviePilotAgent",
|
||||
FakeWebAgent,
|
||||
):
|
||||
response = asyncio.run(web_agent_stream(payload, request, user))
|
||||
body = "".join(asyncio.run(_collect_streaming_response(response)))
|
||||
body = asyncio.run(scenario())
|
||||
|
||||
assert "状态正常" in body
|
||||
assert MessageChain._user_sessions["1"][0] == session_id
|
||||
@@ -609,6 +715,197 @@ def test_web_agent_stream_binds_session_to_agent_manager():
|
||||
worker.cancel()
|
||||
|
||||
|
||||
def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait():
|
||||
"""长时间没有 Agent 事件时应发送 SSE heartbeat 保持连接。"""
|
||||
payload = schemas.AgentWebChatRequest(text="分析系统状态", session_id="browser-heartbeat")
|
||||
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
|
||||
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
|
||||
|
||||
async def slow_process_message(**kwargs):
|
||||
"""模拟工具执行期间暂时没有可见输出。"""
|
||||
await asyncio.sleep(0.035)
|
||||
kwargs["output_callback"]("状态正常")
|
||||
|
||||
async def scenario():
|
||||
response = await web_agent_stream(payload, request, user)
|
||||
return "".join(await _collect_streaming_response(response))
|
||||
|
||||
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
|
||||
"app.api.endpoints.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS",
|
||||
0.01,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._is_web_agent_traditional_message",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._has_web_agent_traditional_interaction",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._build_web_agent_session_id",
|
||||
return_value="web-agent:heartbeat",
|
||||
), patch.object(
|
||||
MessageChain,
|
||||
"bind_user_session",
|
||||
), patch.object(
|
||||
agent_manager,
|
||||
"process_message",
|
||||
side_effect=slow_process_message,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._save_web_agent_display_snapshot",
|
||||
):
|
||||
body = asyncio.run(scenario())
|
||||
|
||||
assert ": heartbeat\n\n" in body
|
||||
assert '"type": "delta"' in body
|
||||
assert '"type": "done"' in body
|
||||
|
||||
|
||||
def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done():
|
||||
"""传统消息等待期间应保活,且展示快照不能阻塞终态。"""
|
||||
payload = schemas.AgentWebChatRequest(text="/状态", session_id="traditional-heartbeat")
|
||||
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
|
||||
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
|
||||
snapshot_started = ThreadEvent()
|
||||
snapshot_release = ThreadEvent()
|
||||
snapshot_finished = ThreadEvent()
|
||||
|
||||
async def slow_collect(**_kwargs):
|
||||
"""模拟传统消息链路等待外部结果。"""
|
||||
await asyncio.sleep(0.035)
|
||||
return [{"type": "delta", "content": "状态正常"}]
|
||||
|
||||
def slow_snapshot(**_kwargs):
|
||||
"""阻塞快照写入,便于断言 done 不等待落库。"""
|
||||
snapshot_started.set()
|
||||
snapshot_release.wait(timeout=2)
|
||||
snapshot_finished.set()
|
||||
|
||||
async def scenario():
|
||||
response = await web_agent_stream(payload, request, user)
|
||||
assert response.headers["cache-control"] == "no-cache, no-transform"
|
||||
iterator = response.body_iterator.__aiter__()
|
||||
received = []
|
||||
while True:
|
||||
chunk = await asyncio.wait_for(anext(iterator), timeout=1)
|
||||
text = chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
received.append(text)
|
||||
if '"type": "done"' in text:
|
||||
break
|
||||
|
||||
for _ in range(100):
|
||||
if snapshot_started.is_set():
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
assert snapshot_started.is_set()
|
||||
assert not snapshot_finished.is_set()
|
||||
await iterator.aclose()
|
||||
return "".join(received)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"app.api.endpoints.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS",
|
||||
0.01,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._is_web_agent_traditional_message",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._ensure_web_agent_command_allowed",
|
||||
return_value=None,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._get_web_agent_unknown_command_message",
|
||||
return_value=None,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._build_web_agent_session_id",
|
||||
return_value="web-agent:traditional-heartbeat",
|
||||
), patch(
|
||||
"app.api.endpoints.agent._collect_web_agent_traditional_events",
|
||||
side_effect=slow_collect,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._save_web_agent_display_snapshot",
|
||||
side_effect=slow_snapshot,
|
||||
):
|
||||
body = asyncio.run(scenario())
|
||||
|
||||
assert ": heartbeat\n\n" in body
|
||||
assert '"type": "delta"' in body
|
||||
assert '"type": "done"' in body
|
||||
assert not snapshot_finished.is_set()
|
||||
finally:
|
||||
snapshot_release.set()
|
||||
|
||||
assert snapshot_finished.wait(timeout=1)
|
||||
|
||||
|
||||
def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes():
|
||||
"""展示快照落库缓慢时,前端终态不应被数据库操作阻塞。"""
|
||||
payload = schemas.AgentWebChatRequest(text="检查系统", session_id="browser-snapshot")
|
||||
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
|
||||
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
|
||||
snapshot_started = ThreadEvent()
|
||||
snapshot_release = ThreadEvent()
|
||||
snapshot_finished = ThreadEvent()
|
||||
|
||||
async def immediate_process_message(**kwargs):
|
||||
"""立即生成一段文本,随后进入终态。"""
|
||||
kwargs["output_callback"]("检查完成")
|
||||
|
||||
def slow_snapshot(**_kwargs):
|
||||
"""阻塞快照写入,便于验证 done 的发送时机。"""
|
||||
snapshot_started.set()
|
||||
snapshot_release.wait(timeout=2)
|
||||
snapshot_finished.set()
|
||||
|
||||
async def scenario():
|
||||
response = await web_agent_stream(payload, request, user)
|
||||
iterator = response.body_iterator.__aiter__()
|
||||
received = []
|
||||
while True:
|
||||
chunk = await asyncio.wait_for(anext(iterator), timeout=1)
|
||||
text = chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
received.append(text)
|
||||
if '"type": "done"' in text:
|
||||
break
|
||||
|
||||
for _ in range(100):
|
||||
if snapshot_started.is_set():
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
assert snapshot_started.is_set()
|
||||
assert not snapshot_finished.is_set()
|
||||
|
||||
await iterator.aclose()
|
||||
return "".join(received)
|
||||
|
||||
try:
|
||||
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
|
||||
"app.api.endpoints.agent._is_web_agent_traditional_message",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._has_web_agent_traditional_interaction",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._build_web_agent_session_id",
|
||||
return_value="web-agent:snapshot",
|
||||
), patch.object(
|
||||
MessageChain,
|
||||
"bind_user_session",
|
||||
), patch.object(
|
||||
agent_manager,
|
||||
"process_message",
|
||||
side_effect=immediate_process_message,
|
||||
), patch(
|
||||
"app.api.endpoints.agent._save_web_agent_display_snapshot",
|
||||
side_effect=slow_snapshot,
|
||||
):
|
||||
body = asyncio.run(scenario())
|
||||
|
||||
assert '"type": "done"' in body
|
||||
assert not snapshot_finished.is_set()
|
||||
finally:
|
||||
snapshot_release.set()
|
||||
|
||||
assert snapshot_finished.wait(timeout=1)
|
||||
|
||||
|
||||
async def _collect_streaming_response(response):
|
||||
"""读取 StreamingResponse,便于断言 SSE 内容。"""
|
||||
chunks = []
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.15.4'
|
||||
FRONTEND_VERSION = 'v2.15.4'
|
||||
APP_VERSION = 'v2.15.5'
|
||||
FRONTEND_VERSION = 'v2.15.5'
|
||||
|
||||
Reference in New Issue
Block a user