mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
Refine existing implementation
This commit is contained in:
@@ -838,6 +838,8 @@ class MoviePilotAgent:
|
||||
detail = cls._exception_detail_text(error).lower()
|
||||
if "no endpoints found that support image input" in detail:
|
||||
return True
|
||||
if "not a vlm" in detail or "text-only prompts" in detail:
|
||||
return True
|
||||
if "unknown variant" in detail and "image_url" in detail:
|
||||
return True
|
||||
if "image input" not in detail and "images" not in detail:
|
||||
|
||||
@@ -691,7 +691,9 @@ class AgentCapabilityManager:
|
||||
@staticmethod
|
||||
def supports_image_input() -> bool:
|
||||
"""当前 Agent 是否启用图片输入能力。"""
|
||||
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
|
||||
return LLMHelper.supports_image_input()
|
||||
|
||||
@staticmethod
|
||||
def supports_audio_input() -> bool:
|
||||
|
||||
+113
-3
@@ -5,7 +5,7 @@ import inspect
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
@@ -700,11 +700,85 @@ class LLMHelper:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def supports_image_input() -> bool:
|
||||
def _metadata_supports_image_input(metadata: Any) -> Optional[bool]:
|
||||
"""从模型元数据中读取图片输入能力,未知时返回 None。"""
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
|
||||
modalities = metadata.get("modalities") or {}
|
||||
input_modalities = modalities.get("input")
|
||||
if isinstance(input_modalities, str):
|
||||
input_modalities = [input_modalities]
|
||||
if isinstance(input_modalities, list):
|
||||
normalized_modalities = {
|
||||
str(item or "").strip().lower() for item in input_modalities
|
||||
}
|
||||
return "image" in normalized_modalities
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _resolve_catalog_image_input_support(
|
||||
cls,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset: Optional[str] = None,
|
||||
) -> Optional[bool]:
|
||||
"""复用 provider 目录缓存解析当前模型是否支持图片输入。"""
|
||||
provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).strip()
|
||||
model_name = str(model if model is not None else settings.LLM_MODEL).strip()
|
||||
if not provider_name or not model_name:
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
metadata = LLMProviderManager().resolve_cached_model_metadata(
|
||||
provider_id=provider_name,
|
||||
model_id=model_name,
|
||||
base_url=base_url if base_url is not None else settings.LLM_BASE_URL,
|
||||
base_url_preset_id=(
|
||||
base_url_preset
|
||||
if base_url_preset is not None
|
||||
else settings.LLM_BASE_URL_PRESET
|
||||
),
|
||||
)
|
||||
except Exception as err:
|
||||
logger.debug(f"解析模型图片能力失败: {err}")
|
||||
return None
|
||||
|
||||
return cls._metadata_supports_image_input(metadata)
|
||||
|
||||
@classmethod
|
||||
def supports_image_input(
|
||||
cls,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
判断当前模型是否启用了图片输入能力。
|
||||
|
||||
用户开关为总开关;当内置模型目录明确标注当前模型不支持 image 输入时,
|
||||
即使总开关开启也降级为纯文本,避免文本模型收到 `image_url` 内容块后
|
||||
被兼容端点以 400 拒绝。无参调用保持旧版“只读总开关”语义,
|
||||
未知自定义模型也保持原有开关语义。
|
||||
"""
|
||||
return bool(settings.LLM_SUPPORT_IMAGE_INPUT)
|
||||
if not settings.LLM_SUPPORT_IMAGE_INPUT:
|
||||
return False
|
||||
if provider is None and model is None:
|
||||
return True
|
||||
|
||||
image_support = cls._resolve_catalog_image_input_support(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
base_url_preset=base_url_preset,
|
||||
)
|
||||
if image_support is not None:
|
||||
return image_support
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _build_legacy_runtime(
|
||||
@@ -798,6 +872,41 @@ class LLMHelper:
|
||||
return True
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _attach_runtime_metadata(model: Any, runtime: dict[str, Any]) -> None:
|
||||
"""
|
||||
将 MoviePilot 已解析出的 provider 运行时信息挂到模型实例上。
|
||||
|
||||
这些字段只供内部中间件识别协议能力,不参与 LangChain 请求序列化。
|
||||
"""
|
||||
runtime_metadata = {
|
||||
"runtime": runtime.get("runtime"),
|
||||
"provider_id": runtime.get("provider_id"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
}
|
||||
|
||||
def _set_metadata_attr(name: str, value: Any) -> None:
|
||||
try:
|
||||
setattr(model, name, value)
|
||||
except Exception:
|
||||
object.__setattr__(model, name, value)
|
||||
|
||||
try:
|
||||
_set_metadata_attr("_moviepilot_llm_runtime", runtime_metadata["runtime"])
|
||||
_set_metadata_attr(
|
||||
"_moviepilot_llm_provider_id",
|
||||
runtime_metadata["provider_id"],
|
||||
)
|
||||
_set_metadata_attr("_moviepilot_llm_base_url", runtime_metadata["base_url"])
|
||||
except Exception as err:
|
||||
logger.debug(f"LLM运行时元数据附加失败: {str(err)}")
|
||||
|
||||
profile = getattr(model, "profile", None)
|
||||
if isinstance(profile, dict):
|
||||
profile["moviepilot_runtime"] = runtime_metadata["runtime"]
|
||||
profile["moviepilot_provider_id"] = runtime_metadata["provider_id"]
|
||||
profile["moviepilot_base_url"] = runtime_metadata["base_url"]
|
||||
|
||||
@classmethod
|
||||
def _resolve_thinking_level(
|
||||
cls,
|
||||
@@ -1011,6 +1120,7 @@ class LLMHelper:
|
||||
"max_input_tokens": int(max_input_tokens),
|
||||
}
|
||||
|
||||
cls._attach_runtime_metadata(model, runtime)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1564,6 +1564,70 @@ class LLMProviderManager(metaclass=Singleton):
|
||||
return models[candidate]
|
||||
return None
|
||||
|
||||
def _cached_models_dev_model(
|
||||
self,
|
||||
provider_id: str,
|
||||
model_id: str,
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset_id: Optional[str] = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""从已缓存或内置的 models.dev 数据中同步读取模型元数据。"""
|
||||
try:
|
||||
spec = self.get_provider(provider_id)
|
||||
except LLMProviderError:
|
||||
return None
|
||||
|
||||
models_dev_provider_id = self._resolve_provider_models_dev_provider_id(
|
||||
spec,
|
||||
base_url,
|
||||
base_url_preset_id=base_url_preset_id,
|
||||
)
|
||||
if not models_dev_provider_id:
|
||||
return None
|
||||
|
||||
payload = self._cached_models_dev_payload().get(models_dev_provider_id, {}) or {}
|
||||
models = payload.get("models") if isinstance(payload, dict) else None
|
||||
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:
|
||||
if candidate in models:
|
||||
return models[candidate]
|
||||
return None
|
||||
|
||||
def resolve_cached_model_metadata(
|
||||
self,
|
||||
provider_id: str,
|
||||
model_id: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
base_url_preset_id: Optional[str] = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""同步解析缓存中的模型元数据,不触发远端 models.dev 刷新。"""
|
||||
if not model_id:
|
||||
return None
|
||||
metadata = self._cached_models_dev_model(
|
||||
provider_id,
|
||||
model_id,
|
||||
base_url=base_url,
|
||||
base_url_preset_id=base_url_preset_id,
|
||||
)
|
||||
if metadata:
|
||||
return metadata
|
||||
if provider_id == "chatgpt":
|
||||
return self._cached_models_dev_model("openai", model_id)
|
||||
if provider_id == "openai":
|
||||
return (
|
||||
self._cached_models_dev_payload()
|
||||
.get("openai", {})
|
||||
.get("models", {})
|
||||
.get(model_id)
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model_record(
|
||||
model_id: str,
|
||||
|
||||
@@ -20,7 +20,7 @@ from langchain.agents.middleware.tool_selection import (
|
||||
LLMToolSelectorMiddleware,
|
||||
)
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.tools import BaseTool
|
||||
from langgraph.runtime import Runtime
|
||||
@@ -70,17 +70,13 @@ class ToolSelectionStateUpdate(TypedDict):
|
||||
|
||||
class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
"""
|
||||
为 DeepSeek 兼容端点提供更稳妥的工具筛选实现。
|
||||
使用 provider-neutral JSON 提示执行工具筛选。
|
||||
|
||||
LangChain 默认会通过 `with_structured_output()` 走 OpenAI 的
|
||||
`response_format=json_schema` 路径,但 DeepSeek 官方 OpenAI 兼容端点公开文档
|
||||
仅保证 `json_object` 模式可用。对于 `deepseek-reasoner`,这会在工具筛选阶段
|
||||
提前触发 400,导致 Agent 还没真正开始执行工具就失败。
|
||||
|
||||
因此这里仅在识别到 DeepSeek 模型/端点时,退回到显式 JSON 输出模式:
|
||||
1. 使用 `response_format={"type": "json_object"}`;
|
||||
2. 在提示词中明确约束返回 JSON 结构;
|
||||
3. 手动解析 `{"tools": [...]}`,其余模型继续沿用 LangChain 默认实现。
|
||||
LangChain 默认会通过 `with_structured_output()` 走 provider-specific 的
|
||||
结构化输出能力,不同 OpenAI/Anthropic 兼容端点对 `response_format`、
|
||||
JSON schema 和工具绑定的支持并不一致。工具筛选只是 Agent 执行前的
|
||||
辅助优化,失败时也会恢复使用全部工具,因此这里统一使用文本提示约束
|
||||
模型返回 `{"tools": [...]}` 并手动解析,避免在筛选阶段引入额外兼容分支。
|
||||
|
||||
另外,LangChain 原生工具筛选挂在 `wrap_model_call` 上,会在同一条用户请求
|
||||
的每次“模型回合”前都重新筛选一次工具。对于会多轮调用工具的复杂任务,
|
||||
@@ -354,40 +350,13 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
request,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_deepseek_compatible_model(model: BaseChatModel) -> bool:
|
||||
"""
|
||||
判断当前模型是否应当走 DeepSeek JSON 兼容分支。
|
||||
|
||||
除了官方 `langchain_deepseek`,用户也可能通过 OpenAI-compatible
|
||||
配置把 DeepSeek 端点接到 `ChatOpenAI`。因此这里同时检查模块名、模型名
|
||||
和 Base URL,避免只靠单一条件漏判。
|
||||
"""
|
||||
module_name = type(model).__module__.lower()
|
||||
model_name = (
|
||||
str(getattr(model, "model_name", "") or getattr(model, "model", ""))
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
base_url = (
|
||||
str(getattr(model, "openai_api_base", "") or getattr(model, "api_base", ""))
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
|
||||
return (
|
||||
"deepseek" in module_name
|
||||
or model_name.startswith("deepseek-")
|
||||
or "api.deepseek.com" in base_url
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(text: str) -> dict[str, Any]:
|
||||
"""
|
||||
解析模型返回的 JSON。
|
||||
|
||||
DeepSeek 在 JSON 模式下通常会返回纯 JSON,但这里仍做一层兜底,
|
||||
兼容模型偶发输出围栏或前后说明文本的情况。
|
||||
不同模型可能偶发输出 Markdown 围栏或前后说明文本,因此这里从
|
||||
响应中提取第一个 JSON 对象作为兜底。
|
||||
"""
|
||||
stripped_text = text.strip()
|
||||
if not stripped_text:
|
||||
@@ -440,12 +409,12 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
)
|
||||
return f"Capability groups from tool tags:\n{rendered_groups}\n\n"
|
||||
|
||||
def _build_deepseek_selection_prompt(self, selection_request: Any) -> str:
|
||||
def _build_json_selection_prompt(self, selection_request: Any) -> str:
|
||||
"""
|
||||
为 DeepSeek 生成显式 JSON 输出提示。
|
||||
生成显式 JSON 输出提示。
|
||||
|
||||
DeepSeek 官方文档要求在 JSON 输出模式下,提示词中必须明确包含 JSON
|
||||
约束,否则兼容端点可能返回空内容或无意义输出。
|
||||
使用纯提示约束可覆盖更多兼容端点,避免在工具筛选阶段依赖某个
|
||||
provider 专属的 `response_format` 或 schema 能力。
|
||||
"""
|
||||
limit_instruction = ""
|
||||
if self.max_tools:
|
||||
@@ -469,7 +438,7 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
|
||||
def _normalize_selection_response(self, response: Any) -> dict[str, list[str]]:
|
||||
"""
|
||||
解析并标准化 DeepSeek JSON 模式的工具筛选结果。
|
||||
解析并标准化显式 JSON 模式的工具筛选结果。
|
||||
"""
|
||||
content = getattr(response, "content", response)
|
||||
text = LLMHelper.extract_text_content(content)
|
||||
@@ -486,22 +455,21 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
logger.debug(f"工具筛选标准化结果: {normalized_tools}")
|
||||
return {"tools": normalized_tools}
|
||||
|
||||
async def _aselect_tools_with_deepseek(
|
||||
async def _aselect_tools_with_json_prompt(
|
||||
self, selection_request: Any
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
使用 DeepSeek 兼容的 JSON 输出模式执行异步工具筛选。
|
||||
使用 JSON 提示执行异步工具筛选。
|
||||
|
||||
:param selection_request: LangChain 工具筛选请求
|
||||
:return: 标准化后的工具名列表
|
||||
"""
|
||||
logger.debug("工具筛选走 DeepSeek JSON 兼容分支")
|
||||
structured_model = selection_request.model.bind(
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
response = await structured_model.ainvoke(
|
||||
logger.debug("工具筛选走 JSON 提示分支")
|
||||
response = await selection_request.model.ainvoke(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": self._build_deepseek_selection_prompt(selection_request),
|
||||
},
|
||||
SystemMessage(
|
||||
content=self._build_json_selection_prompt(selection_request)
|
||||
),
|
||||
selection_request.last_user_message,
|
||||
]
|
||||
)
|
||||
@@ -550,26 +518,17 @@ class ToolSelectorMiddleware(LLMToolSelectorMiddleware):
|
||||
if selection_request is None:
|
||||
return request
|
||||
|
||||
if not self._is_deepseek_compatible_model(selection_request.model):
|
||||
captured_request: ModelRequest[ContextT] = request
|
||||
|
||||
async def _capture_handler(
|
||||
updated_request: ModelRequest[ContextT],
|
||||
) -> ModelRequest[ContextT]:
|
||||
nonlocal captured_request
|
||||
captured_request = updated_request
|
||||
return updated_request
|
||||
|
||||
await super().awrap_model_call(request, _capture_handler)
|
||||
return captured_request
|
||||
|
||||
response = await self._aselect_tools_with_deepseek(selection_request)
|
||||
return self._process_selection_response(
|
||||
response,
|
||||
selection_request.available_tools,
|
||||
selection_request.valid_tool_names,
|
||||
request,
|
||||
)
|
||||
try:
|
||||
response = await self._aselect_tools_with_json_prompt(selection_request)
|
||||
return self._process_selection_response(
|
||||
response,
|
||||
selection_request.available_tools,
|
||||
selection_request.valid_tool_names,
|
||||
request,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.warning(f"工具筛选失败,将恢复使用所有工具: {str(err)}")
|
||||
return request
|
||||
|
||||
async def abefore_agent( # noqa
|
||||
self,
|
||||
|
||||
@@ -243,7 +243,7 @@ class MessageChain(ChainBase):
|
||||
processing_status=processing_status,
|
||||
)
|
||||
finally:
|
||||
if continues_async is not True:
|
||||
if continues_async:
|
||||
self._mark_message_processing_finished(
|
||||
channel=channel,
|
||||
source=source,
|
||||
@@ -1278,7 +1278,10 @@ class MessageChain(ChainBase):
|
||||
# 将可直接输入给 LLM 的附件统一转换为 data URL
|
||||
original_images = images
|
||||
all_files = list(files or [])
|
||||
if images and LLMHelper.supports_image_input():
|
||||
if images and LLMHelper.supports_image_input(
|
||||
provider=settings.LLM_PROVIDER,
|
||||
model=settings.LLM_MODEL,
|
||||
):
|
||||
images = self._download_attachments_to_data_urls(
|
||||
images, channel, source
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user