diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index eea8d2a3..c45a68a6 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -2,15 +2,16 @@ import { ArrowDownIcon, - Code2Icon, - CopyIcon, - DownloadIcon, - EyeIcon, - LoaderIcon, - PackageIcon, - SquareArrowOutUpRightIcon, - XIcon, -} from "lucide-react"; + Code2Icon, + CopyIcon, + DownloadIcon, + EyeIcon, + LoaderIcon, + PackageIcon, + SaveIcon, + SquareArrowOutUpRightIcon, + XIcon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -33,11 +34,14 @@ import { import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { apiFetch } from "@/core/api"; -import { - downloadArtifactFile, - openArtifactInNewTab, -} from "@/core/artifacts/download"; -import { useArtifactContent } from "@/core/artifacts/hooks"; +import { + downloadArtifactFile, + openArtifactInNewTab, +} from "@/core/artifacts/download"; +import { + useArtifactContent, + useUpdateArtifactContent, +} from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { installSkill } from "@/core/skills/api"; @@ -113,13 +117,18 @@ export function ArtifactFileDetail({ const isSupportPreview = useMemo(() => { return language === "html" || language === "markdown"; }, [language]); - const { content } = useArtifactContent({ - threadId, - filepath: filepathFromProps, - enabled: isCodeFile && !isWriteFile, - }); - - const displayContent = content ?? ""; + const { content, isApplyingEdit } = useArtifactContent({ + threadId, + filepath: filepathFromProps, + enabled: isCodeFile && !isWriteFile, + }); + + const canEditArtifact = + language === "markdown" && !isWriteFile && !isSkillFile && !isMock; + const [draftContent, setDraftContent] = useState(""); + const [hasLocalEdits, setHasLocalEdits] = useState(false); + const updateArtifact = useUpdateArtifactContent(); + const displayContent = canEditArtifact ? draftContent : (content ?? ""); const isHtmlStreaming = language === "html" && thread.isLoading; const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const fileTypeLabel = useMemo(() => { @@ -139,8 +148,20 @@ export function ArtifactFileDetail({ const [binaryPreviewUrl, setBinaryPreviewUrl] = useState( null, ); - const artifactContentRef = useRef(null); - const resumeButtonTimeoutRef = useRef(null); + const artifactContentRef = useRef(null); + const resumeButtonTimeoutRef = useRef(null); + + useEffect(() => { + setDraftContent(content ?? ""); + setHasLocalEdits(false); + }, [filepathFromProps]); + + useEffect(() => { + if (!canEditArtifact || hasLocalEdits) { + return; + } + setDraftContent(content ?? ""); + }, [canEditArtifact, content, hasLocalEdits]); useEffect(() => { setAllowAutoViewSwitch(true); @@ -308,7 +329,7 @@ export function ArtifactFileDetail({ }, 1600); }, [scrollArtifactToBottom]); - const handleInstallSkill = useCallback(async () => { + const handleInstallSkill = useCallback(async () => { if (isInstalling) return; setIsInstalling(true); @@ -328,7 +349,33 @@ export function ArtifactFileDetail({ } finally { setIsInstalling(false); } - }, [threadId, filepath, isInstalling]); + }, [threadId, filepath, isInstalling]); + + const handleSaveArtifact = useCallback(async () => { + if (!canEditArtifact || updateArtifact.isPending) { + return; + } + try { + await updateArtifact.mutateAsync({ + filepath, + threadId, + isMock, + content: draftContent, + }); + setHasLocalEdits(false); + toast.success("Markdown 已保存"); + } catch (error) { + console.error("Failed to save artifact:", error); + toast.error(error instanceof Error ? error.message : "Markdown 保存失败"); + } + }, [ + canEditArtifact, + draftContent, + filepath, + isMock, + threadId, + updateArtifact, + ]); return ( @@ -405,10 +452,19 @@ export function ArtifactFileDetail({ )} -
- - {!isWriteFile && filepath.endsWith(".skill") && ( - +
+ + {canEditArtifact && ( + + )} + {!isWriteFile && filepath.endsWith(".skill") && ( +
- - {showResumeAutoScroll && ( -
+ )} {!isCodeFile && (binaryPreviewUrl ? (