识图与文件上传/预览:可配置视觉模型 + 修复上传与回显

后端:
- 新增可配置识图模型(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>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-02 16:43:53 +08:00
1 parent 43cbcd86be
commit 5aa1fdca04
17 files changed
+706 -74

No files matched your search

@@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from "react";
@@ -24,6 +25,10 @@ import {
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import {
UploadedFilePreviewDialog,
useAuthedObjectUrl,
} from "@/components/workspace/messages/uploaded-file-preview-dialog";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config";
@@ -466,8 +471,17 @@ function RichFileCard({
}) {
const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId);
const [previewOpen, setPreviewOpen] = useState(false);
const isUploading = file.status === "uploading";
const isImage = isImageFile(file.filename);
const fileUrl = file.path ? resolveArtifact(file.path) : null;
// Thumbnails must be fetched with auth: the artifact route is gated and a
// plain <img src> sends no Authorization header → 401 (broken image). Fetch
// the bytes via apiFetch and display the resulting object URL instead.
const { objectUrl: thumbUrl, loading: thumbLoading } = useAuthedObjectUrl(
fileUrl,
isImage && !isUploading && !!fileUrl,
);
if (isUploading) {
return (
@@ -496,50 +510,81 @@ function RichFileCard({
);
}
if (!file.path) return null;
if (!fileUrl) return null;
const fileUrl = resolveArtifact(file.path);
const preview = (
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
);
if (isImage) {
return (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="group border-border/40 relative block overflow-hidden rounded-lg border"
>
<img
src={fileUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/>
</a>
<>
<button
type="button"
onClick={() => setPreviewOpen(true)}
title={file.filename}
className="group border-border/40 relative block cursor-pointer overflow-hidden rounded-lg border"
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="bg-muted/40 text-muted-foreground flex h-32 w-44 items-center justify-center gap-2 text-xs">
{thumbLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<>
<FileIcon className="size-4" />
<span className="truncate">{file.filename}</span>
</>
)}
</div>
)}
</button>
{preview}
</>
);
}
return (
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{formatBytes(file.size)}
</span>
</div>
</div>
<>
<button
type="button"
onClick={() => setPreviewOpen(true)}
title={file.filename}
className="bg-background border-border/40 hover:bg-accent/50 flex max-w-50 min-w-30 cursor-pointer flex-col gap-1 rounded-lg border p-3 text-left shadow-sm transition-colors"
>
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{formatBytes(file.size)}
</span>
</div>
</button>
{preview}
</>
);
}
@@ -0,0 +1,278 @@
"use client";
import { DownloadIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse " +
"[&_th]:border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:px-2 [&_td]:py-1 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const TEXT_EXT = [
"txt", "md", "markdown", "csv", "tsv", "json", "log", "yaml", "yml",
"xml", "html", "htm", "css", "js", "ts", "tsx", "jsx", "py", "java",
"go", "rs", "c", "cpp", "h", "hpp", "sh", "sql", "ini", "toml", "env",
];
// Office documents are converted to a sibling Markdown file server-side
// (uploads.auto_convert_documents). We preview that .md instead of the binary.
const OFFICE_EXT = ["xlsx", "xls", "docx", "doc", "pptx", "ppt"];
function extOf(filename: string): string {
const i = filename.lastIndexOf(".");
return i >= 0 ? filename.slice(i + 1).toLowerCase() : "";
}
type PreviewKind = "image" | "text" | "office" | "pdf" | "binary";
function kindOf(filename: string): PreviewKind {
const ext = extOf(filename);
if (IMAGE_EXT.includes(ext)) return "image";
if (ext === "pdf") return "pdf";
if (OFFICE_EXT.includes(ext)) return "office";
if (TEXT_EXT.includes(ext)) return "text";
return "binary";
}
function withReplacedExtension(url: string): string {
// Replace the path extension with .md, keeping any query string intact.
const [base, query] = url.split("?");
const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped;
}
/**
* Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled || !url) {
setObjectUrl(null);
return;
}
let cancelled = false;
let created: string | null = null;
setLoading(true);
setError(null);
apiFetch(url)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
if (cancelled) return;
created = URL.createObjectURL(blob);
setObjectUrl(created);
})
.catch((err: unknown) => {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
return { objectUrl, loading, error };
}
/**
* Preview dialog for a file attached to a chat message. All fetches go through
* `apiFetch` so the `Authorization` header is attached (artifacts are auth-gated).
*
* - image → fetched as blob, rendered inline
* - text → fetched and shown (markdown rendered for .md, otherwise <pre>)
* - office → fetches the server-converted sibling `<name>.md` and renders it
* - pdf → fetched as blob, embedded via <iframe>
* - else → download link
*/
export function UploadedFilePreviewDialog({
filename,
fileUrl,
open,
onOpenChange,
}: {
filename: string;
/** Absolute artifact URL for the original uploaded file. */
fileUrl: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]);
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
// text / office → fetch text (office reads the converted sibling .md).
const textUrl = useMemo(
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
[kind, fileUrl],
);
const [text, setText] = useState<string | null>(null);
const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null);
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
setTextLoading(true);
setTextError(null);
setText(null);
apiFetch(textUrl)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((body) => {
if (!cancelled) setText(body);
})
.catch((err: unknown) => {
if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setTextLoading(false);
});
return () => {
cancelled = true;
};
}, [open, kind, textUrl]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
const loadingView = (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{kind === "image" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<div className="flex items-center justify-center">
<img
src={objectUrl}
alt={filename}
className="max-h-full max-w-full rounded border"
/>
</div>
)
) : kind === "pdf" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<iframe
src={objectUrl}
title={filename}
className="h-full min-h-[500px] w-full rounded border"
/>
)
) : kind === "text" || kind === "office" ? (
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
)}
</div>
</DialogContent>
</Dialog>
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</div>
);
}
+4 -4
View File
@@ -462,10 +462,10 @@ export function parseUploadedFiles(content: string): FileInMessage[] {
return [];
}
// Check if the backend reported no new files were uploaded in this message
if (uploadedFilesContent?.includes("(empty)")) {
return [];
}
// NOTE: do NOT bail on "(empty)" here. The backend writes "(empty)" only for
// the "uploaded in this message" section; files uploaded in previous messages
// are still listed below it. Bailing dropped those valid entries. The file
// regex below naturally skips the "(empty)" line, so parsing stays correct.
// Parse file list
// Format: - filename (size)\n Path: /path/to/file
+14 -5
View File
@@ -637,12 +637,21 @@ Automatic conversation summarization when approaching token limits:
See [docs/summarization.md](docs/summarization.md) for details.
### Vision Support
### Vision Support / Image Recognition (识图)
For models with `supports_vision: true`:
- `ViewImageMiddleware` processes images in conversation
- `view_image_tool` added to agent's toolset
- Images automatically converted to base64 and injected into state
Two paths, selected by `config.yaml` → `vision`:
**Dedicated vision model (delegated recognition)** — set `vision.model_name` to a model from `models[]`. The `view_image` tool sends the image to that model and returns its **text description** as the tool result, so a text-only main chat model can still "see" images. `view_image` accepts an optional `question` argument for targeted queries; the default prompt is `vision.prompt` (overridable), and `vision.max_tokens` caps the recognition output. Resolution/gating lives in `deerflow/models/vision.py` (`resolve_vision_model_name`, `vision_enabled`, `is_delegated_vision`, `recognize_image`).
**Native vision (fallback)** — when `vision.model_name` is unset, behavior falls back to the main chat model, which only works if it has `supports_vision: true`:
- `ViewImageMiddleware` injects base64 image data into the conversation before the LLM call
- `view_image` stores the image in `state.viewed_images` for that middleware
Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vision_enabled(model_name)` is true (dedicated model configured **or** main model supports vision). Config schema: `deerflow/config/vision_config.py` (`VisionConfig`); tests: `tests/test_vision.py`.
### Upload symlink-safety (cross-platform)
`deerflow/uploads/manager.py:open_upload_file_no_symlink` writes uploads without following a pre-existing destination symlink. `O_NOFOLLOW` is applied where available (POSIX) but is **optional** — Windows lacks `os.O_NOFOLLOW`/`O_NONBLOCK`, so requiring it previously made every Windows upload fail with `UnsafeUploadPathError`. Pre-existing symlinks are still rejected via `lstat`, and the opened fd is re-checked with `fstat`. `O_BINARY` is added on Windows. Regression: `tests/test_uploads_windows_safe.py`.
## Code Style
@@ -84,13 +84,23 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
converted = []
for msg in messages:
if isinstance(msg, dict):
role = msg.get("role", msg.get("type", "user"))
content = msg.get("content", "")
if role in ("user", "human"):
converted.append(HumanMessage(content=content))
else:
# TODO: handle other message types (system, ai, tool)
converted.append(HumanMessage(content=content))
# Preserve additional_kwargs (e.g. uploaded `files`, `client_ts`,
# `prompt_prefix`) and optional id/name so they round-trip through
# the checkpoint to the UI and to UploadsMiddleware. Dropping them
# here previously made uploaded files invisible in the chat and
# broke the per-message ask-time timestamp.
msg_kwargs: dict[str, Any] = {"content": content}
extra = msg.get("additional_kwargs")
if isinstance(extra, dict) and extra:
msg_kwargs["additional_kwargs"] = extra
if msg.get("id"):
msg_kwargs["id"] = msg["id"]
if msg.get("name"):
msg_kwargs["name"] = msg["name"]
# TODO: handle other message types (system, ai, tool); for now all
# inbound dict messages are treated as human turns.
converted.append(HumanMessage(**msg_kwargs))
else:
converted.append(msg)
return {**raw_input, "messages": converted}
+34 -3
View File
@@ -33,7 +33,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: deepseek-v4-flash
@@ -46,7 +46,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: deepseek-v4-pro
@@ -59,7 +59,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
@@ -81,6 +81,37 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
title:
enabled: true
@@ -288,10 +288,14 @@ def _build_middlewares(
if not is_bootstrap:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
model_config = resolved_app_config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
# ingests raw image data). In delegated mode the view_image tool already
# returns a text description and stores nothing in `viewed_images`, so the
# middleware would otherwise inject a spurious "No images have been viewed."
# message. Use the resolved runtime model_name to avoid stale config values.
from deerflow.models.vision import is_delegated_vision, vision_enabled
if vision_enabled(model_name, app_config=resolved_app_config) and not is_delegated_vision(app_config=resolved_app_config):
middlewares.append(ViewImageMiddleware())
# Add DeferredToolFilterMiddleware to hide deferred tool schemas from model binding
@@ -18,6 +18,12 @@ logger = logging.getLogger(__name__)
_OUTLINE_PREVIEW_LINES = 5
# Image files are understood via the `view_image` tool (which routes to the
# configured vision model), never by reading their bytes. Listing them with the
# generic read_file/grep guidance made the agent try to parse image binaries
# with bash/python, which fails and wastes turns.
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
"""Return the document outline and fallback preview for *file_path*.
@@ -88,6 +94,16 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
lines.append(f"- {file['filename']} ({size_str})")
lines.append(f" Path: {file['path']}")
# Images must be understood through the vision model, not by reading bytes.
extension = str(file.get("extension") or Path(file["filename"]).suffix).lower()
if extension in _IMAGE_EXTENSIONS:
lines.append(f" This is an image. To understand it, call the `view_image` tool with image_path='{file['path']}' (optionally pass a `question`).")
lines.append(" The configured vision model will analyze it and return a description — trust that result.")
lines.append(" Do NOT read, open, or parse the image with read_file, grep, bash, python, PIL, or any byte-level inspection; those cannot interpret image content.")
lines.append("")
return
outline = file.get("outline") or []
if outline:
truncated = outline[-1].get("truncated", False)
@@ -139,6 +139,12 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
if not messages:
return False
# Nothing to inject when no image data was captured (e.g. delegated
# recognition handled the image in the tool and stored no base64).
# Avoids injecting a useless "No images have been viewed." message.
if not state.get("viewed_images"):
return False
# Get the last assistant message
last_assistant_msg = self._get_last_assistant_message(messages)
if not last_assistant_msg:
@@ -32,6 +32,7 @@ from deerflow.config.title_config import TitleConfig, load_title_config_from_dic
from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
from deerflow.config.vision_config import VisionConfig
load_dotenv()
@@ -104,6 +105,7 @@ class AppConfig(BaseModel):
log_level: str = Field(default="info", description="Logging level for deerflow and app modules (debug/info/warning/error); third-party libraries are not affected")
token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration")
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
sandbox: SandboxConfig = Field(description="Sandbox configuration")
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
@@ -0,0 +1,29 @@
from pydantic import BaseModel, Field
# Default instruction sent to the vision model when the agent does not provide a
# more specific question. Kept in Chinese to match the product's primary locale.
DEFAULT_VISION_PROMPT = "请仔细观察这张图片,尽可能详细、准确地描述其中的全部内容,包括文字、图表、物体、场景与布局等关键信息。"
class VisionConfig(BaseModel):
"""Configuration for image recognition (识图).
When ``model_name`` names a model from ``models[]``, 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 ``model_name`` is unset, vision falls back to the main
chat model, which only works when that model has ``supports_vision: true``.
"""
model_name: str | None = Field(
default=None,
description="Name of the model (from models[]) used for image recognition. When unset, falls back to the main chat model if it supports vision.",
)
prompt: str = Field(
default=DEFAULT_VISION_PROMPT,
description="Default instruction for the vision model when no per-call question is given.",
)
max_tokens: int | None = Field(
default=None,
description="Optional override for the vision model's max output tokens; falls back to the model's own config when unset.",
)
@@ -1,3 +1,10 @@
from .factory import create_chat_model
from .vision import is_delegated_vision, recognize_image, resolve_vision_model_name, vision_enabled
__all__ = ["create_chat_model"]
__all__ = [
"create_chat_model",
"is_delegated_vision",
"recognize_image",
"resolve_vision_model_name",
"vision_enabled",
]
@@ -0,0 +1,121 @@
"""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)
@@ -51,20 +51,33 @@ def view_image_tool(
runtime: ToolRuntime[ContextT, ThreadState],
image_path: str,
tool_call_id: Annotated[str, InjectedToolCallId],
question: str | None = None,
) -> Command:
"""Read an image file.
"""Read and understand an image file.
Use this tool to read an image file and make it available for display.
This is the ONLY correct way to understand an image. The image is analyzed by
the configured vision model and its description/answer is returned to you as
the tool result. Treat that result as authoritative.
When to use the view_image tool:
- When you need to view an image file.
- Whenever you need to view, describe, or answer a question about an image file.
When NOT to use the view_image tool:
- For non-image files (use present_files instead)
- For multiple files at once (use present_files instead)
IMPORTANT — do NOT attempt to understand images any other way:
- Never read, open, decode, or parse an image with read_file, grep, bash,
python, PIL/Pillow, OpenCV, EXIF parsing, or byte/hex inspection. Those
cannot interpret visual content and will only waste turns.
- If `view_image` returns a weak or incomplete description, do not fall back
to code. Either call `view_image` again with a more specific `question`,
or tell the user the configured vision model could not extract more detail
(the vision model may need to be changed in config).
Args:
image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp.
question: Optional specific question to ask about the image (e.g. "What is the total on this invoice?"). When omitted, a general detailed description is produced.
"""
from deerflow.sandbox.exceptions import SandboxRuntimeError
from deerflow.sandbox.tools import (
@@ -153,8 +166,27 @@ def view_image_tool(
mime_type = detected_mime_type
image_base64 = base64.b64encode(image_data).decode("utf-8")
# Update viewed_images in state
# The merge_viewed_images reducer will handle merging with existing images
# Delegated recognition: when a dedicated vision model is configured, send
# the image to that model and return its description as the tool result, so
# a text-only main chat model can still "see" the image.
from deerflow.models.vision import is_delegated_vision, recognize_image
if is_delegated_vision():
try:
description = recognize_image(image_base64, mime_type, question=question)
except Exception as e: # noqa: BLE001 - surface as a recoverable tool error
return Command(
update={"messages": [ToolMessage(f"Error recognizing image: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
)
if not description:
description = "The vision model returned an empty description."
return Command(
update={"messages": [ToolMessage(f"Image recognition result for {image_path}:\n\n{description}", tool_call_id=tool_call_id)]},
)
# Native vision: store the image so ViewImageMiddleware injects it into the
# (vision-capable) main model before the next LLM call.
# The merge_viewed_images reducer will handle merging with existing images.
new_viewed_images = {image_path: {"base64": image_base64, "mime_type": mime_type}}
return Command(
@@ -138,11 +138,14 @@ def get_available_tools(
if model_name is None and config.models:
model_name = config.models[0].name
# Add view_image_tool only if the model supports vision
model_config = config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
# Add view_image_tool when vision is available — either the main model
# supports vision natively, or a dedicated vision model is configured
# (vision.model_name) so recognition can be delegated to it.
from deerflow.models.vision import vision_enabled
if vision_enabled(model_name, app_config=config):
builtin_tools.append(view_image_tool)
logger.info(f"Including view_image_tool for model '{model_name}' (supports_vision=True)")
logger.info(f"Including view_image_tool for model '{model_name}' (vision available)")
# Get cached MCP tools if enabled
# NOTE: We use ExtensionsConfig.from_file() instead of config.extensions
@@ -138,12 +138,21 @@ def open_upload_file_no_symlink(base_dir: Path, filename: str) -> tuple[Path, ob
validate_path_traversal(dest, base_dir)
if not hasattr(os, "O_NOFOLLOW"):
raise UnsafeUploadPathError("Upload writes require O_NOFOLLOW support")
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
# ``O_NOFOLLOW`` closes the TOCTOU window between the ``lstat`` above and the
# ``open`` below, but it is a POSIX-only flag — it does not exist on Windows
# (``os.O_NOFOLLOW`` is undefined there). Windows also lacks ``O_NONBLOCK``
# and needs ``O_BINARY`` to avoid CRLF translation on the raw fd. Where
# ``O_NOFOLLOW`` is unavailable we still reject pre-existing symlinks via the
# ``lstat`` check and re-verify the opened file with ``fstat`` below; the
# residual race only matters under a hostile local user, which is out of
# scope for the single-user local deployment this path targets.
flags = os.O_WRONLY | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
if hasattr(os, "O_NONBLOCK"):
flags |= os.O_NONBLOCK
if hasattr(os, "O_BINARY"):
flags |= os.O_BINARY
try:
fd = os.open(dest, flags, 0o600)
+32 -2
View File
@@ -11,7 +11,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
@@ -35,7 +35,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: minimax-m2.7
display_name: MiniMax M2.7
@@ -57,6 +57,36 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
tool_groups:
- name: web
- name: file:read