沙箱内容可保存
This commit is contained in:
1 parent
4e0787ec77
commit
044370f13c
4 files changed
+142
-18
No files matched your search
@@ -8,6 +8,8 @@ import {
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
PencilIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
@@ -32,6 +34,8 @@ 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,
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
} from "@/core/artifacts/download";
|
||||
import { useArtifactContent } 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";
|
||||
@@ -119,8 +124,27 @@ export function ArtifactFileDetail({
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const displayContent = content ?? "";
|
||||
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<string | null>(null);
|
||||
const [editedContent, setEditedContent] = useState<string | null>(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<string | null>(null);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? 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";
|
||||
@@ -130,22 +154,12 @@ 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<string | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setEditedContent(null);
|
||||
setSavedContent(null);
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
@@ -308,6 +322,40 @@ 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 () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
@@ -406,7 +454,27 @@ export function ArtifactFileDetail({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{canEdit && viewMode === "code" && !isDirty && (
|
||||
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
|
||||
<PencilIcon className="size-3" />
|
||||
可编辑
|
||||
</span>
|
||||
)}
|
||||
{canEdit && viewMode === "code" && isDirty && (
|
||||
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
|
||||
未保存
|
||||
</span>
|
||||
)}
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEdit && viewMode === "code" && (
|
||||
<ArtifactAction
|
||||
icon={isSaving ? LoaderIcon : SaveIcon}
|
||||
label="保存"
|
||||
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
|
||||
disabled={isSaving || !isDirty}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
@@ -513,17 +581,26 @@ export function ArtifactFileDetail({
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
content={activeContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<div className="size-full">
|
||||
<div
|
||||
className="size-full"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly
|
||||
value={activeContent}
|
||||
readonly={!canEdit}
|
||||
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,6 +41,7 @@ export function CodeEditor({
|
||||
readonly,
|
||||
disabled,
|
||||
autoFocus,
|
||||
onChange,
|
||||
settings,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -49,6 +50,7 @@ export function CodeEditor({
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
settings?: unknown;
|
||||
}) {
|
||||
const {
|
||||
@@ -107,6 +109,7 @@ export function CodeEditor({
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user