feat: 新增写作画布系统
This commit is contained in:
1 parent
d533ddde88
commit
de9f6b6711
14 files changed
+580
-217
No files matched your search
@@ -2,15 +2,16 @@
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
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";
|
||||
@@ -33,11 +34,14 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
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 { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
@@ -113,13 +117,18 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const { content } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const displayContent = 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 hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
@@ -139,8 +148,20 @@ export function ArtifactFileDetail({
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | 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);
|
||||
@@ -308,7 +329,7 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
@@ -328,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)}>
|
||||
@@ -405,10 +452,19 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
{!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}
|
||||
@@ -494,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"
|
||||
@@ -512,21 +574,31 @@ export function ArtifactFileDetail({
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
/>
|
||||
)}
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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} />
|
||||
@@ -541,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(() => {
|
||||
@@ -580,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 (
|
||||
|
||||
@@ -34,23 +34,27 @@ const customLightTheme = basicLightInit({
|
||||
},
|
||||
});
|
||||
|
||||
export function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
readonly,
|
||||
disabled,
|
||||
autoFocus,
|
||||
settings,
|
||||
}: {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
settings?: unknown;
|
||||
}) {
|
||||
export function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
disabled,
|
||||
autoFocus,
|
||||
settings,
|
||||
streamingReadonlyFallback = true,
|
||||
}: {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
settings?: unknown;
|
||||
streamingReadonlyFallback?: boolean;
|
||||
}) {
|
||||
const {
|
||||
thread: { isLoading },
|
||||
} = useThread();
|
||||
@@ -77,8 +81,8 @@ export function CodeEditor({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Textarea
|
||||
{isLoading && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
"resize-none p-4! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
|
||||
@@ -105,10 +109,11 @@ export function CodeEditor({
|
||||
lineNumbers:
|
||||
(settings as { lineNumbers?: boolean })?.lineNumbers ?? false,
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
GraduationCapIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
@@ -122,9 +123,10 @@ export function InputBox({
|
||||
AgentThreadContext,
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
};
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
threadId: string;
|
||||
@@ -134,10 +136,11 @@ export function InputBox({
|
||||
AgentThreadContext,
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
},
|
||||
) => void;
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
onStop?: () => void;
|
||||
@@ -236,15 +239,22 @@ export function InputBox({
|
||||
[onContextChange, context, supportThinking],
|
||||
);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
reasoning_effort: effort,
|
||||
});
|
||||
},
|
||||
[onContextChange, context],
|
||||
);
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
writing_mode: !context.writing_mode,
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
@@ -505,8 +515,28 @@ export function InputBox({
|
||||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
|
||||
<PromptInputActionMenu>
|
||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
|
||||
<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={
|
||||
context.mode === "flash" ||
|
||||
|
||||
@@ -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";
|
||||
@@ -5,11 +5,12 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
enabled: true,
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
@@ -37,9 +37,10 @@ export default function ChatPage() {
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
useSpecificChatMode();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -53,7 +54,7 @@ export default function ChatPage() {
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
@@ -83,17 +84,34 @@ export default function ChatPage() {
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
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) => {
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(
|
||||
threadId,
|
||||
message,
|
||||
settings.context.writing_mode && writingArtifactPath
|
||||
? { writing_artifact_path: writingArtifactPath }
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
[sendMessage, settings.context.writing_mode, threadId, writingArtifactPath],
|
||||
);
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
|
||||
@@ -4,21 +4,38 @@ import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["artifacts"])
|
||||
router = APIRouter(prefix="/api", tags=["artifacts"])
|
||||
|
||||
ACTIVE_CONTENT_MIME_TYPES = {
|
||||
ACTIVE_CONTENT_MIME_TYPES = {
|
||||
"text/html",
|
||||
"application/xhtml+xml",
|
||||
"image/svg+xml",
|
||||
}
|
||||
}
|
||||
|
||||
EDITABLE_TEXT_SUFFIXES = {
|
||||
".md",
|
||||
".markdown",
|
||||
".txt",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".csv",
|
||||
".xml",
|
||||
".log",
|
||||
}
|
||||
|
||||
|
||||
class ArtifactUpdateRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _build_content_disposition(disposition_type: str, filename: str) -> str:
|
||||
@@ -180,4 +197,45 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
|
||||
if is_text_file_by_content(actual_path):
|
||||
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)})
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/threads/{thread_id}/artifacts/{path:path}",
|
||||
summary="Update Artifact File",
|
||||
description="Update a text artifact file in the current thread workspace.",
|
||||
)
|
||||
@require_permission("threads", "write", owner_check=True)
|
||||
async def update_artifact(
|
||||
thread_id: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
body: ArtifactUpdateRequest,
|
||||
) -> Response:
|
||||
"""Update a text artifact.
|
||||
|
||||
This endpoint is intentionally limited to normal text artifacts. Files inside
|
||||
.skill archives and active web content remain read-only from the artifact UI.
|
||||
"""
|
||||
if ".skill/" in path:
|
||||
raise HTTPException(status_code=400, detail="Skill archive contents are read-only.")
|
||||
|
||||
actual_path = resolve_thread_virtual_path(thread_id, path)
|
||||
logger.info(f"Updating artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_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}")
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(actual_path)
|
||||
suffix = actual_path.suffix.lower()
|
||||
if mime_type in ACTIVE_CONTENT_MIME_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Active web artifacts are read-only.")
|
||||
|
||||
if suffix not in EDITABLE_TEXT_SUFFIXES and not (mime_type and mime_type.startswith("text/")):
|
||||
raise HTTPException(status_code=400, detail="Only text artifacts can be edited.")
|
||||
|
||||
actual_path.write_text(body.content, encoding="utf-8")
|
||||
return PlainTextResponse(content=body.content, media_type=mime_type or "text/plain")
|
||||
@@ -110,8 +110,10 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"model_name",
|
||||
"mode",
|
||||
"thinking_enabled",
|
||||
"reasoning_effort",
|
||||
"is_plan_mode",
|
||||
"reasoning_effort",
|
||||
"writing_mode",
|
||||
"writing_artifact_path",
|
||||
"is_plan_mode",
|
||||
"subagent_enabled",
|
||||
"max_concurrent_subagents",
|
||||
"agent_name",
|
||||
|
||||
+31
-20
@@ -328,9 +328,13 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
writing_mode = bool(cfg.get("writing_mode", False))
|
||||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||
writing_artifact_path = None
|
||||
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
||||
|
||||
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
|
||||
agent_display_name = agent_config.name if agent_config else agent_id
|
||||
@@ -349,14 +353,15 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
thinking_enabled = False
|
||||
|
||||
logger.info(
|
||||
"Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s",
|
||||
agent_id or "default",
|
||||
thinking_enabled,
|
||||
reasoning_effort,
|
||||
model_name,
|
||||
is_plan_mode,
|
||||
subagent_enabled,
|
||||
max_concurrent_subagents,
|
||||
"Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s, writing_mode: %s",
|
||||
agent_id or "default",
|
||||
thinking_enabled,
|
||||
reasoning_effort,
|
||||
model_name,
|
||||
is_plan_mode,
|
||||
subagent_enabled,
|
||||
max_concurrent_subagents,
|
||||
writing_mode,
|
||||
)
|
||||
|
||||
# Inject run metadata for LangSmith trace tagging
|
||||
@@ -380,8 +385,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"is_plan_mode": is_plan_mode,
|
||||
"subagent_enabled": subagent_enabled,
|
||||
"tool_groups": agent_config.tool_groups if agent_config else None,
|
||||
"subagent_enabled": subagent_enabled,
|
||||
"writing_mode": writing_mode,
|
||||
"writing_artifact_path": writing_artifact_path,
|
||||
"tool_groups": agent_config.tool_groups if agent_config else None,
|
||||
"available_skills": runtime_available_skills,
|
||||
}
|
||||
)
|
||||
@@ -400,9 +407,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
available_skills=set(["bootstrap"]),
|
||||
app_config=resolved_app_config,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
writing_mode=writing_mode,
|
||||
writing_artifact_path=writing_artifact_path,
|
||||
available_skills=set(["bootstrap"]),
|
||||
app_config=resolved_app_config,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
@@ -421,10 +430,12 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_display_name,
|
||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||
app_config=resolved_app_config,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_display_name,
|
||||
writing_mode=writing_mode,
|
||||
writing_artifact_path=writing_artifact_path,
|
||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||
app_config=resolved_app_config,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
+47
-16
@@ -472,9 +472,11 @@ You: "Deploying to staging..." [proceed]
|
||||
|
||||
{skills_section}
|
||||
|
||||
{deferred_tools_section}
|
||||
|
||||
{subagent_section}
|
||||
{deferred_tools_section}
|
||||
|
||||
{writing_mode_section}
|
||||
|
||||
{subagent_section}
|
||||
|
||||
<working_directory existed="true">
|
||||
- User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context)
|
||||
@@ -787,15 +789,17 @@ def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str:
|
||||
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
||||
|
||||
|
||||
def apply_prompt_template(
|
||||
subagent_enabled: bool = False,
|
||||
max_concurrent_subagents: int = 3,
|
||||
*,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
) -> str:
|
||||
def apply_prompt_template(
|
||||
subagent_enabled: bool = False,
|
||||
max_concurrent_subagents: int = 3,
|
||||
*,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
writing_mode: bool = False,
|
||||
writing_artifact_path: str | None = None,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
) -> str:
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
|
||||
# Get memory context
|
||||
@@ -827,7 +831,33 @@ def apply_prompt_template(
|
||||
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
||||
|
||||
# Get deferred tools section (tool_search)
|
||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||
current_writing_artifact = (
|
||||
f"\n**Current Markdown Artifact**\n- Continue editing this existing Markdown file unless the user explicitly asks for a new document: `{writing_artifact_path}`\n"
|
||||
if writing_artifact_path
|
||||
else ""
|
||||
)
|
||||
writing_mode_section = (
|
||||
f"""
|
||||
<writing_mode>
|
||||
Writing mode is enabled for this run.
|
||||
|
||||
**Mandatory Output Contract**
|
||||
- Treat the user's request as a writing/report-generation task.
|
||||
- Your final deliverable MUST be a Markdown file, not only an inline chat answer.
|
||||
- If a current Markdown artifact is provided below, you MUST revise that existing file rather than creating a new file, unless the user explicitly asks for a separate/new document.
|
||||
- When revising an existing Markdown artifact, read the file first if needed, then overwrite the same path with the complete updated Markdown content.
|
||||
- Write the deliverable to `/mnt/user-data/outputs/` with a clear `.md` filename, for example `/mnt/user-data/outputs/report.md`.
|
||||
- Use `write_file` or an equivalent file-writing tool to save the complete Markdown content.
|
||||
- After writing the file, call `present_files` with the Markdown file path so the user can open it as an artifact/canvas.
|
||||
- The final chat response should be brief and point to the generated Markdown artifact. Do not duplicate the full report inline unless the user explicitly asks.
|
||||
- If you revise an existing Markdown artifact, update the Markdown file and present it again.
|
||||
{current_writing_artifact}
|
||||
</writing_mode>
|
||||
"""
|
||||
if writing_mode
|
||||
else ""
|
||||
)
|
||||
|
||||
# Build ACP agent section only if ACP agents are configured
|
||||
acp_section = _build_acp_section(app_config=app_config)
|
||||
@@ -841,9 +871,10 @@ def apply_prompt_template(
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
agent_name=agent_name or storage_agent_id or "DeerFlow 2.0",
|
||||
soul=get_agent_soul(storage_agent_id),
|
||||
skills_section=skills_section,
|
||||
deferred_tools_section=deferred_tools_section,
|
||||
memory_context=memory_context,
|
||||
skills_section=skills_section,
|
||||
deferred_tools_section=deferred_tools_section,
|
||||
writing_mode_section=writing_mode_section,
|
||||
memory_context=memory_context,
|
||||
network_policy_section=network_policy_section,
|
||||
subagent_section=subagent_section,
|
||||
subagent_reminder=subagent_reminder,
|
||||
|
||||
Reference in new issue
Block a user