识图与文件上传/预览:可配置视觉模型 + 修复上传与回显
后端: - 新增可配置识图模型(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:
1 parent
43cbcd86be
commit
5aa1fdca04
17 files changed
+706
-74
No files matched your search
@@ -5,6 +5,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useState,
|
||||||
type AnchorHTMLAttributes,
|
type AnchorHTMLAttributes,
|
||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -24,6 +25,10 @@ import {
|
|||||||
} from "@/components/ai-elements/reasoning";
|
} from "@/components/ai-elements/reasoning";
|
||||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
UploadedFilePreviewDialog,
|
||||||
|
useAuthedObjectUrl,
|
||||||
|
} from "@/components/workspace/messages/uploaded-file-preview-dialog";
|
||||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||||
import { useChatAskTimes } from "@/core/chat-timestamps";
|
import { useChatAskTimes } from "@/core/chat-timestamps";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
@@ -466,8 +471,17 @@ function RichFileCard({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const resolveArtifact = useArtifactResolver(threadId);
|
const resolveArtifact = useArtifactResolver(threadId);
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
const isUploading = file.status === "uploading";
|
const isUploading = file.status === "uploading";
|
||||||
const isImage = isImageFile(file.filename);
|
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) {
|
if (isUploading) {
|
||||||
return (
|
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) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
<a
|
<>
|
||||||
href={fileUrl}
|
<button
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noopener noreferrer"
|
onClick={() => setPreviewOpen(true)}
|
||||||
className="group border-border/40 relative block overflow-hidden rounded-lg border"
|
title={file.filename}
|
||||||
>
|
className="group border-border/40 relative block cursor-pointer overflow-hidden rounded-lg border"
|
||||||
<img
|
>
|
||||||
src={fileUrl}
|
{thumbUrl ? (
|
||||||
alt={file.filename}
|
<img
|
||||||
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
src={thumbUrl}
|
||||||
/>
|
alt={file.filename}
|
||||||
</a>
|
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 (
|
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">
|
<button
|
||||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
type="button"
|
||||||
<span
|
onClick={() => setPreviewOpen(true)}
|
||||||
className="text-foreground truncate text-sm font-medium"
|
title={file.filename}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{file.filename}
|
<div className="flex items-start gap-2">
|
||||||
</span>
|
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||||
</div>
|
<span
|
||||||
<div className="flex items-center justify-between gap-2">
|
className="text-foreground truncate text-sm font-medium"
|
||||||
<Badge
|
title={file.filename}
|
||||||
variant="secondary"
|
>
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
{file.filename}
|
||||||
>
|
</span>
|
||||||
{getFileTypeLabel(file.filename)}
|
</div>
|
||||||
</Badge>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<Badge
|
||||||
{formatBytes(file.size)}
|
variant="secondary"
|
||||||
</span>
|
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
||||||
</div>
|
>
|
||||||
</div>
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -462,10 +462,10 @@ export function parseUploadedFiles(content: string): FileInMessage[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the backend reported no new files were uploaded in this message
|
// NOTE: do NOT bail on "(empty)" here. The backend writes "(empty)" only for
|
||||||
if (uploadedFilesContent?.includes("(empty)")) {
|
// the "uploaded in this message" section; files uploaded in previous messages
|
||||||
return [];
|
// 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
|
// Parse file list
|
||||||
// Format: - filename (size)\n Path: /path/to/file
|
// Format: - filename (size)\n Path: /path/to/file
|
||||||
|
|||||||
@@ -637,12 +637,21 @@ Automatic conversation summarization when approaching token limits:
|
|||||||
|
|
||||||
See [docs/summarization.md](docs/summarization.md) for details.
|
See [docs/summarization.md](docs/summarization.md) for details.
|
||||||
|
|
||||||
### Vision Support
|
### Vision Support / Image Recognition (识图)
|
||||||
|
|
||||||
For models with `supports_vision: true`:
|
Two paths, selected by `config.yaml` → `vision`:
|
||||||
- `ViewImageMiddleware` processes images in conversation
|
|
||||||
- `view_image_tool` added to agent's toolset
|
**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`).
|
||||||
- Images automatically converted to base64 and injected into state
|
|
||||||
|
**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
|
## Code Style
|
||||||
|
|
||||||
|
|||||||
@@ -84,13 +84,23 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
converted = []
|
converted = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if isinstance(msg, dict):
|
if isinstance(msg, dict):
|
||||||
role = msg.get("role", msg.get("type", "user"))
|
|
||||||
content = msg.get("content", "")
|
content = msg.get("content", "")
|
||||||
if role in ("user", "human"):
|
# Preserve additional_kwargs (e.g. uploaded `files`, `client_ts`,
|
||||||
converted.append(HumanMessage(content=content))
|
# `prompt_prefix`) and optional id/name so they round-trip through
|
||||||
else:
|
# the checkpoint to the UI and to UploadsMiddleware. Dropping them
|
||||||
# TODO: handle other message types (system, ai, tool)
|
# here previously made uploaded files invisible in the chat and
|
||||||
converted.append(HumanMessage(content=content))
|
# 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:
|
else:
|
||||||
converted.append(msg)
|
converted.append(msg)
|
||||||
return {**raw_input, "messages": converted}
|
return {**raw_input, "messages": converted}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ models:
|
|||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: false
|
||||||
supports_thinking: true
|
supports_thinking: true
|
||||||
|
|
||||||
- name: deepseek-v4-flash
|
- name: deepseek-v4-flash
|
||||||
@@ -46,7 +46,7 @@ models:
|
|||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: false
|
||||||
supports_thinking: true
|
supports_thinking: true
|
||||||
|
|
||||||
- name: deepseek-v4-pro
|
- name: deepseek-v4-pro
|
||||||
@@ -59,7 +59,7 @@ models:
|
|||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: false
|
||||||
supports_thinking: true
|
supports_thinking: true
|
||||||
- name: MiniMax-M2.7
|
- name: MiniMax-M2.7
|
||||||
display_name: MiniMax-M2.7
|
display_name: MiniMax-M2.7
|
||||||
@@ -81,6 +81,37 @@ models:
|
|||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_thinking: 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:
|
title:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
+8
-4
@@ -288,10 +288,14 @@ def _build_middlewares(
|
|||||||
if not is_bootstrap:
|
if not is_bootstrap:
|
||||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
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.
|
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
|
||||||
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
|
# ingests raw image data). In delegated mode the view_image tool already
|
||||||
model_config = resolved_app_config.get_model_config(model_name) if model_name else None
|
# returns a text description and stores nothing in `viewed_images`, so the
|
||||||
if model_config is not None and model_config.supports_vision:
|
# 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())
|
middlewares.append(ViewImageMiddleware())
|
||||||
|
|
||||||
# Add DeferredToolFilterMiddleware to hide deferred tool schemas from model binding
|
# Add DeferredToolFilterMiddleware to hide deferred tool schemas from model binding
|
||||||
|
|||||||
+16
@@ -18,6 +18,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_OUTLINE_PREVIEW_LINES = 5
|
_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]]:
|
def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
|
||||||
"""Return the document outline and fallback preview for *file_path*.
|
"""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"
|
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"- {file['filename']} ({size_str})")
|
||||||
lines.append(f" Path: {file['path']}")
|
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 []
|
outline = file.get("outline") or []
|
||||||
if outline:
|
if outline:
|
||||||
truncated = outline[-1].get("truncated", False)
|
truncated = outline[-1].get("truncated", False)
|
||||||
|
|||||||
+6
@@ -139,6 +139,12 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
|
|||||||
if not messages:
|
if not messages:
|
||||||
return False
|
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
|
# Get the last assistant message
|
||||||
last_assistant_msg = self._get_last_assistant_message(messages)
|
last_assistant_msg = self._get_last_assistant_message(messages)
|
||||||
if not last_assistant_msg:
|
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.token_usage_config import TokenUsageConfig
|
||||||
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
|
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.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
|
||||||
|
from deerflow.config.vision_config import VisionConfig
|
||||||
|
|
||||||
load_dotenv()
|
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")
|
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")
|
token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration")
|
||||||
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
|
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")
|
sandbox: SandboxConfig = Field(description="Sandbox configuration")
|
||||||
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
|
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
|
||||||
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
|
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 .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)
|
||||||
+37
-5
@@ -51,20 +51,33 @@ def view_image_tool(
|
|||||||
runtime: ToolRuntime[ContextT, ThreadState],
|
runtime: ToolRuntime[ContextT, ThreadState],
|
||||||
image_path: str,
|
image_path: str,
|
||||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||||
|
question: str | None = None,
|
||||||
) -> Command:
|
) -> 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 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:
|
When NOT to use the view_image tool:
|
||||||
- For non-image files (use present_files instead)
|
- For non-image files (use present_files instead)
|
||||||
- For multiple files at once (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:
|
Args:
|
||||||
image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp.
|
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.exceptions import SandboxRuntimeError
|
||||||
from deerflow.sandbox.tools import (
|
from deerflow.sandbox.tools import (
|
||||||
@@ -153,8 +166,27 @@ def view_image_tool(
|
|||||||
mime_type = detected_mime_type
|
mime_type = detected_mime_type
|
||||||
image_base64 = base64.b64encode(image_data).decode("utf-8")
|
image_base64 = base64.b64encode(image_data).decode("utf-8")
|
||||||
|
|
||||||
# Update viewed_images in state
|
# Delegated recognition: when a dedicated vision model is configured, send
|
||||||
# The merge_viewed_images reducer will handle merging with existing images
|
# 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}}
|
new_viewed_images = {image_path: {"base64": image_base64, "mime_type": mime_type}}
|
||||||
|
|
||||||
return Command(
|
return Command(
|
||||||
|
|||||||
@@ -138,11 +138,14 @@ def get_available_tools(
|
|||||||
if model_name is None and config.models:
|
if model_name is None and config.models:
|
||||||
model_name = config.models[0].name
|
model_name = config.models[0].name
|
||||||
|
|
||||||
# Add view_image_tool only if the model supports vision
|
# Add view_image_tool when vision is available — either the main model
|
||||||
model_config = config.get_model_config(model_name) if model_name else None
|
# supports vision natively, or a dedicated vision model is configured
|
||||||
if model_config is not None and model_config.supports_vision:
|
# (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)
|
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
|
# Get cached MCP tools if enabled
|
||||||
# NOTE: We use ExtensionsConfig.from_file() instead of config.extensions
|
# 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)
|
validate_path_traversal(dest, base_dir)
|
||||||
|
|
||||||
if not hasattr(os, "O_NOFOLLOW"):
|
# ``O_NOFOLLOW`` closes the TOCTOU window between the ``lstat`` above and the
|
||||||
raise UnsafeUploadPathError("Upload writes require O_NOFOLLOW support")
|
# ``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``
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
|
# 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"):
|
if hasattr(os, "O_NONBLOCK"):
|
||||||
flags |= os.O_NONBLOCK
|
flags |= os.O_NONBLOCK
|
||||||
|
if hasattr(os, "O_BINARY"):
|
||||||
|
flags |= os.O_BINARY
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fd = os.open(dest, flags, 0o600)
|
fd = os.open(dest, flags, 0o600)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ models:
|
|||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: false
|
||||||
supports_thinking: true
|
supports_thinking: true
|
||||||
- name: zai-org/GLM-5-FP8
|
- name: zai-org/GLM-5-FP8
|
||||||
display_name: zai-org/GLM-5-FP8
|
display_name: zai-org/GLM-5-FP8
|
||||||
@@ -35,7 +35,7 @@ models:
|
|||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: false
|
||||||
supports_thinking: true
|
supports_thinking: true
|
||||||
- name: minimax-m2.7
|
- name: minimax-m2.7
|
||||||
display_name: MiniMax M2.7
|
display_name: MiniMax M2.7
|
||||||
@@ -57,6 +57,36 @@ models:
|
|||||||
temperature: 1.0
|
temperature: 1.0
|
||||||
supports_vision: true
|
supports_vision: true
|
||||||
supports_thinking: 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:
|
tool_groups:
|
||||||
- name: web
|
- name: web
|
||||||
- name: file:read
|
- name: file:read
|
||||||
|
|||||||
Reference in new issue
Block a user