下载图片

This commit is contained in:
fengxiangdebaozi committed 2026-06-04 19:08:52 +08:00
1 parent 77f8ded1e6
commit 788118ce3d
4 files changed
+131 -24

No files matched your search

@@ -48,7 +48,13 @@ import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import {
checkCodeFile,
getFileName,
isImageFile,
} from "@/core/utils/files";
import { getBackendBaseURL } from "@/core/config";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -96,21 +102,25 @@ export function ArtifactFileDetail({
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread(); const { isMock, thread, publicShareCode } = useThread();
const isWriteFile = useMemo(() => { const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
const filepath = useMemo(() => { const filepath = useMemo(() => {
if (isWriteFile) { if (isWriteFileVirtual) {
const url = new URL(filepathFromProps); const url = new URL(filepathFromProps);
return decodeURIComponent(url.pathname); return decodeURIComponent(url.pathname);
} }
return filepathFromProps; return filepathFromProps;
}, [filepathFromProps, isWriteFile]); }, [filepathFromProps, isWriteFileVirtual]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath);
}, [filepath, isWriteFileVirtual]);
const isSkillFile = useMemo(() => { const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill"); return filepath.endsWith(".skill");
}, [filepath]); }, [filepath]);
const { isCodeFile, language } = useMemo(() => { const { isCodeFile, language } = useMemo(() => {
if (isWriteFile) { if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language; let nextLanguage = checkCodeFile(filepath).language;
nextLanguage ??= "text"; nextLanguage ??= "text";
return { isCodeFile: true, language: nextLanguage }; return { isCodeFile: true, language: nextLanguage };
@@ -119,18 +129,21 @@ export function ArtifactFileDetail({
return { isCodeFile: true, language: "markdown" }; return { isCodeFile: true, language: "markdown" };
} }
return checkCodeFile(filepath); return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]); }, [filepath, useWriteFileStreaming, isSkillFile]);
const isSupportPreview = useMemo(() => { const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown"; return language === "html" || language === "markdown";
}, [language]); }, [language]);
const { content, isApplyingEdit } = useArtifactContent({ const { content, isApplyingEdit } = useArtifactContent({
threadId, threadId,
filepath: filepathFromProps, filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile, enabled: isCodeFile && !useWriteFileStreaming,
}); });
const canEditArtifact = const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock; language === "markdown" &&
!useWriteFileStreaming &&
!isSkillFile &&
!isMock;
const [draftContent, setDraftContent] = useState(""); const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false); const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent(); const updateArtifact = useUpdateArtifactContent();
@@ -138,12 +151,13 @@ export function ArtifactFileDetail({
const isHtmlStreaming = language === "html" && thread.isLoading; const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => { const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (language === "html") return "HTML"; if (language === "html") return "HTML";
if (language === "markdown") return "MD"; if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL"; if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase(); if (language) return language.toUpperCase();
return "FILE"; return "FILE";
}, [isSkillFile, language]); }, [filepath, isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true); const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
@@ -453,7 +467,7 @@ export function ArtifactFileDetail({
{fileTypeLabel} {fileTypeLabel}
</span> </span>
<ArtifactTitle className="min-w-0"> <ArtifactTitle className="min-w-0">
{isWriteFile ? ( {useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div> <div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? ( ) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}> <Select value={filepath} onValueChange={select}>
@@ -530,7 +544,7 @@ export function ArtifactFileDetail({
onClick={handleSaveArtifact} onClick={handleSaveArtifact}
/> />
)} )}
{!isWriteFile && filepath.endsWith(".skill") && ( {!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}> <Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction <ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon} icon={isInstalling ? LoaderIcon : PackageIcon}
@@ -549,7 +563,7 @@ export function ArtifactFileDetail({
label={t.common.openInNewWindow} label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow} tooltip={t.common.openInNewWindow}
onClick={async () => { onClick={async () => {
if (isWriteFile) { if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab(); handleOpenWriteFileInNewTab();
return; return;
} }
@@ -587,7 +601,7 @@ export function ArtifactFileDetail({
tooltip={t.clipboard.copyToClipboard} tooltip={t.clipboard.copyToClipboard}
/> />
)} )}
{!isWriteFile && language === "markdown" && ( {!useWriteFileStreaming && language === "markdown" && (
<ArtifactAction <ArtifactAction
icon={isExportingWord ? LoaderIcon : FileType2Icon} icon={isExportingWord ? LoaderIcon : FileType2Icon}
label="另存为 Word" label="另存为 Word"
@@ -601,7 +615,7 @@ export function ArtifactFileDetail({
label={t.common.download} label={t.common.download}
tooltip={t.common.download} tooltip={t.common.download}
onClick={async () => { onClick={async () => {
if (isWriteFile) { if (useWriteFileStreaming) {
handleDownloadWriteFile(); handleDownloadWriteFile();
return; return;
} }
@@ -659,6 +673,8 @@ export function ArtifactFileDetail({
<ArtifactFilePreview <ArtifactFilePreview
content={desensitize(displayContent ?? "")} content={desensitize(displayContent ?? "")}
language={language ?? "text"} language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse} animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit} activeCursor={isApplyingEdit}
/> />
@@ -699,14 +715,57 @@ export function ArtifactFileDetail({
); );
} }
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
let url = src;
if (src.startsWith("/mnt/")) {
if (publicShareCode) {
const normalized = src.startsWith("/") ? src : `/${src}`;
url = `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
} else {
url = resolveArtifactURL(src, threadId);
}
}
return (
<img
{...props}
className={cn(
"my-3 max-w-full overflow-hidden rounded-lg",
props.className,
)}
src={url}
alt={alt}
/>
);
}
export function ArtifactFilePreview({ export function ArtifactFilePreview({
content, content,
language, language,
threadId,
publicShareCode,
animated = false, animated = false,
activeCursor = false, activeCursor = false,
}: { }: {
content: string; content: string;
language: string; language: string;
threadId: string;
publicShareCode?: string;
animated?: boolean; animated?: boolean;
activeCursor?: boolean; activeCursor?: boolean;
}) { }) {
@@ -742,7 +801,16 @@ export function ArtifactFilePreview({
<Streamdown <Streamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
components={{ a: ArtifactLink }} components={{
a: ArtifactLink,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
/>
),
}}
> >
{content ?? ""} {content ?? ""}
</Streamdown> </Streamdown>
+6 -1
View File
@@ -60,7 +60,12 @@ export async function downloadArtifactFile({
filenameFromContentDisposition(cd) ?? getFileName(filepath); filenameFromContentDisposition(cd) ?? getFileName(filepath);
const blob = await res.blob(); const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob); const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const downloadBlob =
contentType && blob.type !== contentType
? new Blob([await blob.arrayBuffer()], { type: contentType })
: blob;
const objectUrl = URL.createObjectURL(downloadBlob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = objectUrl; a.href = objectUrl;
a.download = filename; a.download = filename;
+25 -8
View File
@@ -3,9 +3,24 @@ import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context"; import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader"; import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { updateArtifactContent } from "./api"; import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) { function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
if (!toolCallId) { if (!toolCallId) {
return false; return false;
@@ -70,16 +85,16 @@ export function useArtifactContent({
threadId: string; threadId: string;
enabled?: boolean; enabled?: boolean;
}) { }) {
const isWriteFile = useMemo(() => { const useWriteFileStreaming = useMemo(() => {
return filepath.startsWith("write-file:"); return isWriteFileStreamingUrl(filepath);
}, [filepath]); }, [filepath]);
const { thread, isMock, publicShareCode } = useThread(); const { thread, isMock, publicShareCode } = useThread();
const content = useMemo(() => { const content = useMemo(() => {
if (isWriteFile) { if (useWriteFileStreaming) {
return loadArtifactContentFromToolCall({ url: filepath, thread }); return loadArtifactContentFromToolCall({ url: filepath, thread });
} }
return null; return null;
}, [filepath, isWriteFile, thread]); }, [filepath, useWriteFileStreaming, thread]);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode], queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
@@ -92,7 +107,7 @@ export function useArtifactContent({
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
const pendingEdit = useMemo(() => { const pendingEdit = useMemo(() => {
if (isWriteFile) { if (useWriteFileStreaming) {
return null; return null;
} }
return applyPendingTextEdit({ return applyPendingTextEdit({
@@ -100,11 +115,13 @@ export function useArtifactContent({
filepath, filepath,
thread, thread,
}); });
}, [data?.content, filepath, isWriteFile, thread]); }, [data?.content, filepath, useWriteFileStreaming, thread]);
return { return {
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content), content: useWriteFileStreaming
url: isWriteFile ? undefined : data?.url, ? content
: (pendingEdit?.content ?? data?.content),
url: useWriteFileStreaming ? undefined : data?.url,
isLoading, isLoading,
isApplyingEdit: pendingEdit?.kind === "editing", isApplyingEdit: pendingEdit?.kind === "editing",
error, error,
+17
View File
@@ -151,6 +151,23 @@ export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase(); return filepath.split(".").pop()!.toLocaleLowerCase();
} }
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
"ico",
"tiff",
"heic",
]);
export function isImageFile(filepath: string): boolean {
return IMAGE_EXTENSIONS.has(getFileExtension(filepath));
}
export function checkCodeFile( export function checkCodeFile(
filepath: string, filepath: string,
): ):