代码提交

This commit is contained in:
董宏凯 committed 2026-05-15 14:08:01 +08:00
1 parent b81f64a253
commit 050b1ad78e
45 files changed
+5011 -2694

No files matched your search

@@ -0,0 +1 @@
export { getAPIClient } from "@/core/api";
@@ -0,0 +1,37 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type { CanvasModel } from "../types/model";
interface BackendModel {
name: string;
display_name?: string;
provider?: string;
is_local?: boolean;
supports_vision?: boolean;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
supports_tool_calling?: boolean;
}
export async function loadCanvasModels(): Promise<CanvasModel[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/models`);
const data = (await res.json()) as { models?: BackendModel[] };
return (data.models ?? []).map(
(m): CanvasModel => ({
name: m.name,
display_name: m.display_name ?? m.name,
provider: m.provider,
is_local: m.is_local,
supports_vision: m.supports_vision,
supports_thinking: m.supports_thinking,
supports_reasoning_effort: m.supports_reasoning_effort,
supports_tool_calling: m.supports_tool_calling ?? true,
supports_canvas_generation: true,
supports_canvas_rewrite: true,
supports_canvas_code_edit: true,
supports_streaming_text: true,
supports_streaming_tool_calls: m.supports_tool_calling ?? true,
}),
);
}
+62
View File
@@ -0,0 +1,62 @@
import { getAPIClient } from "@/core/api";
import { CANVAS_AGENT_ID } from "../constants";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasMessage } from "../types/thread";
export type CanvasRunInput = {
messages: Array<{ type: string; content: string }>;
canvas_mode?: "text" | "code";
highlighted_text?: string;
highlighted_code?: string;
quick_action_id?: string;
};
export type CanvasStreamCallbacks = {
onMessage?: (msg: CanvasMessage) => void;
onArtifact?: (artifact: CanvasArtifact) => void;
onDone?: () => void;
onError?: (err: unknown) => void;
};
export async function streamCanvasRun(
threadId: string,
input: CanvasRunInput,
callbacks: CanvasStreamCallbacks,
): Promise<void> {
const client = getAPIClient();
try {
const stream = client.runs.stream(threadId, CANVAS_AGENT_ID, {
input,
streamMode: ["values", "messages-tuple"],
});
for await (const chunk of stream) {
// Cast to string: SDK typings only list a subset of event names
const event = chunk.event as string;
const data = (chunk as { event: string; data: unknown }).data;
if (event === "values") {
const state = data as Record<string, unknown>;
if (state.artifact) {
callbacks.onArtifact?.(state.artifact as CanvasArtifact);
}
} else if (event === "messages/tuple") {
const [msg] = data as [CanvasMessage];
if (msg) {
callbacks.onMessage?.(msg);
}
}
}
callbacks.onDone?.();
} catch (err) {
callbacks.onError?.(err);
}
}
export async function stopCanvasRun(
threadId: string,
runId: string,
): Promise<void> {
const client = getAPIClient();
await client.runs.cancel(threadId, runId);
}
@@ -0,0 +1,35 @@
import { getAPIClient } from "@/core/api";
import { CANVAS_THREAD_METADATA } from "../constants";
import type { CanvasThreadMetadata, CanvasThreadValues } from "../types/thread";
export async function createCanvasThread(
metadata?: Partial<CanvasThreadMetadata>,
): Promise<string> {
const client = getAPIClient();
const thread = await client.threads.create({
metadata: { ...CANVAS_THREAD_METADATA, ...metadata },
});
return thread.thread_id;
}
export async function getCanvasThread(
threadId: string,
): Promise<CanvasThreadValues | null> {
const client = getAPIClient();
try {
const state =
await client.threads.getState<CanvasThreadValues>(threadId);
return (state.values as CanvasThreadValues) ?? null;
} catch {
return null;
}
}
export async function updateCanvasThreadState(
threadId: string,
values: Partial<CanvasThreadValues>,
): Promise<void> {
const client = getAPIClient();
await client.threads.updateState(threadId, { values });
}
@@ -0,0 +1,34 @@
import { FileText, Code2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { getCurrentArtifactContent } from "../../utils/artifact";
import { CanvasArtifactVersionNav } from "./CanvasArtifactVersionNav";
export function CanvasArtifactHeader() {
const { artifact } = useCanvasGraphContext();
const content = getCurrentArtifactContent(artifact);
if (!content) return null;
const isCode = content.type === "code";
const Icon = isCode ? Code2 : FileText;
return (
<div className="h-11 border-b flex items-center px-4 gap-2 shrink-0 bg-background">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1 min-w-0">
{content.title}
</span>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="secondary" className="text-[10px] h-5 px-1.5">
{isCode ? (content as { language: string }).language : "文本"}
</Badge>
<CanvasArtifactVersionNav />
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { useSearchParams } from "react-router-dom";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import { CanvasArtifactHeader } from "./CanvasArtifactHeader";
import { CanvasEmptyState } from "./CanvasEmptyState";
import { TextArtifactRenderer } from "./TextArtifactRenderer";
import { CodeArtifactRenderer } from "./CodeArtifactRenderer";
import type {
CanvasArtifactCode,
CanvasArtifactText,
} from "../../types/artifact";
export function CanvasArtifactPanel() {
const { artifact } = useCanvasGraphContext();
const [searchParams] = useSearchParams();
const initialType = searchParams.get("type") as "text" | "code" | null;
if (!artifact) {
return (
<div className="h-full bg-muted/10">
<CanvasEmptyState initialType={initialType} />
</div>
);
}
const content = getCurrentArtifactContent(artifact);
return (
<div className="h-full flex flex-col overflow-hidden">
<CanvasArtifactHeader />
<div className="flex-1 overflow-hidden">
{content && isCodeArtifact(content) ? (
<CodeArtifactRenderer content={content as CanvasArtifactCode} />
) : content ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { setCurrentArtifactIndex } from "../../utils/artifact";
export function CanvasArtifactVersionNav() {
const { artifact, setArtifact } = useCanvasGraphContext();
if (!artifact || artifact.contents.length <= 1) return null;
const total = artifact.contents.length;
const current = artifact.currentIndex;
const canGoPrev = current > 1;
const canGoNext = current < total;
function go(delta: -1 | 1) {
if (!artifact) return;
setArtifact(setCurrentArtifactIndex(artifact, current + delta));
}
return (
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => go(-1)}
disabled={!canGoPrev}
title="上一个版本"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums w-10 text-center">
{current}/{total}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => go(1)}
disabled={!canGoNext}
title="下一个版本"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
);
}
@@ -0,0 +1,168 @@
import { FileText, Code2, Globe, Atom, Terminal } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { createDraftArtifact } from "../../utils/artifact";
import type { ProgrammingLanguageOptions } from "../../types/artifact";
const HTML_TEMPLATE = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面标题</title>
<style>
body { font-family: sans-serif; margin: 2rem; color: #333; }
</style>
</head>
<body>
<h1>Hello World</h1>
<p>在这里开始编写你的页面</p>
</body>
</html>`;
const REACT_TEMPLATE = `import React from "react";
interface Props {
// 在这里定义 props
}
export default function MyComponent({}: Props) {
return (
<div>
<h1>My Component</h1>
<p>在这里开始编写你的组件</p>
</div>
);
}`;
const PYTHON_TEMPLATE = `def main():
"""主函数"""
print("Hello, World!")
if __name__ == "__main__":
main()`;
interface QuickStartOption {
icon: LucideIcon;
title: string;
description: string;
type: "text" | "code";
templateTitle: string;
content: string;
language?: ProgrammingLanguageOptions;
}
const QUICK_START_OPTIONS: QuickStartOption[] = [
{
icon: FileText,
title: "空白文档",
description: "从空白 Markdown 文档开始",
type: "text",
templateTitle: "未命名文档",
content: "",
},
{
icon: Code2,
title: "空白代码",
description: "从空白代码文件开始",
type: "code",
templateTitle: "未命名代码",
content: "",
language: "python",
},
{
icon: Globe,
title: "HTML 页面",
description: "基础 HTML 页面模板",
type: "code",
templateTitle: "HTML 页面",
content: HTML_TEMPLATE,
language: "html",
},
{
icon: Atom,
title: "React 组件",
description: "TypeScript React 组件模板",
type: "code",
templateTitle: "React 组件",
content: REACT_TEMPLATE,
language: "typescript",
},
{
icon: Terminal,
title: "Python 脚本",
description: "Python 入口脚本模板",
type: "code",
templateTitle: "Python 脚本",
content: PYTHON_TEMPLATE,
language: "python",
},
];
interface CanvasEmptyStateProps {
initialType?: "text" | "code" | null;
}
export function CanvasEmptyState({ initialType }: CanvasEmptyStateProps) {
const { setArtifact } = useCanvasGraphContext();
function handleSelect(option: QuickStartOption) {
setArtifact(
createDraftArtifact(option.type, {
title: option.templateTitle,
content: option.content,
language: option.language,
}),
);
}
const filtered =
initialType
? QUICK_START_OPTIONS.filter(
(o) => !initialType || o.type === initialType || o.type === "text",
)
: QUICK_START_OPTIONS;
return (
<div className="h-full flex flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<p className="text-sm font-medium">选择模板快速开始</p>
<p className="text-xs text-muted-foreground">
或直接在左侧聊天区发送消息
</p>
</div>
<div className="space-y-2">
{QUICK_START_OPTIONS.map((option) => {
const Icon = option.icon;
const isHighlighted = initialType && option.type === initialType && option.content === "";
return (
<button
key={option.title}
onClick={() => handleSelect(option)}
className={`w-full flex items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all hover:border-primary/50 hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isHighlighted
? "border-primary/40 bg-primary/5"
: "bg-card"
}`}
>
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{option.title}</p>
<p className="text-xs text-muted-foreground truncate">
{option.description}
</p>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { html } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import type { Extension } from "@codemirror/state";
import type { ViewUpdate } from "@codemirror/view";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useCallback, useMemo } from "react";
import type {
CanvasArtifactCode,
ProgrammingLanguageOptions,
} from "../../types/artifact";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
// ---------------------------------------------------------------------------
// Themes — match code-editor.tsx defaults
// ---------------------------------------------------------------------------
const darkTheme = monokaiInit({
settings: {
background: "transparent",
gutterBackground: "transparent",
gutterForeground: "#555",
gutterActiveForeground: "#fff",
fontSize: "var(--text-sm)",
},
});
const lightTheme = basicLightInit({
settings: {
background: "transparent",
fontSize: "var(--text-sm)",
},
});
// ---------------------------------------------------------------------------
// Language detection
// ---------------------------------------------------------------------------
function getLanguageExtension(
lang: ProgrammingLanguageOptions,
): Extension | null {
switch (lang) {
case "typescript":
return javascript({ typescript: true, jsx: true });
case "javascript":
return javascript({ jsx: true });
case "python":
return python();
case "html":
return html();
case "json":
return json();
// cpp, java, rust, sql, xml, clojure, csharp, php → plain text fallback
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface CodeArtifactRendererProps {
content: CanvasArtifactCode;
}
export function CodeArtifactRenderer({ content }: CodeArtifactRendererProps) {
const { resolvedTheme } = useTheme();
const { handleCodeSelectionChange } = useCanvasSelection();
const extensions = useMemo<Extension[]>(() => {
const ext = getLanguageExtension(content.language);
return ext ? [ext] : [];
}, [content.language]);
const handleUpdate = useCallback(
(update: ViewUpdate) => {
if (!update.selectionSet) return;
const { from, to } = update.state.selection.main;
handleCodeSelectionChange(from, to);
},
[handleCodeSelectionChange],
);
if (!content.code) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">
空白代码,发送消息让 AI 填充内容
</p>
</div>
);
}
return (
<div className="h-full overflow-auto">
<CodeMirror
value={content.code}
readOnly
height="100%"
className="h-full font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!"
theme={resolvedTheme === "dark" ? darkTheme : lightTheme}
extensions={extensions}
onUpdate={handleUpdate}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: false,
highlightActiveLineGutter: false,
autocompletion: false,
}}
/>
</div>
);
}
@@ -0,0 +1,142 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { CanvasArtifactText } from "../../types/artifact";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
interface TextArtifactRendererProps {
content: CanvasArtifactText;
}
export function TextArtifactRenderer({ content }: TextArtifactRendererProps) {
const { handleTextPointerUp } = useCanvasSelection();
if (!content.fullMarkdown) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">
空白文档,发送消息让 AI 填充内容
</p>
</div>
);
}
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) {
return (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
);
}
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
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}
</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>
</div>
</div>
);
}
@@ -0,0 +1,205 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
import { useRef, useState, type KeyboardEvent } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import {
buildMessageWithSelection,
hasActiveSelection,
resolveCodeSelection,
} from "../../utils/selection";
function ChatWelcome() {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 space-y-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">开始创作</p>
<p className="text-xs text-muted-foreground leading-relaxed">
发送消息让 AI 帮你生成文档或代码,
<br />
或在右侧选择快速开始模板
</p>
</div>
</div>
);
}
export function CanvasChatPanel() {
const { messages, isStreaming, artifact } = useCanvasGraphContext();
const { sendMessage } = useCanvasStream();
const { selection, clearSelection } = useCanvasSelection();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasMessages = messages.length > 0;
const hasSelection = hasActiveSelection(selection);
// Resolve selected code snippet when artifact is a code artifact
const currentContent = getCurrentArtifactContent(artifact);
const resolvedCode =
selection.codeSelection && currentContent && isCodeArtifact(currentContent)
? resolveCodeSelection(
currentContent.code,
selection.codeSelection.startCharIndex,
selection.codeSelection.endCharIndex,
)
: undefined;
// Build a short preview for the selection badge
const selectionPreview = (() => {
if (selection.textSelection?.selectedText) {
const t = selection.textSelection.selectedText;
return t.length > 40 ? t.slice(0, 40) + "…" : t;
}
if (resolvedCode) {
return resolvedCode.length > 40
? resolvedCode.slice(0, 40) + "…"
: resolvedCode;
}
return null;
})();
async function handleSend() {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
// Embed selection context into the message when something is selected
const finalText = hasSelection
? buildMessageWithSelection(text, selection, resolvedCode)
: text;
clearSelection();
await sendMessage(finalText);
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}
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";
const text =
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content);
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>
)}
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
isHuman
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
}`}
>
{text}
</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 && (
<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">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
) : (
<ChatWelcome />
)}
</div>
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2 shrink-0">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">
{selectionPreview}
</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Input area */}
<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}
placeholder={
hasSelection
? "针对选中内容发出指令…"
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)"
}
className="min-h-[40px] max-h-[160px] resize-none text-sm"
rows={1}
disabled={isStreaming}
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
disabled={!input.trim() || isStreaming}
onClick={() => void handleSend()}
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizonal className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Loader2, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { getCurrentArtifactContent } from "../../utils/artifact";
export function CanvasHeader() {
const navigate = useNavigate();
const { threadId } = useCanvasThreadContext();
const { artifact, isStreaming } = useCanvasGraphContext();
const { isChatCollapsed, setIsChatCollapsed } = useCanvasUIContext();
const currentContent = getCurrentArtifactContent(artifact);
const title = currentContent?.title ?? "新 Canvas";
return (
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate("/page/canvas")}
title="返回 Canvas 列表"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setIsChatCollapsed(!isChatCollapsed)}
>
{isChatCollapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isChatCollapsed ? "展开聊天区" : "收起聊天区"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-4" />
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm font-medium truncate">{title}</span>
{isStreaming && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" />
)}
</div>
{threadId && (
<span className="text-[11px] text-muted-foreground font-mono hidden sm:block shrink-0 max-w-[120px] truncate">
{threadId.slice(0, 8)}
</span>
)}
</div>
);
}
@@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
export function CanvasResizableLayout() {
const { isChatCollapsed } = useCanvasUIContext();
const layoutRef = useRef<GroupImperativeHandle>(null);
useEffect(() => {
layoutRef.current?.setLayout(
isChatCollapsed ? LAYOUT_COLLAPSED : LAYOUT_DEFAULT,
);
}, [isChatCollapsed]);
return (
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
className="h-full"
>
<ResizablePanel
id="chat"
defaultSize={35}
minSize={0}
collapsible
collapsedSize={0}
className="overflow-hidden"
>
<CanvasChatPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
id="artifact"
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<CanvasArtifactPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { getCanvasThread } from "../../api/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { CanvasHeader } from "./CanvasHeader";
import { CanvasResizableLayout } from "./CanvasResizableLayout";
export function CanvasShell() {
const { threadId } = useCanvasThreadContext();
const { setMessages, setArtifact } = useCanvasGraphContext();
// Load existing thread state when navigating to a saved thread
useEffect(() => {
if (!threadId) return;
let cancelled = false;
getCanvasThread(threadId)
.then((values) => {
if (cancelled || !values) return;
if (values.messages?.length) {
setMessages(values.messages);
}
if (values.artifact) {
setArtifact(values.artifact);
}
})
.catch((err) => {
console.error("[canvas] failed to load thread state:", err);
});
return () => {
cancelled = true;
};
}, [threadId, setMessages, setArtifact]);
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasHeader />
<CanvasResizableLayout />
</div>
);
}
@@ -0,0 +1,43 @@
import type { ProgrammingLanguageOptions } from "../types/artifact";
import type { QuickAction } from "../types/quick-action";
export const CANVAS_AGENT_ID = "canvas_agent";
export const CANVAS_THREAD_METADATA = {
ui_mode: "canvas",
agent_id: CANVAS_AGENT_ID,
} as const;
export const PROGRAMMING_LANGUAGES: Array<{
language: ProgrammingLanguageOptions;
label: string;
}> = [
{ language: "typescript", label: "TypeScript" },
{ language: "javascript", label: "JavaScript" },
{ language: "python", label: "Python" },
{ language: "html", label: "HTML" },
{ language: "cpp", label: "C++" },
{ language: "java", label: "Java" },
{ language: "rust", label: "Rust" },
{ language: "sql", label: "SQL" },
{ language: "json", label: "JSON" },
{ language: "php", label: "PHP" },
{ language: "xml", label: "XML" },
{ language: "clojure", label: "Clojure" },
{ language: "csharp", label: "C#" },
{ language: "other", label: "Other" },
];
export const QUICK_ACTIONS_TEXT: QuickAction[] = [
{ id: "expand", label: "扩写", applicable_to: "text" },
{ id: "shorten", label: "精简", applicable_to: "text" },
{ id: "formalize", label: "改正式语气", applicable_to: "text" },
{ id: "casualize", label: "改口语语气", applicable_to: "text" },
];
export const QUICK_ACTIONS_CODE: QuickAction[] = [
{ id: "add_comments", label: "加注释", applicable_to: "code" },
{ id: "add_logs", label: "加日志", applicable_to: "code" },
{ id: "fix_bugs", label: "修复 Bug", applicable_to: "code" },
{ id: "optimize_code", label: "优化代码", applicable_to: "code" },
];
@@ -0,0 +1,72 @@
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasMessage } from "../types/thread";
export interface CanvasGraphContextValue {
messages: CanvasMessage[];
artifact: CanvasArtifact | null;
isStreaming: boolean;
setMessages: (messages: CanvasMessage[]) => void;
/** Upsert a message by id — concatenates string content for streaming deltas. */
appendMessage: (msg: CanvasMessage) => void;
setArtifact: (artifact: CanvasArtifact | null) => void;
setIsStreaming: (v: boolean) => void;
}
const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
export function CanvasGraphProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<CanvasMessage[]>([]);
const [artifact, setArtifact] = useState<CanvasArtifact | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const appendMessage = useCallback((msg: CanvasMessage) => {
setMessages((prev) => {
if (!msg.id) return [...prev, msg];
const idx = prev.findIndex((m) => m.id === msg.id);
if (idx >= 0) {
const existing = prev[idx]!;
const existingText =
typeof existing.content === "string" ? existing.content : "";
const deltaText =
typeof msg.content === "string" ? msg.content : "";
const next = [...prev];
next[idx] = { ...existing, content: existingText + deltaText };
return next;
}
return [...prev, msg];
});
}, []);
return (
<CanvasGraphContext.Provider
value={{
messages,
artifact,
isStreaming,
setMessages,
appendMessage,
setArtifact,
setIsStreaming,
}}
>
{children}
</CanvasGraphContext.Provider>
);
}
export function useCanvasGraphContext() {
const ctx = useContext(CanvasGraphContext);
if (!ctx)
throw new Error(
"useCanvasGraphContext must be used within CanvasGraphProvider",
);
return ctx;
}
@@ -0,0 +1,40 @@
import { createContext, useContext, useState, type ReactNode } from "react";
export interface CanvasThreadContextValue {
threadId: string | null;
modelName: string | null;
setThreadId: (id: string | null) => void;
setModelName: (name: string | null) => void;
}
const CanvasThreadContext = createContext<CanvasThreadContextValue | null>(null);
export function CanvasThreadProvider({
children,
initialThreadId,
}: {
children: ReactNode;
initialThreadId?: string | null;
}) {
const [threadId, setThreadId] = useState<string | null>(
initialThreadId ?? null,
);
const [modelName, setModelName] = useState<string | null>(null);
return (
<CanvasThreadContext.Provider
value={{ threadId, setThreadId, modelName, setModelName }}
>
{children}
</CanvasThreadContext.Provider>
);
}
export function useCanvasThreadContext() {
const ctx = useContext(CanvasThreadContext);
if (!ctx)
throw new Error(
"useCanvasThreadContext must be used within CanvasThreadProvider",
);
return ctx;
}
@@ -0,0 +1,79 @@
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type {
CanvasCodeSelection,
CanvasSelectionState,
CanvasTextSelection,
} from "../types/selection";
export interface CanvasUIContextValue {
isChatCollapsed: boolean;
isEditing: boolean;
isWebSearchOpen: boolean;
selection: CanvasSelectionState;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
/** Set text selection; clears any existing code selection. */
setTextSelection: (s: CanvasTextSelection | null) => void;
/** Set code selection; clears any existing text selection. */
setCodeSelection: (s: CanvasCodeSelection | null) => void;
clearSelection: () => void;
}
const EMPTY_SELECTION: CanvasSelectionState = {
textSelection: null,
codeSelection: null,
};
const CanvasUIContext = createContext<CanvasUIContextValue | null>(null);
export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
setSelection({ textSelection: s, codeSelection: null });
}, []);
const setCodeSelection = useCallback((s: CanvasCodeSelection | null) => {
setSelection({ textSelection: null, codeSelection: s });
}, []);
return (
<CanvasUIContext.Provider
value={{
isChatCollapsed,
isEditing,
isWebSearchOpen,
selection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
}}
>
{children}
</CanvasUIContext.Provider>
);
}
export function useCanvasUIContext() {
const ctx = useContext(CanvasUIContext);
if (!ctx)
throw new Error(
"useCanvasUIContext must be used within CanvasUIProvider",
);
return ctx;
}
@@ -0,0 +1,2 @@
// Phase 6-7: artifact version management helpers
export {};
@@ -0,0 +1,2 @@
// Phase 9: trigger quick actions and write quick_action_id to run input
export {};
@@ -0,0 +1,43 @@
import { useCallback } from "react";
import { useCanvasUIContext } from "../contexts/CanvasUIContext";
import { captureTextSelection } from "../utils/selection";
export function useCanvasSelection() {
const { selection, setTextSelection, setCodeSelection, clearSelection } =
useCanvasUIContext();
/**
* Call from a text renderer's onMouseUp handler.
* Reads window.getSelection() and stores the result.
*/
const handleTextPointerUp = useCallback(
(fullMarkdown: string) => {
const captured = captureTextSelection(fullMarkdown);
setTextSelection(captured);
},
[setTextSelection],
);
/**
* Call from CodeMirror's onUpdate/onStatistics handler.
* Pass the selection range (from/to char indices).
*/
const handleCodeSelectionChange = useCallback(
(from: number, to: number) => {
if (from === to) {
setCodeSelection(null);
} else {
setCodeSelection({ startCharIndex: from, endCharIndex: to });
}
},
[setCodeSelection],
);
return {
selection,
handleTextPointerUp,
handleCodeSelectionChange,
clearSelection,
};
}
@@ -0,0 +1,88 @@
import { useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { streamCanvasRun } from "../api/runs";
import type { CanvasRunInput } from "../api/runs";
import { createCanvasThread } from "../api/threads";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
import type { CanvasMessage } from "../types/thread";
export function useCanvasStream() {
const navigate = useNavigate();
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
const { messages, setMessages, appendMessage, setArtifact, setIsStreaming } =
useCanvasGraphContext();
// Ref so callbacks inside streamCanvasRun always see the latest messages
// without adding `messages` to useCallback's dep array (avoids re-creation
// on every message append during streaming).
const messagesRef = useRef(messages);
messagesRef.current = messages;
const sendMessage = useCallback(
async (
text: string,
extra?: Partial<Omit<CanvasRunInput, "messages">>,
) => {
// 1. Ensure we have a thread
let activeThreadId = threadId;
if (!activeThreadId) {
activeThreadId = await createCanvasThread(
modelName ? { model_name: modelName } : undefined,
);
setThreadId(activeThreadId);
navigate(`/page/canvas/${activeThreadId}`, { replace: true });
}
// 2. Optimistic human message
const humanMsg: CanvasMessage = {
id: `human-${Date.now()}`,
type: "human",
content: text,
};
setMessages([...messagesRef.current, humanMsg]);
setIsStreaming(true);
// 3. Build run input
const input: CanvasRunInput = {
messages: [{ type: "human", content: text }],
...extra,
};
// 4. Stream
await streamCanvasRun(activeThreadId, input, {
onMessage(msg) {
// Only surface AI text replies; skip tool messages
if (msg.type === "ai") {
appendMessage(msg);
}
},
onArtifact(artifact) {
setArtifact(artifact);
},
onDone() {
setIsStreaming(false);
},
onError(err) {
console.error("[canvas] stream error:", err);
setIsStreaming(false);
},
});
},
// messagesRef replaces direct `messages` dep — intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
[
threadId,
modelName,
setThreadId,
navigate,
appendMessage,
setMessages,
setArtifact,
setIsStreaming,
],
);
return { sendMessage };
}
@@ -0,0 +1 @@
export { useCanvasThreadContext as useCanvasThread } from "../contexts/CanvasThreadContext";
@@ -0,0 +1,76 @@
import { useNavigate } from "react-router-dom";
import { v4 as uuidv4 } from "uuid";
import { FileText, Code2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
const QUICK_START_ITEMS = [
{
type: "text" as const,
icon: FileText,
title: "新建文本文档",
description: "撰写和编辑 Markdown 文档,AI 实时协作修改",
},
{
type: "code" as const,
icon: Code2,
title: "新建代码",
description: "编写和重构多语言代码,AI 辅助生成与优化",
},
];
export default function CanvasHomePage() {
const navigate = useNavigate();
function handleQuickStart(type: "text" | "code") {
const threadId = uuidv4();
navigate(`${threadId}?type=${type}`);
}
return (
<div className="h-full flex flex-col items-center justify-center bg-background px-6">
<div className="w-full max-w-xl space-y-10">
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 mb-2">
<Sparkles className="w-6 h-6 text-primary" />
</div>
<h1 className="text-3xl font-bold tracking-tight">Canvas 模式</h1>
<p className="text-muted-foreground text-base leading-relaxed">
在左侧聊天区与 AI 协作,右侧实时生成和编辑文档或代码
</p>
</div>
<div className="grid grid-cols-2 gap-4">
{QUICK_START_ITEMS.map(({ type, icon: Icon, title, description }) => (
<button
key={type}
onClick={() => handleQuickStart(type)}
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:border-primary/50 hover:shadow-md hover:bg-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="space-y-1">
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{description}
</p>
</div>
</button>
))}
</div>
<div className="text-center">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground text-xs"
onClick={() => navigate(uuidv4())}
>
或直接开始空白 Canvas
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { useParams } from "react-router-dom";
import { CanvasThreadProvider } from "../contexts/CanvasThreadContext";
import { CanvasGraphProvider } from "../contexts/CanvasGraphContext";
import { CanvasUIProvider } from "../contexts/CanvasUIContext";
import { CanvasShell } from "../components/layout/CanvasShell";
export default function CanvasThreadPage() {
const { thread_id } = useParams<{ thread_id: string }>();
return (
<CanvasThreadProvider initialThreadId={thread_id}>
<CanvasGraphProvider>
<CanvasUIProvider>
<CanvasShell />
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -0,0 +1,13 @@
import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
}
@@ -0,0 +1,39 @@
export type ProgrammingLanguageOptions =
| "typescript"
| "javascript"
| "cpp"
| "java"
| "php"
| "python"
| "html"
| "sql"
| "json"
| "rust"
| "xml"
| "clojure"
| "csharp"
| "other";
export type ArtifactType = "code" | "text";
export type CanvasArtifactText = {
index: number;
type: "text";
title: string;
fullMarkdown: string;
};
export type CanvasArtifactCode = {
index: number;
type: "code";
title: string;
language: ProgrammingLanguageOptions;
code: string;
};
export type CanvasArtifactContent = CanvasArtifactText | CanvasArtifactCode;
export type CanvasArtifact = {
currentIndex: number;
contents: CanvasArtifactContent[];
};
@@ -0,0 +1,15 @@
export type CanvasModel = {
name: string;
display_name: string;
provider?: string;
is_local?: boolean;
supports_vision?: boolean;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
supports_tool_calling?: boolean;
supports_canvas_generation?: boolean;
supports_canvas_rewrite?: boolean;
supports_canvas_code_edit?: boolean;
supports_streaming_text?: boolean;
supports_streaming_tool_calls?: boolean;
};
@@ -0,0 +1,20 @@
export type QuickActionApplicableTo = "text" | "code" | "both";
export type QuickAction = {
id: string;
label: string;
applicable_to: QuickActionApplicableTo;
prompt_template?: string;
};
export type QuickActionId =
| "expand"
| "shorten"
| "formalize"
| "casualize"
| "add_comments"
| "add_logs"
| "fix_bugs"
| "optimize_code"
| "port_language"
| string;
@@ -0,0 +1,15 @@
export type CanvasTextSelection = {
fullMarkdown: string;
markdownBlock: string;
selectedText: string;
};
export type CanvasCodeSelection = {
startCharIndex: number;
endCharIndex: number;
};
export type CanvasSelectionState = {
textSelection: CanvasTextSelection | null;
codeSelection: CanvasCodeSelection | null;
};
@@ -0,0 +1,22 @@
import type { CanvasArtifact } from "./artifact";
export type CanvasMessage = {
id?: string;
type: string;
content: string | unknown[];
additional_kwargs?: Record<string, unknown>;
};
export type CanvasThreadValues = {
title?: string;
messages?: CanvasMessage[];
artifact?: CanvasArtifact;
web_search_results?: unknown[];
todos?: unknown[];
};
export type CanvasThreadMetadata = {
ui_mode: "canvas";
agent_id: "canvas_agent";
model_name?: string;
};
@@ -0,0 +1,84 @@
import type {
CanvasArtifact,
CanvasArtifactContent,
CanvasArtifactText,
CanvasArtifactCode,
ProgrammingLanguageOptions,
} from "../types/artifact";
export function getCurrentArtifactContent(
artifact: CanvasArtifact | null,
): CanvasArtifactContent | null {
if (!artifact || artifact.contents.length === 0) return null;
return (
artifact.contents.find((c) => c.index === artifact.currentIndex) ??
artifact.contents[artifact.contents.length - 1]
);
}
export function isTextArtifact(
content: CanvasArtifactContent,
): content is CanvasArtifactText {
return content.type === "text";
}
export function isCodeArtifact(
content: CanvasArtifactContent,
): content is CanvasArtifactCode {
return content.type === "code";
}
export function appendArtifactVersion(
artifact: CanvasArtifact,
content: Omit<CanvasArtifactContent, "index">,
): CanvasArtifact {
const nextIndex = artifact.contents.length + 1;
const newContent = { ...content, index: nextIndex } as CanvasArtifactContent;
return {
currentIndex: nextIndex,
contents: [...artifact.contents, newContent],
};
}
export function setCurrentArtifactIndex(
artifact: CanvasArtifact,
index: number,
): CanvasArtifact {
return { ...artifact, currentIndex: index };
}
export function createDraftArtifact(
type: "text" | "code",
options?: {
title?: string;
content?: string;
language?: ProgrammingLanguageOptions;
},
): CanvasArtifact {
const { title, content = "", language = "python" } = options ?? {};
if (type === "text") {
return {
currentIndex: 1,
contents: [
{
index: 1,
type: "text",
title: title ?? "未命名文档",
fullMarkdown: content,
},
],
};
}
return {
currentIndex: 1,
contents: [
{
index: 1,
type: "code",
title: title ?? "未命名代码",
language,
code: content,
},
],
};
}
@@ -0,0 +1,2 @@
// Phase 9: map quick action ids to run inputs
export {};
@@ -0,0 +1,98 @@
import type {
CanvasSelectionState,
CanvasTextSelection,
} from "../types/selection";
export function hasActiveSelection(selection: CanvasSelectionState): boolean {
return selection.textSelection !== null || selection.codeSelection !== null;
}
/**
* Find the nearest line-level block in markdown that contains selectedText.
* Falls back to the selectedText itself when no clean boundary is found.
*/
function findMarkdownBlock(fullMarkdown: string, selectedText: string): string {
const idx = fullMarkdown.indexOf(selectedText);
if (idx === -1) return selectedText;
let blockStart = idx;
while (blockStart > 0 && fullMarkdown[blockStart - 1] !== "\n") {
blockStart--;
}
let blockEnd = idx + selectedText.length;
while (blockEnd < fullMarkdown.length && fullMarkdown[blockEnd] !== "\n") {
blockEnd++;
}
return fullMarkdown.slice(blockStart, blockEnd).trim();
}
/**
* Read the browser selection and map it to a CanvasTextSelection.
* Returns null when nothing meaningful is selected.
*/
export function captureTextSelection(
fullMarkdown: string,
): CanvasTextSelection | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return null;
const selectedText = sel.toString().trim();
if (!selectedText) return null;
return {
fullMarkdown,
markdownBlock: findMarkdownBlock(fullMarkdown, selectedText),
selectedText,
};
}
/**
* Extract the code snippet from a full code string given char indices.
*/
export function resolveCodeSelection(
code: string,
start: number,
end: number,
): string {
return code.slice(start, end).trim();
}
/**
* Build the human message with embedded selection context so the canvas_agent
* knows exactly which portion to rewrite.
*/
export function buildMessageWithSelection(
userText: string,
selection: CanvasSelectionState,
resolvedCode?: string,
): string {
const { textSelection, codeSelection } = selection;
if (textSelection?.selectedText) {
return [
"请针对以下选中的文本内容进行操作:",
"",
"<selection>",
textSelection.selectedText,
"</selection>",
"",
`用户指令:${userText}`,
].join("\n");
}
if (codeSelection && resolvedCode) {
return [
"请针对以下选中的代码片段进行操作:",
"",
"<selection>",
resolvedCode,
"</selection>",
"",
`用户指令:${userText}`,
].join("\n");
}
return userText;
}
@@ -0,0 +1,2 @@
// Phase 6: parse LangGraph stream events into messages and artifact updates
export {};
@@ -0,0 +1,2 @@
// Phase 5: thread creation, metadata tagging, and state recovery helpers
export {};
+2
View File
@@ -6,6 +6,7 @@ import { PageSidebar } from "@/components/page-sidebar";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
const SIDEBAR_ENABLED = false;
@@ -41,6 +42,7 @@ function PageLayout() {
}
/>
<Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
</Routes>
</main>
</div>