沙箱内容可保存
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>
|
||||
|
||||
@@ -6,6 +6,7 @@ from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
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 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}
|
||||
+1
-1
@@ -106,7 +106,7 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
messages[-1] = HumanMessage(
|
||||
content=last_message.content,
|
||||
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()},
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user