From e8a31b5d371cac48b76b87a45fc8cbaf2fd04add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 16 May 2026 14:24:44 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/artifacts/context.tsx | 4 + .../workspace/messages/message-group.tsx | 80 ++--- .../artifact/CanvasArtifactPanel.tsx | 14 + .../artifact/TextArtifactRenderer.tsx | 274 ++++++++++------- .../components/chat/CanvasChatPanel.tsx | 291 ++---------------- .../hooks/useCanvasThreadStream.ts | 237 ++++++++++++++ .../backend/app/gateway/services.py | 10 +- .../deerflow/agents/canvas_agent/agent.py | 18 +- .../deerflow/agents/canvas_agent/prompt.py | 22 +- 9 files changed, 533 insertions(+), 417 deletions(-) create mode 100644 frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts diff --git a/frontend-web/src/components/workspace/artifacts/context.tsx b/frontend-web/src/components/workspace/artifacts/context.tsx index 8d8a1b93..e15481af 100644 --- a/frontend-web/src/components/workspace/artifacts/context.tsx +++ b/frontend-web/src/components/workspace/artifacts/context.tsx @@ -94,3 +94,7 @@ export function useArtifacts() { } return context; } + +export function useArtifactsOptional(): ArtifactsContextType | null { + return useContext(ArtifactsContext) ?? null; +} diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index e995fb02..cf820620 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -33,7 +33,7 @@ import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { env } from "@/env"; import { cn } from "@/lib/utils"; -import { useArtifacts } from "../artifacts"; +import { useArtifactsOptional } from "../artifacts"; import { FlipDisplay } from "../flip-display"; import { Tooltip } from "../tooltip"; @@ -201,8 +201,7 @@ function ToolCall({ isLoading?: boolean; }) { const { t } = useI18n(); - const { setOpen, autoOpen, autoSelect, selectedArtifact, select } = - useArtifacts(); + const artifacts = useArtifactsOptional(); if (name === "web_search") { let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; @@ -329,43 +328,43 @@ function ToolCall({ )} ); - } else if (name === "write_file" || name === "str_replace") { - let description: string | undefined = (args as { description: string }) - ?.description; - if (!description) { - description = t.toolCalls.writeFile; - } - const path: string | undefined = (args as { path: string })?.path; - const artifactPath = - name === "write_file" && path - ? new URL( - `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, - ).toString() - : path; - if (isLoading && isLast && autoOpen && autoSelect && path && !result) { - setTimeout(() => { - if (!artifactPath || selectedArtifact === artifactPath) { - return; - } - select(artifactPath, true); - setOpen(true); - }, 100); - } + } else if (name === "write_file" || name === "str_replace") { + let description: string | undefined = (args as { description: string }) + ?.description; + if (!description) { + description = t.toolCalls.writeFile; + } + const path: string | undefined = (args as { path: string })?.path; + const artifactPath = + name === "write_file" && path + ? new URL( + `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, + ).toString() + : path; + if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) { + setTimeout(() => { + if (!artifactPath || artifacts.selectedArtifact === artifactPath) { + return; + } + artifacts.select(artifactPath, true); + artifacts.setOpen(true); + }, 100); + } return ( { - if (!artifactPath) { - return; - } - select(artifactPath); - setOpen(true); - }} - > + label={description} + icon={NotebookPenIcon} + onClick={() => { + if (!artifactPath || !artifacts) { + return; + } + artifacts.select(artifactPath); + artifacts.setOpen(true); + }} + > {path && ( {path} @@ -412,6 +411,17 @@ function ToolCall({ icon={ListTodoIcon} > ); + } else if (name === "update_artifact") { + const title = typeof args.title === "string" ? args.title : undefined; + const type = args.type as string | undefined; + const label = title ?? (type === "code" ? "生成代码" : "生成文档"); + return ( + + + {type === "code" ? "代码文件" : "Markdown 文档"} + + + ); } else { const description: string | undefined = (args as { description: string }) ?.description; diff --git a/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx index 82a1bd2e..fe99daa1 100644 --- a/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx +++ b/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx @@ -87,6 +87,20 @@ export function CanvasArtifactPanel() { ); } + // While streaming but no partial content yet — show a loading placeholder + // so the template picker doesn't flash during generation. + if (isStreaming && !artifact) { + return ( +
+ +
+

AI 正在生成…

+

内容将在右侧显示

+
+
+ ); + } + if (!artifact) { return (
diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx index c7d6b3d7..db7cdba2 100644 --- a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -1,6 +1,9 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowDown } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { Button } from "@/components/ui/button"; import type { CanvasArtifactText } from "../../types/artifact"; import { useCanvasSelection } from "../../hooks/useCanvasSelection"; @@ -11,6 +14,37 @@ interface TextArtifactRendererProps { export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) { const { handleTextPointerUp } = useCanvasSelection(); + const scrollRef = useRef(null); + const [showScrollBtn, setShowScrollBtn] = useState(false); + const autoScrollRef = useRef(true); + + // Auto-scroll to bottom while streaming, unless user has scrolled up + useEffect(() => { + if (!isStreaming) { + autoScrollRef.current = true; + setShowScrollBtn(false); + return; + } + if (!autoScrollRef.current) return; + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [isStreaming, content.fullMarkdown]); + + const handleScroll = useCallback(() => { + if (!isStreaming) return; + const el = scrollRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + autoScrollRef.current = atBottom; + setShowScrollBtn(!atBottom); + }, [isStreaming]); + + const handleScrollToBottom = useCallback(() => { + const el = scrollRef.current; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + autoScrollRef.current = true; + setShowScrollBtn(false); + }, []); if (!content.fullMarkdown) { return ( @@ -23,124 +57,142 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende } return ( -
handleTextPointerUp(content.fullMarkdown)} - > -
- ( -

- {children} -

- ), - h2: ({ children }) => ( -

- {children} -

- ), - h3: ({ children }) => ( -

- {children} -

- ), - h4: ({ children }) => ( -

- {children} -

- ), - p: ({ children }) => ( -

- {children} -

- ), - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • {children}
  • - ), - blockquote: ({ children }) => ( -
    - {children} -
    - ), - pre: ({ children }) => ( -
    -                {children}
    -              
    - ), - code: ({ className, children }) => { - const isBlock = Boolean(className?.startsWith("language-")); - if (isBlock) { +
    +
    handleTextPointerUp(content.fullMarkdown)} + onScroll={handleScroll} + > +
    + ( +

    + {children} +

    + ), + h2: ({ children }) => ( +

    + {children} +

    + ), + h3: ({ children }) => ( +

    + {children} +

    + ), + h4: ({ children }) => ( +

    + {children} +

    + ), + p: ({ children }) => ( +

    + {children} +

    + ), + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • {children}
  • + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) => ( +
    +                  {children}
    +                
    + ), + code: ({ className, children }) => { + const isBlock = Boolean(className?.startsWith("language-")); + if (isBlock) { + return ( + + {children} + + ); + } return ( - + {children} ); - } - return ( - + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + {children} -
    - ); - }, - table: ({ children }) => ( -
    - + + ), + td: ({ children }) => ( +
    {children} -
    -
    - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - hr: () =>
    , - a: ({ href, children }) => ( - - {children} - - ), - strong: ({ children }) => ( - - {children} - - ), - em: ({ children }) => ( - {children} - ), - }} - > - {content.fullMarkdown} -
    - {isStreaming && ( - - )} + + ), + hr: () =>
    , + a: ({ href, children }) => ( + + {children} + + ), + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => ( + {children} + ), + }} + > + {content.fullMarkdown} + + {isStreaming && ( + + )} +
    + + {isStreaming && showScrollBtn && ( +
    + +
    + )}
    ); } diff --git a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx index d6664cb7..28ec72cc 100644 --- a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx +++ b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx @@ -1,16 +1,16 @@ -import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react"; -import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import type { BaseStream } from "@langchain/langgraph-sdk/react"; +import { SendHorizonal, Sparkles, X } from "lucide-react"; +import { useCallback, useState, type KeyboardEvent } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; -import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { MessageList } from "@/components/workspace/messages"; +import type { AgentThreadState } from "@/core/threads"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; import { useCanvasSelection } from "../../hooks/useCanvasSelection"; -import { useCanvasStream } from "../../hooks/useCanvasStream"; -import type { CanvasMessage } from "../../types/thread"; +import { useCanvasThreadStream } from "../../hooks/useCanvasThreadStream"; import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact"; import { buildMessageWithSelection, @@ -18,178 +18,6 @@ import { resolveCodeSelection, } from "../../utils/selection"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type ClarificationOption = { - id: string; - label: string; - description?: string; -}; - -type ClarificationPayload = { - question?: string; - context?: string | null; - options?: ClarificationOption[]; - allow_custom?: boolean; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function stripThinkBlocks(text: string): string { - return text.replace(/[\s\S]*?<\/think>/g, "").trim(); -} - -function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null { - const c = msg.additional_kwargs?.clarification; - if (typeof c !== "object" || c === null) return null; - return c as ClarificationPayload; -} - -function getTextContent(msg: CanvasMessage): string { - return typeof msg.content === "string" - ? msg.content - : JSON.stringify(msg.content); -} - -// --------------------------------------------------------------------------- -// ClarificationCard -// --------------------------------------------------------------------------- - -function ClarificationCard({ - message, - disabled, - onSubmit, -}: { - message: CanvasMessage; - disabled: boolean; - onSubmit: (answer: string) => void; -}) { - const payload = getClarificationPayload(message)!; - const [customAnswer, setCustomAnswer] = useState(""); - const [submitted, setSubmitted] = useState(false); - - const options = (payload.options ?? []).filter( - (o) => typeof o.label === "string" && o.label.trim(), - ); - - const busy = disabled || submitted; - - function submitAnswer(answer: string) { - const trimmed = answer.trim(); - if (!trimmed || busy) return; - setSubmitted(true); - onSubmit(trimmed); - } - - const fallback = stripThinkBlocks(getTextContent(message)); - - return ( -
    -
    -
    需要你的确认
    - {payload.context ? ( -

    {payload.context}

    - ) : null} -
    - {payload.question || fallback} -
    -
    - - {options.length > 0 ? ( -
    - {options.map((option) => ( - - ))} -
    - ) : null} - - {payload.allow_custom !== false ? ( -
    -