后端: - 新增可配置识图模型(config: vision.model_name/prompt/max_tokens)。配置后 view_image 委托该模型识别并返回文字结果,文本主模型也能“看图”;未配置时回退到主模型自带视觉能力。新增 deerflow/models/vision.py 与 VisionConfig。 - 修复 Windows 上传全部报 unsafe:open_upload_file_no_symlink 不再强制要求 POSIX 专有的 O_NOFOLLOW,缺失时降级(仍用 lstat/fstat 防符号链接),Windows 追加 O_BINARY。 - 修复 normalize_input 丢弃 human 消息 additional_kwargs:保留 files/client_ts 等,使上传文件能回传到 UI 并被 UploadsMiddleware 识别。 - 约束 agent 行为:上传图片时引导只用 view_image,禁止用 read_file/grep/bash/python 解析图片字节;view_image 工具说明同步加固。 - 启用文档转换(uploads.auto_convert_documents),PDF/Word/Excel/PPT 自动转 Markdown 供 read_file/grep 读取。 - 订正若干模型错误的 supports_vision 标记。 前端: - 修复上传文件不回显:parseUploadedFiles 不再因 "(empty)" 提前返回。 - 修复 artifact 401 破图:改用带 Authorization 头的 apiFetch 取 blob 显示(新增 useAuthedObjectUrl)。 - 新增上传文件预览弹窗:点击卡片可查看图片/文本/Markdown、Office(读转换后的 .md)、PDF;文件卡片改为可点击。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.5 KiB
Python
122 lines
4.5 KiB
Python
"""Image recognition (识图) via a configurable vision model.
|
|
|
|
When ``config.yaml`` defines ``vision.model_name``, the lead agent's
|
|
``view_image`` tool delegates image understanding to that dedicated model and
|
|
returns a text description — so a text-only chat model can still "see" uploaded
|
|
images. When no dedicated model is configured, vision falls back to the main
|
|
chat model, which only works when that model has ``supports_vision: true``.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from deerflow.config import get_app_config
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.vision_config import DEFAULT_VISION_PROMPT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _configured_vision_model(config: AppConfig) -> str | None:
|
|
"""Return a valid dedicated vision model name from config, or None."""
|
|
vision_cfg = getattr(config, "vision", None)
|
|
name = getattr(vision_cfg, "model_name", None) if vision_cfg else None
|
|
if not name:
|
|
return None
|
|
if config.get_model_config(name) is None:
|
|
logger.warning("vision.model_name %r is not defined in models[]; ignoring", name)
|
|
return None
|
|
return name
|
|
|
|
|
|
def resolve_vision_model_name(model_name: str | None, *, app_config: AppConfig | None = None) -> str | None:
|
|
"""Resolve which model should handle image recognition for this run.
|
|
|
|
Priority:
|
|
1. A dedicated ``vision.model_name`` configured in ``config.yaml``.
|
|
2. The main chat model, only if it declares ``supports_vision: true``.
|
|
3. ``None`` — vision is unavailable.
|
|
"""
|
|
config = app_config or get_app_config()
|
|
configured = _configured_vision_model(config)
|
|
if configured:
|
|
return configured
|
|
if model_name:
|
|
mc = config.get_model_config(model_name)
|
|
if mc is not None and mc.supports_vision:
|
|
return model_name
|
|
return None
|
|
|
|
|
|
def vision_enabled(model_name: str | None, *, app_config: AppConfig | None = None) -> bool:
|
|
"""Whether the ``view_image`` tool/middleware should be available this run."""
|
|
return resolve_vision_model_name(model_name, app_config=app_config) is not None
|
|
|
|
|
|
def is_delegated_vision(*, app_config: AppConfig | None = None) -> bool:
|
|
"""Whether image recognition is delegated to a dedicated vision model.
|
|
|
|
True when ``vision.model_name`` is configured and valid — in that case
|
|
``view_image`` calls the dedicated model and returns a text description
|
|
instead of injecting raw image data into the main chat model.
|
|
"""
|
|
config = app_config or get_app_config()
|
|
return _configured_vision_model(config) is not None
|
|
|
|
|
|
def _extract_text(message) -> str:
|
|
"""Flatten an LLM response message into plain text."""
|
|
content = getattr(message, "content", message)
|
|
if isinstance(content, str):
|
|
return content.strip()
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for block in content:
|
|
if isinstance(block, str):
|
|
parts.append(block)
|
|
elif isinstance(block, dict) and block.get("type") == "text":
|
|
parts.append(str(block.get("text", "")))
|
|
return "\n".join(p for p in parts if p).strip()
|
|
return str(content).strip()
|
|
|
|
|
|
def recognize_image(
|
|
image_base64: str,
|
|
mime_type: str,
|
|
*,
|
|
question: str | None = None,
|
|
app_config: AppConfig | None = None,
|
|
) -> str:
|
|
"""Describe/analyze an image using the configured dedicated vision model.
|
|
|
|
Returns the model's text answer. Raises ``ValueError`` if no dedicated
|
|
vision model is configured, and propagates model/network errors to the
|
|
caller for handling.
|
|
"""
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
from deerflow.models.factory import create_chat_model
|
|
|
|
config = app_config or get_app_config()
|
|
model_name = _configured_vision_model(config)
|
|
if model_name is None:
|
|
raise ValueError("No dedicated vision model is configured (vision.model_name).")
|
|
|
|
vision_cfg = getattr(config, "vision", None)
|
|
default_prompt = getattr(vision_cfg, "prompt", None) or DEFAULT_VISION_PROMPT
|
|
prompt = (question or "").strip() or default_prompt
|
|
|
|
extra: dict = {}
|
|
max_tokens = getattr(vision_cfg, "max_tokens", None) if vision_cfg else None
|
|
if max_tokens:
|
|
extra["max_tokens"] = max_tokens
|
|
|
|
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config, **extra)
|
|
message = HumanMessage(
|
|
content=[
|
|
{"type": "text", "text": prompt},
|
|
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_base64}"}},
|
|
]
|
|
)
|
|
result = model.invoke([message])
|
|
return _extract_text(result)
|