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 eea8d2a3..0333ac41 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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(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 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( - null, - ); - const artifactContentRef = useRef(null); - const resumeButtonTimeoutRef = useRef(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({ )}
+ {canEdit && viewMode === "code" && !isDirty && ( + + + 可编辑 + + )} + {canEdit && viewMode === "code" && isDirty && ( + + 未保存 + + )} + {canEdit && viewMode === "code" && ( + + )} {!isWriteFile && filepath.endsWith(".skill") && ( )} {isCodeFile && viewMode === "code" && ( -
+
{ + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault(); + handleSave(); + } + }} + > setEditedContent(v) : undefined} />
)} diff --git a/frontend-web/src/components/workspace/code-editor.tsx b/frontend-web/src/components/workspace/code-editor.tsx index d6188ae0..81534d0c 100644 --- a/frontend-web/src/components/workspace/code-editor.tsx +++ b/frontend-web/src/components/workspace/code-editor.tsx @@ -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} /> )}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py index 54d54de6..4ccc4d01 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py @@ -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} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py index e6802079..c13ad1fd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py @@ -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()}, )