Merge branch 'dhk-1' into 'master'

Dhk 1

See merge request knowledge-base-bj/deerflow-server!17
This commit is contained in:
董宏凯 committed 2026-05-15 15:28:24 +00:00
commit 05548b0fa6
49 files changed
+5691 -2721

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
# Open Canvas 集成进度文档
> 更新时间:2026-05-16
> 当前分支:dhk-1
---
## 一、整体进度概览
| 阶段 | 名称 | 状态 |
|------|------|------|
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
| 前端阶段四 | API 适配层 | ✅ 已完成 |
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
**当前状态**:全部 9 个阶段已完成 🎉
---
## 二、已完成工作详情
### 2.1 前端路由接入
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
```tsx
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 在 PageLayout 内部添加:
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
```
子路由:
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
### 2.2 前端目录结构(已建立,26 个文件)
```
frontend-web/src/open-canvas/
api/
client.ts # stub,Phase 5 实现
models.ts # stub,Phase 5 实现
threads.ts # stub,Phase 5 实现
runs.ts # stub,Phase 5 实现
components/
layout/
CanvasShell.tsx # 顶层布局壳,flex-col
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
chat/
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
artifact/
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
contexts/
CanvasThreadContext.tsx # threadId、modelName
CanvasGraphContext.tsx # messages、artifact、isStreaming
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
hooks/
useCanvasStream.ts # stub,Phase 5 实现
useCanvasArtifact.ts # stub,Phase 6 实现
useCanvasSelection.ts # stub,Phase 7 实现
useCanvasQuickActions.ts # stub,Phase 8 实现
pages/
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
routes/
OpenCanvasRoutes.tsx # React Router 子路由
types/
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
selection.ts # CanvasTextSelection、CanvasCodeSelection
quick-action.ts # QuickAction、QuickActionId
utils/
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
selection.ts # stub
thread.ts # stub
stream.ts # stub
quick-actions.ts # stub
constants/
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
```
### 2.3 核心类型定义
```ts
// types/artifact.ts
export type CanvasArtifact = {
currentIndex: number; // 从 1 开始
contents: CanvasArtifactContent[];
};
export type CanvasArtifactText = {
index: number; type: "text"; title: string; fullMarkdown: string;
};
export type CanvasArtifactCode = {
index: number; type: "code"; title: string;
language: ProgrammingLanguageOptions; code: string;
};
```
### 2.4 Context 结构
```tsx
// CanvasThreadPage.tsx 的装配顺序
<CanvasThreadProvider initialThreadId={thread_id}>
<CanvasGraphProvider>
<CanvasUIProvider>
<CanvasShell />
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
```
---
## 三、已解决的技术难题
### 3.1 react-resizable-panels v4.6.2 API 差异
此版本与旧版 API 不同,必须使用:
```tsx
// ✅ 正确写法
import type { GroupImperativeHandle } from "react-resizable-panels";
const layoutRef = useRef<GroupImperativeHandle>(null);
<ResizablePanelGroup
orientation="horizontal" // ← 不是 direction
groupRef={layoutRef} // ← 不是 ref
>
<ResizablePanel id="chat" ... /> // ← 必须有 id
<ResizablePanel id="artifact" ... /> // ← 必须有 id
</ResizablePanelGroup>
// 切换折叠
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
```
### 3.2 Flex 高度链(防止底部内容被裁剪)
```
PageLayout(h-screen)
└── main(flex-1)
└── CanvasShell div(h-full flex flex-col)
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
└── ResizablePanelGroup(h-full)
└── 面板内容(h-full)
```
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
### 3.3 折叠/展开按钮
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
```tsx
useEffect(() => {
layoutRef.current?.setLayout(
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
);
}, [isChatCollapsed]);
```
---
## 四、下一步:后端 ThreadState 扩展
**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py`
**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物):
```python
# 新增 TypedDict(建议单独定义)
class CanvasArtifactText(TypedDict):
index: int
type: Literal["text"]
title: str
fullMarkdown: str
class CanvasArtifactCode(TypedDict):
index: int
type: Literal["code"]
title: str
language: str
code: str
class CanvasArtifact(TypedDict):
currentIndex: int
contents: list[CanvasArtifactText | CanvasArtifactCode]
# 在 ThreadState 中新增字段
artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表)
highlighted_text: Optional[str] # 用户选中的文本片段
highlighted_code: Optional[str] # 用户选中的代码片段
canvas_mode: Optional[Literal["text", "code"]]
quick_action_id: Optional[str] # 快捷动作 id
web_search_enabled: bool # 默认 False
web_search_results: Optional[list[dict]]
```
**验收标准**:
- 旧线程无 `artifact` 字段时不报错
- `pnpm typecheck` 无新增错误(前端字段已对齐)
- `make test` 全量回归通过
**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环
---
## 五、关键文件位置
| 文件 | 用途 |
|------|------|
| `frontend-web/docs/openCanvas.md` | 主需求文档 |
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
---
## 六、架构决策记录
| 决策 | 内容 |
|------|------|
| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)|
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
---
## 七、开始新对话的推荐提示词
```
我们在集成 open-canvas 到 DeerFlow 项目。
参考进度文档:frontend-web/docs/openCanvas-progress.md
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
```
@@ -0,0 +1 @@
export { getAPIClient } from "@/core/api";
@@ -0,0 +1,39 @@
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;
// Canvas-phase-9 fields (now returned by backend)
description?: string;
}
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,
}),
);
}
+192
View File
@@ -0,0 +1,192 @@
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;
};
/** Partial artifact data extracted from streaming tool_call_chunks */
export type StreamingArtifactPartial = {
type?: "text" | "code";
title?: string;
content?: string;
language?: string;
};
export type CanvasStreamCallbacks = {
onMessage?: (msg: CanvasMessage) => void;
onArtifact?: (artifact: CanvasArtifact) => void;
/** Called repeatedly during streaming as update_artifact args accumulate */
onStreamingArtifact?: (partial: StreamingArtifactPartial) => void;
onDone?: () => void;
onError?: (err: unknown) => void;
};
// ---------------------------------------------------------------------------
// Streaming JSON arg extraction helpers
// ---------------------------------------------------------------------------
function unescapeJsonFragment(raw: string): string {
// Remove trailing incomplete escape sequence
const safe = raw.replace(/\\[^"nrts\\bfu]?$/, "");
let out = "";
let i = 0;
while (i < safe.length) {
if (safe[i] === "\\" && i + 1 < safe.length) {
const next = safe[i + 1]!;
if (next === "n") { out += "\n"; i += 2; }
else if (next === "r") { out += "\r"; i += 2; }
else if (next === "t") { out += "\t"; i += 2; }
else if (next === '"') { out += '"'; i += 2; }
else if (next === "\\") { out += "\\"; i += 2; }
else if (next === "b") { out += "\b"; i += 2; }
else if (next === "f") { out += "\f"; i += 2; }
else { out += safe[i]!; i++; }
} else if (safe[i] === '"') {
// Unescaped closing quote — content value ends here
break;
} else {
out += safe[i]!;
i++;
}
}
return out;
}
/** Extract a fully-quoted string field, e.g. "type": "text" */
function extractStringField(args: string, field: string): string | undefined {
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, "");
const m = re.exec(args);
if (!m) return undefined;
return unescapeJsonFragment(m[1]!);
}
/** Extract the content field even from incomplete JSON (no closing quote yet) */
function extractContentField(args: string): string | undefined {
const keyIdx = args.indexOf('"content"');
if (keyIdx === -1) return undefined;
const afterKey = args.slice(keyIdx + '"content"'.length);
const colonQuote = /^\s*:\s*"/.exec(afterKey);
if (!colonQuote) return undefined;
const valueStart = colonQuote[0].length;
const raw = afterKey.slice(valueStart);
return unescapeJsonFragment(raw);
}
function extractPartialArtifactArgs(args: string): StreamingArtifactPartial {
return {
type: extractStringField(args, "type") as "text" | "code" | undefined,
title: extractStringField(args, "title"),
language: extractStringField(args, "language"),
content: extractContentField(args),
};
}
// ---------------------------------------------------------------------------
// Main stream function
// ---------------------------------------------------------------------------
export async function streamCanvasRun(
threadId: string,
input: CanvasRunInput,
callbacks: CanvasStreamCallbacks,
modelName?: string | null,
): Promise<void> {
const client = getAPIClient();
try {
const stream = client.runs.stream(threadId, CANVAS_AGENT_ID, {
input,
streamMode: ["values", "messages-tuple"],
...(modelName ? { config: { configurable: { model_name: modelName } } } : {}),
});
// Accumulate tool call args by index position
const toolCallAccumulator: Record<number, { name: string; args: string }> = {};
for await (const chunk of stream) {
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") {
const tuple = data as [unknown, unknown] | unknown;
const rawMsg = Array.isArray(tuple) ? tuple[0] : tuple;
if (!rawMsg || typeof rawMsg !== "object") continue;
const msg = rawMsg as Record<string, unknown>;
// Emit text content to chat panel
callbacks.onMessage?.(msg as unknown as CanvasMessage);
// ---- Streaming tool call args (tool_call_chunks) ----
const toolCallChunks = msg.tool_call_chunks;
if (Array.isArray(toolCallChunks) && toolCallChunks.length > 0) {
for (const tcChunk of toolCallChunks as Array<Record<string, unknown>>) {
const idx = (tcChunk.index as number | undefined) ?? 0;
if (!toolCallAccumulator[idx]) {
toolCallAccumulator[idx] = {
name: (tcChunk.name as string | undefined) ?? "",
args: "",
};
} else if (tcChunk.name) {
toolCallAccumulator[idx]!.name = tcChunk.name as string;
}
toolCallAccumulator[idx]!.args += (tcChunk.args as string | undefined) ?? "";
if (toolCallAccumulator[idx]!.name === "update_artifact") {
const partial = extractPartialArtifactArgs(toolCallAccumulator[idx]!.args);
if (partial.content !== undefined || partial.title !== undefined) {
callbacks.onStreamingArtifact?.(partial);
}
}
}
}
// ---- Complete tool_calls (non-streaming or final snapshot) ----
const toolCalls = msg.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls as Array<Record<string, unknown>>) {
if (tc.name === "update_artifact" && tc.args) {
try {
const args =
typeof tc.args === "string"
? (JSON.parse(tc.args) as Record<string, unknown>)
: (tc.args as Record<string, unknown>);
callbacks.onStreamingArtifact?.({
type: args.type as "text" | "code" | undefined,
title: args.title as string | undefined,
content: args.content as string | undefined,
language: args.language as string | undefined,
});
} catch {
// ignore
}
}
}
}
}
}
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,113 @@
import { FileText, Loader2 } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import type { StreamingArtifactPartial } from "../../api/runs";
import {
getCurrentArtifactContent,
isCodeArtifact,
} from "../../utils/artifact";
import { CanvasArtifactHeader } from "./CanvasArtifactHeader";
import { CanvasEmptyState } from "./CanvasEmptyState";
import { CanvasQuickActionBar } from "./CanvasQuickActionBar";
import { TextArtifactRenderer } from "./TextArtifactRenderer";
import { CodeArtifactRenderer } from "./CodeArtifactRenderer";
import type {
CanvasArtifactCode,
CanvasArtifactText,
} from "../../types/artifact";
// ---------------------------------------------------------------------------
// StreamingOverlay — shown while update_artifact args are streaming in
// ---------------------------------------------------------------------------
function StreamingTextPreview({ partial }: { partial: StreamingArtifactPartial }) {
const hasContent = partial.content && partial.content.trim();
if (!hasContent) {
// Args started but content not yet arrived — show skeleton
return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
<div className="space-y-1 text-center">
{partial.title ? (
<p className="text-sm font-medium text-foreground">{partial.title}</p>
) : null}
<p className="text-xs">AI 正在生成文档…</p>
</div>
</div>
);
}
// Stream content into the real renderer (read-only, isStreaming = true)
if (partial.type === "code") {
const fakeContent: CanvasArtifactCode = {
index: 0,
type: "code",
title: partial.title ?? "代码",
language: (partial.language as CanvasArtifactCode["language"]) ?? "other",
code: partial.content ?? "",
};
return <CodeArtifactRenderer content={fakeContent} isStreaming />;
}
const fakeContent: CanvasArtifactText = {
index: 0,
type: "text",
title: partial.title ?? "文档",
fullMarkdown: partial.content ?? "",
};
return <TextArtifactRenderer content={fakeContent} isStreaming />;
}
// ---------------------------------------------------------------------------
// CanvasArtifactPanel
// ---------------------------------------------------------------------------
export function CanvasArtifactPanel() {
const { artifact, streamingArtifact, isStreaming } = useCanvasGraphContext();
const [searchParams] = useSearchParams();
const initialType = searchParams.get("type") as "text" | "code" | null;
// While streaming and we have partial data, show the streaming preview
if (isStreaming && streamingArtifact) {
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b shrink-0 bg-muted/30">
<FileText className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium truncate flex-1">
{streamingArtifact.title ?? "正在生成…"}
</span>
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary shrink-0" />
</div>
<div className="flex-1 overflow-hidden">
<StreamingTextPreview partial={streamingArtifact} />
</div>
</div>
);
}
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 />
<CanvasQuickActionBar />
<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,34 @@
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasQuickActions } from "../../hooks/useCanvasQuickActions";
import type { QuickAction } from "../../types/quick-action";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import { QUICK_ACTIONS_CODE, QUICK_ACTIONS_TEXT } from "../../constants";
export function CanvasQuickActionBar() {
const { artifact, isStreaming } = useCanvasGraphContext();
const { triggerQuickAction } = useCanvasQuickActions();
const content = getCurrentArtifactContent(artifact);
if (!content) return null;
const actions: QuickAction[] = isCodeArtifact(content)
? QUICK_ACTIONS_CODE
: QUICK_ACTIONS_TEXT;
return (
<div className="border-b px-4 py-1.5 flex items-center gap-1.5 flex-wrap shrink-0 bg-background">
{actions.map((action) => (
<button
key={action.id}
disabled={isStreaming}
onClick={() => void triggerQuickAction(action.id)}
className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors
bg-background text-foreground hover:bg-accent hover:text-accent-foreground
disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{action.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,121 @@
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;
isStreaming?: boolean;
}
export function CodeArtifactRenderer({ content, isStreaming: _isStreaming }: 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,146 @@
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;
isStreaming?: boolean;
}
export function TextArtifactRenderer({ content, isStreaming }: 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>
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div>
</div>
);
}
@@ -0,0 +1,414 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
import { useEffect, 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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream";
import type { CanvasMessage } from "../../types/thread";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import {
buildMessageWithSelection,
hasActiveSelection,
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
// ---------------------------------------------------------------------------
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>
);
}
// ---------------------------------------------------------------------------
// CanvasChatPanel
// ---------------------------------------------------------------------------
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 bottomRef = useRef<HTMLDivElement>(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;
})();
// 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;
setInput("");
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();
}
}
// 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;
})();
// 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;
})();
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>
) : (
<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,73 @@
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";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
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>
<CanvasModelSelector />
</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,50 @@
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, setThreadId } = useCanvasThreadContext();
const { setMessages, setArtifact } = useCanvasGraphContext();
// Load existing thread state when navigating to a saved thread.
// If the thread doesn't exist in the backend (e.g. pre-generated UUID from
// CanvasHomePage or stale URL after a DB reset), clear threadId so that
// the next sendMessage call creates a fresh thread instead of hitting 404.
useEffect(() => {
if (!threadId) return;
let cancelled = false;
getCanvasThread(threadId)
.then((values) => {
if (cancelled) return;
if (!values) {
setThreadId(null);
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, setThreadId]);
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasHeader />
<CanvasResizableLayout />
</div>
);
}
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { Cpu } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { loadCanvasModels } from "../../api/models";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import type { CanvasModel } from "../../types/model";
export function CanvasModelSelector() {
const { modelName, setModelName } = useCanvasThreadContext();
const [models, setModels] = useState<CanvasModel[]>([]);
useEffect(() => {
loadCanvasModels()
.then((list) => {
setModels(list);
if (!modelName && list.length > 0) {
setModelName(list[0].name);
}
})
.catch(() => {
// fail silently — backend may be unavailable during dev
});
// only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (models.length === 0) return null;
const selected = models.find((m) => m.name === modelName) ?? models[0];
return (
<Select value={selected.name} onValueChange={setModelName}>
<SelectTrigger className="h-7 text-xs border-dashed w-auto min-w-[120px] max-w-[200px] gap-1.5">
<SelectValue>
<span className="flex items-center gap-1.5 truncate">
{selected.is_local && (
<Cpu className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="truncate">
{selected.display_name ?? selected.name}
</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{m.display_name ?? m.name}</span>
{m.is_local && (
<Badge variant="secondary" className="text-[10px] h-4 px-1 shrink-0">
本地
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -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,79 @@
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasMessage } from "../types/thread";
import type { StreamingArtifactPartial } from "../api/runs";
export interface CanvasGraphContextValue {
messages: CanvasMessage[];
artifact: CanvasArtifact | null;
streamingArtifact: StreamingArtifactPartial | 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;
setStreamingArtifact: (partial: StreamingArtifactPartial | 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 [streamingArtifact, setStreamingArtifact] =
useState<StreamingArtifactPartial | 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,
streamingArtifact,
isStreaming,
setMessages,
appendMessage,
setArtifact,
setStreamingArtifact,
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,25 @@
import { useCallback } from "react";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import type { QuickActionId } from "../types/quick-action";
import { getCurrentArtifactContent } from "../utils/artifact";
import { buildQuickActionMessage } from "../utils/quick-actions";
import { useCanvasStream } from "./useCanvasStream";
export function useCanvasQuickActions() {
const { sendMessage } = useCanvasStream();
const { artifact } = useCanvasGraphContext();
const triggerQuickAction = useCallback(
async (actionId: QuickActionId) => {
const content = getCurrentArtifactContent(artifact);
if (!content) return;
const message = buildQuickActionMessage(actionId);
await sendMessage(message, { quick_action_id: actionId });
},
[sendMessage, artifact],
);
return { triggerQuickAction };
}
@@ -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,105 @@
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,
setStreamingArtifact,
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) {
if (msg.type === "ai" || msg.type === "AIMessageChunk") {
appendMessage(msg);
}
},
onStreamingArtifact(partial) {
setStreamingArtifact(partial);
},
onArtifact(artifact) {
setArtifact(artifact);
setStreamingArtifact(null);
},
onDone() {
setIsStreaming(false);
setStreamingArtifact(null);
},
onError(err) {
console.error("[canvas] stream error:", err);
setIsStreaming(false);
setStreamingArtifact(null);
},
},
modelName ?? undefined,
);
},
// messagesRef replaces direct `messages` dep — intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
[
threadId,
modelName,
setThreadId,
navigate,
appendMessage,
setMessages,
setArtifact,
setStreamingArtifact,
setIsStreaming,
],
);
return { sendMessage };
}
@@ -0,0 +1 @@
export { useCanvasThreadContext as useCanvasThread } from "../contexts/CanvasThreadContext";
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createCanvasThread } from "../api/threads";
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();
const [loading, setLoading] = useState(false);
async function handleQuickStart(type: "text" | "code") {
if (loading) return;
setLoading(true);
try {
const threadId = await createCanvasThread();
navigate(`${threadId}?type=${type}`);
} catch (err) {
console.error("[canvas] failed to create thread:", err);
setLoading(false);
}
}
async function handleBlankCanvas() {
if (loading) return;
setLoading(true);
try {
const threadId = await createCanvasThread();
navigate(threadId);
} catch (err) {
console.error("[canvas] failed to create thread:", err);
setLoading(false);
}
}
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={() => void handleQuickStart(type)}
disabled={loading}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
{loading ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<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={() => void handleBlankCanvas()}
disabled={loading}
>
{loading ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : null}
或直接开始空白 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,16 @@
import type { QuickActionId } from "../types/quick-action";
const QUICK_ACTION_MESSAGES: Record<string, string> = {
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug",
optimize_code: "请对当前代码进行性能和可读性优化",
};
export function buildQuickActionMessage(id: QuickActionId): string {
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
}
@@ -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>
@@ -3,9 +3,50 @@ from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
router = APIRouter(prefix="/api", tags=["models"])
_PROVIDER_KEYWORDS: list[tuple[str, str]] = [
("langchain_openai", "openai"),
("langchain_anthropic", "anthropic"),
("langchain_google", "google"),
("langchain_groq", "groq"),
("langchain_mistralai", "mistral"),
("langchain_cohere", "cohere"),
("ollama", "ollama"),
("vllm", "vllm"),
]
_LOCAL_PROVIDERS = {"ollama", "vllm"}
def _derive_provider(use: str) -> str | None:
use_lower = use.lower()
for keyword, provider in _PROVIDER_KEYWORDS:
if keyword in use_lower:
return provider
return None
def _build_model_response(model: ModelConfig) -> "ModelResponse":
extra = model.model_extra or {}
provider = str(extra["provider"]) if "provider" in extra else _derive_provider(model.use)
is_local = bool(extra["is_local"]) if "is_local" in extra else (provider in _LOCAL_PROVIDERS)
supports_tool_calling = bool(extra["supports_tool_calling"]) if "supports_tool_calling" in extra else True
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
supports_vision=model.supports_vision,
supports_tool_calling=supports_tool_calling,
is_local=is_local,
provider=provider,
)
class ModelResponse(BaseModel):
"""Response model for model information."""
@@ -16,6 +57,10 @@ class ModelResponse(BaseModel):
description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
supports_vision: bool = Field(default=False, description="Whether model supports image inputs")
supports_tool_calling: bool = Field(default=True, description="Whether model supports tool/function calling")
is_local: bool = Field(default=False, description="Whether this is a locally-hosted model")
provider: str | None = Field(None, description="Model provider name (openai, anthropic, ollama, etc.)")
class TokenUsageResponse(BaseModel):
@@ -73,17 +118,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
}
```
"""
models = [
ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
for model in config.models
]
models = [_build_model_response(model) for model in config.models]
return ModelsListResponse(
models=models,
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
@@ -122,11 +157,4 @@ async def get_model(model_name: str, config: AppConfig = Depends(get_config)) ->
if model is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
return _build_model_response(model)
@@ -98,6 +98,10 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
_DEFAULT_ASSISTANT_ID = "lead_agent"
# Built-in LangGraph graph IDs registered in langgraph.json.
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent"})
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
@@ -180,12 +184,15 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
def resolve_agent_factory(assistant_id: str | None):
"""Resolve the agent factory callable from config.
Custom agents are implemented as ``lead_agent`` + an ``agent_id``
injected into ``configurable`` or ``context`` — see
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
same factory; the routing happens inside ``make_lead_agent`` when it reads
``cfg["agent_id"]``.
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
to their own factory. Custom agents share the ``lead_agent`` factory with
``agent_id`` injected into configurable/context by :func:`build_run_config`.
"""
if assistant_id == "canvas_agent":
from deerflow.agents.canvas_agent.agent import make_canvas_agent
return make_canvas_agent
from deerflow.agents.lead_agent.agent import make_lead_agent
return make_lead_agent
@@ -244,8 +251,9 @@ def build_run_config(
config["configurable"] = {"thread_id": thread_id}
# Inject custom agent id when the caller specified a non-default assistant.
# Honour an explicit agent_id in the active runtime options container.
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
# Built-in graphs (lead_agent, canvas_agent) are dispatched via their own
# factory and must NOT receive an agent_id override.
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
normalized = assistant_id.strip()
if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized):
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, hyphens, and underscores.")
@@ -265,7 +273,7 @@ def build_run_config(
def _collect_requested_agent_ids(body: Any) -> set[str]:
"""Collect agent ids from assistant_id/config/context for access checks."""
agent_ids: set[str] = set()
if body.assistant_id and body.assistant_id != _DEFAULT_ASSISTANT_ID:
if body.assistant_id and body.assistant_id not in _BUILTIN_GRAPH_IDS:
agent_ids.add(str(body.assistant_id))
def collect(container: Mapping[str, Any] | None) -> None:
@@ -6,7 +6,8 @@
],
"env": ".env",
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent"
"lead_agent": "deerflow.agents:make_lead_agent",
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent"
},
"auth": {
"path": "./app/gateway/langgraph_auth.py:auth"
@@ -0,0 +1,3 @@
from deerflow.agents.canvas_agent.agent import make_canvas_agent
__all__ = ["make_canvas_agent"]
@@ -0,0 +1,68 @@
from __future__ import annotations
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.tools import update_artifact_tool
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
from deerflow.agents.thread_state import ThreadState
from deerflow.config.app_config import get_app_config
from deerflow.models import create_chat_model
from deerflow.tools.builtins import ask_clarification_tool
logger = logging.getLogger(__name__)
def _get_runtime_config(config: RunnableConfig) -> dict:
cfg = dict(config.get("configurable", {}) or {})
context = config.get("context", {}) or {}
if isinstance(context, dict):
cfg.update(context)
return cfg
def make_canvas_agent(config: RunnableConfig):
"""LangGraph graph factory for canvas mode; signature compatible with LangGraph Server."""
app_config = get_app_config()
cfg = _get_runtime_config(config)
requested_model = cfg.get("model_name") or cfg.get("model")
if requested_model and app_config.get_model_config(requested_model):
model_name = requested_model
else:
if requested_model:
logger.warning("Canvas agent: model '%s' not found; falling back to default.", requested_model)
model_name = app_config.models[0].name if app_config.models else None
if model_name is None:
raise ValueError("No chat models configured. Please add at least one model in config.yaml.")
thinking_enabled = cfg.get("thinking_enabled", False)
model_config = app_config.get_model_config(model_name)
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)
middlewares = [
DanglingToolCallMiddleware(),
ToolErrorHandlingMiddleware(),
TitleMiddleware(app_config=app_config),
LoopDetectionMiddleware(),
ClarificationMiddleware(),
]
return create_agent(
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,
state_schema=ThreadState,
)
@@ -0,0 +1,40 @@
CANVAS_SYSTEM_PROMPT = """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.
ALWAYS call `update_artifact` to save your work. Never embed the artifact content in your chat response.
Rules:
- On every request that creates or changes the artifact, call `update_artifact` ONCE with the COMPLETE updated content.
- In your text reply, describe what you did in 1–2 sentences.
- If the user is only asking a question (no edit needed), answer in text — skip the tool call.
</canvas_behavior>
<artifact_context>
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>
<selection_rewrite>
When the user's message contains a <selection> block, you MUST:
1. Identify the exact selection in the current artifact content.
2. Rewrite ONLY that selection according to the user's instruction.
3. Keep the rest of the artifact exactly unchanged.
4. Call update_artifact with the complete artifact where the selection has been replaced.
Example: if the artifact is "A B C" and the selection is "B" with instruction "make it bold",
produce "A **B** C" — not a new document.
</selection_rewrite>
<quick_actions>
When the user triggers a quick action, apply it to the full artifact unless specific highlighted text is provided:
- expand: Add depth and detail while preserving structure
- shorten: Condense to the essential points
- formalize: Rewrite in professional, formal language
- casualize: Rewrite in friendly, conversational language
- add_comments: Add clear, helpful code comments
- add_logs: Add appropriate logging statements
- fix_bugs: Identify and fix all bugs in the code
- optimize_code: Improve performance and readability
</quick_actions>
"""
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Annotated, Literal
from langchain.tools import InjectedToolCallId, ToolRuntime, tool
from langchain_core.messages import ToolMessage
from langgraph.types import Command
from langgraph.typing import ContextT
from deerflow.agents.thread_state import CanvasArtifact, CanvasArtifactCode, CanvasArtifactText, ThreadState
@tool("update_artifact", parse_docstring=True)
def update_artifact_tool(
runtime: ToolRuntime[ContextT, ThreadState],
tool_call_id: Annotated[str, InjectedToolCallId],
type: Literal["text", "code"],
title: str,
content: str,
language: str = "other",
) -> Command:
"""Create or update the canvas artifact. Always call this to persist your work.
Args:
type: Artifact type — "text" for markdown documents, "code" for source code.
title: Human-readable title of the artifact.
content: Full artifact content: markdown text or complete source code.
language: Programming language (for code type only, e.g. "python", "typescript"). Ignored for text.
"""
current: CanvasArtifact | None = (runtime.state or {}).get("artifact") if runtime.state else None
if current is not None:
existing_contents = list(current.get("contents", []))
new_index = current.get("currentIndex", len(existing_contents)) + 1
else:
existing_contents = []
new_index = 1
if type == "text":
new_entry: CanvasArtifactText | CanvasArtifactCode = CanvasArtifactText(
index=new_index,
type="text",
title=title,
fullMarkdown=content,
)
else:
new_entry = CanvasArtifactCode(
index=new_index,
type="code",
title=title,
language=language,
code=content,
)
updated: CanvasArtifact = {
"currentIndex": new_index,
"contents": existing_contents + [new_entry],
}
return Command(
update={
"artifact": updated,
"messages": [ToolMessage("Artifact updated.", tool_call_id=tool_call_id)],
}
)
@@ -1,4 +1,4 @@
from typing import Annotated, NotRequired, TypedDict
from typing import Annotated, Literal, NotRequired, Optional, TypedDict
from langchain.agents import AgentState
@@ -18,6 +18,26 @@ class ViewedImageData(TypedDict):
mime_type: str
class CanvasArtifactText(TypedDict):
index: int
type: Literal["text"]
title: str
fullMarkdown: str
class CanvasArtifactCode(TypedDict):
index: int
type: Literal["code"]
title: str
language: str
code: str
class CanvasArtifact(TypedDict):
currentIndex: int
contents: list[CanvasArtifactText | CanvasArtifactCode]
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
"""Reducer for artifacts list - merges and deduplicates artifacts."""
if existing is None:
@@ -53,3 +73,11 @@ class ThreadState(AgentState):
todos: NotRequired[list | None]
uploaded_files: NotRequired[list[dict] | None]
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
# Canvas fields (coexist with artifacts: list[str] which tracks file outputs)
artifact: NotRequired[Optional[CanvasArtifact]]
highlighted_text: NotRequired[Optional[str]]
highlighted_code: NotRequired[Optional[str]]
canvas_mode: NotRequired[Optional[Literal["text", "code"]]]
quick_action_id: NotRequired[Optional[str]]
web_search_enabled: NotRequired[bool]
web_search_results: NotRequired[Optional[list[dict]]]
+2692 -2692
View File
File diff suppressed because it is too large. Load diff