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 {
|
import {
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
Code2Icon,
|
Code2Icon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
SquareArrowOutUpRightIcon,
|
SaveIcon,
|
||||||
XIcon,
|
SquareArrowOutUpRightIcon,
|
||||||
} from "lucide-react";
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
@@ -33,11 +34,14 @@ import {
|
|||||||
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 { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
import {
|
import {
|
||||||
downloadArtifactFile,
|
downloadArtifactFile,
|
||||||
openArtifactInNewTab,
|
openArtifactInNewTab,
|
||||||
} from "@/core/artifacts/download";
|
} from "@/core/artifacts/download";
|
||||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
import {
|
||||||
|
useArtifactContent,
|
||||||
|
useUpdateArtifactContent,
|
||||||
|
} from "@/core/artifacts/hooks";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
@@ -113,13 +117,18 @@ export function ArtifactFileDetail({
|
|||||||
const isSupportPreview = useMemo(() => {
|
const isSupportPreview = useMemo(() => {
|
||||||
return language === "html" || language === "markdown";
|
return language === "html" || language === "markdown";
|
||||||
}, [language]);
|
}, [language]);
|
||||||
const { content } = useArtifactContent({
|
const { content, isApplyingEdit } = useArtifactContent({
|
||||||
threadId,
|
threadId,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
enabled: isCodeFile && !isWriteFile,
|
enabled: isCodeFile && !isWriteFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayContent = content ?? "";
|
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 isHtmlStreaming = language === "html" && thread.isLoading;
|
||||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||||
const fileTypeLabel = useMemo(() => {
|
const fileTypeLabel = useMemo(() => {
|
||||||
@@ -139,8 +148,20 @@ export function ArtifactFileDetail({
|
|||||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const resumeButtonTimeoutRef = useRef<number | 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(() => {
|
useEffect(() => {
|
||||||
setAllowAutoViewSwitch(true);
|
setAllowAutoViewSwitch(true);
|
||||||
@@ -308,7 +329,7 @@ export function ArtifactFileDetail({
|
|||||||
}, 1600);
|
}, 1600);
|
||||||
}, [scrollArtifactToBottom]);
|
}, [scrollArtifactToBottom]);
|
||||||
|
|
||||||
const handleInstallSkill = useCallback(async () => {
|
const handleInstallSkill = useCallback(async () => {
|
||||||
if (isInstalling) return;
|
if (isInstalling) return;
|
||||||
|
|
||||||
setIsInstalling(true);
|
setIsInstalling(true);
|
||||||
@@ -328,7 +349,33 @@ export function ArtifactFileDetail({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsInstalling(false);
|
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 (
|
return (
|
||||||
<Artifact className={cn(className)}>
|
<Artifact className={cn(className)}>
|
||||||
@@ -405,10 +452,19 @@ export function ArtifactFileDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<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">
|
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
{canEditArtifact && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<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
|
<ArtifactAction
|
||||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||||
label={t.common.install}
|
label={t.common.install}
|
||||||
@@ -494,13 +550,19 @@ export function ArtifactFileDetail({
|
|||||||
</ArtifactActions>
|
</ArtifactActions>
|
||||||
</div>
|
</div>
|
||||||
</ArtifactHeader>
|
</ArtifactHeader>
|
||||||
<ArtifactContent
|
<ArtifactContent
|
||||||
ref={artifactContentRef}
|
ref={artifactContentRef}
|
||||||
className="relative p-0"
|
className="relative p-0"
|
||||||
onWheelCapture={handlePauseAutoScroll}
|
onWheelCapture={handlePauseAutoScroll}
|
||||||
>
|
>
|
||||||
{showResumeAutoScroll && (
|
{isApplyingEdit && (
|
||||||
<button
|
<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"
|
type="button"
|
||||||
onClick={handleResumeAutoScroll}
|
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"
|
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 &&
|
{isSupportPreview &&
|
||||||
viewMode === "preview" &&
|
viewMode === "preview" &&
|
||||||
(language === "markdown" || language === "html") && (
|
(language === "markdown" || language === "html") && (
|
||||||
<ArtifactFilePreview
|
<ArtifactFilePreview
|
||||||
content={displayContent}
|
content={displayContent}
|
||||||
language={language ?? "text"}
|
language={language ?? "text"}
|
||||||
animated={showPreviewSwitchPulse}
|
animated={showPreviewSwitchPulse}
|
||||||
/>
|
activeCursor={isApplyingEdit}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
{isCodeFile && viewMode === "code" && (
|
{isCodeFile && viewMode === "code" && (
|
||||||
<div className="size-full">
|
<div className="size-full">
|
||||||
<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={displayContent ?? ""}
|
||||||
readonly
|
readonly={!canEditArtifact}
|
||||||
/>
|
onChange={
|
||||||
</div>
|
canEditArtifact
|
||||||
)}
|
? (value) => {
|
||||||
|
setDraftContent(value);
|
||||||
|
setHasLocalEdits(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
streamingReadonlyFallback={!canEditArtifact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isCodeFile &&
|
{!isCodeFile &&
|
||||||
(binaryPreviewUrl ? (
|
(binaryPreviewUrl ? (
|
||||||
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
||||||
@@ -541,15 +613,17 @@ export function ArtifactFileDetail({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtifactFilePreview({
|
export function ArtifactFilePreview({
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
animated = false,
|
animated = false,
|
||||||
}: {
|
activeCursor = false,
|
||||||
content: string;
|
}: {
|
||||||
language: string;
|
content: string;
|
||||||
animated?: boolean;
|
language: string;
|
||||||
}) {
|
animated?: boolean;
|
||||||
|
activeCursor?: boolean;
|
||||||
|
}) {
|
||||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -580,12 +654,15 @@ export function ArtifactFilePreview({
|
|||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
components={{ a: ArtifactLink }}
|
components={{ a: ArtifactLink }}
|
||||||
>
|
>
|
||||||
{content ?? ""}
|
{content ?? ""}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
</div>
|
{activeCursor && (
|
||||||
);
|
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||||
}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (language === "html") {
|
if (language === "html") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,23 +34,27 @@ const customLightTheme = basicLightInit({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CodeEditor({
|
export function CodeEditor({
|
||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
value,
|
value,
|
||||||
readonly,
|
onChange,
|
||||||
disabled,
|
readonly,
|
||||||
autoFocus,
|
disabled,
|
||||||
settings,
|
autoFocus,
|
||||||
}: {
|
settings,
|
||||||
className?: string;
|
streamingReadonlyFallback = true,
|
||||||
placeholder?: string;
|
}: {
|
||||||
value: string;
|
className?: string;
|
||||||
readonly?: boolean;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
value: string;
|
||||||
autoFocus?: boolean;
|
onChange?: (value: string) => void;
|
||||||
settings?: unknown;
|
readonly?: boolean;
|
||||||
}) {
|
disabled?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
settings?: unknown;
|
||||||
|
streamingReadonlyFallback?: boolean;
|
||||||
|
}) {
|
||||||
const {
|
const {
|
||||||
thread: { isLoading },
|
thread: { isLoading },
|
||||||
} = useThread();
|
} = useThread();
|
||||||
@@ -77,8 +81,8 @@ export function CodeEditor({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading && streamingReadonlyFallback ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
"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!",
|
"resize-none p-4! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
|
||||||
@@ -105,10 +109,11 @@ export function CodeEditor({
|
|||||||
lineNumbers:
|
lineNumbers:
|
||||||
(settings as { lineNumbers?: boolean })?.lineNumbers ?? false,
|
(settings as { lineNumbers?: boolean })?.lineNumbers ?? false,
|
||||||
}}
|
}}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
onChange={onChange}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
GraduationCapIcon,
|
FileTextIcon,
|
||||||
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -122,9 +123,10 @@ export function InputBox({
|
|||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||||
> & {
|
> & {
|
||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
};
|
writing_mode?: boolean;
|
||||||
|
};
|
||||||
extraHeader?: React.ReactNode;
|
extraHeader?: React.ReactNode;
|
||||||
isNewThread?: boolean;
|
isNewThread?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -134,10 +136,11 @@ export function InputBox({
|
|||||||
AgentThreadContext,
|
AgentThreadContext,
|
||||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||||
> & {
|
> & {
|
||||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
},
|
writing_mode?: boolean;
|
||||||
) => void;
|
},
|
||||||
|
) => void;
|
||||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||||
onSubmit?: (message: PromptInputMessage) => void;
|
onSubmit?: (message: PromptInputMessage) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
@@ -236,15 +239,22 @@ export function InputBox({
|
|||||||
[onContextChange, context, supportThinking],
|
[onContextChange, context, supportThinking],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReasoningEffortSelect = useCallback(
|
const handleReasoningEffortSelect = useCallback(
|
||||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
...context,
|
...context,
|
||||||
reasoning_effort: effort,
|
reasoning_effort: effort,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onContextChange, context],
|
[onContextChange, context],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleWritingModeToggle = useCallback(() => {
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
writing_mode: !context.writing_mode,
|
||||||
|
});
|
||||||
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (message: PromptInputMessage) => {
|
async (message: PromptInputMessage) => {
|
||||||
@@ -505,8 +515,28 @@ export function InputBox({
|
|||||||
/>
|
/>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
|
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
|
||||||
<PromptInputActionMenu>
|
<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
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
context.mode === "flash" ||
|
context.mode === "flash" ||
|
||||||
|
|||||||
@@ -329,41 +329,43 @@ function ToolCall({
|
|||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
} else if (name === "write_file" || name === "str_replace") {
|
} else if (name === "write_file" || name === "str_replace") {
|
||||||
let description: string | undefined = (args as { description: string })
|
let description: string | undefined = (args as { description: string })
|
||||||
?.description;
|
?.description;
|
||||||
if (!description) {
|
if (!description) {
|
||||||
description = t.toolCalls.writeFile;
|
description = t.toolCalls.writeFile;
|
||||||
}
|
}
|
||||||
const path: string | undefined = (args as { path: string })?.path;
|
const path: string | undefined = (args as { path: string })?.path;
|
||||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
const artifactPath =
|
||||||
setTimeout(() => {
|
name === "write_file" && path
|
||||||
const url = new URL(
|
? new URL(
|
||||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||||
).toString();
|
).toString()
|
||||||
if (selectedArtifact === url) {
|
: path;
|
||||||
return;
|
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||||
}
|
setTimeout(() => {
|
||||||
select(url, true);
|
if (!artifactPath || selectedArtifact === artifactPath) {
|
||||||
setOpen(true);
|
return;
|
||||||
}, 100);
|
}
|
||||||
}
|
select(artifactPath, true);
|
||||||
|
setOpen(true);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={id}
|
key={id}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
label={description}
|
label={description}
|
||||||
icon={NotebookPenIcon}
|
icon={NotebookPenIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
select(
|
if (!artifactPath) {
|
||||||
new URL(
|
return;
|
||||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
}
|
||||||
).toString(),
|
select(artifactPath);
|
||||||
);
|
setOpen(true);
|
||||||
setOpen(true);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
|
||||||
{path && (
|
{path && (
|
||||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||||
{path}
|
{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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { useThread } from "@/components/workspace/messages/context";
|
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({
|
export function useArtifactContent({
|
||||||
filepath,
|
filepath,
|
||||||
@@ -25,19 +81,48 @@ export function useArtifactContent({
|
|||||||
return null;
|
return null;
|
||||||
}, [filepath, isWriteFile, thread]);
|
}, [filepath, isWriteFile, thread]);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["artifact", filepath, threadId, isMock],
|
queryKey: ["artifact", filepath, threadId, isMock],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
return loadArtifactContent({ filepath, threadId, isMock });
|
return loadArtifactContent({ filepath, threadId, isMock });
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
refetchInterval: thread.isLoading ? 1000 : false,
|
||||||
staleTime: 5 * 60 * 1000,
|
// 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,
|
const pendingEdit = useMemo(() => {
|
||||||
url: isWriteFile ? undefined : data?.url,
|
if (isWriteFile) {
|
||||||
isLoading,
|
return null;
|
||||||
error,
|
}
|
||||||
};
|
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 "./api";
|
||||||
export * from "./loader";
|
export * from "./download";
|
||||||
|
export * from "./loader";
|
||||||
@@ -5,11 +5,12 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
mode: undefined,
|
mode: undefined,
|
||||||
reasoning_effort: undefined,
|
reasoning_effort: undefined,
|
||||||
},
|
writing_mode: false,
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
model_name: string | undefined;
|
model_name: string | undefined;
|
||||||
thinking_enabled: boolean;
|
thinking_enabled: boolean;
|
||||||
is_plan_mode: boolean;
|
is_plan_mode: boolean;
|
||||||
subagent_enabled: boolean;
|
subagent_enabled: boolean;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
agent_id?: string;
|
writing_mode?: boolean;
|
||||||
|
writing_artifact_path?: string;
|
||||||
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
ChatBox,
|
ChatBox,
|
||||||
useSpecificChatMode,
|
useSpecificChatMode,
|
||||||
@@ -37,9 +37,10 @@ export default function ChatPage() {
|
|||||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||||
useThreadChat();
|
useThreadChat();
|
||||||
const [settings, setSettings] = useThreadSettings(threadId);
|
const [settings, setSettings] = useThreadSettings(threadId);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { tokenUsageEnabled } = useModels();
|
const { tokenUsageEnabled } = useModels();
|
||||||
useSpecificChatMode();
|
const { selectedArtifact } = useArtifacts();
|
||||||
|
useSpecificChatMode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -53,7 +54,7 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
context: settings.context,
|
context: settings.context,
|
||||||
isMock,
|
isMock,
|
||||||
@@ -83,17 +84,34 @@ export default function ChatPage() {
|
|||||||
showNotification(state.title, { body });
|
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(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||||
);
|
);
|
||||||
void sendMessage(threadId, message);
|
void sendMessage(
|
||||||
},
|
threadId,
|
||||||
[sendMessage, threadId],
|
message,
|
||||||
|
settings.context.writing_mode && writingArtifactPath
|
||||||
|
? { writing_artifact_path: writingArtifactPath }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[sendMessage, settings.context.writing_mode, threadId, writingArtifactPath],
|
||||||
);
|
);
|
||||||
const handleClarificationSubmit = useCallback(
|
const handleClarificationSubmit = useCallback(
|
||||||
async (answer: string) => {
|
async (answer: string) => {
|
||||||
|
|||||||
@@ -4,21 +4,38 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
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
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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",
|
"text/html",
|
||||||
"application/xhtml+xml",
|
"application/xhtml+xml",
|
||||||
"image/svg+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:
|
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):
|
if is_text_file_by_content(actual_path):
|
||||||
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)})
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
"model_name",
|
||||||
"mode",
|
"mode",
|
||||||
"thinking_enabled",
|
"thinking_enabled",
|
||||||
"reasoning_effort",
|
"reasoning_effort",
|
||||||
"is_plan_mode",
|
"writing_mode",
|
||||||
|
"writing_artifact_path",
|
||||||
|
"is_plan_mode",
|
||||||
"subagent_enabled",
|
"subagent_enabled",
|
||||||
"max_concurrent_subagents",
|
"max_concurrent_subagents",
|
||||||
"agent_name",
|
"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")
|
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
|
||||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||||
subagent_enabled = cfg.get("subagent_enabled", False)
|
subagent_enabled = cfg.get("subagent_enabled", False)
|
||||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||||
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
|
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_config = load_agent_config(agent_id) if not is_bootstrap else None
|
||||||
agent_display_name = agent_config.name if agent_config else agent_id
|
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
|
thinking_enabled = False
|
||||||
|
|
||||||
logger.info(
|
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",
|
"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",
|
agent_id or "default",
|
||||||
thinking_enabled,
|
thinking_enabled,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
model_name,
|
model_name,
|
||||||
is_plan_mode,
|
is_plan_mode,
|
||||||
subagent_enabled,
|
subagent_enabled,
|
||||||
max_concurrent_subagents,
|
max_concurrent_subagents,
|
||||||
|
writing_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inject run metadata for LangSmith trace tagging
|
# Inject run metadata for LangSmith trace tagging
|
||||||
@@ -380,8 +385,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
"thinking_enabled": thinking_enabled,
|
"thinking_enabled": thinking_enabled,
|
||||||
"reasoning_effort": reasoning_effort,
|
"reasoning_effort": reasoning_effort,
|
||||||
"is_plan_mode": is_plan_mode,
|
"is_plan_mode": is_plan_mode,
|
||||||
"subagent_enabled": subagent_enabled,
|
"subagent_enabled": subagent_enabled,
|
||||||
"tool_groups": agent_config.tool_groups if agent_config else None,
|
"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,
|
"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),
|
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||||
system_prompt=apply_prompt_template(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
available_skills=set(["bootstrap"]),
|
writing_mode=writing_mode,
|
||||||
app_config=resolved_app_config,
|
writing_artifact_path=writing_artifact_path,
|
||||||
|
available_skills=set(["bootstrap"]),
|
||||||
|
app_config=resolved_app_config,
|
||||||
),
|
),
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
)
|
)
|
||||||
@@ -421,10 +430,12 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
system_prompt=apply_prompt_template(
|
system_prompt=apply_prompt_template(
|
||||||
subagent_enabled=subagent_enabled,
|
subagent_enabled=subagent_enabled,
|
||||||
max_concurrent_subagents=max_concurrent_subagents,
|
max_concurrent_subagents=max_concurrent_subagents,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
agent_name=agent_display_name,
|
agent_name=agent_display_name,
|
||||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
writing_mode=writing_mode,
|
||||||
app_config=resolved_app_config,
|
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,
|
state_schema=ThreadState,
|
||||||
)
|
)
|
||||||
+47
-16
@@ -472,9 +472,11 @@ You: "Deploying to staging..." [proceed]
|
|||||||
|
|
||||||
{skills_section}
|
{skills_section}
|
||||||
|
|
||||||
{deferred_tools_section}
|
{deferred_tools_section}
|
||||||
|
|
||||||
{subagent_section}
|
{writing_mode_section}
|
||||||
|
|
||||||
|
{subagent_section}
|
||||||
|
|
||||||
<working_directory existed="true">
|
<working_directory existed="true">
|
||||||
- User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context)
|
- 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"
|
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(
|
def apply_prompt_template(
|
||||||
subagent_enabled: bool = False,
|
subagent_enabled: bool = False,
|
||||||
max_concurrent_subagents: int = 3,
|
max_concurrent_subagents: int = 3,
|
||||||
*,
|
*,
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
agent_name: str | None = None,
|
agent_name: str | None = None,
|
||||||
available_skills: set[str] | None = None,
|
writing_mode: bool = False,
|
||||||
app_config: AppConfig | None = None,
|
writing_artifact_path: str | None = None,
|
||||||
) -> str:
|
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
|
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||||
|
|
||||||
# Get memory context
|
# Get memory context
|
||||||
@@ -827,7 +831,33 @@ def apply_prompt_template(
|
|||||||
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
||||||
|
|
||||||
# Get deferred tools section (tool_search)
|
# 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
|
# Build ACP agent section only if ACP agents are configured
|
||||||
acp_section = _build_acp_section(app_config=app_config)
|
acp_section = _build_acp_section(app_config=app_config)
|
||||||
@@ -841,9 +871,10 @@ def apply_prompt_template(
|
|||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
agent_name=agent_name or storage_agent_id or "DeerFlow 2.0",
|
agent_name=agent_name or storage_agent_id or "DeerFlow 2.0",
|
||||||
soul=get_agent_soul(storage_agent_id),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
memory_context=memory_context,
|
writing_mode_section=writing_mode_section,
|
||||||
|
memory_context=memory_context,
|
||||||
network_policy_section=network_policy_section,
|
network_policy_section=network_policy_section,
|
||||||
subagent_section=subagent_section,
|
subagent_section=subagent_section,
|
||||||
subagent_reminder=subagent_reminder,
|
subagent_reminder=subagent_reminder,
|
||||||
|
|||||||
Reference in new issue
Block a user