下载图片
This commit is contained in:
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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in new issue
Block a user