Merge branch 'dhk-1' into 'master'
Dhk 1 See merge request knowledge-base-bj/deerflow-server!17
This commit is contained in:
commit
05548b0fa6
49 files changed
+5691
-2721
No files matched your search
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
@@ -6,6 +6,7 @@ import { PageSidebar } from "@/components/page-sidebar";
|
|||||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||||
import StrategyRoutes from "./StrategyRoutes";
|
import StrategyRoutes from "./StrategyRoutes";
|
||||||
import Header from "@/strategy-components/Header";
|
import Header from "@/strategy-components/Header";
|
||||||
|
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||||
|
|
||||||
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
|
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
|
||||||
const SIDEBAR_ENABLED = false;
|
const SIDEBAR_ENABLED = false;
|
||||||
@@ -41,6 +42,7 @@ function PageLayout() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||||
|
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,50 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.model_config import ModelConfig
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["models"])
|
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):
|
class ModelResponse(BaseModel):
|
||||||
"""Response model for model information."""
|
"""Response model for model information."""
|
||||||
@@ -16,6 +57,10 @@ class ModelResponse(BaseModel):
|
|||||||
description: str | None = Field(None, description="Model description")
|
description: str | None = Field(None, description="Model description")
|
||||||
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
|
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_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):
|
class TokenUsageResponse(BaseModel):
|
||||||
@@ -73,17 +118,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
models = [
|
models = [_build_model_response(model) for model in config.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
|
|
||||||
]
|
|
||||||
return ModelsListResponse(
|
return ModelsListResponse(
|
||||||
models=models,
|
models=models,
|
||||||
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
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:
|
if model is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
||||||
|
|
||||||
return ModelResponse(
|
return _build_model_response(model)
|
||||||
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,
|
|
||||||
)
|
|
||||||
@@ -98,6 +98,10 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
|
|
||||||
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
_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]:
|
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.
|
"""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):
|
def resolve_agent_factory(assistant_id: str | None):
|
||||||
"""Resolve the agent factory callable from config.
|
"""Resolve the agent factory callable from config.
|
||||||
|
|
||||||
Custom agents are implemented as ``lead_agent`` + an ``agent_id``
|
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
|
||||||
injected into ``configurable`` or ``context`` — see
|
to their own factory. Custom agents share the ``lead_agent`` factory with
|
||||||
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
``agent_id`` injected into configurable/context by :func:`build_run_config`.
|
||||||
same factory; the routing happens inside ``make_lead_agent`` when it reads
|
|
||||||
``cfg["agent_id"]``.
|
|
||||||
"""
|
"""
|
||||||
|
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
|
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||||
|
|
||||||
return make_lead_agent
|
return make_lead_agent
|
||||||
@@ -244,8 +251,9 @@ def build_run_config(
|
|||||||
config["configurable"] = {"thread_id": thread_id}
|
config["configurable"] = {"thread_id": thread_id}
|
||||||
|
|
||||||
# Inject custom agent id when the caller specified a non-default assistant.
|
# Inject custom agent id when the caller specified a non-default assistant.
|
||||||
# Honour an explicit agent_id in the active runtime options container.
|
# Built-in graphs (lead_agent, canvas_agent) are dispatched via their own
|
||||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
# factory and must NOT receive an agent_id override.
|
||||||
|
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||||
normalized = assistant_id.strip()
|
normalized = assistant_id.strip()
|
||||||
if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized):
|
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.")
|
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]:
|
def _collect_requested_agent_ids(body: Any) -> set[str]:
|
||||||
"""Collect agent ids from assistant_id/config/context for access checks."""
|
"""Collect agent ids from assistant_id/config/context for access checks."""
|
||||||
agent_ids: set[str] = set()
|
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))
|
agent_ids.add(str(body.assistant_id))
|
||||||
|
|
||||||
def collect(container: Mapping[str, Any] | None) -> None:
|
def collect(container: Mapping[str, Any] | None) -> None:
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
],
|
],
|
||||||
"env": ".env",
|
"env": ".env",
|
||||||
"graphs": {
|
"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": {
|
"auth": {
|
||||||
"path": "./app/gateway/langgraph_auth.py:auth"
|
"path": "./app/gateway/langgraph_auth.py:auth"
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from deerflow.agents.canvas_agent.agent import make_canvas_agent
|
||||||
|
|
||||||
|
__all__ = ["make_canvas_agent"]
|
||||||
+68
@@ -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,
|
||||||
|
)
|
||||||
+40
@@ -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>
|
||||||
|
"""
|
||||||
+65
@@ -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
|
from langchain.agents import AgentState
|
||||||
|
|
||||||
@@ -18,6 +18,26 @@ class ViewedImageData(TypedDict):
|
|||||||
mime_type: str
|
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]:
|
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
|
||||||
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
"""Reducer for artifacts list - merges and deduplicates artifacts."""
|
||||||
if existing is None:
|
if existing is None:
|
||||||
@@ -53,3 +73,11 @@ class ThreadState(AgentState):
|
|||||||
todos: NotRequired[list | None]
|
todos: NotRequired[list | None]
|
||||||
uploaded_files: NotRequired[list[dict] | None]
|
uploaded_files: NotRequired[list[dict] | None]
|
||||||
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
|
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]]]
|
||||||
Generated
+2692
-2692
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user