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 0333ac41..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,17 +2,16 @@ import { ArrowDownIcon, - Code2Icon, - CopyIcon, - DownloadIcon, - EyeIcon, - LoaderIcon, - PackageIcon, - PencilIcon, - SaveIcon, - 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"; @@ -34,16 +33,16 @@ import { } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; -import { useQueryClient } from "@tanstack/react-query"; - 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 { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; @@ -118,33 +117,19 @@ export function ArtifactFileDetail({ const isSupportPreview = useMemo(() => { return language === "html" || language === "markdown"; }, [language]); - const { content } = useArtifactContent({ - threadId, - filepath: filepathFromProps, - enabled: isCodeFile && !isWriteFile, - }); - - const queryClient = useQueryClient(); - const [viewMode, setViewMode] = useState<"code" | "preview">("code"); - const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true); - const [isInstalling, setIsInstalling] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false); - const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); - const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false); - const [binaryPreviewUrl, setBinaryPreviewUrl] = useState(null); - const [editedContent, setEditedContent] = useState(null); - // Tracks last manually-saved content for write-file: artifacts (whose displayContent - // always reflects the original tool-call output, not the on-disk file after save). - const [savedContent, setSavedContent] = useState(null); - const artifactContentRef = useRef(null); - const resumeButtonTimeoutRef = useRef(null); - - const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? 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 canEdit = isCodeFile && !isSkillFile && !thread.isLoading; - const activeContent = editedContent ?? displayContent; - const isDirty = editedContent !== null && editedContent !== displayContent; const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const fileTypeLabel = useMemo(() => { if (language === "html") return "HTML"; @@ -154,12 +139,34 @@ export function ArtifactFileDetail({ return "FILE"; }, [isSkillFile, language]); + const [viewMode, setViewMode] = useState<"code" | "preview">("code"); + const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true); + const [isInstalling, setIsInstalling] = useState(false); + const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false); + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); + const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false); + const [binaryPreviewUrl, setBinaryPreviewUrl] = useState( + 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); setAutoScrollEnabled(true); setShowResumeAutoScroll(false); - setEditedContent(null); - setSavedContent(null); if (language === "html") { setViewMode(thread.isLoading ? "code" : "preview"); @@ -322,41 +329,7 @@ export function ArtifactFileDetail({ }, 1600); }, [scrollArtifactToBottom]); - const handleSave = useCallback(async () => { - if (!isDirty || isSaving) return; - setIsSaving(true); - try { - const pathWithoutLeadingSlash = filepath.replace(/^\//, ""); - const res = await apiFetch( - `${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: activeContent }), - }, - ); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.detail ?? "保存失败"); - } - if (isWriteFile) { - setSavedContent(activeContent); - } else { - await queryClient.invalidateQueries({ - queryKey: ["artifact", filepathFromProps, threadId], - }); - } - setEditedContent(null); - toast.success("已保存"); - } catch (error) { - console.error("Save artifact failed:", error); - toast.error(error instanceof Error ? error.message : "保存失败"); - } finally { - setIsSaving(false); - } - }, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]); - - const handleInstallSkill = useCallback(async () => { + const handleInstallSkill = useCallback(async () => { if (isInstalling) return; setIsInstalling(true); @@ -376,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 ( @@ -453,30 +452,19 @@ export function ArtifactFileDetail({ )} -
- {canEdit && viewMode === "code" && !isDirty && ( - - - 可编辑 - - )} - {canEdit && viewMode === "code" && isDirty && ( - - 未保存 - - )} - - {canEdit && viewMode === "code" && ( - - )} - {!isWriteFile && filepath.endsWith(".skill") && ( - +
+ + {canEditArtifact && ( + + )} + {!isWriteFile && filepath.endsWith(".skill") && ( +
- - {showResumeAutoScroll && ( -