Merge remote-tracking branch 'origin/likun' into ji
# Conflicts: # frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx # frontend-web/src/components/workspace/code-editor.tsx # frontend-web/src/components/workspace/input-box.tsx # frontend-web/src/pages/ChatPage.tsx # offline-backend-20260512/backend/app/gateway/routers/artifacts.py
This commit is contained in:
commit
33161c5bad
307 files changed
+105201
-278
No files matched your search
@@ -2,17 +2,16 @@
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
PencilIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
@@ -34,16 +33,16 @@ 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,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
} 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";
|
||||
@@ -118,33 +117,19 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const { content } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
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 { content, isApplyingEdit } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const canEditArtifact =
|
||||
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (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";
|
||||
@@ -154,12 +139,34 @@ 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(() => {
|
||||
setDraftContent(content ?? "");
|
||||
setHasLocalEdits(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditArtifact || hasLocalEdits) {
|
||||
return;
|
||||
}
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, hasLocalEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setEditedContent(null);
|
||||
setSavedContent(null);
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
@@ -322,41 +329,7 @@ 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 () => {
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
@@ -376,7 +349,33 @@ export function ArtifactFileDetail({
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
if (!canEditArtifact || updateArtifact.isPending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateArtifact.mutateAsync({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
content: draftContent,
|
||||
});
|
||||
setHasLocalEdits(false);
|
||||
toast.success("Markdown 已保存");
|
||||
} catch (error) {
|
||||
console.error("Failed to save artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
|
||||
}
|
||||
}, [
|
||||
canEditArtifact,
|
||||
draftContent,
|
||||
filepath,
|
||||
isMock,
|
||||
threadId,
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
@@ -453,30 +452,19 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
)}
|
||||
</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}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||
label="保存 Markdown"
|
||||
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||||
disabled={!hasLocalEdits || updateArtifact.isPending}
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
label={t.common.install}
|
||||
@@ -562,13 +550,19 @@ export function ArtifactFileDetail({
|
||||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{isApplyingEdit && (
|
||||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
正在改写 Markdown
|
||||
</div>
|
||||
)}
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResumeAutoScroll}
|
||||
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
|
||||
@@ -580,30 +574,31 @@ export function ArtifactFileDetail({
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={activeContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
/>
|
||||
)}
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<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={activeContent}
|
||||
readonly={!canEdit}
|
||||
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isCodeFile &&
|
||||
(binaryPreviewUrl ? (
|
||||
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
||||
@@ -618,15 +613,17 @@ export function ArtifactFileDetail({
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
}) {
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -657,12 +654,15 @@ export function ArtifactFilePreview({
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
return (
|
||||
|
||||
@@ -38,20 +38,22 @@ export function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
disabled,
|
||||
autoFocus,
|
||||
onChange,
|
||||
settings,
|
||||
streamingReadonlyFallback = true,
|
||||
}: {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
settings?: unknown;
|
||||
streamingReadonlyFallback?: boolean;
|
||||
}) {
|
||||
const {
|
||||
thread: { isLoading },
|
||||
@@ -79,7 +81,7 @@ export function CodeEditor({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
@@ -129,6 +130,7 @@ export function InputBox({
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -141,6 +143,7 @@ export function InputBox({
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -263,6 +266,13 @@ export function InputBox({
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
writing_mode: !context.writing_mode,
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -541,6 +551,26 @@ export function InputBox({
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
|
||||
<Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none",
|
||||
context.writing_mode
|
||||
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
|
||||
)}
|
||||
onClick={handleWritingModeToggle}
|
||||
>
|
||||
<FileTextIcon className="size-3" />
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
|
||||
@@ -329,41 +329,43 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
const url = new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString();
|
||||
if (selectedArtifact === url) {
|
||||
return;
|
||||
}
|
||||
select(url, true);
|
||||
setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
const artifactPath =
|
||||
name === "write_file" && path
|
||||
? new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString()
|
||||
: path;
|
||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
if (!artifactPath || selectedArtifact === artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath, true);
|
||||
setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
select(
|
||||
new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString(),
|
||||
);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import { urlOfArtifact } from "./utils";
|
||||
|
||||
async function errorMessageFromResponse(res: Response): Promise<string> {
|
||||
try {
|
||||
const data: unknown = await res.json();
|
||||
if (data && typeof data === "object" && "detail" in data) {
|
||||
const detail = (data as { detail: unknown }).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-json errors
|
||||
}
|
||||
return res.statusText || "Request failed";
|
||||
}
|
||||
|
||||
export async function updateArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
content,
|
||||
isMock,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
content: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
const res = await apiFetch(urlOfArtifact({ filepath, threadId, isMock }), {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
@@ -1,9 +1,65 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
}
|
||||
return thread.messages.some(
|
||||
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
function applyPendingTextEdit({
|
||||
content,
|
||||
filepath,
|
||||
thread,
|
||||
}: {
|
||||
content: string | undefined;
|
||||
filepath: string;
|
||||
thread: ReturnType<typeof useThread>["thread"];
|
||||
}) {
|
||||
if (!content || !thread.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const message of [...thread.messages].reverse()) {
|
||||
if (message.type !== "ai" || !message.tool_calls) {
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of [...message.tool_calls].reverse()) {
|
||||
if (toolCall.name !== "str_replace" || hasToolResult(thread, toolCall.id)) {
|
||||
continue;
|
||||
}
|
||||
const args = toolCall.args as {
|
||||
path?: unknown;
|
||||
old_str?: unknown;
|
||||
new_str?: unknown;
|
||||
replace_all?: unknown;
|
||||
};
|
||||
if (args.path !== filepath) {
|
||||
continue;
|
||||
}
|
||||
if (typeof args.old_str !== "string" || typeof args.new_str !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (!content.includes(args.old_str)) {
|
||||
return { content, kind: "editing" as const };
|
||||
}
|
||||
const nextContent =
|
||||
args.replace_all === true
|
||||
? content.split(args.old_str).join(args.new_str)
|
||||
: content.replace(args.old_str, args.new_str);
|
||||
return { content: nextContent, kind: "editing" as const };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
@@ -25,19 +81,48 @@ export function useArtifactContent({
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock });
|
||||
},
|
||||
enabled,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return {
|
||||
content: isWriteFile ? content : data?.content,
|
||||
url: isWriteFile ? undefined : data?.url,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
enabled,
|
||||
refetchInterval: thread.isLoading ? 1000 : false,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const pendingEdit = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return null;
|
||||
}
|
||||
return applyPendingTextEdit({
|
||||
content: data?.content,
|
||||
filepath,
|
||||
thread,
|
||||
});
|
||||
}, [data?.content, filepath, isWriteFile, thread]);
|
||||
|
||||
return {
|
||||
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
|
||||
url: isWriteFile ? undefined : data?.url,
|
||||
isLoading,
|
||||
isApplyingEdit: pendingEdit?.kind === "editing",
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateArtifactContent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateArtifactContent,
|
||||
onSuccess: (content, variables) => {
|
||||
queryClient.setQueryData(
|
||||
["artifact", variables.filepath, variables.threadId, variables.isMock],
|
||||
{
|
||||
content,
|
||||
url: undefined,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./download";
|
||||
export * from "./loader";
|
||||
export * from "./api";
|
||||
export * from "./download";
|
||||
export * from "./loader";
|
||||
@@ -8,11 +8,12 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
streamSpeedMeterEnabled: false,
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
},
|
||||
};
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
|
||||
@@ -14,9 +14,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
model_name: string | undefined;
|
||||
thinking_enabled: boolean;
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
agent_id?: string;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
@@ -44,6 +44,7 @@ export default function ChatPage() {
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -91,6 +92,17 @@ export default function ChatPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||||
if (isMarkdown(selectedArtifact)) {
|
||||
return selectedArtifact ?? undefined;
|
||||
}
|
||||
return [...(thread.values.artifacts ?? [])]
|
||||
.reverse()
|
||||
.find((path) => isMarkdown(path));
|
||||
}, [selectedArtifact, thread.values.artifacts]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
// Only the opening turn of a brand-new thread may carry the prefix.
|
||||
@@ -106,14 +118,31 @@ export default function ChatPage() {
|
||||
// The prefix is intentionally NOT concatenated into the message text:
|
||||
// doing so would make the user see it. We only flag the request via
|
||||
// context; the backend tags the message and PromptPrefixMiddleware
|
||||
// injects the prefix into the LLM request only.
|
||||
// injects the prefix into the LLM request only. Writing mode adds the
|
||||
// active markdown artifact path so the agent keeps editing the canvas.
|
||||
const extraContext: Record<string, unknown> = {};
|
||||
if (shouldApplyPrefix) {
|
||||
extraContext.prompt_prefix_enabled = true;
|
||||
}
|
||||
if (settings.context.writing_mode && writingArtifactPath) {
|
||||
extraContext.writing_artifact_path = writingArtifactPath;
|
||||
}
|
||||
void sendMessage(
|
||||
threadId,
|
||||
message,
|
||||
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
|
||||
Object.keys(extraContext).length > 0 ? extraContext : undefined,
|
||||
);
|
||||
},
|
||||
[sendMessage, threadId, isNewThread, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
|
||||
[
|
||||
sendMessage,
|
||||
threadId,
|
||||
isNewThread,
|
||||
hasStartedConversation,
|
||||
promptPrefixOn,
|
||||
promptPrefixSettings,
|
||||
settings.context.writing_mode,
|
||||
writingArtifactPath,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
|
||||
Reference in new issue
Block a user