- {hasMessages ? (
-
- {messages.map((msg, i) => {
- const isHuman = msg.type === "human";
-
- return (
-
- {!isHuman && (
-
-
-
- )}
-
- {isHuman ? (
-
- {getTextContent(msg)}
-
- ) : (
-
-
void sendMessage(answer)}
- />
-
- )}
-
- {isHuman && (
-
-
-
- )}
-
- );
- })}
-
- {isStreaming && !lastAiHasVisibleContent && (
-
- )}
-
-
-
+ {hasMessages || isStreaming || thread.isThreadLoading ? (
+
) : (
)}
@@ -382,7 +154,6 @@ export function CanvasChatPanel() {
diff --git a/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts b/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts
new file mode 100644
index 00000000..3b61c1c5
--- /dev/null
+++ b/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts
@@ -0,0 +1,237 @@
+// Canvas-specific variant of useThreadStream (core/threads/hooks.ts).
+// Copied and adapted for canvas_agent so canvas writing-mode interactions
+// can be customized independently from the main lead_agent flow.
+
+import type { AIMessage, Message } from "@langchain/langgraph-sdk";
+import { useStream } from "@langchain/langgraph-sdk/react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { toast } from "sonner";
+
+import { getAPIClient } from "@/core/api";
+import { useUpdateSubtask } from "@/core/tasks/context";
+
+import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
+import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
+import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
+import type { StreamingArtifactPartial } from "../api/runs";
+import type { CanvasArtifact } from "../types/artifact";
+import type { CanvasThreadValues } from "../types/thread";
+import { getCurrentArtifactContent } from "../utils/artifact";
+
+function getStreamErrorMessage(error: unknown): string {
+ if (typeof error === "string" && error.trim()) return error;
+ if (error instanceof Error && error.message.trim()) return error.message;
+ if (typeof error === "object" && error !== null) {
+ const message = Reflect.get(error, "message");
+ if (typeof message === "string" && message.trim()) return message;
+ }
+ return "Request failed.";
+}
+
+/**
+ * Canvas-specific stream hook — a direct copy of the main agent's useThreadStream,
+ * configured for canvas_agent. Modify sendMessage here to inject canvas-specific
+ * context (writing_artifact_path, canvas_mode, highlighted text, etc.).
+ */
+export function useCanvasThreadStream() {
+ const navigate = useNavigate();
+ const { threadId, setThreadId, modelName } = useCanvasThreadContext();
+ const { artifact, setArtifact, setIsStreaming, setStreamingArtifact } = useCanvasGraphContext();
+ const updateSubtask = useUpdateSubtask();
+
+ const [optimisticMessages, setOptimisticMessages] = useState
([]);
+ const sendInFlightRef = useRef(false);
+ const prevMsgCountRef = useRef(0);
+
+ const [onStreamThreadId, setOnStreamThreadId] = useState(
+ () => threadId,
+ );
+ const threadIdRef = useRef(threadId ?? null);
+
+ useEffect(() => {
+ const normalized = threadId ?? null;
+ setOnStreamThreadId(normalized);
+ threadIdRef.current = normalized;
+ if (!normalized) {
+ sendInFlightRef.current = false;
+ prevMsgCountRef.current = 0;
+ setOptimisticMessages([]);
+ }
+ }, [threadId]);
+
+ const thread = useStream({
+ client: getAPIClient(),
+ assistantId: CANVAS_AGENT_ID,
+ threadId: onStreamThreadId,
+ fetchStateHistory: { limit: 1 },
+ onCreated(meta) {
+ setOnStreamThreadId(meta.thread_id);
+ threadIdRef.current = meta.thread_id;
+ setThreadId(meta.thread_id);
+ navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
+ void getAPIClient()
+ .threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
+ .catch(() => {});
+ },
+ onUpdateEvent(data) {
+ const updates: Array | null> = Object.values(
+ data || {},
+ );
+ for (const update of updates) {
+ if (update?.artifact) {
+ setArtifact(update.artifact as CanvasArtifact);
+ }
+ }
+ },
+ onCustomEvent(event: unknown) {
+ if (
+ typeof event === "object" &&
+ event !== null &&
+ "type" in event &&
+ (event as { type: unknown }).type === "task_running"
+ ) {
+ const e = event as {
+ type: "task_running";
+ task_id: string;
+ message: AIMessage;
+ };
+ updateSubtask({ id: e.task_id, latestMessage: e.message });
+ return;
+ }
+ if (
+ typeof event === "object" &&
+ event !== null &&
+ "type" in event &&
+ (event as { type: unknown }).type === "llm_retry" &&
+ "message" in event &&
+ typeof (event as { message: unknown }).message === "string"
+ ) {
+ toast((event as { message: string }).message);
+ }
+ },
+ onError(error) {
+ setOptimisticMessages([]);
+ toast.error(getStreamErrorMessage(error));
+ },
+ });
+
+ // Keep CanvasGraphContext.isStreaming in sync so the artifact panel and
+ // quick-action bar can react to streaming state.
+ useEffect(() => {
+ setIsStreaming(thread.isLoading);
+ }, [thread.isLoading, setIsStreaming]);
+
+ // Detect update_artifact tool calls from streaming messages to populate
+ // streamingArtifact so the right panel shows content before the final
+ // state update fires via onUpdateEvent.
+ useEffect(() => {
+ if (!thread.isLoading) {
+ setStreamingArtifact(null);
+ return;
+ }
+ const msgs = thread.messages;
+ for (let i = msgs.length - 1; i >= 0; i--) {
+ const msg = msgs[i];
+ if (msg.type !== "ai") continue;
+ const toolCalls = (msg as AIMessage).tool_calls;
+ if (!toolCalls?.length) continue;
+ const call = toolCalls.find((tc) => tc.name === "update_artifact");
+ if (!call?.args) continue;
+ const args = call.args as Record;
+ const partial: StreamingArtifactPartial = {
+ type: args.type as "text" | "code" | undefined,
+ title: typeof args.title === "string" ? args.title : undefined,
+ content: typeof args.content === "string" ? args.content : undefined,
+ language: typeof args.language === "string" ? args.language : undefined,
+ };
+ if (partial.title !== undefined || partial.content !== undefined) {
+ setStreamingArtifact(partial);
+ return;
+ }
+ }
+ }, [thread.messages, thread.isLoading, setStreamingArtifact]);
+
+ // Clear optimistic messages once the server stream begins delivering real ones.
+ useEffect(() => {
+ if (
+ optimisticMessages.length > 0 &&
+ thread.messages.length > prevMsgCountRef.current
+ ) {
+ setOptimisticMessages([]);
+ }
+ }, [thread.messages.length, optimisticMessages.length]);
+
+ /**
+ * Canvas writing-mode sendMessage — copied from ChatPage's handleSubmit flow.
+ * Modify here to inject writing_artifact_path, canvas_mode, highlighted
+ * text/code, or any other canvas-specific context the agent needs.
+ */
+ const sendMessage = useCallback(
+ async (text: string, extraContext?: Record) => {
+ const trimmed = text.trim();
+ if (!trimmed || sendInFlightRef.current) return;
+ sendInFlightRef.current = true;
+
+ prevMsgCountRef.current = thread.messages.length;
+ setOptimisticMessages([
+ {
+ type: "human",
+ id: `opt-human-${Date.now()}`,
+ content: [{ type: "text", text: trimmed }],
+ additional_kwargs: {},
+ },
+ ]);
+
+ // Pass current artifact info so the backend can instruct the LLM to
+ // continue editing the same artifact instead of creating a new one.
+ const currentContent = artifact ? getCurrentArtifactContent(artifact) : null;
+ const artifactContext = currentContent
+ ? {
+ canvas_artifact_title: currentContent.title,
+ canvas_artifact_type: currentContent.type,
+ }
+ : {};
+
+ try {
+ await thread.submit(
+ {
+ messages: [
+ { type: "human", content: [{ type: "text", text: trimmed }] },
+ ],
+ },
+ {
+ threadId: threadIdRef.current ?? undefined,
+ streamSubgraphs: true,
+ streamResumable: true,
+ config: {
+ recursion_limit: 1000,
+ ...(modelName ? { configurable: { model_name: modelName } } : {}),
+ },
+ context: {
+ ...extraContext,
+ ...artifactContext,
+ thread_id: threadIdRef.current ?? "",
+ writing_mode: true,
+ },
+ },
+ );
+ } catch {
+ setOptimisticMessages([]);
+ } finally {
+ sendInFlightRef.current = false;
+ }
+ },
+ [thread, modelName, artifact],
+ );
+
+ const mergedThread =
+ optimisticMessages.length > 0
+ ? ({
+ ...thread,
+ messages: [...thread.messages, ...optimisticMessages],
+ } as typeof thread)
+ : thread;
+
+ return { thread: mergedThread, sendMessage, threadId };
+}
diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py
index f14d8df4..4159d635 100644
--- a/offline-backend-20260512/backend/app/gateway/services.py
+++ b/offline-backend-20260512/backend/app/gateway/services.py
@@ -152,10 +152,12 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
- "reasoning_effort",
- "writing_mode",
- "writing_artifact_path",
- "is_plan_mode",
+ "reasoning_effort",
+ "writing_mode",
+ "writing_artifact_path",
+ "canvas_artifact_title",
+ "canvas_artifact_type",
+ "is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
"agent_name",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py
index 91fd122c..dd8968a7 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py
@@ -5,7 +5,7 @@ import logging
from langchain.agents import create_agent
from langchain_core.runnables import RunnableConfig
-from deerflow.agents.canvas_agent.prompt import CANVAS_SYSTEM_PROMPT
+from deerflow.agents.canvas_agent.prompt import build_canvas_system_prompt
from deerflow.agents.canvas_agent.tools import update_artifact_tool
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
@@ -49,7 +49,19 @@ def make_canvas_agent(config: RunnableConfig):
if thinking_enabled and model_config and not model_config.supports_thinking:
thinking_enabled = False
- logger.info("Create CanvasAgent -> model_name: %s, thinking_enabled: %s", model_name, thinking_enabled)
+ canvas_artifact_title = cfg.get("canvas_artifact_title") or None
+ canvas_artifact_type = cfg.get("canvas_artifact_type") or None
+ system_prompt = build_canvas_system_prompt(
+ artifact_title=canvas_artifact_title,
+ artifact_type=canvas_artifact_type,
+ )
+
+ logger.info(
+ "Create CanvasAgent -> model_name: %s, thinking_enabled: %s, artifact_title: %s",
+ model_name,
+ thinking_enabled,
+ canvas_artifact_title,
+ )
middlewares = [
DanglingToolCallMiddleware(),
@@ -63,6 +75,6 @@ def make_canvas_agent(config: RunnableConfig):
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=app_config),
tools=[update_artifact_tool, ask_clarification_tool],
middleware=middlewares,
- system_prompt=CANVAS_SYSTEM_PROMPT,
+ system_prompt=system_prompt,
state_schema=ThreadState,
)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py
index 42314b2c..bd003e5e 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py
@@ -1,4 +1,18 @@
-CANVAS_SYSTEM_PROMPT = """You are a Canvas AI assistant that helps users create and edit documents and code.
+def build_canvas_system_prompt(
+ artifact_title: str | None = None,
+ artifact_type: str | None = None,
+) -> str:
+ existing_artifact_block = ""
+ if artifact_title:
+ type_label = "代码" if artifact_type == "code" else "文档"
+ existing_artifact_block = f"""
+
+用户当前有一个{type_label}制品,标题为 "{artifact_title}"。
+请继续编辑该制品——除非用户明确要求新建,否则不要创建新制品。
+
+"""
+
+ return f"""You are a Canvas AI assistant that helps users create and edit documents and code.
You work with an **artifact** — a document or code file shown in the canvas panel on the right.
@@ -14,7 +28,7 @@ Rules:
The current artifact state is injected into your context. When editing, build on the existing content exactly.
When creating from scratch, produce a complete, high-quality artifact in one shot.
-
+{existing_artifact_block}
When the user's message contains a block, you MUST:
1. Identify the exact selection in the current artifact content.
@@ -38,3 +52,7 @@ When the user triggers a quick action, apply it to the full artifact unless spec
- optimize_code: Improve performance and readability
"""
+
+
+# Backward-compatible constant for callers that don't pass artifact context.
+CANVAS_SYSTEM_PROMPT = build_canvas_system_prompt()