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

后端:
- 新增可配置识图模型(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