代码修改
This commit is contained in:
1 parent
5a25dd08f6
commit
e8a31b5d37
9 files changed
+533
-417
No files matched your search
@@ -94,3 +94,7 @@ export function useArtifacts() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
@@ -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({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} 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 (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
@@ -412,6 +411,17 @@ function ToolCall({
|
||||
icon={ListTodoIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
} 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 (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={NotebookPenIcon}>
|
||||
<ChainOfThoughtSearchResult>
|
||||
{type === "code" ? "代码文件" : "Markdown 文档"}
|
||||
</ChainOfThoughtSearchResult>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">AI 正在生成…</p>
|
||||
<p className="text-xs">内容将在右侧显示</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10">
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="h-full overflow-y-auto"
|
||||
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-8 py-8">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-7 text-sm text-foreground/90">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-7">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
if (isBlock) {
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full overflow-y-auto"
|
||||
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-8 py-8">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-7 text-sm text-foreground/90">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-7">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="mb-4 overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted/50">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="mb-4 overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted/50">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
hr: () => <hr className="my-6 border-border" />,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-foreground/80">{children}</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</td>
|
||||
),
|
||||
hr: () => <hr className="my-6 border-border" />,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-foreground/80">{children}</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isStreaming && showScrollBtn && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="pointer-events-auto shadow-md gap-1.5"
|
||||
onClick={handleScrollToBottom}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
回到底部
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(/<think>[\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 (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">需要你的确认</div>
|
||||
{payload.context ? (
|
||||
<p className="text-muted-foreground text-sm leading-6">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 font-medium">
|
||||
{payload.question || fallback}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id || option.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={busy}
|
||||
onClick={() => submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span className="text-sm">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allow_custom !== false ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={customAnswer}
|
||||
onChange={(e) => setCustomAnswer(e.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
disabled={busy}
|
||||
className="min-h-20 resize-none text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !customAnswer.trim()}
|
||||
onClick={() => submitAnswer(customAnswer)}
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
发送回答
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiMessageContent — renders a single AI message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AiMessageContent({
|
||||
message,
|
||||
isActiveStream,
|
||||
isStreaming,
|
||||
onClarificationSubmit,
|
||||
}: {
|
||||
message: CanvasMessage;
|
||||
isActiveStream: boolean;
|
||||
isStreaming: boolean;
|
||||
onClarificationSubmit: (answer: string) => void;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
|
||||
const clarification = getClarificationPayload(message);
|
||||
|
||||
if (clarification) {
|
||||
return (
|
||||
<ClarificationCard
|
||||
message={message}
|
||||
disabled={isStreaming}
|
||||
onSubmit={onClarificationSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayText = stripThinkBlocks(getTextContent(message));
|
||||
if (!displayText) return null;
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={displayText}
|
||||
isLoading={isActiveStream}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatWelcome
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -217,15 +45,14 @@ function ChatWelcome() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasChatPanel() {
|
||||
const { messages, isStreaming, artifact } = useCanvasGraphContext();
|
||||
const { sendMessage } = useCanvasStream();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const { thread, sendMessage, threadId } = useCanvasThreadStream();
|
||||
const { selection, clearSelection } = useCanvasSelection();
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
const hasSelection = hasActiveSelection(selection);
|
||||
const isStreaming = thread.isLoading;
|
||||
const hasMessages = thread.messages.length > 0;
|
||||
|
||||
// Resolve selected code snippet when artifact is a code artifact
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
@@ -252,11 +79,6 @@ export function CanvasChatPanel() {
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Scroll to bottom whenever messages change or streaming state updates
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
async function handleSend() {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
@@ -277,80 +99,30 @@ export function CanvasChatPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the last AI message — used to decide which message gets streaming animation
|
||||
const lastAiIndex = (() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]!.type !== "human") return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
await sendMessage(answer);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
// Whether the last AI message has any visible text (not just think blocks / empty)
|
||||
const lastAiHasVisibleContent = (() => {
|
||||
if (lastAiIndex < 0) return false;
|
||||
const msg = messages[lastAiIndex]!;
|
||||
if (getClarificationPayload(msg)) return true;
|
||||
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
|
||||
})();
|
||||
// MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
|
||||
// BaseStream<CanvasThreadValues> — structurally compatible for message rendering
|
||||
// since MessageList only reads thread.messages, isLoading, and isThreadLoading.
|
||||
const threadForList = thread as unknown as BaseStream<AgentThreadState>;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{hasMessages ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{messages.map((msg, i) => {
|
||||
const isHuman = msg.type === "human";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id ?? i}
|
||||
className={`flex gap-2 ${isHuman ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isHuman && (
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHuman ? (
|
||||
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
|
||||
{getTextContent(msg)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
<AiMessageContent
|
||||
message={msg}
|
||||
isActiveStream={isStreaming && i === lastAiIndex}
|
||||
isStreaming={isStreaming}
|
||||
onClarificationSubmit={(answer) => void sendMessage(answer)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHuman && (
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
|
||||
<User className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreaming && !lastAiHasVisibleContent && (
|
||||
<div className="flex gap-2 justify-start">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">正在生成…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{hasMessages || isStreaming || thread.isThreadLoading ? (
|
||||
<MessageList
|
||||
className="size-full"
|
||||
threadId={threadId ?? "canvas"}
|
||||
thread={threadForList}
|
||||
paddingBottom={0}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
) : (
|
||||
<ChatWelcome />
|
||||
)}
|
||||
@@ -382,7 +154,6 @@ export function CanvasChatPanel() {
|
||||
<div className="border-t p-3 shrink-0">
|
||||
<div className="flex gap-2 items-end">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -401,11 +172,7 @@ export function CanvasChatPanel() {
|
||||
disabled={!input.trim() || isStreaming}
|
||||
onClick={() => void handleSend()}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
)}
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Message[]>([]);
|
||||
const sendInFlightRef = useRef(false);
|
||||
const prevMsgCountRef = useRef(0);
|
||||
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState<string | null>(
|
||||
() => threadId,
|
||||
);
|
||||
const threadIdRef = useRef<string | null>(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<CanvasThreadValues>({
|
||||
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<Partial<CanvasThreadValues> | 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<string, unknown>;
|
||||
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<string, unknown>) => {
|
||||
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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+15
-3
@@ -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,
|
||||
)
|
||||
+20
-2
@@ -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"""
|
||||
<existing_artifact>
|
||||
用户当前有一个{type_label}制品,标题为 "{artifact_title}"。
|
||||
请继续编辑该制品——除非用户明确要求新建,否则不要创建新制品。
|
||||
</existing_artifact>
|
||||
"""
|
||||
|
||||
return f"""You are a Canvas AI assistant that helps users create and edit documents and code.
|
||||
|
||||
<canvas_behavior>
|
||||
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.
|
||||
</artifact_context>
|
||||
|
||||
{existing_artifact_block}
|
||||
<selection_rewrite>
|
||||
When the user's message contains a <selection> 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
|
||||
</quick_actions>
|
||||
"""
|
||||
|
||||
|
||||
# Backward-compatible constant for callers that don't pass artifact context.
|
||||
CANVAS_SYSTEM_PROMPT = build_canvas_system_prompt()
|
||||
Reference in new issue
Block a user