沙箱内容可保存
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,
|
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}
|
||||||
+1
-1
@@ -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()},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user