feat: 新增写作画布系统
This commit is contained in:
1 parent
d533ddde88
commit
de9f6b6711
14 files changed
+391
-28
No files matched your search
@@ -8,6 +8,7 @@ import {
|
|||||||
EyeIcon,
|
EyeIcon,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
|
SaveIcon,
|
||||||
SquareArrowOutUpRightIcon,
|
SquareArrowOutUpRightIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -37,7 +38,10 @@ 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(() => {
|
||||||
@@ -142,6 +151,18 @@ export function ArtifactFileDetail({
|
|||||||
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);
|
||||||
setAutoScrollEnabled(true);
|
setAutoScrollEnabled(true);
|
||||||
@@ -330,6 +351,32 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
}, [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)}>
|
||||||
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
|
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
|
||||||
@@ -407,6 +454,15 @@ export function ArtifactFileDetail({
|
|||||||
</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">
|
||||||
|
{canEditArtifact && (
|
||||||
|
<ArtifactAction
|
||||||
|
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||||
|
label="保存 Markdown"
|
||||||
|
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||||||
|
disabled={!hasLocalEdits || updateArtifact.isPending}
|
||||||
|
onClick={handleSaveArtifact}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
@@ -499,6 +555,12 @@ export function ArtifactFileDetail({
|
|||||||
className="relative p-0"
|
className="relative p-0"
|
||||||
onWheelCapture={handlePauseAutoScroll}
|
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 && (
|
{showResumeAutoScroll && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -512,18 +574,28 @@ 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={
|
||||||
|
canEditArtifact
|
||||||
|
? (value) => {
|
||||||
|
setDraftContent(value);
|
||||||
|
setHasLocalEdits(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
streamingReadonlyFallback={!canEditArtifact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -545,10 +617,12 @@ export function ArtifactFilePreview({
|
|||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
animated = false,
|
animated = false,
|
||||||
|
activeCursor = false,
|
||||||
}: {
|
}: {
|
||||||
content: string;
|
content: string;
|
||||||
language: string;
|
language: string;
|
||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
|
activeCursor?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||||
|
|
||||||
@@ -583,6 +657,9 @@ export function ArtifactFilePreview({
|
|||||||
>
|
>
|
||||||
{content ?? ""}
|
{content ?? ""}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
|
{activeCursor && (
|
||||||
|
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,18 +38,22 @@ export function CodeEditor({
|
|||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
value,
|
value,
|
||||||
|
onChange,
|
||||||
readonly,
|
readonly,
|
||||||
disabled,
|
disabled,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
settings,
|
settings,
|
||||||
|
streamingReadonlyFallback = true,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
|
streamingReadonlyFallback?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
thread: { isLoading },
|
thread: { isLoading },
|
||||||
@@ -77,7 +81,7 @@ 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!",
|
||||||
@@ -107,6 +111,7 @@ export function CodeEditor({
|
|||||||
}}
|
}}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
value={value}
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { ChatStatus } from "ai";
|
import type { ChatStatus } from "ai";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
FileTextIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
@@ -124,6 +125,7 @@ export function InputBox({
|
|||||||
> & {
|
> & {
|
||||||
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;
|
||||||
@@ -136,6 +138,7 @@ export function InputBox({
|
|||||||
> & {
|
> & {
|
||||||
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;
|
||||||
@@ -246,6 +249,13 @@ export function InputBox({
|
|||||||
[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) => {
|
||||||
if (status === "streaming") {
|
if (status === "streaming") {
|
||||||
@@ -506,6 +516,26 @@ 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" />
|
||||||
|
<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>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
|
|||||||
@@ -336,15 +336,18 @@ function ToolCall({
|
|||||||
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;
|
||||||
|
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) {
|
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const url = new URL(
|
if (!artifactPath || selectedArtifact === artifactPath) {
|
||||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
|
||||||
).toString();
|
|
||||||
if (selectedArtifact === url) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
select(url, true);
|
select(artifactPath, true);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@@ -356,11 +359,10 @@ function ToolCall({
|
|||||||
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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -31,13 +87,42 @@ export function useArtifactContent({
|
|||||||
return loadArtifactContent({ filepath, threadId, isMock });
|
return loadArtifactContent({ filepath, threadId, isMock });
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
|
refetchInterval: thread.isLoading ? 1000 : false,
|
||||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
const pendingEdit = useMemo(() => {
|
||||||
|
if (isWriteFile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return applyPendingTextEdit({
|
||||||
|
content: data?.content,
|
||||||
|
filepath,
|
||||||
|
thread,
|
||||||
|
});
|
||||||
|
}, [data?.content, filepath, isWriteFile, thread]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: isWriteFile ? content : data?.content,
|
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
|
||||||
url: isWriteFile ? undefined : data?.url,
|
url: isWriteFile ? undefined : data?.url,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isApplyingEdit: pendingEdit?.kind === "editing",
|
||||||
error,
|
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 "./api";
|
||||||
export * from "./download";
|
export * from "./download";
|
||||||
export * from "./loader";
|
export * from "./loader";
|
||||||
@@ -8,6 +8,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
mode: undefined,
|
mode: undefined,
|
||||||
reasoning_effort: undefined,
|
reasoning_effort: undefined,
|
||||||
|
writing_mode: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
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";
|
||||||
|
writing_mode?: boolean;
|
||||||
|
writing_artifact_path?: string;
|
||||||
agent_id?: 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,
|
||||||
@@ -39,6 +39,7 @@ export default function ChatPage() {
|
|||||||
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();
|
||||||
|
const { selectedArtifact } = useArtifacts();
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,15 +86,32 @@ 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(
|
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,
|
||||||
|
message,
|
||||||
|
settings.context.writing_mode && writingArtifactPath
|
||||||
|
? { writing_artifact_path: writingArtifactPath }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[sendMessage, threadId],
|
[sendMessage, settings.context.writing_mode, threadId, writingArtifactPath],
|
||||||
);
|
);
|
||||||
const handleClarificationSubmit = useCallback(
|
const handleClarificationSubmit = useCallback(
|
||||||
async (answer: string) => {
|
async (answer: string) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
@@ -20,6 +21,22 @@ ACTIVE_CONTENT_MIME_TYPES = {
|
|||||||
"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:
|
||||||
"""Build an RFC 5987 encoded Content-Disposition header value."""
|
"""Build an RFC 5987 encoded Content-Disposition header value."""
|
||||||
@@ -181,3 +198,44 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
|
|||||||
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
|
return PlainTextResponse(content=actual_path.read_text(encoding="utf-8"), media_type=mime_type)
|
||||||
|
|
||||||
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
|
return Response(content=actual_path.read_bytes(), media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
@@ -111,6 +111,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
|||||||
"mode",
|
"mode",
|
||||||
"thinking_enabled",
|
"thinking_enabled",
|
||||||
"reasoning_effort",
|
"reasoning_effort",
|
||||||
|
"writing_mode",
|
||||||
|
"writing_artifact_path",
|
||||||
"is_plan_mode",
|
"is_plan_mode",
|
||||||
"subagent_enabled",
|
"subagent_enabled",
|
||||||
"max_concurrent_subagents",
|
"max_concurrent_subagents",
|
||||||
|
|||||||
+12
-1
@@ -330,6 +330,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
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)
|
||||||
|
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_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
|
||||||
@@ -349,7 +353,7 @@ 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,
|
||||||
@@ -357,6 +361,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
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
|
||||||
@@ -381,6 +386,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
"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,
|
||||||
|
"writing_mode": writing_mode,
|
||||||
|
"writing_artifact_path": writing_artifact_path,
|
||||||
"tool_groups": agent_config.tool_groups if agent_config else None,
|
"tool_groups": agent_config.tool_groups if agent_config else None,
|
||||||
"available_skills": runtime_available_skills,
|
"available_skills": runtime_available_skills,
|
||||||
}
|
}
|
||||||
@@ -401,6 +408,8 @@ 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,
|
||||||
|
writing_mode=writing_mode,
|
||||||
|
writing_artifact_path=writing_artifact_path,
|
||||||
available_skills=set(["bootstrap"]),
|
available_skills=set(["bootstrap"]),
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
),
|
),
|
||||||
@@ -423,6 +432,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
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,
|
||||||
|
writing_mode=writing_mode,
|
||||||
|
writing_artifact_path=writing_artifact_path,
|
||||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
),
|
),
|
||||||
|
|||||||
+31
@@ -474,6 +474,8 @@ You: "Deploying to staging..." [proceed]
|
|||||||
|
|
||||||
{deferred_tools_section}
|
{deferred_tools_section}
|
||||||
|
|
||||||
|
{writing_mode_section}
|
||||||
|
|
||||||
{subagent_section}
|
{subagent_section}
|
||||||
|
|
||||||
<working_directory existed="true">
|
<working_directory existed="true">
|
||||||
@@ -793,6 +795,8 @@ def apply_prompt_template(
|
|||||||
*,
|
*,
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
agent_name: str | None = None,
|
agent_name: str | None = None,
|
||||||
|
writing_mode: bool = False,
|
||||||
|
writing_artifact_path: str | None = None,
|
||||||
available_skills: set[str] | None = None,
|
available_skills: set[str] | None = None,
|
||||||
app_config: AppConfig | None = None,
|
app_config: AppConfig | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -828,6 +832,32 @@ def apply_prompt_template(
|
|||||||
|
|
||||||
# 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)
|
||||||
@@ -843,6 +873,7 @@ def apply_prompt_template(
|
|||||||
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,
|
||||||
|
writing_mode_section=writing_mode_section,
|
||||||
memory_context=memory_context,
|
memory_context=memory_context,
|
||||||
network_policy_section=network_policy_section,
|
network_policy_section=network_policy_section,
|
||||||
subagent_section=subagent_section,
|
subagent_section=subagent_section,
|
||||||
|
|||||||
Reference in new issue
Block a user