feat: 新增写作画布系统

This commit is contained in:
likun committed 2026-05-15 16:22:04 +08:00
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);
}} }}
> >
+40
View File
@@ -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();
}
+87 -2
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from "./api";
export * from "./download"; export * from "./download";
export * from "./loader"; export * from "./loader";
+1
View File
@@ -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,
}, },
}; };
+2
View File
@@ -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;
+22 -4
View File
@@ -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",
@@ -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,
), ),
@@ -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,