沙箱内容可保存

This commit is contained in:
董宏凯 committed 2026-05-14 22:12:20 +08:00
1 parent 4e0787ec77
commit 044370f13c
4 files changed
+142 -18

No files matched your search

@@ -8,6 +8,8 @@ import {
EyeIcon, EyeIcon,
LoaderIcon, LoaderIcon,
PackageIcon, PackageIcon,
PencilIcon,
SaveIcon,
SquareArrowOutUpRightIcon, SquareArrowOutUpRightIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
@@ -32,6 +34,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor"; import { CodeEditor } from "@/components/workspace/code-editor";
import { useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { import {
downloadArtifactFile, downloadArtifactFile,
@@ -39,6 +43,7 @@ import {
} from "@/core/artifacts/download"; } from "@/core/artifacts/download";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
@@ -119,8 +124,27 @@ export function ArtifactFileDetail({
enabled: isCodeFile && !isWriteFile, 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 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 hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => { const fileTypeLabel = useMemo(() => {
if (language === "html") return "HTML"; if (language === "html") return "HTML";
@@ -130,22 +154,12 @@ export function ArtifactFileDetail({
return "FILE"; return "FILE";
}, [isSkillFile, language]); }, [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(() => { useEffect(() => {
setAllowAutoViewSwitch(true); setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true); setAutoScrollEnabled(true);
setShowResumeAutoScroll(false); setShowResumeAutoScroll(false);
setEditedContent(null);
setSavedContent(null);
if (language === "html") { if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview"); setViewMode(thread.isLoading ? "code" : "preview");
@@ -308,6 +322,40 @@ export function ArtifactFileDetail({
}, 1600); }, 1600);
}, [scrollArtifactToBottom]); }, [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; if (isInstalling) return;
@@ -406,7 +454,27 @@ export function ArtifactFileDetail({
)} )}
</div> </div>
<div className="flex items-center justify-end gap-2"> <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"> <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") && ( {!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}> <Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction <ArtifactAction
@@ -513,17 +581,26 @@ export function ArtifactFileDetail({
viewMode === "preview" && viewMode === "preview" &&
(language === "markdown" || language === "html") && ( (language === "markdown" || language === "html") && (
<ArtifactFilePreview <ArtifactFilePreview
content={displayContent} content={activeContent}
language={language ?? "text"} language={language ?? "text"}
animated={showPreviewSwitchPulse} animated={showPreviewSwitchPulse}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {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 <CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent" className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""} value={activeContent}
readonly readonly={!canEdit}
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
/> />
</div> </div>
)} )}
@@ -41,6 +41,7 @@ export function CodeEditor({
readonly, readonly,
disabled, disabled,
autoFocus, autoFocus,
onChange,
settings, settings,
}: { }: {
className?: string; className?: string;
@@ -49,6 +50,7 @@ export function CodeEditor({
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
onChange?: (value: string) => void;
settings?: unknown; settings?: unknown;
}) { }) {
const { const {
@@ -107,6 +109,7 @@ export function CodeEditor({
}} }}
autoFocus={autoFocus} autoFocus={autoFocus}
value={value} value={value}
onChange={onChange}
/> />
)} )}
</div> </div>
@@ -6,6 +6,7 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response from fastapi.responses import FileResponse, PlainTextResponse, Response
from pydantic import BaseModel
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.path_utils import resolve_thread_virtual_path from app.gateway.path_utils import resolve_thread_virtual_path
@@ -181,3 +182,46 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type) return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)}) return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
class ArtifactUpdateRequest(BaseModel):
content: str
@router.put(
"/threads/{thread_id}/artifacts/{path:path}",
summary="Update Artifact File",
description="Update the content of an artifact file.",
)
@require_permission("threads", "write", owner_check=True)
async def update_artifact(thread_id: str, path: str, request: Request, body: ArtifactUpdateRequest) -> dict:
"""Update an artifact file's content.
Args:
thread_id: The thread ID.
path: The artifact path (e.g. mnt/user-data/outputs/index.html).
body: Request body containing the new file content.
Returns:
{"ok": true} on success.
Raises:
HTTPException:
- 400 if path is invalid, not a file, or is a .skill archive
- 403 if access denied
- 404 if file not found
"""
if ".skill/" in path or path.endswith(".skill"):
raise HTTPException(status_code=400, detail="Cannot edit .skill archives")
actual_path = resolve_thread_virtual_path(thread_id, path)
if not actual_path.exists():
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
if not actual_path.is_file():
raise HTTPException(status_code=400, detail=f"Path is not a file: {path}")
actual_path.write_text(body.content, encoding="utf-8")
logger.info(f"Updated artifact: thread_id={thread_id}, path={path}")
return {"ok": True}
@@ -106,7 +106,7 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
messages[-1] = HumanMessage( messages[-1] = HumanMessage(
content=last_message.content, content=last_message.content,
id=last_message.id, id=last_message.id,
name=last_message.name or "user-input", name=last_message.name,
additional_kwargs={**last_message.additional_kwargs, "run_id": runtime.context.get("run_id"), "timestamp": datetime.now(UTC).isoformat()}, additional_kwargs={**last_message.additional_kwargs, "run_id": runtime.context.get("run_id"), "timestamp": datetime.now(UTC).isoformat()},
) )