Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa1f81a30a | ||
|
|
1c50c73aa4 | ||
|
|
6cc28c7edd | ||
|
|
1c2cf768f8 | ||
|
|
481e718d86 | ||
|
|
29f74afbf6 | ||
|
|
02111ab5b8 | ||
|
|
a2f7157bc3 | ||
|
|
e6591abfbc | ||
|
|
50bda1ddee | ||
|
|
31abad86d5 | ||
|
|
af7e90c87a | ||
|
|
2e4bdf122e | ||
|
|
83d7fa26fe | ||
|
|
efc4d89fdf | ||
|
|
a27206c543 | ||
|
|
faca2c9a2d | ||
|
|
0cbd137b4f | ||
|
|
aa034f8669 | ||
|
|
839386f0b4 |
No files matched your search
@@ -0,0 +1 @@
|
||||
remote connectivity test - 2026-05-13
|
||||
+12
-6
@@ -1,13 +1,17 @@
|
||||
# Workspace/editor
|
||||
.vscode/
|
||||
frontend-web/.vscode/
|
||||
|
||||
# Local runtime logs and pid files
|
||||
.runtime/
|
||||
# Workspace/editor
|
||||
.vscode/
|
||||
frontend-web/.vscode/
|
||||
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Local runtime logs and pid files
|
||||
.runtime/
|
||||
|
||||
# Frontend local/runtime artifacts
|
||||
frontend-web/.env
|
||||
frontend-web/node_modules/
|
||||
frontend-web/.pnpm-store/
|
||||
frontend-web/tsconfig.tsbuildinfo
|
||||
frontend-web/vite-dev.log
|
||||
frontend-web/vite-dev.err.log
|
||||
@@ -54,6 +58,8 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||
offline-backend-20260512/extensions_config.json
|
||||
offline-backend-20260512/skills/
|
||||
offline-backend-20260512/wheelhouse/
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
Whitespace-only changes.
@@ -0,0 +1,140 @@
|
||||
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
当前 `frontend-web/src/App.tsx` 同时承载了:
|
||||
|
||||
1. 登录/首页相关路由(如 `/login`、`/landing`)
|
||||
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
|
||||
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
|
||||
|
||||
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
|
||||
|
||||
---
|
||||
|
||||
## 二、目标(你提出的核心诉求)
|
||||
|
||||
你希望:
|
||||
|
||||
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
|
||||
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
|
||||
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
|
||||
|
||||
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构范围
|
||||
|
||||
### 1)保留在 `App.tsx` 的内容
|
||||
|
||||
- 全局 Provider:`HashRouter`、`AppProviders`
|
||||
- 登录/首页路由:
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/login/:username`
|
||||
- `/landing`
|
||||
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
|
||||
|
||||
### 2)迁移出 `App.tsx` 的内容
|
||||
|
||||
将以下与工作台有关的路由从 `App.tsx` 抽离:
|
||||
|
||||
- `/workspace`(重定向)
|
||||
- `/workspace/chats`
|
||||
- `/workspace/chats/:thread_id`
|
||||
- `/workspace/agents`
|
||||
- `/workspace/agents/new`
|
||||
- `/workspace/agents/:agent_id/chats`(重定向)
|
||||
- `/workspace/agents/:agent_id/chats/:thread_id`
|
||||
- `/workspace/scheduled`
|
||||
- `/workspace/scheduled/tasks`
|
||||
- `/workspace/scheduled/runs/:run_id`
|
||||
|
||||
以及配套的包装组件职责:
|
||||
|
||||
- `WorkspaceLayout`
|
||||
- `ChatRuntime`
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的新结构(方案)
|
||||
|
||||
## 方案 A(推荐):新增“业务路由容器组件”
|
||||
|
||||
在 `pages` 下新增一个路由容器组件,例如:
|
||||
|
||||
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
|
||||
|
||||
职责:
|
||||
- 专门维护 `/workspace/*` 相关路由
|
||||
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
|
||||
- 输出一个可被 `App.tsx` 挂载的路由节点
|
||||
|
||||
同时在 `App.tsx` 里只保留:
|
||||
- 登录/首页/404
|
||||
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
|
||||
|
||||
---
|
||||
|
||||
## 五、“再建立一个页面放置导航”的落地解释
|
||||
|
||||
你说的“建立一个页面放置这些导航”,可以有两种理解:
|
||||
|
||||
1. **路由容器页(推荐)**:
|
||||
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
|
||||
- 即上面的 `WorkspaceRoutes.tsx`。
|
||||
|
||||
2. **可见的导航页(可选)**:
|
||||
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
|
||||
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
|
||||
|
||||
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施步骤(开发任务拆解)
|
||||
|
||||
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
|
||||
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
|
||||
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
|
||||
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
|
||||
5. 校验以下访问路径行为一致:
|
||||
- `/workspace` 重定向
|
||||
- chats/agents/scheduled 各页面可正常进入
|
||||
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
|
||||
- 404 不受影响
|
||||
6. 运行前端并做手工回归。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
|
||||
2. `/workspace/*` 全部由新建组件统一管理。
|
||||
3. 功能行为与改造前一致(或符合新定义)。
|
||||
4. 代码可读性提升:登录入口与业务入口职责分离。
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
|
||||
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
|
||||
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
|
||||
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议的命名(可直接采用)
|
||||
|
||||
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
|
||||
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 十、后续可选优化
|
||||
|
||||
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
|
||||
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
|
||||
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
|
||||
@@ -0,0 +1,975 @@
|
||||
# DeerFlow 前端开发文档
|
||||
|
||||
## 目录
|
||||
|
||||
1. [整体目录结构](#1-整体目录结构)
|
||||
2. [路由结构](#2-路由结构)
|
||||
3. [核心数据流与状态管理](#3-核心数据流与状态管理)
|
||||
4. [主要页面组件](#4-主要页面组件)
|
||||
5. [核心业务组件](#5-核心业务组件)
|
||||
6. [核心 Hooks 与工具函数](#6-核心-hooks-与工具函数)
|
||||
7. [API 调用层](#7-api-调用层)
|
||||
8. [国际化机制](#8-国际化机制)
|
||||
9. [主题与样式体系](#9-主题与样式体系)
|
||||
10. [关键业务流程](#10-关键业务流程)
|
||||
11. [项目配置与依赖](#11-项目配置与依赖)
|
||||
12. [常见开发模式](#12-常见开发模式)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体目录结构
|
||||
|
||||
```
|
||||
frontend-web/src/
|
||||
├── App.tsx # 应用入口,顶层路由 + Provider
|
||||
├── env.ts # 环境变量类型定义
|
||||
├── pages/ # 路由级页面组件
|
||||
│ ├── PageRoutes.tsx # /page/* 根路由容器(含外部系统预留位)
|
||||
│ ├── WorkspaceRoutes.tsx # /page/workspace/* 工作区路由
|
||||
│ ├── LoginPage.tsx # 登录页
|
||||
│ ├── LandingPage.tsx # 营销落地页(强制 dark 主题)
|
||||
│ ├── ChatsPage.tsx # 对话列表
|
||||
│ ├── ChatPage.tsx # 单条对话(核心页面)
|
||||
│ ├── AgentsPage.tsx # 智能体库
|
||||
│ ├── AgentChatPage.tsx # 与智能体对话
|
||||
│ ├── NewAgentPage.tsx # 创建智能体(两步流程)
|
||||
│ ├── ScheduledTasksPage.tsx # 定时任务管理
|
||||
│ ├── ScheduledTaskRunDetailPage.tsx # 定时任务执行详情
|
||||
│ ├── ScheduledChatPage.tsx # 定时任务对话跳转页
|
||||
│ └── NotFoundPage.tsx # 404 页面
|
||||
├── components/ # 业务组件库
|
||||
│ ├── ui/ # shadcn/ui 基础组件(Button、Card、Dialog 等 40+)
|
||||
│ ├── ai-elements/ # AI 交互可视化组件
|
||||
│ │ ├── prompt-input/ # 输入框 Context + 子组件系统
|
||||
│ │ ├── artifact.tsx # 制品展示
|
||||
│ │ ├── canvas.tsx
|
||||
│ │ ├── conversation.tsx # 虚拟滚动对话容器
|
||||
│ │ ├── message.tsx
|
||||
│ │ ├── model-selector.tsx
|
||||
│ │ ├── code-block.tsx
|
||||
│ │ └── shimmer.tsx
|
||||
│ ├── workspace/ # 工作区业务组件(详见第 5 节)
|
||||
│ └── landing/ # 落地页组件
|
||||
├── core/ # 核心业务逻辑层(非 UI)
|
||||
│ ├── api/ # API 客户端(LangGraph SDK + Fetch)
|
||||
│ ├── auth/ # 认证管理
|
||||
│ ├── config/ # 环境配置 & Base URL
|
||||
│ ├── i18n/ # 国际化(上下文、翻译文件、语言检测)
|
||||
│ ├── threads/ # 对话线程管理(最核心)
|
||||
│ ├── agents/ # 智能体 CRUD
|
||||
│ ├── models/ # 模型列表
|
||||
│ ├── settings/ # 本地设置(LocalStorage 发布订阅)
|
||||
│ ├── uploads/ # 文件上传
|
||||
│ ├── artifacts/ # 制品管理
|
||||
│ ├── memory/ # 用户记忆
|
||||
│ ├── skills/ # 技能管理
|
||||
│ ├── scheduled-tasks/ # 定时任务
|
||||
│ ├── messages/ # 消息处理工具函数
|
||||
│ ├── tasks/ # 子任务 Context
|
||||
│ ├── notification/ # 系统通知
|
||||
│ └── utils/ # 通用工具(文件下载、日期格式化等)
|
||||
└── hooks/ # 全局自定义 hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 路由结构
|
||||
|
||||
> 项目使用 **HashRouter**,所有路由访问形如 `http://host/#/xxx`
|
||||
|
||||
### 2.1 App.tsx 顶层路由
|
||||
|
||||
| 路径 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | Navigate | 重定向到 `/login` |
|
||||
| `/login` | LoginPage | 登录(自动登录,无表单) |
|
||||
| `/login/:username` | LoginPage | 带用户名登录 |
|
||||
| `/landing` | LandingPage | 营销落地页(固定 dark 主题) |
|
||||
| `/page/*` | PageRoutes | 应用内容根容器 |
|
||||
| `*` | NotFoundPage | 404 |
|
||||
|
||||
### 2.2 PageRoutes.tsx(`/page/*`)
|
||||
|
||||
```
|
||||
/page/
|
||||
├── index → Navigate to "workspace"
|
||||
├── workspace/* → WorkspaceRoutes
|
||||
└── [TODO 预留] → 外部系统路由挂载位置
|
||||
```
|
||||
|
||||
### 2.3 WorkspaceRoutes.tsx(`/page/workspace/*`)
|
||||
|
||||
| 路径 | 页面组件 | 布局包裹 |
|
||||
|------|---------|--------|
|
||||
| `index` | Navigate → `chats/new` | - |
|
||||
| `chats` | ChatsPage | WorkspaceLayout |
|
||||
| `chats/:thread_id` | ChatPage | WorkspaceLayout + ChatRuntime |
|
||||
| `agents` | AgentsPage | WorkspaceLayout |
|
||||
| `agents/new` | NewAgentPage | WorkspaceLayout |
|
||||
| `agents/:agent_id/chats` | Navigate → `/page/workspace/agents` | - |
|
||||
| `agents/:agent_id/chats/:thread_id` | AgentChatPage | WorkspaceLayout + ChatRuntime |
|
||||
| `scheduled` | Navigate → `.../scheduled/tasks` | - |
|
||||
| `scheduled/tasks` | ScheduledTasksPage | WorkspaceLayout |
|
||||
| `scheduled/runs/:run_id` | ScheduledTaskRunDetailPage | WorkspaceLayout |
|
||||
|
||||
### 2.4 布局 Provider 层级结构
|
||||
|
||||
```
|
||||
WorkspaceLayout
|
||||
├── SidebarProvider(侧边栏展开/收起状态)
|
||||
│ ├── WorkspaceSidebar
|
||||
│ │ ├── WorkspaceHeader(Logo、新建按钮)
|
||||
│ │ ├── WorkspaceNavChatList(Chats / Agents / Scheduled 导航)
|
||||
│ │ ├── RecentChatList(最近会话列表)
|
||||
│ │ └── WorkspaceNavMenu(Settings / About / GitHub)
|
||||
│ ├── SidebarInset(主内容区)
|
||||
│ │ └── [Page Content]
|
||||
│ ├── CommandPalette(快捷命令面板)
|
||||
│ └── Toaster(全局通知)
|
||||
|
||||
ChatRuntime(仅 ChatPage / AgentChatPage 使用)
|
||||
├── SubtasksProvider(子任务执行状态)
|
||||
├── ArtifactsProvider(制品选中/打开状态)
|
||||
└── PromptInputProvider(输入框内容/附件状态)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心数据流与状态管理
|
||||
|
||||
### 3.1 全局 Provider 架构
|
||||
|
||||
```
|
||||
App
|
||||
└── HashRouter
|
||||
└── AppProviders(useLocation 驱动主题判断)
|
||||
├── NextThemesProvider
|
||||
│ └── attribute="class",/landing 强制 forcedTheme="dark"
|
||||
├── I18nProvider
|
||||
│ └── initialLocale = detectLocale()(从 cookie 或浏览器语言)
|
||||
└── QueryClientProvider(TanStack React Query 全局缓存)
|
||||
```
|
||||
|
||||
### 3.2 全局 Context 列表
|
||||
|
||||
| Context | 文件 | 职责 | 消费 Hook |
|
||||
|---------|------|------|-----------|
|
||||
| `I18nContext` | `core/i18n/context.tsx` | 当前语言 + 翻译对象 | `useI18n()` |
|
||||
| `SubtaskContext` | `core/tasks/context.tsx` | 实时子任务执行状态 | `useSubtaskContext()` |
|
||||
| `ArtifactsContext` | `components/workspace/artifacts/context.tsx` | 制品选中/面板开关 | `useArtifacts()` |
|
||||
| `PromptInputContext` | `components/ai-elements/prompt-input/` | 输入框内容、附件列表 | `usePromptInputController()` |
|
||||
| `ThreadContext` | `components/workspace/messages/context.tsx` | 当前 thread 对象下传 | 直接读 context.thread |
|
||||
|
||||
### 3.3 LocalStorage 数据
|
||||
|
||||
**操作层:** `core/settings/store.ts`(发布-订阅,`useSyncExternalStore` 驱动响应式)
|
||||
|
||||
| Key | 内容 |
|
||||
|-----|------|
|
||||
| `deerflow.auth` | `{ access_token, token_type, user_id, email }` |
|
||||
| `deerflow.local-settings` | `{ notification, context: { model_name, mode, reasoning_effort } }` |
|
||||
| `deerflow.thread-model.{threadId}` | 每条线程的模型覆盖 |
|
||||
|
||||
### 3.4 SessionStorage 数据
|
||||
|
||||
| Key | 内容 |
|
||||
|-----|------|
|
||||
| `deerflow.run-id.{threadId}` | WebSocket run_id,用于断线重连 |
|
||||
|
||||
### 3.5 React Query 缓存 Key
|
||||
|
||||
| Query Key | 数据 |
|
||||
|-----------|------|
|
||||
| `["threads"]` | 线程列表 |
|
||||
| `["thread", threadId]` | 单条线程 |
|
||||
| `["agents"]` | 智能体列表 |
|
||||
| `["models"]` | 模型列表 |
|
||||
| `["skills"]` | 技能列表 |
|
||||
| `["memory"]` | 用户记忆 |
|
||||
| `["scheduled-tasks"]` | 定时任务列表 |
|
||||
| `["scheduled-runs", taskId]` | 任务执行记录 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 主要页面组件
|
||||
|
||||
### 4.1 ChatPage.tsx — 单条对话页面
|
||||
|
||||
**职责:** 管理单条聊天线程的完整交互,包括消息流、输入、工具调用展示、制品等。
|
||||
|
||||
**关键状态:**
|
||||
|
||||
```typescript
|
||||
const [threadId, setThreadId] = useState(routeThreadId || uuid());
|
||||
const [isNewThread, setIsNewThread] = useState(!routeThreadId || routeThreadId === "new");
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
```
|
||||
|
||||
**核心 Hook:**
|
||||
|
||||
```typescript
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// 不刷新页面,直接更新 URL hash
|
||||
history.replaceState(null, "", `...#/page/workspace/chats/${createdThreadId}`);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
// 后台通知
|
||||
showNotification(state.title, { body: lastMessageText });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**主要子组件:**
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `<MessageList>` | 消息渲染(含工具调用、子任务、制品) |
|
||||
| `<Welcome>` | 新对话欢迎屏 |
|
||||
| `<InputBox>` | 输入框(模式选择、附件、发送) |
|
||||
| `<ThreadTitle>` | 线程标题(自动生成后同步更新) |
|
||||
| `<ExportTrigger>` | 导出对话按钮 |
|
||||
| `<ArtifactTrigger>` | 制品面板开关 |
|
||||
| `<TokenUsageIndicator>` | Token 用量展示(可选) |
|
||||
| `<TodoList>` | 任务清单 |
|
||||
| `<Suggestions>` | 追问建议 |
|
||||
|
||||
**数据流:**
|
||||
|
||||
```
|
||||
用户输入 (InputBox)
|
||||
↓ handleSubmit(message: PromptInputMessage)
|
||||
↓ sendMessage(threadId, message)
|
||||
↓ [内部] 上传文件 → 提交到 LangGraph API
|
||||
↓ WebSocket 流返回消息
|
||||
↓ thread.messages 更新 → MessageList 重渲染
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 AgentChatPage.tsx — 与智能体对话
|
||||
|
||||
与 ChatPage 结构基本一致,差异点:
|
||||
|
||||
- 从路由获取 `agent_id`,传入 `useThreadStream({ assistantId: agent_id })`
|
||||
- Header 展示智能体名称 Badge
|
||||
- "新建对话"跳转到 `/page/workspace/agents/${agent_id}/chats/new`
|
||||
- URL 更新为 `#/page/workspace/agents/${agent_id}/chats/${threadId}`
|
||||
|
||||
---
|
||||
|
||||
### 4.3 NewAgentPage.tsx — 创建智能体(两步流程)
|
||||
|
||||
**Step 1:name(命名 + 选技能)**
|
||||
|
||||
```typescript
|
||||
// 自动预选前 3 个启用的技能
|
||||
useEffect(() => {
|
||||
if (!isSkillSelectionTouched) {
|
||||
setSelectedSkills(enabledSkills.slice(0, 3).map(s => s.name));
|
||||
}
|
||||
}, [enabledSkills]);
|
||||
|
||||
// 提交:验证名称 → 创建智能体
|
||||
await checkAgentName(name); // 检查重名
|
||||
await createAgent(request); // 创建,返回 agent.id
|
||||
setStep("chat"); // 进入 Step 2
|
||||
```
|
||||
|
||||
**Step 2:chat(初始化对话)**
|
||||
|
||||
```typescript
|
||||
// 发送 bootstrap 消息触发智能体初始化
|
||||
sendMessage(threadId, bootstrapMessage, { agent_id: agentId });
|
||||
|
||||
// 监听 setup_agent 工具完成事件
|
||||
onToolEnd: (event) => {
|
||||
if (event.name === "setup_agent") {
|
||||
// 带重试轮询加载 agent 最新信息
|
||||
retryLoadAgent(agentId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 ChatsPage.tsx — 对话列表
|
||||
|
||||
```typescript
|
||||
const { data: threads } = useThreads({ limit: 50, sortBy: "updated_at" });
|
||||
|
||||
// 客户端过滤:排除定时任务线程 + 搜索关键词
|
||||
const filteredThreads = useMemo(() =>
|
||||
threads?.filter(t =>
|
||||
t.metadata?.thread_type !== "scheduler" &&
|
||||
titleOfThread(t).toLowerCase().includes(search)
|
||||
), [threads, search]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 LoginPage.tsx — 登录
|
||||
|
||||
```typescript
|
||||
// 自动登录,无需用户操作
|
||||
useEffect(() => {
|
||||
loginByUsername(username || "guest")
|
||||
.then(auth => {
|
||||
setStoredAuth(auth);
|
||||
router.replace("/page/workspace/chats/new");
|
||||
})
|
||||
.catch(err => setStatus("error"));
|
||||
}, [username]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.6 ScheduledTasksPage.tsx — 定时任务
|
||||
|
||||
**功能:** 创建/编辑/发布/暂停/恢复定时任务,查看执行历史。
|
||||
|
||||
**Cron 表达式构建:**
|
||||
|
||||
```typescript
|
||||
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
|
||||
|
||||
function buildCron(config): string {
|
||||
// hourly → "15 * * * *"
|
||||
// daily → "0 9 * * *"
|
||||
// weekly → "0 9 * * 1"
|
||||
// monthly → "0 9 1 * *"
|
||||
}
|
||||
```
|
||||
|
||||
**核心操作:**
|
||||
|
||||
```typescript
|
||||
const actions = useScheduledTaskActions();
|
||||
actions.create.mutateAsync(payload);
|
||||
actions.publish.mutateAsync(taskId);
|
||||
actions.pause.mutateAsync(taskId);
|
||||
actions.trigger.mutateAsync(taskId); // 立即执行一次
|
||||
actions.subscribe.mutateAsync(params); // 订阅推送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心业务组件
|
||||
|
||||
### 5.1 消息系统(`components/workspace/messages/`)
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `message-list.tsx` | 消息列表容器,调用 `groupMessages` 分组后渲染 |
|
||||
| `message-list-item.tsx` | 单条消息渲染(含 role 判断) |
|
||||
| `message-group.tsx` | 消息组(human / assistant / processing 等) |
|
||||
| `markdown-content.tsx` | Markdown 渲染(rehype 系列插件) |
|
||||
| `subtask-card.tsx` | 子任务状态卡片 |
|
||||
| `message-token-usage.tsx` | 单条消息 Token 用量 |
|
||||
| `skeleton.tsx` | 流式加载骨架屏 |
|
||||
| `context.tsx` | ThreadContext,向下传递 thread 对象 |
|
||||
|
||||
**消息分组类型(`groupMessages`):**
|
||||
|
||||
```typescript
|
||||
type MessageGroup =
|
||||
| "human" // 用户输入
|
||||
| "assistant:processing" // 推理/工具调用中
|
||||
| "assistant" // 最终响应
|
||||
| "assistant:clarification" // 澄清请求
|
||||
| "assistant:present-files" // 文件呈现
|
||||
| "assistant:subagent"; // 子智能体消息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 输入框(`components/workspace/input-box.tsx`)
|
||||
|
||||
**子组件层级:**
|
||||
|
||||
```
|
||||
InputBox
|
||||
└── PromptInput(Context Provider)
|
||||
├── PromptInputBody
|
||||
│ ├── PromptInputTextarea(文本输入)
|
||||
│ ├── PromptInputAttachments(附件列表)
|
||||
│ └── PromptInputTools(工具栏:附件上传、模式选择)
|
||||
├── PromptInputFooter
|
||||
│ ├── PromptInputSubmit(发送/停止按钮)
|
||||
│ └── Suggestions(追问建议列表)
|
||||
└── ModelSelector(模型下拉选择)
|
||||
```
|
||||
|
||||
**四种会话模式:**
|
||||
|
||||
| Mode | 说明 |
|
||||
|------|------|
|
||||
| `flash` | 快速模式,无推理步骤 |
|
||||
| `thinking` | 推理模式,开启深度思考 |
|
||||
| `pro` | 专业模式,多模态 + 规划 |
|
||||
| `ultra` | 超级模式,启用子智能体 |
|
||||
|
||||
**推理努力等级(mode ≠ flash 时可选):** `minimal` / `low` / `medium` / `high`
|
||||
|
||||
---
|
||||
|
||||
### 5.3 侧边栏(`components/workspace/workspace-sidebar.tsx`)
|
||||
|
||||
```
|
||||
WorkspaceSidebar
|
||||
├── SidebarHeader → WorkspaceHeader
|
||||
│ ├── Logo(可点击,跳转 /page/workspace/chats/new)
|
||||
│ └── 新建对话按钮
|
||||
├── SidebarContent
|
||||
│ ├── WorkspaceNavChatList
|
||||
│ │ ├── 新建对话(/page/workspace/chats/new)
|
||||
│ │ ├── 所有对话(/page/workspace/chats)
|
||||
│ │ ├── 智能体(/page/workspace/agents)
|
||||
│ │ └── 任务管理(/page/workspace/scheduled/tasks)
|
||||
│ └── RecentChatList(最近会话,含搜索)
|
||||
└── SidebarFooter → WorkspaceNavMenu
|
||||
├── Settings(打开设置 Dialog)
|
||||
├── About
|
||||
└── GitHub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 制品管理(`components/workspace/artifacts/`)
|
||||
|
||||
```typescript
|
||||
interface ArtifactsContextType {
|
||||
artifacts: string[]; // artifact ID 列表
|
||||
selectedArtifact: string | null;
|
||||
autoSelect: boolean; // 是否自动选中最新制品
|
||||
open: boolean; // 面板是否展开
|
||||
autoOpen: boolean;
|
||||
select(artifact: string, autoSelect?: boolean): void;
|
||||
deselect(): void;
|
||||
setOpen(open: boolean): void;
|
||||
}
|
||||
```
|
||||
|
||||
**文件:**
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `context.tsx` | ArtifactsContext + Provider |
|
||||
| `artifact-trigger.tsx` | 面板展开/收起按钮 |
|
||||
| `artifact-file-list.tsx` | 制品文件列表 |
|
||||
| `artifact-file-detail.tsx` | 单个制品详情(含代码编辑器) |
|
||||
|
||||
---
|
||||
|
||||
### 5.5 设置面板(`components/workspace/settings/`)
|
||||
|
||||
| 页面文件 | 内容 |
|
||||
|---------|------|
|
||||
| `appearance-settings-page.tsx` | 主题切换(light/dark/system)、语言切换 |
|
||||
| `memory-settings-page.tsx` | 用户记忆(事实列表、总结管理) |
|
||||
| `notification-settings-page.tsx` | 桌面通知开关 |
|
||||
| `skill-settings-page.tsx` | 技能启用/禁用 |
|
||||
| `tool-settings-page.tsx` | 工具配置 |
|
||||
| `about-settings-page.tsx` | 版本号、仓库链接 |
|
||||
|
||||
---
|
||||
|
||||
### 5.6 命令面板(`components/workspace/command-palette.tsx`)
|
||||
|
||||
通过 `Cmd/Ctrl + K` 触发,提供快速导航和操作入口。
|
||||
|
||||
```typescript
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push("/page/workspace/chats/new");
|
||||
setOpen(false);
|
||||
}, [router]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心 Hooks 与工具函数
|
||||
|
||||
### 6.1 useThreadStream(最核心 Hook)
|
||||
|
||||
**位置:** `core/threads/hooks.ts`
|
||||
|
||||
管理 LangGraph WebSocket 连接、消息流、乐观更新。
|
||||
|
||||
**签名:**
|
||||
|
||||
```typescript
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId?: string, // 有值则连接现有线程,无值表示新建
|
||||
context: LocalSettings["context"], // 模型、模式等
|
||||
isMock?: boolean,
|
||||
assistantId?: string, // 默认 "lead_agent"
|
||||
onStart?: (threadId: string) => void,
|
||||
onFinish?: (state: AgentThreadState) => void,
|
||||
onToolEnd?: (event: ToolEndEvent) => void,
|
||||
});
|
||||
```
|
||||
|
||||
**thread 对象关键属性:**
|
||||
|
||||
```typescript
|
||||
thread: {
|
||||
messages: Message[], // 含乐观消息的完整列表
|
||||
isLoading: boolean, // 流是否进行中
|
||||
isThreadLoading: boolean, // 初始线程数据是否加载中
|
||||
values: AgentThreadState, // 线程最终状态
|
||||
error?: Error,
|
||||
stop: async () => void, // 终止当前流
|
||||
}
|
||||
```
|
||||
|
||||
**sendMessage 签名:**
|
||||
|
||||
```typescript
|
||||
sendMessage(
|
||||
threadId: string | undefined,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Partial<AgentThreadContext>,
|
||||
options?: { isMock?: boolean }
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**内部机制:**
|
||||
- **乐观更新:** 用户发送后立即本地展示消息,不等服务端响应
|
||||
- **文件上传:** 自动调用 `uploadFiles()` 后再提交
|
||||
- **run_id 持久化:** sessionStorage 保存,支持页面刷新后断线重连
|
||||
- **消息同步:** 流结束后通过 `queryClient.invalidateQueries(["threads"])` 刷新列表
|
||||
|
||||
---
|
||||
|
||||
### 6.2 useThreadSettings
|
||||
|
||||
**位置:** `core/settings/hooks.ts`
|
||||
|
||||
```typescript
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
|
||||
// settings 结构
|
||||
{
|
||||
notification: { enabled: boolean },
|
||||
context: {
|
||||
model_name?: string,
|
||||
mode: "flash" | "thinking" | "pro" | "ultra",
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high"
|
||||
}
|
||||
}
|
||||
|
||||
// 修改(合并更新)
|
||||
setSettings("context", { mode: "pro" });
|
||||
setSettings("notification", { enabled: true });
|
||||
```
|
||||
|
||||
**工作原理:** `useSyncExternalStore` 监听 localStorage 变化,跨标签页同步。
|
||||
|
||||
---
|
||||
|
||||
### 6.3 useI18n
|
||||
|
||||
**位置:** `core/i18n/hooks.ts`
|
||||
|
||||
```typescript
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
|
||||
t.sidebar.newChat // "New Chat"
|
||||
t.agents.createPageTitle // "Create Agent"
|
||||
t.toolCalls.moreSteps(3) // "Show 3 more steps"
|
||||
|
||||
changeLocale("zh-CN"); // 切换语言,自动存 cookie
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.4 useScheduledTaskActions
|
||||
|
||||
**位置:** `core/scheduled-tasks/hooks.ts`
|
||||
|
||||
```typescript
|
||||
const actions = useScheduledTaskActions();
|
||||
|
||||
// 所有返回值均为 TanStack Query Mutation
|
||||
actions.create // 创建定时任务
|
||||
actions.update // 更新任务配置
|
||||
actions.remove // 删除任务
|
||||
actions.removeRun // 删除执行记录
|
||||
actions.pause // 暂停
|
||||
actions.resume // 恢复
|
||||
actions.trigger // 立即触发一次
|
||||
actions.publish // 发布(激活)
|
||||
actions.unpublish // 取消发布
|
||||
actions.subscribe // 订阅推送
|
||||
actions.updateSubscription
|
||||
actions.unsubscribe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.5 工具函数
|
||||
|
||||
**消息处理(`core/messages/utils.ts`):**
|
||||
|
||||
```typescript
|
||||
groupMessages(messages, mapper) // 按类型分组消息列表
|
||||
extractTextFromMessage(message) // 提取纯文本
|
||||
extractContentFromMessage(message) // 提取 Markdown/图像内容
|
||||
extractReasoningContentFromMessage(msg) // 提取推理过程
|
||||
hasToolCalls(message) // 是否含工具调用
|
||||
hasPresentFiles(message) // 是否包含文件呈现
|
||||
extractPresentFilesFromMessage(message) // 获取文件列表
|
||||
isClarificationToolMessage(message) // 是否为澄清请求
|
||||
```
|
||||
|
||||
**线程路由(`core/threads/utils.ts`):**
|
||||
|
||||
```typescript
|
||||
pathOfThread(thread, context?)
|
||||
// → "/page/workspace/agents/{agentRef}/chats/{threadId}"
|
||||
// 或 "/page/workspace/chats/{threadId}"
|
||||
|
||||
titleOfThread(thread)
|
||||
// → thread.values?.title ?? "Untitled"
|
||||
|
||||
textOfMessage(message)
|
||||
// → 消息文本内容(string 或 content 数组中第一个 text)
|
||||
```
|
||||
|
||||
**其他工具:**
|
||||
|
||||
```typescript
|
||||
// core/utils/files.tsx
|
||||
downloadFile(blob: Blob, filename: string)
|
||||
|
||||
// core/utils/datetime.ts
|
||||
formatTimeAgo(dateString: string) // → "2 小时前" / "3 days ago"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API 调用层
|
||||
|
||||
### 7.1 API 客户端架构
|
||||
|
||||
**两层设计:**
|
||||
|
||||
**第一层:LangGraph SDK Client**(`core/api/api-client.ts`)
|
||||
|
||||
```typescript
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
headers.set("Authorization", getAuthorizationHeaderValue());
|
||||
return { ...init, headers };
|
||||
}
|
||||
});
|
||||
|
||||
export function getAPIClient(isMock?: boolean): LangGraphClient
|
||||
```
|
||||
|
||||
**第二层:Fetch Client**(`core/api/fetch-client.ts`)
|
||||
|
||||
```typescript
|
||||
function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: withAuthHeaders(init?.headers)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 主要 API 模块
|
||||
|
||||
| 模块 | 文件 | 关键函数 |
|
||||
|------|------|---------|
|
||||
| **Auth** | `core/auth/api.ts` | `loginByUsername(username)` → `LoginByUsernameResponse` |
|
||||
| **Agents** | `core/agents/api.ts` | `createAgent()`, `updateAgent()`, `listAgents()`, `deleteAgent()`, `checkAgentName()` |
|
||||
| **Models** | `core/models/api.ts` | `loadModels()` → `Model[]` |
|
||||
| **Threads** | (via LangGraph SDK) | `useThreadStream()`, `useThreads()`, `useDeleteThread()`, `useRenameThread()` |
|
||||
| **Skills** | `core/skills/api.ts` | `loadSkills()`, `enableSkill(name, enabled)` |
|
||||
| **Uploads** | `core/uploads/api.ts` | `uploadFiles(threadId, files[])`, `listUploadedFiles()`, `deleteUploadedFile()` |
|
||||
| **Memory** | `core/memory/api.ts` | `loadMemory()`, `createMemoryFact()`, `updateMemoryFact()`, `deleteMemoryFact()` |
|
||||
| **Scheduled Tasks** | `core/scheduled-tasks/api.ts` | CRUD、publish、pause、resume、trigger、subscribe |
|
||||
|
||||
### 7.3 Base URL 配置
|
||||
|
||||
**文件:** `core/config/index.ts`
|
||||
|
||||
```typescript
|
||||
export function getBackendBaseURL(): string {
|
||||
// env.NEXT_PUBLIC_BACKEND_BASE_URL
|
||||
// 默认:""(当前域名根路径)
|
||||
}
|
||||
|
||||
export function getLangGraphBaseURL(isMock?: boolean): string {
|
||||
// mock 模式:${origin}/mock/api
|
||||
// 正常:${origin}/api/langgraph
|
||||
// 或读取 env.NEXT_PUBLIC_LANGGRAPH_BASE_URL
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 认证流程
|
||||
|
||||
```
|
||||
LoginPage 调用 loginByUsername(username)
|
||||
↓ POST /api/auth/login
|
||||
↓ 返回 { access_token, token_type, user_id, email }
|
||||
↓ setStoredAuth() 存入 localStorage["deerflow.auth"]
|
||||
↓
|
||||
后续所有 API 请求
|
||||
↓ getAuthorizationHeaderValue()
|
||||
↓ → "Bearer {access_token}"
|
||||
↓ 自动添加到 Authorization header
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 国际化机制
|
||||
|
||||
### 8.1 文件结构
|
||||
|
||||
```
|
||||
core/i18n/
|
||||
├── context.tsx # I18nProvider(包裹 App)
|
||||
├── hooks.ts # useI18n()
|
||||
├── locale.ts # detectLocale(),Locale = "en-US" | "zh-CN"
|
||||
├── cookies.ts # cookie 读写(持久化语言选择)
|
||||
├── translations.ts # 翻译映射表入口
|
||||
└── locales/
|
||||
├── types.ts # Translations 接口定义(所有翻译 key 的类型)
|
||||
├── en-US.ts # 英文翻译
|
||||
└── zh-CN.ts # 简体中文翻译
|
||||
```
|
||||
|
||||
### 8.2 Translations 接口分类
|
||||
|
||||
```typescript
|
||||
interface Translations {
|
||||
locale: { localName: string } // 语言自身名称
|
||||
common: { ... } // 通用词汇(确认、取消等)
|
||||
welcome: { ... } // 欢迎屏
|
||||
inputBox: { ... } // 输入框(模式、占位符)
|
||||
sidebar: { ... } // 侧边栏导航
|
||||
agents: { ... } // 智能体相关
|
||||
chats: { ... } // 对话相关
|
||||
workspace: { ... } // 工作区通用
|
||||
settings: { ... } // 设置面板
|
||||
toolCalls: { ... } // 工具调用展示
|
||||
uploads: { ... } // 文件上传
|
||||
subtasks: { ... } // 子任务
|
||||
tokenUsage: { ... } // Token 用量
|
||||
shortcuts: { ... } // 快捷键说明
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 语言检测优先级
|
||||
|
||||
1. 读取 cookie `deerflow.locale`
|
||||
2. 读取浏览器 `navigator.language`
|
||||
3. 回退到 `en-US`
|
||||
|
||||
---
|
||||
|
||||
## 9. 主题与样式体系
|
||||
|
||||
### 9.1 主题系统
|
||||
|
||||
**库:** `next-themes`
|
||||
|
||||
```typescript
|
||||
// App.tsx
|
||||
<NextThemesProvider
|
||||
attribute="class" // 通过 <html class="dark"> 应用主题
|
||||
enableSystem // 跟随系统偏好
|
||||
disableTransitionOnChange // 切换时禁用过渡动画
|
||||
forcedTheme={pathname === "/landing" ? "dark" : undefined}
|
||||
>
|
||||
```
|
||||
|
||||
**可选主题值:** `light` / `dark` / `system`
|
||||
|
||||
### 9.2 样式方案
|
||||
|
||||
**基础:** Tailwind CSS
|
||||
|
||||
**组件库:** shadcn/ui(`components/ui/` 目录,40+ 基础组件)
|
||||
|
||||
**主要 CSS 自定义属性:**
|
||||
|
||||
```css
|
||||
--container-width-sm /* 小容器宽度 */
|
||||
--container-width-md /* 中等容器宽度(对话主区域) */
|
||||
--primary /* 主色 */
|
||||
--background /* 页面背景 */
|
||||
--foreground /* 主文本色 */
|
||||
--muted-foreground /* 次要文本色 */
|
||||
--border /* 边框色 */
|
||||
--sidebar-foreground /* 侧边栏文本 */
|
||||
```
|
||||
|
||||
### 9.3 样式使用规范
|
||||
|
||||
- 工具类优先(Tailwind)
|
||||
- 动态样式用 `cn()` 工具函数合并(基于 `clsx` + `tailwind-merge`)
|
||||
- 主题感知样式使用 CSS 变量(如 `text-muted-foreground`)
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键业务流程
|
||||
|
||||
### 10.1 新建对话流程
|
||||
|
||||
```
|
||||
1. 点击"新建对话" → navigate("/page/workspace/chats/new")
|
||||
2. ChatPage 挂载,threadId = uuid(),isNewThread = true
|
||||
3. 用户输入消息,点击发送
|
||||
4. sendMessage(undefined, message) 调用
|
||||
5. useThreadStream 内部:
|
||||
a. 上传文件(若有)
|
||||
b. 创建 LangGraph 线程,后端返回 threadId
|
||||
c. onStart(threadId) 回调:
|
||||
- setIsNewThread(false)
|
||||
- history.replaceState 更新 URL hash
|
||||
6. WebSocket 流开始接收 AI 消息
|
||||
7. MessageList 实时渲染消息
|
||||
8. 流结束,onFinish 回调,可能触发桌面通知
|
||||
```
|
||||
|
||||
### 10.2 创建智能体流程
|
||||
|
||||
```
|
||||
Step 1(name 步骤)
|
||||
↓ 用户输入名称
|
||||
↓ checkAgentName(name) → 验证是否重名
|
||||
↓ createAgent({ name, skills: selectedSkills })
|
||||
↓ 后端返回 agent.id,setStep("chat")
|
||||
|
||||
Step 2(chat 步骤)
|
||||
↓ 生成临时 threadId
|
||||
↓ useThreadStream({ assistantId: agent.id })
|
||||
↓ 发送 bootstrap 消息(触发智能体自我设置)
|
||||
↓ onToolEnd 监听:event.name === "setup_agent"
|
||||
↓ 指数退避重试加载 agent 信息,确认已保存
|
||||
↓ 显示"Agent Created"完成界面
|
||||
↓ 用户可点击"开始对话"或"返回列表"
|
||||
```
|
||||
|
||||
### 10.3 文件上传流程
|
||||
|
||||
```
|
||||
1. 用户在 InputBox 选择/粘贴文件
|
||||
2. PromptInputAttachments 展示预览列表
|
||||
3. 用户点击发送
|
||||
4. sendMessage 内部调用 uploadFiles(threadId, files)
|
||||
5. POST /api/threads/{threadId}/upload
|
||||
6. 后端返回 { files: [{ filename, size, virtual_path }] }
|
||||
7. 构建 Message additional_kwargs.files
|
||||
8. 完整 Message 提交到 LangGraph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 项目配置与依赖
|
||||
|
||||
### 11.1 主要依赖
|
||||
|
||||
| 包 | 版本 | 用途 |
|
||||
|----|------|------|
|
||||
| `react` | ^18 | 核心框架 |
|
||||
| `react-router-dom` | ^6 | 客户端路由(HashRouter) |
|
||||
| `@tanstack/react-query` | - | 服务端状态缓存 |
|
||||
| `@langchain/langgraph-sdk` | - | LangGraph WebSocket 连接 |
|
||||
| `next-themes` | - | 主题管理 |
|
||||
| `sonner` | - | Toast 通知 |
|
||||
| `lucide-react` | - | 图标库 |
|
||||
| `tailwindcss` | - | CSS 框架 |
|
||||
| `rehype` 系列 | - | Markdown 渲染 |
|
||||
| `uuid` | - | 生成临时 thread ID |
|
||||
|
||||
### 11.2 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `NEXT_PUBLIC_BACKEND_BASE_URL` | 后端接口基础 URL | `""` (当前域名) |
|
||||
| `NEXT_PUBLIC_LANGGRAPH_BASE_URL` | LangGraph API URL | `/api/langgraph` |
|
||||
| `NEXT_PUBLIC_STATIC_WEBSITE_ONLY` | 纯静态演示模式 | `"false"` |
|
||||
|
||||
---
|
||||
|
||||
## 12. 常见开发模式
|
||||
|
||||
### 12.1 添加新工作区页面
|
||||
|
||||
```
|
||||
1. 创建 src/pages/MyPage.tsx
|
||||
2. 在 WorkspaceRoutes.tsx 中添加路由:
|
||||
<Route path="my-page" element={<WorkspaceLayout><MyPage /></WorkspaceLayout>} />
|
||||
3. 在 workspace-nav-chat-list.tsx 中添加导航入口(如需要)
|
||||
4. 更新相关的跳转路径
|
||||
```
|
||||
|
||||
### 12.2 添加新 API 接口
|
||||
|
||||
```
|
||||
1. 在 core/xxx/api.ts 中定义 API 函数(使用 apiFetch 或 getAPIClient())
|
||||
2. 在 core/xxx/hooks.ts 中封装 useQuery / useMutation
|
||||
3. 在页面或组件中调用 hook
|
||||
```
|
||||
|
||||
### 12.3 添加新 Context
|
||||
|
||||
```
|
||||
1. 创建 core/xxx/context.tsx:定义 Context + Provider + 默认值
|
||||
2. 创建消费 hook(如 useXxx())
|
||||
3. 在合适的组件树根部插入 Provider(WorkspaceRoutes 或 ChatRuntime)
|
||||
```
|
||||
|
||||
### 12.4 添加国际化文本
|
||||
|
||||
```
|
||||
1. 在 core/i18n/locales/types.ts 的 Translations 接口中添加字段
|
||||
2. 在 core/i18n/locales/en-US.ts 添加英文内容
|
||||
3. 在 core/i18n/locales/zh-CN.ts 添加中文内容
|
||||
4. 组件中通过 useI18n() 访问:const { t } = useI18n(); t.xxx.yyy
|
||||
```
|
||||
|
||||
### 12.5 引入外部系统路由
|
||||
|
||||
在 `src/pages/PageRoutes.tsx` 中找到 TODO 注释位置,填入:
|
||||
|
||||
```typescript
|
||||
import AnotherSystemRoutes from "外部系统路径";
|
||||
|
||||
// 在 Routes 中添加:
|
||||
<Route path="other/*" element={<AnotherSystemRoutes />} />
|
||||
```
|
||||
|
||||
如外部系统有独立 Provider 依赖,在此文件或 App.tsx 中包裹。
|
||||
|
||||
---
|
||||
|
||||
## 附:关键文件速查
|
||||
|
||||
| 需求 | 关键文件 |
|
||||
|------|---------|
|
||||
| 修改路由结构 | `App.tsx`, `PageRoutes.tsx`, `WorkspaceRoutes.tsx` |
|
||||
| 修改侧边栏导航 | `components/workspace/workspace-nav-chat-list.tsx` |
|
||||
| 修改对话逻辑 | `pages/ChatPage.tsx`, `core/threads/hooks.ts` |
|
||||
| 修改输入框 | `components/workspace/input-box.tsx`, `components/ai-elements/prompt-input/` |
|
||||
| 修改消息渲染 | `components/workspace/messages/` |
|
||||
| 添加 API 接口 | `core/xxx/api.ts` + `core/xxx/hooks.ts` |
|
||||
| 修改全局设置 | `core/settings/store.ts`, `core/settings/hooks.ts` |
|
||||
| 修改翻译文本 | `core/i18n/locales/en-US.ts`, `core/i18n/locales/zh-CN.ts` |
|
||||
| 修改主题 | `App.tsx` (NextThemesProvider) |
|
||||
| 修改认证逻辑 | `core/auth/api.ts`, `pages/LoginPage.tsx` |
|
||||
| 定时任务 | `pages/ScheduledTasksPage.tsx`, `core/scheduled-tasks/` |
|
||||
| 智能体管理 | `pages/NewAgentPage.tsx`, `core/agents/` |
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DeerFlow Vite</title>
|
||||
<title>CM 助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"gsap": "^3.13.0",
|
||||
|
||||
+50
-155
@@ -1,155 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { Toaster } from "sonner";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import ChatsPage from "./pages/ChatsPage";
|
||||
import ChatPage from "./pages/ChatPage";
|
||||
import AgentsPage from "./pages/AgentsPage";
|
||||
import AgentChatPage from "./pages/AgentChatPage";
|
||||
import NewAgentPage from "./pages/NewAgentPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./pages/ScheduledTasksPage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider className="h-screen" defaultOpen>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRuntime({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route
|
||||
path="/workspace"
|
||||
element={<Navigate to="/workspace/chats/new" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/chats"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/scheduled"
|
||||
element={<Navigate to="/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents/new"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents/:agent_id/chats"
|
||||
element={<Navigate to="/workspace/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
import { useMemo } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
@@ -674,12 +674,22 @@ export const PromptInput = ({
|
||||
useEffect(() => {
|
||||
if (!globalDrop) return;
|
||||
|
||||
// Suppress the global chat-upload handler when the drop happens inside an open
|
||||
// modal dialog or popover — those owners (e.g. the skill-install dialog) handle
|
||||
// their own file uploads and should not leak into the active chat thread.
|
||||
const isInsideModal = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return target.closest('[role="dialog"], [role="alertdialog"]') !== null;
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (isInsideModal(e.target)) return;
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const onDrop = (e: DragEvent) => {
|
||||
if (isInsideModal(e.target)) return;
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Footer() {
|
||||
</div>
|
||||
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
||||
<p>Licensed under MIT License</p>
|
||||
<p>© {year} CM业务中心-问答管理</p>
|
||||
<p>© {year} CM 助手</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
import type { Locale } from "@/core/i18n/locale";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type HeaderProps = {
|
||||
@@ -30,11 +25,11 @@ export function Header({ className, homeURL }: HeaderProps) {
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
||||
href={homeURL ?? "/"}
|
||||
target={isExternalHome ? "_blank" : "_self"}
|
||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
|
||||
<h1 className="font-serif text-xl">CM 助手</h1>
|
||||
</a>
|
||||
</div>
|
||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
||||
@@ -51,77 +46,7 @@ export function Header({ className, homeURL }: HeaderProps) {
|
||||
{t.home.blog}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
|
||||
filter: "blur(16px)",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="group relative z-10"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubLogoIcon className="size-4" />
|
||||
Star on GitHub
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StarCounter() {
|
||||
const [stars, setStars] = useState(10000);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadStars() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/bytedance/deer-flow",
|
||||
{
|
||||
headers: env.GITHUB_OAUTH_TOKEN
|
||||
? {
|
||||
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
: {},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!cancelled && typeof data?.stargazers_count === "number") {
|
||||
setStars(data.stargazers_count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching GitHub stars:", error);
|
||||
}
|
||||
}
|
||||
|
||||
void loadStars();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
|
||||
<NumberTicker className="font-mono tabular-nums" value={stars} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function Hero({ className }: { className?: string }) {
|
||||
"Learn Anything",
|
||||
]}
|
||||
/>{" "}
|
||||
<div>CM业务中心-问答管理</div>
|
||||
<div>CM 助手</div>
|
||||
</h1>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
||||
<a
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function ProgressiveSkillsAnimation() {
|
||||
<div className="border-b border-zinc-800 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-zinc-400">CM业务中心-问答管理 Agent</span>
|
||||
<span className="text-sm text-zinc-400">CM 助手 Agent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -691,7 +691,7 @@ export default function ProgressiveSkillsAnimation() {
|
||||
{/* Chat Input (decorative) */}
|
||||
<div className="border-t border-zinc-800 p-4">
|
||||
<div className="rounded-xl bg-zinc-800 px-4 py-3 text-sm text-zinc-500">
|
||||
Ask CM业务中心-问答管理 anything...
|
||||
Ask CM 助手 anything...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
||||
<Section
|
||||
className={className}
|
||||
title="Case Studies"
|
||||
subtitle="CM业务中心-问答管理"
|
||||
subtitle="CM 助手"
|
||||
>
|
||||
<div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
|
||||
{caseStudies.map((caseStudy) => (
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AuroraText } from "@/components/ui/aurora-text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
@@ -16,20 +12,9 @@ export function CommunitySection() {
|
||||
Join the Community
|
||||
</AuroraText>
|
||||
}
|
||||
subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts."
|
||||
subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts."
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<Button className="text-xl" size="lg" asChild>
|
||||
<Link
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubLogoIcon />
|
||||
Contribute Now
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) {
|
||||
title="Agent Runtime Environment"
|
||||
subtitle={
|
||||
<p>
|
||||
We give DeerFlow a "computer", which can execute commands,
|
||||
We give CM Assistant a "computer", which can execute commands,
|
||||
manage files, and run long tasks — all in a secure Docker-based
|
||||
sandbox
|
||||
</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SkillsSection({ className }: { className?: string }) {
|
||||
Agent Skills are loaded progressively — only what's needed, when
|
||||
it's needed.
|
||||
<br />
|
||||
Extend DeerFlow with your own skill files, or use our built-in
|
||||
Extend CM Assistant with your own skill files, or use our built-in
|
||||
library.
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ export function WhatsNewSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn("", className)}
|
||||
title="CM业务中心-问答管理"
|
||||
subtitle="CM业务中心-问答管理"
|
||||
title="CM 助手"
|
||||
subtitle="CM 助手"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<MagicBento data={features} />
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Compass,
|
||||
Database,
|
||||
FileBox,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Scale,
|
||||
Settings,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
type PageSidebarProps = {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "QA Center",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "new-qa", label: "New QA", icon: Plus },
|
||||
{ id: "general-qa", label: "General QA", icon: CircleHelp },
|
||||
{ id: "agent-qa", label: "Agent QA", icon: Bot },
|
||||
{ id: "multi-agent-qa", label: "Multi Agent", icon: Layers },
|
||||
{ id: "sixstep-chat", label: "Six Step Chat", icon: Workflow },
|
||||
{ id: "qa-records", label: "QA Records", icon: History },
|
||||
{ id: "template-editor", label: "Template Config", icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-app-center",
|
||||
label: "Light Apps",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{ id: "light-app-square", label: "App Square", icon: Layout },
|
||||
{ id: "light-app-records", label: "Run Records", icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "Task Management",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK Tasks",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "Task JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "Task Dispatch", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "Planning Tasks",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-fx", label: "Task Analysis", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "Action Strategy", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "MNTY Tasks",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "Mechanism Study", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "Plan Task", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "ZSTY Task", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effect-task",
|
||||
label: "Effect PG Tasks",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "Task PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "Action PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "Info PG", icon: ListTodo },
|
||||
{ id: "effect-account-pg", label: "Account PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{ id: "task-create", label: "Create Task", icon: Plus },
|
||||
{ id: "task-single", label: "Single Agent Task", icon: Bot },
|
||||
{ id: "task-scene", label: "Scene Task", icon: Workflow },
|
||||
{ id: "task-consult-create", label: "Multi Agent Consult", icon: MessageSquare },
|
||||
{ id: "final-plan", label: "Final Output", icon: FileOutput },
|
||||
],
|
||||
},
|
||||
{ id: "scene-list", label: "Scene Management", icon: Layers },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: "Agent Management",
|
||||
icon: Bot,
|
||||
children: [
|
||||
{ id: "agent-overview", label: "Agent List", icon: Users },
|
||||
{ id: "agent-trial", label: "Single Agent Trial", icon: PlayCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-management",
|
||||
label: "Result Management",
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{
|
||||
id: "result-planning",
|
||||
label: "Planning Results",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "result-planning-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-planning-goal", label: "Goal Analysis", icon: Target },
|
||||
{ id: "result-planning-brand", label: "Brand Strategy", icon: Target },
|
||||
{ id: "result-planning-content", label: "Content Planning", icon: Target },
|
||||
{ id: "result-planning-channel", label: "Channel Planning", icon: Target },
|
||||
{ id: "result-planning-narrative", label: "Narrative Strategy", icon: Target },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-mnty",
|
||||
label: "MNTY Results",
|
||||
icon: Brain,
|
||||
children: [
|
||||
{ id: "result-mnty-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-mnty-mechanism", label: "Mechanism Research", icon: Brain },
|
||||
{ id: "result-mnty-plan", label: "Plan Design", icon: Brain },
|
||||
{ id: "result-mnty-zsty", label: "ZSTY Analysis", icon: Brain },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-effect-pg",
|
||||
label: "Effect PG Results",
|
||||
icon: TrendingUp,
|
||||
children: [
|
||||
{ id: "result-effect-pg-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-effect-pg-behavior", label: "Behavior PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-evaluation", label: "Effect Review", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-info", label: "Info PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-account", label: "Account PG", icon: TrendingUp },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-xk",
|
||||
label: "XK Results",
|
||||
icon: Compass,
|
||||
children: [
|
||||
{ id: "result-xk-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-xk-jc", label: "Task JC", icon: Compass },
|
||||
{ id: "result-xk-dispatch", label: "Task Dispatch", icon: Compass },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-dq",
|
||||
label: "DQ Results",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "result-dq-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-dq-audience", label: "Audience Analysis", icon: Database },
|
||||
{ id: "result-dq-kb", label: "Knowledge Research", icon: Database },
|
||||
{ id: "result-dq-trend", label: "Trend Research", icon: Database },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-other",
|
||||
label: "Other Results",
|
||||
icon: MoreHorizontal,
|
||||
children: [
|
||||
{ id: "result-other-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-other-general", label: "General Analysis", icon: MoreHorizontal },
|
||||
{ id: "result-other-temp", label: "Temp Tasks", icon: MoreHorizontal },
|
||||
{ id: "result-other-other", label: "Other", icon: MoreHorizontal },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "Resource Management",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "resource-zz", label: "ZZ Resources", icon: FileBox },
|
||||
{ id: "resource-network-actor", label: "Network Actor", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "Knowledge Base", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "Evaluation Index", icon: Scale },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultExpandedIds = new Set([
|
||||
"qa-center",
|
||||
"task-management",
|
||||
"xk-task",
|
||||
"result-management",
|
||||
"result-planning",
|
||||
]);
|
||||
|
||||
export function PageSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
|
||||
const iconButtonClass =
|
||||
"rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
const panelClass = "ml-4 space-y-1 border-l border-red-100 pl-4";
|
||||
const labelClass = (compact: boolean, small = false) =>
|
||||
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
|
||||
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
|
||||
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderLeaf = (item: MenuItem, depth = 0) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={collapsed && depth === 0 ? item.label : undefined}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
||||
{(!collapsed || depth > 0) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBranch = (item: MenuItem, depth = 0) => {
|
||||
const expanded = expandedIds.has(item.id);
|
||||
const isTopLevel = depth === 0;
|
||||
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (collapsed && isTopLevel) {
|
||||
onToggleCollapsed();
|
||||
return;
|
||||
}
|
||||
toggleExpanded(item.id);
|
||||
}}
|
||||
title={collapsed && isTopLevel ? item.label : undefined}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
|
||||
collapsed && isTopLevel ? "justify-center px-2" : "gap-2 px-3"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
|
||||
expanded && depth > 0 ? "scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
{(!collapsed || !isTopLevel) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{(!collapsed || !isTopLevel) && (
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(!collapsed || !isTopLevel) && item.children && (
|
||||
<div
|
||||
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
|
||||
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{item.children.map((child) =>
|
||||
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 z-40 h-screen overflow-y-auto border-r border-red-100 bg-white shadow-sm transition-all duration-300 ${
|
||||
collapsed ? "w-16" : "w-64"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 items-center border-b border-red-100 ${
|
||||
collapsed ? "justify-center px-2" : "justify-between px-6"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-400 transition-transform duration-300 ease-out">
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
|
||||
<h1 className="text-sm font-semibold text-gray-900">DeerFlow</h1>
|
||||
<p className="text-xs text-red-400/80">Page Navigation</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={iconButtonClass}
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className={`${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={`flex w-full items-center justify-center ${iconButtonClass}`}
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{menuItems.map((item) =>
|
||||
item.children ? renderBranch(item) : renderLeaf(item),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full bg-red-500/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full bg-red-400/5 blur-3xl" />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -59,11 +59,17 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
agent.skills ?? [],
|
||||
);
|
||||
|
||||
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []);
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const isBuiltin = agent.builtin;
|
||||
const isOwned =
|
||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||
const isFromOthers = !isBuiltin && !isOwned;
|
||||
// System admins are allowed to edit built-in agents in place. Delete and
|
||||
// publish-toggle stay owner-only — built-ins shouldn't be removable, and
|
||||
// they are inherently visible to everyone so toggling published is moot.
|
||||
const canEdit = isOwned || (isBuiltin && isAdmin);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
@@ -235,40 +241,46 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
{isOwned && (
|
||||
{(canEdit || isOwned) && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => void handleTogglePublished()}
|
||||
disabled={updateAgent.isPending}
|
||||
title={agent.published ? "取消发布" : "发布"}
|
||||
>
|
||||
{agent.published ? (
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
title="编辑智能体"
|
||||
>
|
||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title={t.agents.delete}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{isOwned && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => void handleTogglePublished()}
|
||||
disabled={updateAgent.isPending}
|
||||
title={agent.published ? "取消发布" : "发布"}
|
||||
>
|
||||
{agent.published ? (
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
title="编辑智能体"
|
||||
>
|
||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title={t.agents.delete}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/workspace/agents/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t.agents.emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/page/workspace/agents/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t.agents.emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
KeyboardIcon,
|
||||
MessageSquarePlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push("/workspace/chats/new");
|
||||
setOpen(false);
|
||||
}, [router]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleShowShortcuts = useCallback(() => {
|
||||
setOpen(false);
|
||||
setShortcutsOpen(true);
|
||||
}, []);
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
|
||||
{ key: "n", meta: true, shift: true, action: handleNewChat },
|
||||
{ key: ",", meta: true, action: handleOpenSettings },
|
||||
{ key: "/", meta: true, action: handleShowShortcuts },
|
||||
],
|
||||
[handleNewChat, handleOpenSettings, handleShowShortcuts],
|
||||
);
|
||||
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.userAgent.includes("Mac"));
|
||||
}, []);
|
||||
const metaKey = isMac ? "⌘" : "Ctrl+";
|
||||
const shiftKey = isMac ? "⇧" : "Shift+";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t.shortcuts.searchActions} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
<CommandItem onSelect={handleNewChat}>
|
||||
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
|
||||
{t.sidebar.newChat}
|
||||
<CommandShortcut>
|
||||
{metaKey}
|
||||
{shiftKey}N
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t.common.settings}
|
||||
<CommandShortcut>{metaKey},</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowShortcuts}>
|
||||
<KeyboardIcon className="mr-2 h-4 w-4" />
|
||||
{t.shortcuts.keyboardShortcuts}
|
||||
<CommandShortcut>{metaKey}/</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.shortcuts.keyboardShortcutsDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{[
|
||||
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
|
||||
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
|
||||
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
|
||||
{ keys: `${metaKey},`, label: t.common.settings },
|
||||
{
|
||||
keys: `${metaKey}/`,
|
||||
label: t.shortcuts.keyboardShortcuts,
|
||||
},
|
||||
].map(({ keys, label }) => (
|
||||
<div key={keys} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
|
||||
{keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
KeyboardIcon,
|
||||
MessageSquarePlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push("/page/workspace/chats/new");
|
||||
setOpen(false);
|
||||
}, [router]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleShowShortcuts = useCallback(() => {
|
||||
setOpen(false);
|
||||
setShortcutsOpen(true);
|
||||
}, []);
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
|
||||
{ key: "n", meta: true, shift: true, action: handleNewChat },
|
||||
{ key: ",", meta: true, action: handleOpenSettings },
|
||||
{ key: "/", meta: true, action: handleShowShortcuts },
|
||||
],
|
||||
[handleNewChat, handleOpenSettings, handleShowShortcuts],
|
||||
);
|
||||
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.userAgent.includes("Mac"));
|
||||
}, []);
|
||||
const metaKey = isMac ? "⌘" : "Ctrl+";
|
||||
const shiftKey = isMac ? "⇧" : "Shift+";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t.shortcuts.searchActions} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
<CommandItem onSelect={handleNewChat}>
|
||||
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
|
||||
{t.sidebar.newChat}
|
||||
<CommandShortcut>
|
||||
{metaKey}
|
||||
{shiftKey}N
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t.common.settings}
|
||||
<CommandShortcut>{metaKey},</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowShortcuts}>
|
||||
<KeyboardIcon className="mr-2 h-4 w-4" />
|
||||
{t.shortcuts.keyboardShortcuts}
|
||||
<CommandShortcut>{metaKey}/</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.shortcuts.keyboardShortcutsDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{[
|
||||
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
|
||||
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
|
||||
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
|
||||
{ keys: `${metaKey},`, label: t.common.settings },
|
||||
{
|
||||
keys: `${metaKey}/`,
|
||||
label: t.shortcuts.keyboardShortcuts,
|
||||
},
|
||||
].map(({ keys, label }) => (
|
||||
<div key={keys} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
|
||||
{keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractPresentFilesFromMessage,
|
||||
@@ -42,17 +42,21 @@ export function MessageList({
|
||||
className,
|
||||
threadId,
|
||||
thread,
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
tokenUsageEnabled = false,
|
||||
onClarificationSubmit,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
tokenUsageEnabled?: boolean;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
}) {
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
tokenUsageEnabled = false,
|
||||
onClarificationSubmit,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
}: {
|
||||
className?: string;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
tokenUsageEnabled?: boolean;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
@@ -83,13 +87,13 @@ export function MessageList({
|
||||
if (message && hasContent(message)) {
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
<ClarificationCard
|
||||
fallbackContent={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
message={message}
|
||||
onSubmit={onClarificationSubmit}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
<ClarificationCard
|
||||
fallbackContent={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
message={message}
|
||||
onSubmit={onClarificationSubmit}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -99,6 +103,23 @@ export function MessageList({
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} else if (group.type === "assistant:setup-agent-approval") {
|
||||
const message = group.messages[0];
|
||||
return message ? (
|
||||
<div key={group.id} className="w-full">
|
||||
<SetupAgentApprovalCard
|
||||
disabled={thread.isLoading}
|
||||
message={message}
|
||||
onApprove={onSetupAgentApprove}
|
||||
onReject={onSetupAgentReject}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
messages={group.messages}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -246,150 +267,289 @@ export function MessageList({
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ClarificationPayload = {
|
||||
question?: string;
|
||||
context?: string | null;
|
||||
clarification_type?: string;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
};
|
||||
|
||||
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||
return null;
|
||||
}
|
||||
const clarification = Reflect.get(additionalKwargs, "clarification");
|
||||
if (typeof clarification !== "object" || clarification === null) {
|
||||
return null;
|
||||
}
|
||||
return clarification as ClarificationPayload;
|
||||
}
|
||||
|
||||
function ClarificationCard({
|
||||
fallbackContent,
|
||||
isLoading,
|
||||
message,
|
||||
onSubmit,
|
||||
rehypePlugins,
|
||||
}: {
|
||||
fallbackContent: string;
|
||||
isLoading: boolean;
|
||||
message: unknown;
|
||||
onSubmit?: (answer: string) => void | Promise<void>;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
(payload?.options ?? []).filter(
|
||||
(option) => typeof option.label === "string" && option.label.trim(),
|
||||
),
|
||||
[payload?.options],
|
||||
);
|
||||
|
||||
if (!payload) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={fallbackContent}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isLoading || submitting || !onSubmit;
|
||||
|
||||
async function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || disabled) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit?.(trimmed);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 || fallbackContent}
|
||||
</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={disabled}
|
||||
onClick={() => void submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span>{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={(event) => setCustomAnswer(event.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
disabled={disabled}
|
||||
className="min-h-20 resize-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || !customAnswer.trim()}
|
||||
onClick={() => void submitAnswer(customAnswer)}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
发送回答
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ClarificationPayload = {
|
||||
question?: string;
|
||||
context?: string | null;
|
||||
clarification_type?: string;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
};
|
||||
|
||||
export type SetupAgentApprovalPayload = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
soul?: string;
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
function getSetupAgentApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupAgentApprovalPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||
return null;
|
||||
}
|
||||
const approval = Reflect.get(additionalKwargs, "setup_agent_approval");
|
||||
if (typeof approval !== "object" || approval === null) {
|
||||
return null;
|
||||
}
|
||||
return approval as SetupAgentApprovalPayload;
|
||||
}
|
||||
|
||||
function SetupAgentApprovalCard({
|
||||
disabled,
|
||||
message,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
message: unknown;
|
||||
onApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getSetupAgentApprovalPayload(message);
|
||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
const skills = approvalPayload.skills ?? [];
|
||||
|
||||
async function run(action: "approve" | "reject") {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(action);
|
||||
try {
|
||||
if (action === "approve") {
|
||||
await onApprove?.(approvalPayload);
|
||||
} else {
|
||||
await onReject?.(approvalPayload);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
创建专家审批
|
||||
</div>
|
||||
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.name ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">名称</div>
|
||||
<p className="text-sm font-medium">{approvalPayload.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{approvalPayload.description ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">描述</div>
|
||||
<p className="text-sm leading-6">{approvalPayload.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{skills.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">技能</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="bg-secondary text-secondary-foreground rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{approvalPayload.soul ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">SOUL.md</div>
|
||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||
{approvalPayload.soul}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !onReject}
|
||||
onClick={() => void run("reject")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !onApprove}
|
||||
onClick={() => void run("approve")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
批准创建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||
return null;
|
||||
}
|
||||
const clarification = Reflect.get(additionalKwargs, "clarification");
|
||||
if (typeof clarification !== "object" || clarification === null) {
|
||||
return null;
|
||||
}
|
||||
return clarification as ClarificationPayload;
|
||||
}
|
||||
|
||||
function ClarificationCard({
|
||||
fallbackContent,
|
||||
isLoading,
|
||||
message,
|
||||
onSubmit,
|
||||
rehypePlugins,
|
||||
}: {
|
||||
fallbackContent: string;
|
||||
isLoading: boolean;
|
||||
message: unknown;
|
||||
onSubmit?: (answer: string) => void | Promise<void>;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
(payload?.options ?? []).filter(
|
||||
(option) => typeof option.label === "string" && option.label.trim(),
|
||||
),
|
||||
[payload?.options],
|
||||
);
|
||||
|
||||
if (!payload) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={fallbackContent}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isLoading || submitting || !onSubmit;
|
||||
|
||||
async function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || disabled) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit?.(trimmed);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 || fallbackContent}
|
||||
</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={disabled}
|
||||
onClick={() => void submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span>{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={(event) => setCustomAnswer(event.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
disabled={disabled}
|
||||
className="min-h-20 resize-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || !customAnswer.trim()}
|
||||
onClick={() => void submitAnswer(customAnswer)}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
发送回答
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,14 +117,7 @@ export function RecentChatList() {
|
||||
|
||||
const handleShare = useCallback(
|
||||
async (thread: AgentThread) => {
|
||||
// Always use Vercel URL for sharing so others can access
|
||||
const VERCEL_URL = "https://deer-flow-v2.vercel.app";
|
||||
const isLocalhost =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1";
|
||||
// On localhost: use Vercel URL; On production: use current origin
|
||||
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
||||
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
|
||||
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success(t.clipboard.linkCopied);
|
||||
|
||||
@@ -1,67 +1,18 @@
|
||||
/**
|
||||
* About DeerFlow markdown content. Inlined to avoid raw-loader dependency
|
||||
* About CM Assistant markdown content. Inlined to avoid raw-loader dependency
|
||||
* (Turbopack cannot resolve raw-loader for .md imports).
|
||||
*/
|
||||
export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
|
||||
export const aboutMarkdown = `# About CM Assistant
|
||||
|
||||
> **From Open Source, Back to Open Source**
|
||||
|
||||
DeerFlow (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is an open-source **super agent harness** that orchestrates **sub-agents**, **memory**, and **sandboxes** to do almost anything — powered by **extensible skills**.
|
||||
CM Assistant is an internal super-agent platform that orchestrates sub-agents, memory, and sandboxes — powered by extensible skills — to research, analyze, and create on your behalf.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Core Features
|
||||
|
||||
* **Skills & Tools**: With built-in and extensible skills and tools, DeerFlow can do almost anything.
|
||||
* **Skills & Tools**: With built-in and extensible skills and tools, CM Assistant can do almost anything.
|
||||
* **Sub-Agents**: Sub-Agents help the main agent to do the tasks that are too complex to be done by the main agent.
|
||||
* **Sandbox & File System**: Safely execute code and manipulate files in the sandbox.
|
||||
* **Context Engineering**: Isolated sub-agent context, summarization to keep the context window sharp.
|
||||
* **Long-Term Memory**: Keep recording the user's profile, top of mind, and conversation history.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 GitHub Repository
|
||||
|
||||

|
||||
|
||||
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
|
||||
|
||||
## 🌐 Official Website
|
||||
|
||||
Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/)
|
||||
|
||||
## 📧 Support
|
||||
|
||||
If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech).
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
DeerFlow is proudly open source and distributed under the **MIT License**.
|
||||
|
||||
---
|
||||
|
||||
## 🙌 Acknowledgments
|
||||
|
||||
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
|
||||
|
||||
### Core Frameworks
|
||||
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
|
||||
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
|
||||
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
|
||||
|
||||
### UI Libraries
|
||||
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
|
||||
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
|
||||
|
||||
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
|
||||
|
||||
### Special Thanks
|
||||
Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0:
|
||||
|
||||
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
|
||||
- **[Henry Li](https://github.com/magiccube/)**
|
||||
|
||||
Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today.
|
||||
`;
|
||||
@@ -1,55 +1,15 @@
|
||||
# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
|
||||
# About CM Assistant
|
||||
|
||||
> **From Open Source, Back to Open Source**
|
||||
|
||||
**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates.
|
||||
With the help of sandboxes, memories, tools and skills, it handles
|
||||
different levels of tasks that could take minutes to hours.
|
||||
CM Assistant is an internal super-agent platform that orchestrates sub-agents,
|
||||
memory, and sandboxes — powered by extensible skills — to research, analyze,
|
||||
and create on your behalf.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 GitHub Repository
|
||||
## Core Features
|
||||
|
||||
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
|
||||
|
||||
## 🌐 Official Website
|
||||
|
||||
Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/)
|
||||
|
||||
## 📧 Support
|
||||
|
||||
If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech).
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
DeerFlow is proudly open source and distributed under the **MIT License**.
|
||||
|
||||
---
|
||||
|
||||
## 🙌 Acknowledgments
|
||||
|
||||
We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants.
|
||||
|
||||
### Core Frameworks
|
||||
|
||||
- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains.
|
||||
- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration.
|
||||
- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications.
|
||||
|
||||
### UI Libraries
|
||||
|
||||
- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI.
|
||||
- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects.
|
||||
|
||||
These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration.
|
||||
|
||||
### Special Thanks
|
||||
|
||||
Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0:
|
||||
|
||||
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
|
||||
- **[Henry Li](https://github.com/magiccube/)**
|
||||
|
||||
Without their vision, passion and dedication, `DeerFlow` would not be what it is today.
|
||||
* **Skills & Tools**: Built-in and extensible skills let CM Assistant tackle a wide range of tasks.
|
||||
* **Sub-Agents**: Specialized sub-agents handle complex jobs the main agent delegates to them.
|
||||
* **Sandbox & File System**: Safely execute code and manipulate files inside an isolated sandbox.
|
||||
* **Context Engineering**: Isolated sub-agent context plus summarization keeps the context window sharp.
|
||||
* **Long-Term Memory**: Continuously records your profile, top-of-mind notes, and conversation history.
|
||||
@@ -389,7 +389,7 @@ export function MemorySettingsPage() {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
const exportedMemory = await exportMemory();
|
||||
const fileName = `deerflow-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
|
||||
const fileName = `cm-assistant-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
|
||||
const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -23,9 +32,16 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useEnableSkill,
|
||||
useInstallSkillUpload,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
@@ -58,6 +74,7 @@ function SkillSettingsList({
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("public");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const { mutate: enableSkill } = useEnableSkill();
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.category === filter),
|
||||
@@ -78,7 +95,16 @@ function SkillSettingsList({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setInstallOpen(true)}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t.settings.skills.installSkill}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreateSkill}>
|
||||
<SparklesIcon className="size-4" />
|
||||
{t.settings.skills.createSkill}
|
||||
@@ -110,6 +136,10 @@ function SkillSettingsList({
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,3 +163,265 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallSkillDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const dialog = t.settings.skills.installDialog;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFile(null);
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
setIsDragging(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (validateMutation.isPending || installMutation.isPending) return;
|
||||
reset();
|
||||
onClose();
|
||||
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
|
||||
|
||||
const acceptFile = useCallback(
|
||||
async (selected: File) => {
|
||||
const lower = selected.name.toLowerCase();
|
||||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||||
toast.error(dialog.rejectInvalidExt);
|
||||
return;
|
||||
}
|
||||
setFile(selected);
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const result = await validateMutation.mutateAsync(selected);
|
||||
setValidation(result);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.rejectInvalidExt, validateMutation],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
// Stop the global chat-upload listener on `document` from also receiving this drop.
|
||||
// React's stopPropagation alone doesn't reach native document listeners — we need to
|
||||
// halt the underlying DOM event too.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setIsDragging(false);
|
||||
const dropped = e.dataTransfer.files?.[0];
|
||||
if (dropped) void acceptFile(dropped);
|
||||
},
|
||||
[acceptFile],
|
||||
);
|
||||
|
||||
const onPick = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files?.[0];
|
||||
if (picked) void acceptFile(picked);
|
||||
},
|
||||
[acceptFile],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (overwrite: boolean) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await installMutation.mutateAsync({ file, overwrite });
|
||||
toast.success(
|
||||
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
||||
"{name}",
|
||||
result.skill_name,
|
||||
),
|
||||
);
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
|
||||
);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
|
||||
const canOverwrite = !!validation && !validationError && isCustomConflict;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialog.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors",
|
||||
isDragging ? "border-primary bg-accent/40" : "border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<FileArchive className="text-muted-foreground size-8" />
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{dialog.dropHint}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary ml-1 underline underline-offset-4"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{dialog.chooseFile}
|
||||
</button>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".zip,.skill,application/zip"
|
||||
hidden
|
||||
onChange={onPick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validateMutation.isPending && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{dialog.validating}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationError && (
|
||||
<div className="text-destructive flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{validationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validation && !validationError && (
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">{validation.skill_name}</span>
|
||||
</div>
|
||||
<Field label={dialog.descriptionLabel}>
|
||||
<span className="line-clamp-3 whitespace-pre-wrap">
|
||||
{validation.description || "—"}
|
||||
</span>
|
||||
</Field>
|
||||
{validation.license && (
|
||||
<Field label={dialog.licenseLabel}>{validation.license}</Field>
|
||||
)}
|
||||
{validation.allowed_tools.length > 0 && (
|
||||
<Field label={dialog.toolsLabel}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{validation.allowed_tools.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="text-xs">
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{validation.warnings.length > 0 && (
|
||||
<Field label={dialog.warningsLabel}>
|
||||
<ul className="list-disc pl-4 text-xs">
|
||||
{validation.warnings.map((w, idx) => (
|
||||
<li key={idx}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Field>
|
||||
)}
|
||||
{isCustomConflict && (
|
||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={installMutation.isPending}
|
||||
>
|
||||
{dialog.cancel}
|
||||
</Button>
|
||||
{canOverwrite && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleInstall(true)}
|
||||
disabled={installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{dialog.replace}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleInstall(false)}
|
||||
disabled={!canInstall || installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{installMutation.isPending ? dialog.installing : dialog.install}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[80px_1fr] gap-2">
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { GithubIcon } from "./github-icon";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function WorkspaceContainer({
|
||||
className,
|
||||
children,
|
||||
@@ -90,18 +87,6 @@ export function WorkspaceHeader({
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="pr-4">
|
||||
<Tooltip content={t.workspace.githubTooltip}>
|
||||
<a
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-75 transition hover:opacity-100"
|
||||
>
|
||||
<GithubIcon className="size-6" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCollapseButton({ className }: { className?: string }) {
|
||||
const { open, toggleSidebar } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
|
||||
className,
|
||||
)}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronLeft className="size-4" strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight className="size-4" strokeWidth={2} />
|
||||
)}
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const { state } = useSidebar();
|
||||
|
||||
const titleBlock = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
|
||||
{t.pages.appName}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px] leading-tight">
|
||||
{t.pages.brandSubtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/workspace-header border-sidebar-border/80 border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{state === "collapsed" ? (
|
||||
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
|
||||
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
|
||||
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
|
||||
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-3">
|
||||
<BrandMark />
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
|
||||
<Link href="/workspace/chats/new" className="min-w-0 flex-1 outline-none">
|
||||
{titleBlock}
|
||||
</Link>
|
||||
) : (
|
||||
titleBlock
|
||||
)}
|
||||
<SidebarCollapseButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCollapseButton({ className }: { className?: string }) {
|
||||
const { open, toggleSidebar } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
|
||||
className,
|
||||
)}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronLeft className="size-4" strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight className="size-4" strokeWidth={2} />
|
||||
)}
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const { state } = useSidebar();
|
||||
|
||||
const titleBlock = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
|
||||
{t.pages.appName}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px] leading-tight">
|
||||
{t.pages.brandSubtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/workspace-header border-sidebar-border/80 border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{state === "collapsed" ? (
|
||||
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
|
||||
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
|
||||
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
|
||||
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-3">
|
||||
<BrandMark />
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
|
||||
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
|
||||
{titleBlock}
|
||||
</Link>
|
||||
) : (
|
||||
titleBlock
|
||||
)}
|
||||
<SidebarCollapseButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/workspace/chats/new"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/chats/new">
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>{t.sidebar.chats}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/workspace/scheduled/tasks"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats/new">
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={pathname === "/page/workspace/chats"} asChild>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>{t.sidebar.chats}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function checkAgentName(
|
||||
);
|
||||
} catch {
|
||||
throw new AgentNameCheckError(
|
||||
"Could not reach the DeerFlow backend.",
|
||||
"Could not reach the CM Assistant backend.",
|
||||
"backend_unreachable",
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export async function checkAgentName(
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
|
||||
throw new AgentNameCheckError(
|
||||
"Could not reach the DeerFlow backend.",
|
||||
"Could not reach the CM Assistant backend.",
|
||||
"backend_unreachable",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ export const enUS: Translations = {
|
||||
welcome: {
|
||||
greeting: "Hello, again!",
|
||||
description:
|
||||
"Welcome to 🦌 DeerFlow, an open source super agent. With built-in and custom skills, DeerFlow helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.",
|
||||
"Welcome to CM Assistant. With built-in and custom skills, CM Assistant helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.",
|
||||
|
||||
createYourOwnSkill: "Create Your Own Skill",
|
||||
createYourOwnSkillDescription:
|
||||
"Create your own skill to release the power of DeerFlow. With customized skills,\nDeerFlow can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.",
|
||||
"Create your own skill to release the power of CM Assistant. With customized skills,\nCM Assistant can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.",
|
||||
},
|
||||
|
||||
// Clipboard
|
||||
@@ -208,13 +208,13 @@ export const enUS: Translations = {
|
||||
save: "Save agent",
|
||||
saving: "Saving agent...",
|
||||
saveRequested:
|
||||
"Save requested. DeerFlow is generating and saving an initial version now.",
|
||||
"Save requested. CM Assistant is generating and saving an initial version now.",
|
||||
saveHint:
|
||||
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
|
||||
saveCommandMessage:
|
||||
"Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.",
|
||||
agentCreatedPendingRefresh:
|
||||
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
|
||||
"The agent was created, but CM Assistant could not load it yet. Please refresh this page in a moment.",
|
||||
more: "More actions",
|
||||
agentCreated: "Agent created!",
|
||||
startChatting: "Start chatting",
|
||||
@@ -229,13 +229,13 @@ export const enUS: Translations = {
|
||||
|
||||
// Workspace
|
||||
workspace: {
|
||||
officialWebsite: "DeerFlow's official website",
|
||||
githubTooltip: "DeerFlow on Github",
|
||||
officialWebsite: "CM Assistant's official website",
|
||||
githubTooltip: "CM Assistant on Github",
|
||||
settingsAndMore: "Settings and more",
|
||||
visitGithub: "DeerFlow on GitHub",
|
||||
visitGithub: "CM Assistant on GitHub",
|
||||
reportIssue: "Report a issue",
|
||||
contactUs: "Contact us",
|
||||
about: "About DeerFlow",
|
||||
about: "About CM Assistant",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
@@ -251,7 +251,7 @@ export const enUS: Translations = {
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: "DeerFlow",
|
||||
appName: "CM Assistant",
|
||||
brandSubtitle: "Smart CM Platform",
|
||||
chats: "Chats",
|
||||
newChat: "New chat",
|
||||
@@ -278,7 +278,7 @@ export const enUS: Translations = {
|
||||
writeFile: "Write file",
|
||||
clickToViewContent: "Click to view file content",
|
||||
writeTodos: "Update to-do list",
|
||||
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
||||
skillInstallTooltip: "Install skill and make it available to CM Assistant",
|
||||
},
|
||||
|
||||
// Subtasks
|
||||
@@ -315,7 +315,7 @@ export const enUS: Translations = {
|
||||
actions: "Actions",
|
||||
keyboardShortcuts: "Keyboard Shortcuts",
|
||||
keyboardShortcutsDescription:
|
||||
"Navigate DeerFlow faster with keyboard shortcuts.",
|
||||
"Navigate CM Assistant faster with keyboard shortcuts.",
|
||||
openCommandPalette: "Open Command Palette",
|
||||
toggleSidebar: "Toggle Sidebar",
|
||||
},
|
||||
@@ -323,7 +323,7 @@ export const enUS: Translations = {
|
||||
// Settings
|
||||
settings: {
|
||||
title: "Settings",
|
||||
description: "Adjust how DeerFlow looks and behaves for you.",
|
||||
description: "Adjust how CM Assistant looks and behaves for you.",
|
||||
sections: {
|
||||
appearance: "Appearance",
|
||||
memory: "Memory",
|
||||
@@ -335,7 +335,7 @@ export const enUS: Translations = {
|
||||
memory: {
|
||||
title: "Memory",
|
||||
description:
|
||||
"DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.",
|
||||
"CM Assistant automatically learns from your conversations in the background. These memories help CM Assistant understand you better and deliver a more personalized experience.",
|
||||
empty: "No memory data to display.",
|
||||
rawJson: "Raw JSON",
|
||||
exportButton: "Export memory",
|
||||
@@ -433,20 +433,43 @@ export const enUS: Translations = {
|
||||
description:
|
||||
"Manage the configuration and enabled status of the agent skills.",
|
||||
createSkill: "Create skill",
|
||||
installSkill: "Install skill",
|
||||
emptyTitle: "No agent skill yet",
|
||||
emptyDescription:
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||
emptyButton: "Create Your First Skill",
|
||||
installDialog: {
|
||||
title: "Install skill",
|
||||
dropHint: "Drop a .zip or .skill archive here, or",
|
||||
chooseFile: "Choose file",
|
||||
validating: "Validating archive…",
|
||||
installing: "Installing…",
|
||||
replace: "Replace existing",
|
||||
install: "Install",
|
||||
cancel: "Cancel",
|
||||
nameLabel: "Name",
|
||||
descriptionLabel: "Description",
|
||||
licenseLabel: "License",
|
||||
toolsLabel: "Allowed tools",
|
||||
warningsLabel: "Warnings",
|
||||
conflictCustom:
|
||||
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
|
||||
conflictPublic:
|
||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||
installSuccess: "Skill '{name}' installed.",
|
||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
title: "Notification",
|
||||
description:
|
||||
"DeerFlow only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.",
|
||||
"CM Assistant only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.",
|
||||
requestPermission: "Request notification permission",
|
||||
deniedHint:
|
||||
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
|
||||
testButton: "Send test notification",
|
||||
testTitle: "DeerFlow",
|
||||
testTitle: "CM Assistant",
|
||||
testBody: "This is a test notification.",
|
||||
notSupported: "Your browser does not support notifications.",
|
||||
disableNotification: "Disable notification",
|
||||
|
||||
@@ -354,9 +354,30 @@ export interface Translations {
|
||||
title: string;
|
||||
description: string;
|
||||
createSkill: string;
|
||||
installSkill: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyButton: string;
|
||||
installDialog: {
|
||||
title: string;
|
||||
dropHint: string;
|
||||
chooseFile: string;
|
||||
validating: string;
|
||||
installing: string;
|
||||
replace: string;
|
||||
install: string;
|
||||
cancel: string;
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
licenseLabel: string;
|
||||
toolsLabel: string;
|
||||
warningsLabel: string;
|
||||
conflictCustom: string;
|
||||
conflictPublic: string;
|
||||
installSuccess: string;
|
||||
installSuccessReplaced: string;
|
||||
rejectInvalidExt: string;
|
||||
};
|
||||
};
|
||||
notification: {
|
||||
title: string;
|
||||
|
||||
@@ -61,11 +61,11 @@ export const zhCN: Translations = {
|
||||
welcome: {
|
||||
greeting: "你好,欢迎回来!",
|
||||
description:
|
||||
"欢迎使用 CM业务中心-问答管理",
|
||||
"欢迎使用 CM 助手",
|
||||
|
||||
createYourOwnSkill: "创建你自己的 Agent SKill",
|
||||
createYourOwnSkillDescription:
|
||||
"创建你的 Agent Skill 来释放 CM业务中心-问答管理的潜力。通过自定义技能,CM业务中心-问答管理\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
|
||||
"创建你的 Agent Skill 来释放 CM 助手的潜力。通过自定义技能,CM 助手\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
|
||||
},
|
||||
|
||||
// Clipboard
|
||||
@@ -196,13 +196,13 @@ export const zhCN: Translations = {
|
||||
save: "保存智能体",
|
||||
saving: "正在保存智能体...",
|
||||
saveRequested:
|
||||
"已提交保存请求,CM业务中心-问答管理 正在根据当前对话生成并保存初版智能体。",
|
||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
|
||||
saveHint:
|
||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
||||
saveCommandMessage:
|
||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||
agentCreatedPendingRefresh:
|
||||
"智能体已创建,但 CM业务中心-问答管理 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
more: "更多操作",
|
||||
agentCreated: "智能体已创建!",
|
||||
startChatting: "开始对话",
|
||||
@@ -217,13 +217,13 @@ export const zhCN: Translations = {
|
||||
|
||||
// Workspace
|
||||
workspace: {
|
||||
officialWebsite: "访问 CM业务中心-问答管理 官方网站",
|
||||
githubTooltip: "访问 CM业务中心-问答管理 的 Github 仓库",
|
||||
officialWebsite: "访问 CM 助手 官方网站",
|
||||
githubTooltip: "访问 CM 助手 的 Github 仓库",
|
||||
settingsAndMore: "设置和更多",
|
||||
visitGithub: "在 Github 上查看 CM业务中心-问答管理",
|
||||
visitGithub: "在 Github 上查看 CM 助手",
|
||||
reportIssue: "报告问题",
|
||||
contactUs: "联系我们",
|
||||
about: "关于 CM业务中心-问答管理",
|
||||
about: "关于 CM 助手",
|
||||
},
|
||||
|
||||
// Conversation
|
||||
@@ -239,7 +239,7 @@ export const zhCN: Translations = {
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: "CM业务中心-问答管理",
|
||||
appName: "CM 助手",
|
||||
brandSubtitle: "Smart CM Platform",
|
||||
chats: "对话",
|
||||
newChat: "新对话",
|
||||
@@ -265,7 +265,7 @@ export const zhCN: Translations = {
|
||||
writeFile: "写入文件",
|
||||
clickToViewContent: "点击查看文件内容",
|
||||
writeTodos: "更新 To-do 列表",
|
||||
skillInstallTooltip: "安装技能并使其可在 CM业务中心-问答管理 中使用",
|
||||
skillInstallTooltip: "安装技能并使其可在 CM 助手 中使用",
|
||||
},
|
||||
|
||||
uploads: {
|
||||
@@ -300,7 +300,7 @@ export const zhCN: Translations = {
|
||||
noResults: "未找到结果。",
|
||||
actions: "操作",
|
||||
keyboardShortcuts: "键盘快捷键",
|
||||
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM业务中心-问答管理。",
|
||||
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM 助手。",
|
||||
openCommandPalette: "打开命令面板",
|
||||
toggleSidebar: "切换侧边栏",
|
||||
},
|
||||
@@ -308,7 +308,7 @@ export const zhCN: Translations = {
|
||||
// Settings
|
||||
settings: {
|
||||
title: "设置",
|
||||
description: "根据你的偏好调整 CM业务中心-问答管理 的界面和行为。",
|
||||
description: "根据你的偏好调整 CM 助手 的界面和行为。",
|
||||
sections: {
|
||||
appearance: "外观",
|
||||
memory: "记忆",
|
||||
@@ -320,7 +320,7 @@ export const zhCN: Translations = {
|
||||
memory: {
|
||||
title: "记忆",
|
||||
description:
|
||||
"CM业务中心-问答管理 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM业务中心-问答管理 更好地理解你,并提供更个性化的体验。",
|
||||
"CM 助手 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM 助手 更好地理解你,并提供更个性化的体验。",
|
||||
empty: "暂无可展示的记忆数据。",
|
||||
rawJson: "原始 JSON",
|
||||
exportButton: "导出记忆",
|
||||
@@ -414,20 +414,42 @@ export const zhCN: Translations = {
|
||||
title: "技能",
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
createSkill: "新建技能",
|
||||
installSkill: "安装技能",
|
||||
emptyTitle: "还没有技能",
|
||||
emptyDescription:
|
||||
"将你的 Agent Skill 文件夹放在 CM业务中心-问答管理 根目录下的 `/skills/custom` 文件夹中。",
|
||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||
emptyButton: "创建你的第一个技能",
|
||||
installDialog: {
|
||||
title: "安装技能",
|
||||
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
||||
chooseFile: "选择文件",
|
||||
validating: "正在校验文件…",
|
||||
installing: "安装中…",
|
||||
replace: "覆盖已有",
|
||||
install: "安装",
|
||||
cancel: "取消",
|
||||
nameLabel: "名称",
|
||||
descriptionLabel: "描述",
|
||||
licenseLabel: "许可证",
|
||||
toolsLabel: "允许的工具",
|
||||
warningsLabel: "警告",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
|
||||
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
|
||||
installSuccess: "技能 “{name}” 已安装。",
|
||||
installSuccessReplaced: "技能 “{name}” 已替换。",
|
||||
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
title: "通知",
|
||||
description:
|
||||
"CM业务中心-问答管理 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
||||
"CM 助手 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
||||
requestPermission: "请求通知权限",
|
||||
deniedHint:
|
||||
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
|
||||
testButton: "发送测试通知",
|
||||
testTitle: "CM业务中心-问答管理",
|
||||
testTitle: "CM 助手",
|
||||
testBody: "这是一条测试通知。",
|
||||
notSupported: "当前浏览器不支持通知功能。",
|
||||
disableNotification: "关闭通知",
|
||||
|
||||
@@ -14,17 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
|
||||
|
||||
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
|
||||
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
| HumanMessageGroup
|
||||
| AssistantProcessingGroup
|
||||
| AssistantMessageGroup
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSubagentGroup;
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
messages: Message[],
|
||||
@@ -41,10 +44,11 @@ export function groupMessages<T>(
|
||||
function lastOpenGroup() {
|
||||
const last = groups[groups.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification"
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -66,16 +70,23 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
if (message.type === "tool") {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
// Add to the preceding processing group to preserve tool-call association,
|
||||
// then also open a standalone clarification group for prominent display.
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:clarification",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
const open = lastOpenGroup();
|
||||
if (open) {
|
||||
open.messages.push(message);
|
||||
@@ -286,10 +297,19 @@ export function hasPresentFiles(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_agent" &&
|
||||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||||
message.additional_kwargs.setup_agent_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
|
||||
@@ -1,216 +1,287 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
ScheduledTask,
|
||||
ScheduledTaskDeliveryProfile,
|
||||
ScheduledTaskResultFileContent,
|
||||
ScheduledTaskRun,
|
||||
SchedulerThread,
|
||||
} from "./types";
|
||||
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/scheduled-tasks`;
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Request failed" }));
|
||||
throw new Error(error.detail ?? "Request failed");
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getSchedulerThread() {
|
||||
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
|
||||
}
|
||||
|
||||
export async function listScheduledTasks() {
|
||||
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
|
||||
}
|
||||
|
||||
export async function createScheduledTask(data: {
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
}) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listScheduledTaskRuns(taskId: string) {
|
||||
return parseResponse<ScheduledTaskRun[]>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/runs`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScheduledTaskRun(runId: string) {
|
||||
return parseResponse<ScheduledTaskRun>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduledTaskRun(runId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function scheduledTaskRunDownloadUrl(runId: string) {
|
||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
||||
}
|
||||
|
||||
export function scheduledTaskRunFileDownloadUrl(runId: string, fileIndex: number) {
|
||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}/download`;
|
||||
}
|
||||
|
||||
export async function getScheduledTaskRunFileContent(runId: string, fileIndex: number) {
|
||||
return parseResponse<ScheduledTaskResultFileContent>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}`),
|
||||
);
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||
if (star) {
|
||||
const raw = star[1].replace(/["']/g, "").trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||
return plain ? plain[1].trim() : null;
|
||||
}
|
||||
|
||||
async function downloadResponse(response: Response, fallbackFilename: string) {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Download failed" }));
|
||||
const detail = error.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
if (
|
||||
detail &&
|
||||
typeof detail === "object" &&
|
||||
"message" in detail &&
|
||||
typeof detail.message === "string"
|
||||
) {
|
||||
throw new Error(detail.message);
|
||||
}
|
||||
throw new Error("Download failed");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const contentType = response.headers.get("Content-Type") ?? blob.type;
|
||||
const fallbackByType =
|
||||
contentType.includes("application/zip") || contentType.includes("application/x-zip-compressed")
|
||||
? fallbackFilename.replace(/\.[^.]+$/, ".zip")
|
||||
: fallbackFilename;
|
||||
const filename =
|
||||
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||
fallbackByType;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
export async function downloadScheduledTaskRun(runId: string) {
|
||||
await downloadResponse(
|
||||
await apiFetch(scheduledTaskRunDownloadUrl(runId)),
|
||||
"scheduled-task-result.zip",
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadScheduledTaskRunFile(runId: string, fileIndex: number) {
|
||||
await downloadResponse(
|
||||
await apiFetch(scheduledTaskRunFileDownloadUrl(runId, fileIndex)),
|
||||
"scheduled-task-file",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScheduledTaskDeliveryProfile() {
|
||||
return parseResponse<ScheduledTaskDeliveryProfile>(
|
||||
await apiFetch(`${baseUrl()}/delivery-profile`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScheduledTaskDeliveryProfile(data: {
|
||||
element_user_id?: string | null;
|
||||
}) {
|
||||
return parseResponse<ScheduledTaskDeliveryProfile>(
|
||||
await apiFetch(`${baseUrl()}/delivery-profile`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScheduledTask(
|
||||
taskId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function pauseScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/pause`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resumeScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/resume`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean; task: ScheduledTask }>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
PublishedScheduledTask,
|
||||
ScheduledTask,
|
||||
ScheduledTaskDeliveryProfile,
|
||||
ScheduledTaskResultFileContent,
|
||||
ScheduledTaskRun,
|
||||
ScheduledTaskRunFileUpdateResponse,
|
||||
ScheduledTaskSubscription,
|
||||
SchedulerThread,
|
||||
} from "./types";
|
||||
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/scheduled-tasks`;
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Request failed" }));
|
||||
throw new Error(error.detail ?? "Request failed");
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getSchedulerThread() {
|
||||
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
|
||||
}
|
||||
|
||||
export async function listScheduledTasks() {
|
||||
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
|
||||
}
|
||||
|
||||
export async function createScheduledTask(data: {
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
}) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listScheduledTaskRuns(taskId: string) {
|
||||
return parseResponse<ScheduledTaskRun[]>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/runs`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScheduledTaskRun(runId: string) {
|
||||
return parseResponse<ScheduledTaskRun>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduledTaskRun(runId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function scheduledTaskRunDownloadUrl(runId: string) {
|
||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
||||
}
|
||||
|
||||
export function scheduledTaskRunFileDownloadUrl(runId: string, fileIndex: number) {
|
||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}/download`;
|
||||
}
|
||||
|
||||
export async function getScheduledTaskRunFileContent(runId: string, fileIndex: number) {
|
||||
return parseResponse<ScheduledTaskResultFileContent>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScheduledTaskRunFile(
|
||||
runId: string,
|
||||
fileIndex: number,
|
||||
content: string,
|
||||
) {
|
||||
return parseResponse<ScheduledTaskRunFileUpdateResponse>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||
if (star) {
|
||||
const raw = star[1].replace(/["']/g, "").trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||
return plain ? plain[1].trim() : null;
|
||||
}
|
||||
|
||||
async function downloadResponse(response: Response, fallbackFilename: string) {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Download failed" }));
|
||||
const detail = error.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
if (
|
||||
detail &&
|
||||
typeof detail === "object" &&
|
||||
"message" in detail &&
|
||||
typeof detail.message === "string"
|
||||
) {
|
||||
throw new Error(detail.message);
|
||||
}
|
||||
throw new Error("Download failed");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const contentType = response.headers.get("Content-Type") ?? blob.type;
|
||||
const fallbackByType =
|
||||
contentType.includes("application/zip") || contentType.includes("application/x-zip-compressed")
|
||||
? fallbackFilename.replace(/\.[^.]+$/, ".zip")
|
||||
: fallbackFilename;
|
||||
const filename =
|
||||
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||
fallbackByType;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
export async function downloadScheduledTaskRun(runId: string) {
|
||||
await downloadResponse(
|
||||
await apiFetch(scheduledTaskRunDownloadUrl(runId)),
|
||||
"scheduled-task-result.zip",
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadScheduledTaskRunFile(runId: string, fileIndex: number) {
|
||||
await downloadResponse(
|
||||
await apiFetch(scheduledTaskRunFileDownloadUrl(runId, fileIndex)),
|
||||
"scheduled-task-file",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScheduledTaskDeliveryProfile() {
|
||||
return parseResponse<ScheduledTaskDeliveryProfile>(
|
||||
await apiFetch(`${baseUrl()}/delivery-profile`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScheduledTaskDeliveryProfile(data: {
|
||||
element_user_id?: string | null;
|
||||
}) {
|
||||
return parseResponse<ScheduledTaskDeliveryProfile>(
|
||||
await apiFetch(`${baseUrl()}/delivery-profile`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScheduledTask(
|
||||
taskId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function pauseScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/pause`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resumeScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/resume`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean; task: ScheduledTask }>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPublishedScheduledTasks(params: { limit?: number; offset?: number } = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.limit != null) query.set("limit", String(params.limit));
|
||||
if (params.offset != null) query.set("offset", String(params.offset));
|
||||
const suffix = query.toString();
|
||||
return parseResponse<PublishedScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}/published${suffix ? `?${suffix}` : ""}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/publish`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function unpublishScheduledTask(taskId: string) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/unpublish`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function subscribeScheduledTask(taskId: string, notifyElement: boolean) {
|
||||
return parseResponse<ScheduledTaskSubscription>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ notify_element: notifyElement }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateScheduledTaskSubscription(taskId: string, notifyElement: boolean) {
|
||||
return parseResponse<ScheduledTaskSubscription>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ notify_element: notifyElement }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function unsubscribeScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,111 +1,148 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createScheduledTask,
|
||||
deleteScheduledTask,
|
||||
deleteScheduledTaskRun,
|
||||
getSchedulerThread,
|
||||
getScheduledTaskDeliveryProfile,
|
||||
getScheduledTaskRun,
|
||||
listScheduledTaskRuns,
|
||||
listScheduledTasks,
|
||||
pauseScheduledTask,
|
||||
resumeScheduledTask,
|
||||
triggerScheduledTask,
|
||||
updateScheduledTaskDeliveryProfile,
|
||||
updateScheduledTask,
|
||||
} from "./api";
|
||||
|
||||
export function useSchedulerThread() {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "thread"],
|
||||
queryFn: getSchedulerThread,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTasks() {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks"],
|
||||
queryFn: listScheduledTasks,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskRuns(taskId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", taskId, "runs"],
|
||||
queryFn: () => listScheduledTaskRuns(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskRun(runId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", runId],
|
||||
queryFn: () => getScheduledTaskRun(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskDeliveryProfile() {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "delivery-profile"],
|
||||
queryFn: getScheduledTaskDeliveryProfile,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
|
||||
const invalidateRuns = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
|
||||
|
||||
return {
|
||||
update: useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
data,
|
||||
}: {
|
||||
taskId: string;
|
||||
data: Parameters<typeof updateScheduledTask>[1];
|
||||
}) => updateScheduledTask(taskId, data),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
create: useMutation({
|
||||
mutationFn: createScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: deleteScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
removeRun: useMutation({
|
||||
mutationFn: deleteScheduledTaskRun,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
pause: useMutation({
|
||||
mutationFn: pauseScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
resume: useMutation({
|
||||
mutationFn: resumeScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
trigger: useMutation({
|
||||
mutationFn: triggerScheduledTask,
|
||||
onSuccess: invalidateRuns,
|
||||
}),
|
||||
updateDeliveryProfile: useMutation({
|
||||
mutationFn: updateScheduledTaskDeliveryProfile,
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["scheduled-tasks", "delivery-profile"],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createScheduledTask,
|
||||
deleteScheduledTask,
|
||||
deleteScheduledTaskRun,
|
||||
getSchedulerThread,
|
||||
getScheduledTaskDeliveryProfile,
|
||||
getScheduledTaskRun,
|
||||
listPublishedScheduledTasks,
|
||||
listScheduledTaskRuns,
|
||||
listScheduledTasks,
|
||||
pauseScheduledTask,
|
||||
publishScheduledTask,
|
||||
resumeScheduledTask,
|
||||
subscribeScheduledTask,
|
||||
triggerScheduledTask,
|
||||
unpublishScheduledTask,
|
||||
unsubscribeScheduledTask,
|
||||
updateScheduledTaskDeliveryProfile,
|
||||
updateScheduledTask,
|
||||
updateScheduledTaskSubscription,
|
||||
} from "./api";
|
||||
|
||||
export function useSchedulerThread() {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "thread"],
|
||||
queryFn: getSchedulerThread,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTasks() {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks"],
|
||||
queryFn: listScheduledTasks,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskRuns(taskId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", taskId, "runs"],
|
||||
queryFn: () => listScheduledTaskRuns(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskRun(runId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", runId],
|
||||
queryFn: () => getScheduledTaskRun(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskDeliveryProfile() {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "delivery-profile"],
|
||||
queryFn: getScheduledTaskDeliveryProfile,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePublishedScheduledTasks(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "published"],
|
||||
queryFn: () => listPublishedScheduledTasks({ limit: 100 }),
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
|
||||
const invalidateRuns = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
|
||||
|
||||
return {
|
||||
update: useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
data,
|
||||
}: {
|
||||
taskId: string;
|
||||
data: Parameters<typeof updateScheduledTask>[1];
|
||||
}) => updateScheduledTask(taskId, data),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
create: useMutation({
|
||||
mutationFn: createScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: deleteScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
removeRun: useMutation({
|
||||
mutationFn: deleteScheduledTaskRun,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
pause: useMutation({
|
||||
mutationFn: pauseScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
resume: useMutation({
|
||||
mutationFn: resumeScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
trigger: useMutation({
|
||||
mutationFn: triggerScheduledTask,
|
||||
onSuccess: invalidateRuns,
|
||||
}),
|
||||
updateDeliveryProfile: useMutation({
|
||||
mutationFn: updateScheduledTaskDeliveryProfile,
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["scheduled-tasks", "delivery-profile"],
|
||||
}),
|
||||
}),
|
||||
publish: useMutation({
|
||||
mutationFn: publishScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
unpublish: useMutation({
|
||||
mutationFn: unpublishScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
subscribe: useMutation({
|
||||
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
|
||||
subscribeScheduledTask(taskId, notifyElement),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
updateSubscription: useMutation({
|
||||
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
|
||||
updateScheduledTaskSubscription(taskId, notifyElement),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
unsubscribe: useMutation({
|
||||
mutationFn: unsubscribeScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Convert markdown text to a Word (.docx) Blob in the browser.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Uses the `docx` npm package to construct an OOXML document client-side.
|
||||
* - Parser is intentionally lightweight: it covers the markdown features that
|
||||
* scheduled-task agent reports actually emit — ATX headings, paragraphs,
|
||||
* ordered/unordered lists, fenced code blocks, blockquotes, horizontal
|
||||
* rules, and inline `**bold**` / `*italic*` / `` `code` `` runs.
|
||||
* - We deliberately do NOT reach for a full markdown AST library (remark/
|
||||
* unified) to keep the bundle small. Edge cases (tables, footnotes, html)
|
||||
* degrade to plain text rather than failing loudly.
|
||||
*/
|
||||
|
||||
import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
type IRunOptions,
|
||||
} from "docx";
|
||||
|
||||
const HEADING_LEVELS = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
];
|
||||
|
||||
type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean };
|
||||
|
||||
/**
|
||||
* Tokenise a single line of markdown into formatted segments.
|
||||
*
|
||||
* Recognised inline syntax:
|
||||
* `code`, **bold**, __bold__, *italic*, _italic_
|
||||
* Order matters: code spans win over emphasis so backticks don't get parsed
|
||||
* as italic delimiters.
|
||||
*/
|
||||
function parseInlineSegments(text: string): InlineSegment[] {
|
||||
const tokens: InlineSegment[] = [];
|
||||
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > cursor) {
|
||||
tokens.push({ text: text.slice(cursor, match.index) });
|
||||
}
|
||||
if (match[2] !== undefined) {
|
||||
tokens.push({ text: match[2], code: true });
|
||||
} else if (match[3] !== undefined) {
|
||||
tokens.push({ text: match[3], bold: true });
|
||||
} else if (match[4] !== undefined) {
|
||||
tokens.push({ text: match[4], bold: true });
|
||||
} else if (match[5] !== undefined) {
|
||||
tokens.push({ text: match[5], italic: true });
|
||||
} else if (match[6] !== undefined) {
|
||||
tokens.push({ text: match[6], italic: true });
|
||||
}
|
||||
cursor = pattern.lastIndex;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
tokens.push({ text: text.slice(cursor) });
|
||||
}
|
||||
return tokens.length > 0 ? tokens : [{ text }];
|
||||
}
|
||||
|
||||
function segmentsToRuns(segments: InlineSegment[], extra?: Partial<IRunOptions>): TextRun[] {
|
||||
return segments.map((seg) => {
|
||||
const opts: IRunOptions = {
|
||||
text: seg.text,
|
||||
bold: seg.bold,
|
||||
italics: seg.italic,
|
||||
...extra,
|
||||
};
|
||||
if (seg.code) {
|
||||
opts.font = { name: "Consolas" };
|
||||
}
|
||||
return new TextRun(opts);
|
||||
});
|
||||
}
|
||||
|
||||
function makeParagraphFromInline(text: string): Paragraph {
|
||||
return new Paragraph({
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
});
|
||||
}
|
||||
|
||||
function makeHeadingParagraph(level: number, text: string): Paragraph {
|
||||
return new Paragraph({
|
||||
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
});
|
||||
}
|
||||
|
||||
function makeListItem(text: string, ordered: boolean, level: number): Paragraph {
|
||||
return new Paragraph({
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
numbering: ordered
|
||||
? { reference: "ordered-list", level: Math.min(level, 8) }
|
||||
: undefined,
|
||||
bullet: ordered ? undefined : { level: Math.min(level, 8) },
|
||||
});
|
||||
}
|
||||
|
||||
function makeCodeParagraph(line: string): Paragraph {
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: line || " ",
|
||||
font: { name: "Consolas" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function makeQuoteParagraph(text: string): Paragraph {
|
||||
return new Paragraph({
|
||||
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
|
||||
indent: { left: 720 },
|
||||
});
|
||||
}
|
||||
|
||||
function makeHorizontalRule(): Paragraph {
|
||||
return new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "────────" })],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown text into an array of docx Paragraph objects.
|
||||
*
|
||||
* Block grammar handled (line-by-line):
|
||||
* - ATX heading: `^(#{1,6})\s+(.*)$`
|
||||
* - Fenced code: lines between ``` ``` are emitted verbatim
|
||||
* - Blockquote: `^>\s?(.*)$`
|
||||
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
|
||||
* - Unordered list: `^\s*[-*+]\s+(.*)$`
|
||||
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
|
||||
* - Blank line: paragraph break
|
||||
* - Anything else: paragraph (with inline formatting)
|
||||
*/
|
||||
function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const paragraphs: Paragraph[] = [];
|
||||
|
||||
let inFence = false;
|
||||
let paragraphBuffer: string[] = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphBuffer.length === 0) return;
|
||||
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
|
||||
paragraphBuffer = [];
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\t/g, " ");
|
||||
|
||||
if (line.trim().startsWith("```")) {
|
||||
flushParagraph();
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
paragraphs.push(makeCodeParagraph(line));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushParagraph();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeHorizontalRule());
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockquote = /^>\s?(.*)$/.exec(line);
|
||||
if (blockquote) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeQuoteParagraph(blockquote[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
|
||||
if (orderedItem) {
|
||||
flushParagraph();
|
||||
const indent = Math.floor(orderedItem[1].length / 2);
|
||||
paragraphs.push(makeListItem(orderedItem[3], true, indent));
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
||||
if (unorderedItem) {
|
||||
flushParagraph();
|
||||
const indent = Math.floor(unorderedItem[1].length / 2);
|
||||
paragraphs.push(makeListItem(unorderedItem[2], false, indent));
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraphBuffer.push(line.trim());
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown string into a .docx Blob suitable for download.
|
||||
*/
|
||||
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
|
||||
const children: Paragraph[] = [];
|
||||
if (title?.trim()) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
children: [new TextRun({ text: title.trim(), bold: true })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
children.push(...markdownToParagraphs(markdown));
|
||||
|
||||
const doc = new Document({
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "ordered-list",
|
||||
levels: [
|
||||
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.START },
|
||||
{ level: 1, format: "decimal", text: "%2.", alignment: AlignmentType.START },
|
||||
{ level: 2, format: "decimal", text: "%3.", alignment: AlignmentType.START },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of the markdown source converted to .docx.
|
||||
*/
|
||||
export async function downloadMarkdownAsDocx(
|
||||
markdown: string,
|
||||
fileName: string,
|
||||
title?: string,
|
||||
): Promise<void> {
|
||||
const blob = await markdownToDocxBlob(markdown, title);
|
||||
const safeName = fileName.replace(/\.(md|markdown)$/i, "") || "document";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${safeName}.docx`;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
@@ -1,67 +1,101 @@
|
||||
export type ScheduledTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule_text: string;
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
source_agent_name?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
scheduler_thread_id: string;
|
||||
};
|
||||
|
||||
export type SchedulerThread = {
|
||||
user_id: string;
|
||||
thread_id: string;
|
||||
};
|
||||
|
||||
export type ScheduledTaskDeliveryProfile = {
|
||||
user_id: string;
|
||||
element_user_id?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskResultFile = {
|
||||
name: string;
|
||||
path?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
kind?: "markdown" | "attachment" | "artifact" | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskResultFileContent = {
|
||||
index: number;
|
||||
name: string;
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
size?: number | null;
|
||||
previewable: boolean;
|
||||
content?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRunResult = {
|
||||
title: string;
|
||||
content: string;
|
||||
format: "markdown" | "text" | "json" | "mixed";
|
||||
files: ScheduledTaskResultFile[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRun = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
scheduler_thread_id: string;
|
||||
agent_run_id?: string | null;
|
||||
scheduled_for: string;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
status: "pending" | "running" | "succeeded" | "failed" | string;
|
||||
error?: string | null;
|
||||
result?: ScheduledTaskRunResult | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
export type ScheduledTaskRole = "owner" | "subscriber";
|
||||
|
||||
export type ScheduledTaskSubscription = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
notify_element: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule_text: string;
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
source_agent_name?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
scheduler_thread_id: string;
|
||||
published?: boolean;
|
||||
published_at?: string | null;
|
||||
role?: ScheduledTaskRole;
|
||||
_subscription?: ScheduledTaskSubscription | null;
|
||||
};
|
||||
|
||||
export type PublishedScheduledTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
prompt_summary: string;
|
||||
schedule_text: string;
|
||||
timezone: string;
|
||||
published_at?: string | null;
|
||||
owner_user_id: string;
|
||||
};
|
||||
|
||||
export type SchedulerThread = {
|
||||
user_id: string;
|
||||
thread_id: string;
|
||||
};
|
||||
|
||||
export type ScheduledTaskDeliveryProfile = {
|
||||
user_id: string;
|
||||
element_user_id?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskResultFile = {
|
||||
name: string;
|
||||
path?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
kind?: "markdown" | "attachment" | "artifact" | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskResultFileContent = {
|
||||
index: number;
|
||||
name: string;
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
size?: number | null;
|
||||
previewable: boolean;
|
||||
content?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRunResult = {
|
||||
title: string;
|
||||
content: string;
|
||||
format: "markdown" | "text" | "json" | "mixed";
|
||||
files: ScheduledTaskResultFile[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRun = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
scheduler_thread_id: string;
|
||||
agent_run_id?: string | null;
|
||||
scheduled_for: string;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
status: "pending" | "running" | "succeeded" | "failed" | string;
|
||||
error?: string | null;
|
||||
result?: ScheduledTaskRunResult | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
can_edit?: boolean;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRunFileUpdateResponse = {
|
||||
index: number;
|
||||
name: string;
|
||||
size: number;
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
};
|
||||
@@ -34,6 +34,18 @@ export interface InstallSkillResponse {
|
||||
success: boolean;
|
||||
skill_name: string;
|
||||
message: string;
|
||||
overwritten?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidateSkillUploadResponse {
|
||||
ok: boolean;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
license: string | null;
|
||||
allowed_tools: string[];
|
||||
conflict: boolean;
|
||||
conflict_kind: "public" | "custom" | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
@@ -61,3 +73,40 @@ export async function installSkill(
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function parseDetailedError(response: Response, fallback: string): Promise<Error> {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const detail = (data as { detail?: unknown }).detail;
|
||||
const message = typeof detail === "string" && detail ? detail : `${fallback} (HTTP ${response.status})`;
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
export async function validateSkillUpload(file: File): Promise<ValidateSkillUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/validate`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Validation failed");
|
||||
}
|
||||
return response.json() as Promise<ValidateSkillUploadResponse>;
|
||||
}
|
||||
|
||||
export async function installSkillUpload(
|
||||
file: File,
|
||||
options: { overwrite?: boolean } = {},
|
||||
): Promise<InstallSkillResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const query = options.overwrite ? "?overwrite=true" : "";
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
||||
{ method: "POST", body: form },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Install failed");
|
||||
}
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { enableSkill } from "./api";
|
||||
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
@@ -29,3 +29,20 @@ export function useEnableSkill() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useValidateSkillUpload() {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => validateSkillUpload(file),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallSkillUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
|
||||
installSkillUpload(file, { overwrite }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,58 +1,58 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { AgentThread, AgentThreadContext } from "./types";
|
||||
|
||||
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
|
||||
|
||||
type ThreadRouteTarget =
|
||||
| string
|
||||
| {
|
||||
thread_id: string;
|
||||
context?: ThreadAgentRef | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function pickAgentRef(
|
||||
context: ThreadAgentRef | null | undefined,
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): string | undefined {
|
||||
if (context?.agent_id) return context.agent_id;
|
||||
if (context?.agent_name) return context.agent_name;
|
||||
const metaId = metadata?.agent_id;
|
||||
if (typeof metaId === "string" && metaId) return metaId;
|
||||
const metaName = metadata?.agent_name;
|
||||
if (typeof metaName === "string" && metaName) return metaName;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: ThreadAgentRef | null,
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const agentRef =
|
||||
typeof thread === "string"
|
||||
? pickAgentRef(context, null)
|
||||
: pickAgentRef(thread.context, thread.metadata);
|
||||
|
||||
return agentRef
|
||||
? `/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
: `/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function titleOfThread(thread: AgentThread) {
|
||||
return thread.values?.title ?? "Untitled";
|
||||
}
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { AgentThread, AgentThreadContext } from "./types";
|
||||
|
||||
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
|
||||
|
||||
type ThreadRouteTarget =
|
||||
| string
|
||||
| {
|
||||
thread_id: string;
|
||||
context?: ThreadAgentRef | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function pickAgentRef(
|
||||
context: ThreadAgentRef | null | undefined,
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): string | undefined {
|
||||
if (context?.agent_id) return context.agent_id;
|
||||
if (context?.agent_name) return context.agent_name;
|
||||
const metaId = metadata?.agent_id;
|
||||
if (typeof metaId === "string" && metaId) return metaId;
|
||||
const metaName = metadata?.agent_name;
|
||||
if (typeof metaName === "string" && metaName) return metaName;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: ThreadAgentRef | null,
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const agentRef =
|
||||
typeof thread === "string"
|
||||
? pickAgentRef(context, null)
|
||||
: pickAgentRef(thread.context, thread.metadata);
|
||||
|
||||
return agentRef
|
||||
? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
: `/page/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function titleOfThread(thread: AgentThread) {
|
||||
return thread.values?.title ?? "Untitled";
|
||||
}
|
||||
@@ -1,233 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_id } = useParams<{
|
||||
agent_id: string;
|
||||
}>();
|
||||
|
||||
const { agent } = useAgent(agent_id);
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/workspace/agents/${agent_id}/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_id } = useParams<{
|
||||
agent_id: string;
|
||||
}>();
|
||||
|
||||
const { agent } = useAgent(agent_id);
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message, { agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message, { agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">
|
||||
{agent?.name ?? agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/workspace/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">
|
||||
{agent?.name ?? agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
+201
-209
@@ -1,219 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/workspace/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
await sendMessage(threadId, { text: answer, files: [] });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{mounted ? (
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{mounted ? (
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
type LoginStatus = "loading" | "success" | "error";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
const router = useRouter();
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
const [status, setStatus] = useState<LoginStatus>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function runLogin() {
|
||||
setStatus("loading");
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const auth = await loginByUsername(resolvedUsername);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
setStatus("success");
|
||||
router.replace("/workspace/chats/new");
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Login request failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void runLogin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedUsername, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
|
||||
{status === "error" && (
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
登录失败:{errorMessage || "未知错误"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
type LoginStatus = "loading" | "success" | "error";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
const router = useRouter();
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
const [status, setStatus] = useState<LoginStatus>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function runLogin() {
|
||||
setStatus("loading");
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const auth = await loginByUsername(resolvedUsername);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
setStatus("success");
|
||||
router.replace("/page/workspace/chats/new");
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Login request failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void runLogin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedUsername, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
|
||||
{status === "error" && (
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
登录失败:{errorMessage || "未知错误"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,517 +1,477 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BotIcon,
|
||||
CheckCircleIcon,
|
||||
InfoIcon,
|
||||
MoreHorizontalIcon,
|
||||
SaveIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
AgentNameCheckError,
|
||||
checkAgentName,
|
||||
createAgent,
|
||||
getAgent,
|
||||
} from "@/core/agents/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = "name" | "chat";
|
||||
type SetupAgentStatus = "idle" | "requested" | "completed";
|
||||
|
||||
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
|
||||
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getAgentWithRetry(agentId: string) {
|
||||
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
|
||||
if (delay > 0) {
|
||||
await wait(delay);
|
||||
}
|
||||
|
||||
try {
|
||||
return await getAgent(agentId);
|
||||
} catch {
|
||||
// Retry until the write settles or the attempts are exhausted.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCreateAgentErrorMessage(
|
||||
error: unknown,
|
||||
networkErrorMessage: string,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
if (error instanceof TypeError && error.message === "Failed to fetch") {
|
||||
return networkErrorMessage;
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
export default function NewAgentPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<Step>("name");
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [isCheckingName, setIsCheckingName] = useState(false);
|
||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [showSaveHint, setShowSaveHint] = useState(false);
|
||||
const [setupAgentStatus, setSetupAgentStatus] =
|
||||
useState<SetupAgentStatus>("idle");
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSkillSelectionTouched) {
|
||||
return;
|
||||
}
|
||||
const defaults = enabledSkills
|
||||
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
|
||||
.map((skill) => skill.name);
|
||||
setSelectedSkills(defaults);
|
||||
}, [enabledSkills, isSkillSelectionTouched]);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: step === "chat" ? threadId : undefined,
|
||||
assistantId: agentId || undefined,
|
||||
context: {
|
||||
mode: "flash",
|
||||
is_bootstrap: true,
|
||||
},
|
||||
onFinish() {
|
||||
if (!agent && setupAgentStatus === "requested") {
|
||||
setSetupAgentStatus("idle");
|
||||
}
|
||||
},
|
||||
onToolEnd({ name }) {
|
||||
if (name !== "setup_agent" || !agentId) return;
|
||||
setSetupAgentStatus("completed");
|
||||
void getAgentWithRetry(agentId).then((fetched) => {
|
||||
if (fetched) {
|
||||
setAgent(fetched);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t.agents.agentCreatedPendingRefresh);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ensureThreadExists = useCallback(async () => {
|
||||
if (threadPrepared) {
|
||||
return;
|
||||
}
|
||||
await getAPIClient().threads.create({
|
||||
threadId,
|
||||
});
|
||||
setThreadPrepared(true);
|
||||
}, [threadPrepared, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || step !== "chat") {
|
||||
return;
|
||||
}
|
||||
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") {
|
||||
return;
|
||||
}
|
||||
setShowSaveHint(true);
|
||||
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1");
|
||||
}, [step]);
|
||||
|
||||
const handleConfirmName = useCallback(async () => {
|
||||
const trimmed = nameInput.trim();
|
||||
if (!trimmed) {
|
||||
setNameError(t.agents.nameStepInvalidError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
|
||||
setNameError("请至少选择一个技能");
|
||||
return;
|
||||
}
|
||||
|
||||
setNameError("");
|
||||
setIsCheckingName(true);
|
||||
try {
|
||||
await checkAgentName(trimmed);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AgentNameCheckError &&
|
||||
err.reason === "backend_unreachable"
|
||||
) {
|
||||
setNameError(t.agents.nameStepNetworkError);
|
||||
} else {
|
||||
setNameError(t.agents.nameStepCheckError);
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
setIsCheckingName(false);
|
||||
}
|
||||
|
||||
let createdId: string;
|
||||
let createdName: string;
|
||||
setIsCreatingAgent(true);
|
||||
try {
|
||||
const created = await createAgent({
|
||||
name: trimmed,
|
||||
description: "",
|
||||
skills: selectedSkills,
|
||||
soul: "",
|
||||
});
|
||||
createdId = created.id;
|
||||
createdName = created.name;
|
||||
} catch (err) {
|
||||
setNameError(
|
||||
getCreateAgentErrorMessage(
|
||||
err,
|
||||
t.agents.nameStepNetworkError,
|
||||
t.agents.nameStepCheckError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setIsCreatingAgent(false);
|
||||
}
|
||||
|
||||
setAgentId(createdId);
|
||||
setStep("chat");
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, {
|
||||
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
|
||||
files: [],
|
||||
});
|
||||
}, [
|
||||
nameInput,
|
||||
sendMessage,
|
||||
t.agents.nameStepNetworkError,
|
||||
t.agents.nameStepBootstrapMessage,
|
||||
t.agents.nameStepCheckError,
|
||||
t.agents.nameStepInvalidError,
|
||||
threadId,
|
||||
enabledSkills.length,
|
||||
selectedSkills,
|
||||
ensureThreadExists,
|
||||
]);
|
||||
|
||||
const toggleSkill = useCallback((skill: Skill) => {
|
||||
setIsSkillSelectionTouched(true);
|
||||
setNameError("");
|
||||
setSelectedSkills((prev) => {
|
||||
if (prev.includes(skill.name)) {
|
||||
return prev.filter((name) => name !== skill.name);
|
||||
}
|
||||
return [...prev, skill.name];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
||||
e.preventDefault();
|
||||
void handleConfirmName();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || thread.isLoading) return;
|
||||
await ensureThreadExists();
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{ text: trimmed, files: [] },
|
||||
{ agent_id: agentId },
|
||||
);
|
||||
},
|
||||
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists],
|
||||
);
|
||||
|
||||
const handleSaveAgent = useCallback(async () => {
|
||||
if (
|
||||
!agentId ||
|
||||
agent ||
|
||||
thread.isLoading ||
|
||||
setupAgentStatus !== "idle"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSetupAgentStatus("requested");
|
||||
setShowSaveHint(false);
|
||||
try {
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{ text: t.agents.saveCommandMessage, files: [] },
|
||||
{ agent_id: agentId },
|
||||
{ additionalKwargs: { hide_from_ui: true } },
|
||||
);
|
||||
toast.success(t.agents.saveRequested);
|
||||
} catch (error) {
|
||||
setSetupAgentStatus("idle");
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [
|
||||
agent,
|
||||
agentId,
|
||||
sendMessage,
|
||||
setupAgentStatus,
|
||||
t.agents.saveCommandMessage,
|
||||
t.agents.saveRequested,
|
||||
thread.isLoading,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const header = (
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => router.push("/workspace/agents")}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
|
||||
</div>
|
||||
|
||||
{step === "chat" ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}>
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void handleSaveAgent()}
|
||||
disabled={
|
||||
!!agent || thread.isLoading || setupAgentStatus !== "idle"
|
||||
}
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{setupAgentStatus === "requested"
|
||||
? t.agents.saving
|
||||
: t.agents.save}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
|
||||
if (step === "name") {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{header}
|
||||
<main className="flex flex-1 flex-col items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-7 w-7" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t.agents.nameStepTitle}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.agents.nameStepHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t.agents.nameStepPlaceholder}
|
||||
value={nameInput}
|
||||
onChange={(e) => {
|
||||
setNameInput(e.target.value);
|
||||
setNameError("");
|
||||
}}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
className={cn(nameError && "border-destructive")}
|
||||
/>
|
||||
{nameError ? (
|
||||
<p className="text-destructive text-sm">{nameError}</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">默认技能(可自定义)</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
已选 {selectedSkills.length} 个
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可用技能,将创建为无技能智能体。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{enabledSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<Button
|
||||
key={skill.name}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill)}
|
||||
className={cn(
|
||||
"h-8",
|
||||
checked ? "" : "text-muted-foreground",
|
||||
)}
|
||||
title={skill.description}
|
||||
>
|
||||
{skill.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleConfirmName()}
|
||||
disabled={
|
||||
!nameInput.trim() ||
|
||||
isCheckingName ||
|
||||
isCreatingAgent ||
|
||||
isSkillsLoading
|
||||
}
|
||||
>
|
||||
{t.agents.nameStepContinue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex size-full flex-col">
|
||||
{header}
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
{showSaveHint ? (
|
||||
<div className="px-4 pt-4">
|
||||
<div className="mx-auto w-full max-w-(--container-width-md)">
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>{t.agents.saveHint}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
onClarificationSubmit={handleChatSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{agent ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
|
||||
<CheckCircleIcon className="text-primary h-10 w-10" />
|
||||
<p className="font-semibold">{t.agents.agentCreated}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/workspace/agents/${agentId}/chats/new`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t.agents.startChatting}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/workspace/agents")}
|
||||
>
|
||||
{t.agents.backToGallery}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
>
|
||||
<PromptInputTextarea
|
||||
autoFocus
|
||||
placeholder={t.agents.createPageSubtitle}
|
||||
disabled={thread.isLoading}
|
||||
/>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit disabled={thread.isLoading} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
SaveIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
MessageList,
|
||||
type SetupAgentApprovalPayload,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
AgentNameCheckError,
|
||||
checkAgentName,
|
||||
createAgent,
|
||||
getAgent,
|
||||
updateAgent,
|
||||
} from "@/core/agents/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getAgentWithRetry(agentId: string) {
|
||||
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
|
||||
if (delay > 0) {
|
||||
await wait(delay);
|
||||
}
|
||||
try {
|
||||
return await getAgent(agentId);
|
||||
} catch {
|
||||
// Retry until the file/database write settles.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCreateAgentErrorMessage(
|
||||
error: unknown,
|
||||
networkErrorMessage: string,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
if (error instanceof TypeError && error.message === "Failed to fetch") {
|
||||
return networkErrorMessage;
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
export default function NewAgentPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [descriptionInput, setDescriptionInput] = useState("");
|
||||
const [soulInput, setSoulInput] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSkillSelectionTouched) {
|
||||
return;
|
||||
}
|
||||
setSelectedSkills(
|
||||
enabledSkills
|
||||
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
|
||||
.map((skill) => skill.name),
|
||||
);
|
||||
}, [enabledSkills, isSkillSelectionTouched]);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId,
|
||||
assistantId: agentId || undefined,
|
||||
context: {
|
||||
mode: "flash",
|
||||
is_bootstrap: true,
|
||||
},
|
||||
onToolEnd({ name }) {
|
||||
if (name !== "setup_agent" || !agentId) {
|
||||
return;
|
||||
}
|
||||
void getAgentWithRetry(agentId).then((fetched) => {
|
||||
if (fetched) {
|
||||
setAgent(fetched);
|
||||
setDescriptionInput(fetched.description ?? "");
|
||||
setSoulInput(fetched.soul ?? "");
|
||||
setSelectedSkills(fetched.skills ?? []);
|
||||
toast.success("专家配置已创建");
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ensureThreadExists = useCallback(async () => {
|
||||
if (threadPrepared) {
|
||||
return;
|
||||
}
|
||||
await getAPIClient().threads.create({ threadId });
|
||||
setThreadPrepared(true);
|
||||
}, [threadId, threadPrepared]);
|
||||
|
||||
const validateForm = useCallback(() => {
|
||||
// Name is intentionally NOT required here so the user can start chatting
|
||||
// immediately. If they leave it blank, ensureAgent() generates a placeholder
|
||||
// and writes it back into the input so they can rename it later.
|
||||
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
|
||||
setFormError("请至少选择一个技能");
|
||||
return null;
|
||||
}
|
||||
setFormError("");
|
||||
return nameInput.trim();
|
||||
}, [enabledSkills.length, nameInput, selectedSkills.length]);
|
||||
|
||||
const ensureAgent = useCallback(async () => {
|
||||
const provided = validateForm();
|
||||
if (provided === null) {
|
||||
return null;
|
||||
}
|
||||
let name = provided;
|
||||
if (!name) {
|
||||
// Names are display-only and may collide, so a timestamped placeholder is
|
||||
// safe. Reflect it in the form so the user sees what got created.
|
||||
name = `未命名专家-${Date.now().toString(36)}`;
|
||||
setNameInput(name);
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
setIsSavingDraft(true);
|
||||
try {
|
||||
const updated = await updateAgent(agentId, {
|
||||
name,
|
||||
description: descriptionInput,
|
||||
soul: soulInput,
|
||||
skills: selectedSkills,
|
||||
});
|
||||
setAgent(updated);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
} finally {
|
||||
setIsSavingDraft(false);
|
||||
}
|
||||
}
|
||||
|
||||
setIsCreatingAgent(true);
|
||||
try {
|
||||
await checkAgentName(name);
|
||||
const created = await createAgent({
|
||||
name,
|
||||
description: descriptionInput,
|
||||
soul: soulInput,
|
||||
skills: selectedSkills,
|
||||
});
|
||||
setAgentId(created.id);
|
||||
setAgent(created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof AgentNameCheckError &&
|
||||
error.reason === "backend_unreachable"
|
||||
) {
|
||||
setFormError(t.agents.nameStepNetworkError);
|
||||
} else {
|
||||
setFormError(
|
||||
getCreateAgentErrorMessage(
|
||||
error,
|
||||
t.agents.nameStepNetworkError,
|
||||
t.agents.nameStepCheckError,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreatingAgent(false);
|
||||
}
|
||||
}, [
|
||||
agentId,
|
||||
descriptionInput,
|
||||
selectedSkills,
|
||||
soulInput,
|
||||
t.agents.nameStepCheckError,
|
||||
t.agents.nameStepNetworkError,
|
||||
validateForm,
|
||||
]);
|
||||
|
||||
const handleSaveAgent = useCallback(async () => {
|
||||
const saved = await ensureAgent();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
toast.success(`专家「${saved.name}」已保存`);
|
||||
}, [ensureAgent]);
|
||||
|
||||
const handleChatSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || thread.isLoading) {
|
||||
return;
|
||||
}
|
||||
// Lazily create the agent on first message so the user can just start
|
||||
// typing — ensureAgent generates a placeholder name when none is provided.
|
||||
let activeAgentId = agentId;
|
||||
if (!activeAgentId) {
|
||||
const created = await ensureAgent();
|
||||
if (!created) {
|
||||
return;
|
||||
}
|
||||
activeAgentId = created.id;
|
||||
}
|
||||
await ensureThreadExists();
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{ text: trimmed, files: [] },
|
||||
{ agent_id: activeAgentId },
|
||||
);
|
||||
},
|
||||
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
|
||||
);
|
||||
|
||||
const handleApproveSetupAgent = useCallback(
|
||||
async (payload: SetupAgentApprovalPayload) => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
// Approval persists directly to the backend — no LLM round-trip. The
|
||||
// backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
|
||||
// config.yaml on disk, which is exactly what setup_agent_tool would have
|
||||
// done if we'd let the model re-invoke it.
|
||||
const proposedName = payload.name?.trim() ?? "";
|
||||
const newDescription = payload.description ?? "";
|
||||
const newSoul = payload.soul ?? "";
|
||||
const newSkills = payload.skills ?? [];
|
||||
|
||||
// Prefer the model's proposed name when present; fall back to whatever
|
||||
// the user already typed, or the existing agent record.
|
||||
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
|
||||
setNameInput(resolvedName);
|
||||
setDescriptionInput(newDescription);
|
||||
setSoulInput(newSoul);
|
||||
setSelectedSkills(newSkills);
|
||||
setIsSkillSelectionTouched(true);
|
||||
|
||||
try {
|
||||
const updated = await updateAgent(agentId, {
|
||||
name: resolvedName,
|
||||
description: newDescription,
|
||||
soul: newSoul,
|
||||
skills: newSkills,
|
||||
});
|
||||
setAgent(updated);
|
||||
toast.success("专家配置已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
},
|
||||
[agent, agentId, nameInput],
|
||||
);
|
||||
|
||||
const handleRejectSetupAgent = useCallback(
|
||||
async () => {
|
||||
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
|
||||
},
|
||||
[handleChatSubmit],
|
||||
);
|
||||
|
||||
const toggleSkill = useCallback((skill: Skill) => {
|
||||
setIsSkillSelectionTouched(true);
|
||||
setFormError("");
|
||||
setSelectedSkills((prev) =>
|
||||
prev.includes(skill.name)
|
||||
? prev.filter((name) => name !== skill.name)
|
||||
: [...prev, skill.name],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => router.push("/workspace/agents")}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-sm font-semibold">创建专家</h1>
|
||||
</div>
|
||||
{agent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)}
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
开始对话
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="flex min-h-0 flex-col border-r">
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessageList
|
||||
className="size-full"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
onClarificationSubmit={handleChatSubmit}
|
||||
onSetupAgentApprove={handleApproveSetupAgent}
|
||||
onSetupAgentReject={handleRejectSetupAgent}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background shrink-0 border-t px-4 py-4">
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
>
|
||||
<PromptInputTextarea
|
||||
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
|
||||
disabled={thread.isLoading}
|
||||
/>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit disabled={thread.isLoading} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="bg-background min-h-0 overflow-y-auto">
|
||||
<div className="space-y-5 p-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">专家配置</h2>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
这些内容会作为创建专家时的初始配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input
|
||||
placeholder="例如:合同审查专家"
|
||||
value={nameInput}
|
||||
onChange={(event) => {
|
||||
setNameInput(event.currentTarget.value);
|
||||
setFormError("");
|
||||
}}
|
||||
className={cn(formError && "border-destructive")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea
|
||||
placeholder="简要说明这个专家擅长什么"
|
||||
value={descriptionInput}
|
||||
onChange={(event) =>
|
||||
setDescriptionInput(event.currentTarget.value)
|
||||
}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">SOUL.md</label>
|
||||
<Textarea
|
||||
placeholder="写下专家的人设、边界、工作方式和输出要求"
|
||||
value={soulInput}
|
||||
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
||||
className="min-h-48 resize-y font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">技能</label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可用技能。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{enabledSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<Button
|
||||
key={skill.name}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill)}
|
||||
title={skill.description}
|
||||
className={cn(
|
||||
"h-8 max-w-full",
|
||||
!checked && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{skill.name}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formError ? (
|
||||
<p className="text-destructive text-sm">{formError}</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleSaveAgent()}
|
||||
disabled={busy || isSkillsLoading}
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{agentId ? "保存专家" : "创建专家"}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||
<h1 className="text-2xl font-semibold">Page not found</h1>
|
||||
<p className="text-muted-foreground max-w-md text-center text-sm">
|
||||
The Vite rewrite currently focuses on the workspace chat flows first.
|
||||
Marketing, docs, blog, and Next-specific server routes still need a
|
||||
dedicated migration pass.
|
||||
</p>
|
||||
<Link
|
||||
to="/workspace/chats/new"
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||
<h1 className="text-2xl font-semibold">Page not found</h1>
|
||||
<p className="text-muted-foreground max-w-md text-center text-sm">
|
||||
The Vite rewrite currently focuses on the workspace chat flows first.
|
||||
Marketing, docs, blog, and Next-specific server routes still need a
|
||||
dedicated migration pass.
|
||||
</p>
|
||||
<Link
|
||||
to="/page/workspace/chats/new"
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { PageSidebar } from "@/components/page-sidebar";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/20">
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
<main
|
||||
className={`min-h-screen transition-[padding] duration-300 ${
|
||||
collapsed ? "pl-16" : "pl-16 md:pl-64"
|
||||
}`}
|
||||
>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="workspace" replace />} />
|
||||
<Route
|
||||
path="workspace/*"
|
||||
element={<WorkspaceRoutes pageSidebarCollapsed={collapsed} />}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PageRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="*" element={<PageLayout />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useSchedulerThread } from "@/core/scheduled-tasks";
|
||||
|
||||
export default function ScheduledChatPage() {
|
||||
const { data, isLoading, error } = useSchedulerThread();
|
||||
|
||||
if (data?.thread_id) {
|
||||
return <Navigate to={`/workspace/chats/${data.thread_id}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading
|
||||
? "正在打开定时任务助手..."
|
||||
: error
|
||||
? "无法打开定时任务助手"
|
||||
: "准备定时任务助手..."}
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useSchedulerThread } from "@/core/scheduled-tasks";
|
||||
|
||||
export default function ScheduledChatPage() {
|
||||
const { data, isLoading, error } = useSchedulerThread();
|
||||
|
||||
if (data?.thread_id) {
|
||||
return <Navigate to={`/page/workspace/chats/${data.thread_id}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading
|
||||
? "正在打开定时任务助手..."
|
||||
: error
|
||||
? "无法打开定时任务助手"
|
||||
: "准备定时任务助手..."}
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,318 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
downloadScheduledTaskRunFile,
|
||||
getScheduledTaskRunFileContent,
|
||||
type ScheduledTaskResultFileContent,
|
||||
type ScheduledTaskRun,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
function formatDuration(run?: ScheduledTaskRun | null) {
|
||||
if (!run?.started_at || !run.finished_at) return "暂无";
|
||||
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
|
||||
if (!Number.isFinite(duration) || duration < 0) return "暂无";
|
||||
return `${Math.round(duration / 1000)} 秒`;
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "等待中";
|
||||
default:
|
||||
return status || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (status === "succeeded") return "default";
|
||||
if (status === "failed") return "destructive";
|
||||
if (status === "running") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
file.kind === "markdown" ||
|
||||
file.mime_type === "text/markdown" ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
}
|
||||
|
||||
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
if (!file.previewable || !file.content) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
该文件不是文本内容,无法直接预览,请下载查看。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={file.content}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
export default function ScheduledTaskRunDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { run_id } = useParams();
|
||||
const actions = useScheduledTaskActions();
|
||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||
const result = run?.result ?? null;
|
||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
if (!result) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
title: result.title,
|
||||
format: result.format,
|
||||
created_at: result.created_at,
|
||||
files: result.files,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadFiles() {
|
||||
if (!run || !result || resultFiles.length === 0) {
|
||||
setFileContents([]);
|
||||
return;
|
||||
}
|
||||
setFilesLoading(true);
|
||||
try {
|
||||
const loaded = await Promise.all(
|
||||
resultFiles.map((f) => {
|
||||
const index = result.files.indexOf(f);
|
||||
return getScheduledTaskRunFileContent(run.id, index);
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setFileContents(loaded);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFilesLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadFiles();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run, result, resultFiles]);
|
||||
|
||||
const handleDownloadResult = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRun(run.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (index: number) => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRunFile(run.id, index);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRun = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(run.id);
|
||||
toast.success("执行记录已删除");
|
||||
navigate("/workspace/scheduled/tasks");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col p-3">
|
||||
<Card className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
|
||||
</span>
|
||||
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
|
||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||
执行信息
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/workspace/scheduled/tasks")}>
|
||||
<ListChecks className="size-3" />
|
||||
返回记录
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||
<Download className="size-3" />
|
||||
下载全部
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
|
||||
<Trash2 className="size-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
{/* 执行信息(可折叠) */}
|
||||
{showMeta && run && (
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
|
||||
<div>ID:{run.id}</div>
|
||||
<div>计划:{formatDate(run.scheduled_for)}</div>
|
||||
<div>开始:{formatDate(run.started_at)}</div>
|
||||
<div>结束:{formatDate(run.finished_at)}</div>
|
||||
<div>耗时:{formatDuration(run)}</div>
|
||||
</div>
|
||||
{run.error && (
|
||||
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showMeta && !isLoading && !run && (
|
||||
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
|
||||
没有找到这条执行记录。
|
||||
</div>
|
||||
)}
|
||||
{/* 内容区 */}
|
||||
{!result && run && (
|
||||
<div className="text-muted-foreground p-4 text-sm">
|
||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={result.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{result.content || "无正文内容"}
|
||||
</pre>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="m-0 p-3">
|
||||
{filesLoading && (
|
||||
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
|
||||
)}
|
||||
{!filesLoading && fileContents.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
本次执行没有生成文件。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileContents.map((file) => (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<RenderPreview file={file} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="meta" className="m-0 p-3">
|
||||
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
|
||||
{resultMetadata}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
downloadScheduledTaskRunFile,
|
||||
getScheduledTaskRunFileContent,
|
||||
type ScheduledTaskResultFileContent,
|
||||
type ScheduledTaskRun,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
function formatDuration(run?: ScheduledTaskRun | null) {
|
||||
if (!run?.started_at || !run.finished_at) return "暂无";
|
||||
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
|
||||
if (!Number.isFinite(duration) || duration < 0) return "暂无";
|
||||
return `${Math.round(duration / 1000)} 秒`;
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "等待中";
|
||||
default:
|
||||
return status || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (status === "succeeded") return "default";
|
||||
if (status === "failed") return "destructive";
|
||||
if (status === "running") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
file.kind === "markdown" ||
|
||||
file.mime_type === "text/markdown" ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
}
|
||||
|
||||
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
if (!file.previewable || !file.content) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
该文件不是文本内容,无法直接预览,请下载查看。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={file.content}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
export default function ScheduledTaskRunDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { run_id } = useParams();
|
||||
const actions = useScheduledTaskActions();
|
||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||
const result = run?.result ?? null;
|
||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
if (!result) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
title: result.title,
|
||||
format: result.format,
|
||||
created_at: result.created_at,
|
||||
files: result.files,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadFiles() {
|
||||
if (!run || !result || resultFiles.length === 0) {
|
||||
setFileContents([]);
|
||||
return;
|
||||
}
|
||||
setFilesLoading(true);
|
||||
try {
|
||||
const loaded = await Promise.all(
|
||||
resultFiles.map((f) => {
|
||||
const index = result.files.indexOf(f);
|
||||
return getScheduledTaskRunFileContent(run.id, index);
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setFileContents(loaded);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFilesLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadFiles();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run, result, resultFiles]);
|
||||
|
||||
const handleDownloadResult = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRun(run.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (index: number) => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRunFile(run.id, index);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRun = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(run.id);
|
||||
toast.success("执行记录已删除");
|
||||
navigate("/page/workspace/scheduled/tasks");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col p-3">
|
||||
<Card className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
|
||||
</span>
|
||||
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
|
||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||
执行信息
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
|
||||
<ListChecks className="size-3" />
|
||||
返回记录
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||
<Download className="size-3" />
|
||||
下载全部
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
|
||||
<Trash2 className="size-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
{/* 执行信息(可折叠) */}
|
||||
{showMeta && run && (
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
|
||||
<div>ID:{run.id}</div>
|
||||
<div>计划:{formatDate(run.scheduled_for)}</div>
|
||||
<div>开始:{formatDate(run.started_at)}</div>
|
||||
<div>结束:{formatDate(run.finished_at)}</div>
|
||||
<div>耗时:{formatDuration(run)}</div>
|
||||
</div>
|
||||
{run.error && (
|
||||
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showMeta && !isLoading && !run && (
|
||||
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
|
||||
没有找到这条执行记录。
|
||||
</div>
|
||||
)}
|
||||
{/* 内容区 */}
|
||||
{!result && run && (
|
||||
<div className="text-muted-foreground p-4 text-sm">
|
||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={result.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{result.content || "无正文内容"}
|
||||
</pre>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="m-0 p-3">
|
||||
{filesLoading && (
|
||||
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
|
||||
)}
|
||||
{!filesLoading && fileContents.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
本次执行没有生成文件。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileContents.map((file) => (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<RenderPreview file={file} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="meta" className="m-0 p-3">
|
||||
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
|
||||
{resultMetadata}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,136 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { Toaster } from "sonner";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
|
||||
function WorkspaceLayout({
|
||||
children,
|
||||
pageSidebarCollapsed,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen w-full overflow-x-hidden [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]"
|
||||
style={
|
||||
{
|
||||
"--page-sidebar-width": pageSidebarCollapsed ? "4rem" : "16rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
// Keep the fixed workspace sidebar aligned to the outer page sidebar width.
|
||||
// This lets it slide together when the outer sidebar collapses or expands.
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen
|
||||
>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceRoutes({
|
||||
pageSidebarCollapsed = false,
|
||||
}: {
|
||||
pageSidebarCollapsed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/new"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/workspace/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,424 +1,425 @@
|
||||
# DeerFlow Backend
|
||||
|
||||
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Nginx (Port 2026) │
|
||||
│ Unified reverse proxy │
|
||||
└───────┬──────────────────┬───────────┘
|
||||
│ │
|
||||
/api/langgraph/* │ │ /api/* (other)
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌────────────────────────┐
|
||||
│ LangGraph Server │ │ Gateway API (8001) │
|
||||
│ (Port 2024) │ │ FastAPI REST │
|
||||
│ │ │ │
|
||||
│ ┌────────────────┐ │ │ Models, MCP, Skills, │
|
||||
│ │ Lead Agent │ │ │ Memory, Uploads, │
|
||||
│ │ ┌──────────┐ │ │ │ Artifacts │
|
||||
│ │ │Middleware│ │ │ └────────────────────────┘
|
||||
│ │ │ Chain │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ │ │
|
||||
│ │ │ Tools │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ │ │
|
||||
│ │ │Subagents │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ └────────────────┘ │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
**Request Routing** (via Nginx):
|
||||
- `/api/langgraph/*` → LangGraph Server - agent interactions, threads, streaming
|
||||
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
|
||||
- `/` (non-API) → Frontend - Next.js web interface
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### Lead Agent
|
||||
|
||||
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines:
|
||||
|
||||
- **Dynamic model selection** with thinking and vision support
|
||||
- **Middleware chain** for cross-cutting concerns (9 middlewares)
|
||||
- **Tool system** with sandbox, MCP, community, and built-in tools
|
||||
- **Subagent delegation** for parallel task execution
|
||||
- **System prompt** with skills injection, memory context, and working directory guidance
|
||||
|
||||
### Middleware Chain
|
||||
|
||||
Middlewares execute in strict order, each handling a specific concern:
|
||||
|
||||
| # | Middleware | Purpose |
|
||||
|---|-----------|---------|
|
||||
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
|
||||
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
|
||||
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
|
||||
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
|
||||
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
|
||||
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
|
||||
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
|
||||
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
|
||||
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
|
||||
|
||||
### Sandbox System
|
||||
|
||||
Per-thread isolated execution with virtual path translation:
|
||||
|
||||
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
### Subagent System
|
||||
|
||||
Async task delegation with concurrent execution:
|
||||
|
||||
- **Built-in agents**: `general-purpose` (full toolset) and `bash` (command specialist, exposed only when shell access is available)
|
||||
- **Concurrency**: Max 3 subagents per turn, 15-minute timeout
|
||||
- **Execution**: Background thread pools with status tracking and SSE events
|
||||
- **Flow**: Agent calls `task()` tool → executor runs subagent in background → polls for completion → returns result
|
||||
|
||||
### Memory System
|
||||
|
||||
LLM-powered persistent context retention across conversations:
|
||||
|
||||
- **Automatic extraction**: Analyzes conversations for user context, facts, and preferences
|
||||
- **Structured storage**: User context (work, personal, top-of-mind), history, and confidence-scored facts
|
||||
- **Debounced updates**: Batches updates to minimize LLM calls (configurable wait time)
|
||||
- **System prompt injection**: Top facts + context injected into agent prompts
|
||||
- **Storage**: JSON file with mtime-based cache invalidation
|
||||
|
||||
### Tool Ecosystem
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **Sandbox** | `bash`, `ls`, `read_file`, `write_file`, `str_replace` |
|
||||
| **Built-in** | `present_files`, `ask_clarification`, `view_image`, `task` (subagent) |
|
||||
| **Community** | Tavily (web search), Jina AI (web fetch), Firecrawl (scraping), DuckDuckGo (image search) |
|
||||
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
|
||||
| **Skills** | Domain-specific workflows injected via system prompt |
|
||||
|
||||
### Gateway API
|
||||
|
||||
FastAPI application providing REST endpoints for frontend integration:
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `GET /api/models` | List available LLM models |
|
||||
| `GET/PUT /api/mcp/config` | Manage MCP server configurations |
|
||||
| `GET/PUT /api/skills` | List and manage skills |
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
| `POST /api/memory/reload` | Force memory reload |
|
||||
| `GET /api/memory/config` | Memory configuration |
|
||||
| `GET /api/memory/status` | Combined config + data |
|
||||
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) |
|
||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||
|
||||
### Custom Agents
|
||||
|
||||
Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created.
|
||||
|
||||
### IM Channels
|
||||
|
||||
The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place.
|
||||
|
||||
For Feishu card updates, DeerFlow stores the running card's `message_id` per inbound message and patches that same card until the run finishes, preserving the existing `OK` / `DONE` reaction flow.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- API keys for your chosen LLM provider
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd deer-flow
|
||||
|
||||
# Copy configuration files
|
||||
cp config.example.yaml config.yaml
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend
|
||||
make install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `config.yaml` in the project root:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: gpt-4o
|
||||
display_name: GPT-4o
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: gpt-4o
|
||||
api_key: $OPENAI_API_KEY
|
||||
supports_thinking: false
|
||||
supports_vision: true
|
||||
|
||||
- name: gpt-5-responses
|
||||
display_name: GPT-5 (Responses API)
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: gpt-5
|
||||
api_key: $OPENAI_API_KEY
|
||||
use_responses_api: true
|
||||
output_version: responses/v1
|
||||
supports_vision: true
|
||||
```
|
||||
|
||||
Set your API keys:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
**Full Application** (from project root):
|
||||
|
||||
```bash
|
||||
make dev # Starts LangGraph + Gateway + Frontend + Nginx
|
||||
```
|
||||
|
||||
Access at: http://localhost:2026
|
||||
|
||||
**Backend Only** (from backend directory):
|
||||
|
||||
```bash
|
||||
# Terminal 1: LangGraph server
|
||||
make dev
|
||||
|
||||
# Terminal 2: Gateway API
|
||||
make gateway
|
||||
```
|
||||
|
||||
Direct access: LangGraph at http://localhost:2024, Gateway at http://localhost:8001
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── agents/ # Agent system
|
||||
│ │ ├── lead_agent/ # Main agent (factory, prompts)
|
||||
│ │ ├── middlewares/ # 9 middleware components
|
||||
│ │ ├── memory/ # Memory extraction & storage
|
||||
│ │ └── thread_state.py # ThreadState schema
|
||||
│ ├── gateway/ # FastAPI Gateway API
|
||||
│ │ ├── app.py # Application setup
|
||||
│ │ └── routers/ # 6 route modules
|
||||
│ ├── sandbox/ # Sandbox execution
|
||||
│ │ ├── local/ # Local filesystem provider
|
||||
│ │ ├── sandbox.py # Abstract interface
|
||||
│ │ ├── tools.py # bash, ls, read/write/str_replace
|
||||
│ │ └── middleware.py # Sandbox lifecycle
|
||||
│ ├── subagents/ # Subagent delegation
|
||||
│ │ ├── builtins/ # general-purpose, bash agents
|
||||
│ │ ├── executor.py # Background execution engine
|
||||
│ │ └── registry.py # Agent registry
|
||||
│ ├── tools/builtins/ # Built-in tools
|
||||
│ ├── mcp/ # MCP protocol integration
|
||||
│ ├── models/ # Model factory
|
||||
│ ├── skills/ # Skill discovery & loading
|
||||
│ ├── config/ # Configuration system
|
||||
│ ├── community/ # Community tools & providers
|
||||
│ ├── reflection/ # Dynamic module loading
|
||||
│ └── utils/ # Utilities
|
||||
├── docs/ # Documentation
|
||||
├── tests/ # Test suite
|
||||
├── langgraph.json # LangGraph server configuration
|
||||
├── pyproject.toml # Python dependencies
|
||||
├── Makefile # Development commands
|
||||
└── Dockerfile # Container build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Main Configuration (`config.yaml`)
|
||||
|
||||
Place in project root. Config values starting with `$` resolve as environment variables.
|
||||
|
||||
Key sections:
|
||||
- `models` - LLM configurations with class paths, API keys, thinking/vision flags
|
||||
- `tools` - Tool definitions with module paths and groups
|
||||
- `tool_groups` - Logical tool groupings
|
||||
- `sandbox` - Execution environment provider
|
||||
- `skills` - Skills directory paths
|
||||
- `title` - Auto-title generation settings
|
||||
- `summarization` - Context summarization settings
|
||||
- `subagents` - Subagent system (enabled/disabled)
|
||||
- `memory` - Memory system settings (enabled, storage, debounce, facts limits)
|
||||
|
||||
Provider note:
|
||||
- `models[*].use` references provider classes by module path (for example `langchain_openai:ChatOpenAI`).
|
||||
- If a provider module is missing, DeerFlow now returns an actionable error with install guidance (for example `uv add langchain-google-genai`).
|
||||
|
||||
### Extensions Configuration (`extensions_config.json`)
|
||||
|
||||
MCP servers and skill states in a single file:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"}
|
||||
},
|
||||
"secure-http": {
|
||||
"enabled": true,
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"oauth": {
|
||||
"enabled": true,
|
||||
"token_url": "https://auth.example.com/oauth/token",
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "$MCP_OAUTH_CLIENT_ID",
|
||||
"client_secret": "$MCP_OAUTH_CLIENT_SECRET"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"pdf-processing": {"enabled": true}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `DEER_FLOW_CONFIG_PATH` - Override config.yaml location
|
||||
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Override extensions_config.json location
|
||||
- Model API keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.
|
||||
- Tool API keys: `TAVILY_API_KEY`, `GITHUB_TOKEN`, etc.
|
||||
|
||||
### LangSmith Tracing
|
||||
|
||||
DeerFlow has built-in [LangSmith](https://smith.langchain.com) integration for observability. When enabled, all LLM calls, agent runs, tool executions, and middleware processing are traced and visible in the LangSmith dashboard.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Sign up at [smith.langchain.com](https://smith.langchain.com) and create a project.
|
||||
2. Add the following to your `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
||||
LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
|
||||
LANGSMITH_PROJECT=xxx
|
||||
```
|
||||
|
||||
**Legacy variables:** The `LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGCHAIN_PROJECT`, and `LANGCHAIN_ENDPOINT` variables are also supported for backward compatibility. `LANGSMITH_*` variables take precedence when both are set.
|
||||
|
||||
### Langfuse Tracing
|
||||
|
||||
DeerFlow also supports [Langfuse](https://langfuse.com) observability for LangChain-compatible runs.
|
||||
|
||||
Add the following to your `.env` file:
|
||||
|
||||
```bash
|
||||
LANGFUSE_TRACING=true
|
||||
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
|
||||
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
|
||||
LANGFUSE_BASE_URL=https://cloud.langfuse.com
|
||||
```
|
||||
|
||||
If you are using a self-hosted Langfuse deployment, set `LANGFUSE_BASE_URL` to your Langfuse host.
|
||||
|
||||
### Dual Provider Behavior
|
||||
|
||||
If both LangSmith and Langfuse are enabled, DeerFlow initializes and attaches both callbacks so the same run data is reported to both systems.
|
||||
|
||||
If a provider is explicitly enabled but required credentials are missing, or the provider callback cannot be initialized, DeerFlow raises an error when tracing is initialized during model creation instead of silently disabling tracing.
|
||||
|
||||
**Docker:** In `docker-compose.yaml`, tracing is disabled by default (`LANGSMITH_TRACING=false`). Set `LANGSMITH_TRACING=true` and/or `LANGFUSE_TRACING=true` in your `.env`, together with the required credentials, to enable tracing in containerized deployments.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
make install # Install dependencies
|
||||
make dev # Run LangGraph server (port 2024)
|
||||
make gateway # Run Gateway API (port 8001)
|
||||
make lint # Run linter (ruff)
|
||||
make format # Format code (ruff)
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- **Linter/Formatter**: `ruff`
|
||||
- **Line length**: 240 characters
|
||||
- **Python**: 3.12+ with type hints
|
||||
- **Quotes**: Double quotes
|
||||
- **Indentation**: 4 spaces
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **LangGraph** (1.0.6+) - Agent framework and multi-agent orchestration
|
||||
- **LangChain** (1.2.3+) - LLM abstractions and tool system
|
||||
- **FastAPI** (0.115.0+) - Gateway REST API
|
||||
- **langchain-mcp-adapters** - Model Context Protocol support
|
||||
- **agent-sandbox** - Sandboxed code execution
|
||||
- **markitdown** - Multi-format document conversion
|
||||
- **tavily-python** / **firecrawl-py** - Web search and scraping
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Configuration Guide](docs/CONFIGURATION.md)
|
||||
- [Architecture Details](docs/ARCHITECTURE.md)
|
||||
- [API Reference](docs/API.md)
|
||||
- [File Upload](docs/FILE_UPLOAD.md)
|
||||
- [Path Examples](docs/PATH_EXAMPLES.md)
|
||||
- [Context Summarization](docs/summarization.md)
|
||||
- [Plan Mode](docs/plan_mode_usage.md)
|
||||
- [Setup Guide](docs/SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See the [LICENSE](../LICENSE) file in the project root.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
||||
# DeerFlow Backend
|
||||
|
||||
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Nginx (Port 2026) │
|
||||
│ Unified reverse proxy │
|
||||
└───────┬──────────────────┬───────────┘
|
||||
│ │
|
||||
/api/langgraph/* │ │ /api/* (other)
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌────────────────────────┐
|
||||
│ LangGraph Server │ │ Gateway API (8001) │
|
||||
│ (Port 2024) │ │ FastAPI REST │
|
||||
│ │ │ │
|
||||
│ ┌────────────────┐ │ │ Models, MCP, Skills, │
|
||||
│ │ Lead Agent │ │ │ Memory, Uploads, │
|
||||
│ │ ┌──────────┐ │ │ │ Artifacts │
|
||||
│ │ │Middleware│ │ │ └────────────────────────┘
|
||||
│ │ │ Chain │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ │ │
|
||||
│ │ │ Tools │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ │ │
|
||||
│ │ │Subagents │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ └────────────────┘ │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
**Request Routing** (via Nginx):
|
||||
- `/api/langgraph/*` → LangGraph Server - agent interactions, threads, streaming
|
||||
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
|
||||
- `/` (non-API) → Frontend - Next.js web interface
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### Lead Agent
|
||||
|
||||
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines:
|
||||
|
||||
- **Dynamic model selection** with thinking and vision support
|
||||
- **Middleware chain** for cross-cutting concerns (9 middlewares)
|
||||
- **Tool system** with sandbox, MCP, community, and built-in tools
|
||||
- **Subagent delegation** for parallel task execution
|
||||
- **System prompt** with skills injection, memory context, and working directory guidance
|
||||
|
||||
### Middleware Chain
|
||||
|
||||
Middlewares execute in strict order, each handling a specific concern:
|
||||
|
||||
| # | Middleware | Purpose |
|
||||
|---|-----------|---------|
|
||||
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
|
||||
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
|
||||
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
|
||||
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
|
||||
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
|
||||
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
|
||||
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
|
||||
| 8 | **ViewImageMiddleware** | Injects image data for vision-capable models (conditional) |
|
||||
| 9 | **ClarificationMiddleware** | Intercepts clarification requests and interrupts execution (must be last) |
|
||||
|
||||
### Sandbox System
|
||||
|
||||
Per-thread isolated execution with virtual path translation:
|
||||
|
||||
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/)
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
### Subagent System
|
||||
|
||||
Async task delegation with concurrent execution:
|
||||
|
||||
- **Built-in agents**: `general-purpose` (full toolset) and `bash` (command specialist, exposed only when shell access is available)
|
||||
- **Concurrency**: Max 3 subagents per turn, 15-minute timeout
|
||||
- **Execution**: Background thread pools with status tracking and SSE events
|
||||
- **Flow**: Agent calls `task()` tool → executor runs subagent in background → polls for completion → returns result
|
||||
|
||||
### Memory System
|
||||
|
||||
LLM-powered persistent context retention across conversations:
|
||||
|
||||
- **Automatic extraction**: Analyzes conversations for user context, facts, and preferences
|
||||
- **Structured storage**: User context (work, personal, top-of-mind), history, and confidence-scored facts
|
||||
- **Debounced updates**: Batches updates to minimize LLM calls (configurable wait time)
|
||||
- **System prompt injection**: Top facts + context injected into agent prompts
|
||||
- **Storage**: JSON file with mtime-based cache invalidation
|
||||
|
||||
### Tool Ecosystem
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **Sandbox** | `bash`, `ls`, `read_file`, `write_file`, `str_replace` |
|
||||
| **Built-in** | `present_files`, `ask_clarification`, `view_image`, `task` (subagent) |
|
||||
| **Community** | Tavily (web search), Jina AI (web fetch), Firecrawl (scraping), DuckDuckGo (image search) |
|
||||
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
|
||||
| **Skills** | Domain-specific workflows injected via system prompt |
|
||||
|
||||
### Gateway API
|
||||
|
||||
FastAPI application providing REST endpoints for frontend integration:
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `GET /api/models` | List available LLM models |
|
||||
| `GET/PUT /api/mcp/config` | Manage MCP server configurations |
|
||||
| `GET/PUT /api/skills` | List and manage skills |
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
| `POST /api/memory/reload` | Force memory reload |
|
||||
| `GET /api/memory/config` | Memory configuration |
|
||||
| `GET /api/memory/status` | Combined config + data |
|
||||
| `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) |
|
||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||
|
||||
### Custom Agents
|
||||
|
||||
Custom agents are indexed in the `agents` database table. The `id` column is the stable runtime identifier and filesystem directory name (`.deer-flow/agents/{id}/SOUL.md`); `name` is display-only, may be Chinese, and may be duplicated. Rows with `user_id = NULL` are built-in agents, user rows are private unless `published = true`, and users can only update or delete agents they created.
|
||||
|
||||
### IM Channels
|
||||
|
||||
The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])` and updates a single in-thread card in place.
|
||||
|
||||
For Feishu card updates, DeerFlow stores the running card's `message_id` per inbound message and patches that same card until the run finishes, preserving the existing `OK` / `DONE` reaction flow.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- API keys for your chosen LLM provider
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd deer-flow
|
||||
|
||||
# Copy configuration files
|
||||
cp config.example.yaml config.yaml
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend
|
||||
make install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `config.yaml` in the project root:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- name: gpt-4o
|
||||
display_name: GPT-4o
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: gpt-4o
|
||||
api_key: $OPENAI_API_KEY
|
||||
supports_thinking: false
|
||||
supports_vision: true
|
||||
|
||||
- name: gpt-5-responses
|
||||
display_name: GPT-5 (Responses API)
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: gpt-5
|
||||
api_key: $OPENAI_API_KEY
|
||||
use_responses_api: true
|
||||
output_version: responses/v1
|
||||
supports_vision: true
|
||||
```
|
||||
|
||||
Set your API keys:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
**Full Application** (from project root):
|
||||
|
||||
```bash
|
||||
make dev # Starts LangGraph + Gateway + Frontend + Nginx
|
||||
```
|
||||
|
||||
Access at: http://localhost:2026
|
||||
|
||||
**Backend Only** (from backend directory):
|
||||
|
||||
```bash
|
||||
# Terminal 1: LangGraph server
|
||||
make dev
|
||||
|
||||
# Terminal 2: Gateway API
|
||||
make gateway
|
||||
```
|
||||
|
||||
Direct access: LangGraph at http://localhost:2024, Gateway at http://localhost:8001
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── agents/ # Agent system
|
||||
│ │ ├── lead_agent/ # Main agent (factory, prompts)
|
||||
│ │ ├── middlewares/ # 9 middleware components
|
||||
│ │ ├── memory/ # Memory extraction & storage
|
||||
│ │ └── thread_state.py # ThreadState schema
|
||||
│ ├── gateway/ # FastAPI Gateway API
|
||||
│ │ ├── app.py # Application setup
|
||||
│ │ └── routers/ # 6 route modules
|
||||
│ ├── sandbox/ # Sandbox execution
|
||||
│ │ ├── local/ # Local filesystem provider
|
||||
│ │ ├── sandbox.py # Abstract interface
|
||||
│ │ ├── tools.py # bash, ls, read/write/str_replace
|
||||
│ │ └── middleware.py # Sandbox lifecycle
|
||||
│ ├── subagents/ # Subagent delegation
|
||||
│ │ ├── builtins/ # general-purpose, bash agents
|
||||
│ │ ├── executor.py # Background execution engine
|
||||
│ │ └── registry.py # Agent registry
|
||||
│ ├── tools/builtins/ # Built-in tools
|
||||
│ ├── mcp/ # MCP protocol integration
|
||||
│ ├── models/ # Model factory
|
||||
│ ├── skills/ # Skill discovery & loading
|
||||
│ ├── config/ # Configuration system
|
||||
│ ├── community/ # Community tools & providers
|
||||
│ ├── reflection/ # Dynamic module loading
|
||||
│ └── utils/ # Utilities
|
||||
├── docs/ # Documentation
|
||||
├── tests/ # Test suite
|
||||
├── langgraph.json # LangGraph server configuration
|
||||
├── pyproject.toml # Python dependencies
|
||||
├── Makefile # Development commands
|
||||
└── Dockerfile # Container build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Main Configuration (`config.yaml`)
|
||||
|
||||
Place in project root. Config values starting with `$` resolve as environment variables.
|
||||
|
||||
Key sections:
|
||||
- `models` - LLM configurations with class paths, API keys, thinking/vision flags
|
||||
- `tools` - Tool definitions with module paths and groups
|
||||
- `tool_groups` - Logical tool groupings
|
||||
- `sandbox` - Execution environment provider
|
||||
- `skills` - Skills directory paths
|
||||
- `title` - Auto-title generation settings
|
||||
- `summarization` - Context summarization settings
|
||||
- `subagents` - Subagent system (enabled/disabled)
|
||||
- `memory` - Memory system settings (enabled, storage, debounce, facts limits)
|
||||
|
||||
Provider note:
|
||||
- `models[*].use` references provider classes by module path (for example `langchain_openai:ChatOpenAI`).
|
||||
- If a provider module is missing, DeerFlow now returns an actionable error with install guidance (for example `uv add langchain-google-genai`).
|
||||
|
||||
### Extensions Configuration (`extensions_config.json`)
|
||||
|
||||
MCP servers and skill states in a single file:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"}
|
||||
},
|
||||
"secure-http": {
|
||||
"enabled": true,
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"oauth": {
|
||||
"enabled": true,
|
||||
"token_url": "https://auth.example.com/oauth/token",
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "$MCP_OAUTH_CLIENT_ID",
|
||||
"client_secret": "$MCP_OAUTH_CLIENT_SECRET"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"pdf-processing": {"enabled": true}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `DEER_FLOW_CONFIG_PATH` - Override config.yaml location
|
||||
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Override extensions_config.json location
|
||||
- Model API keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.
|
||||
- Tool API keys: `TAVILY_API_KEY`, `GITHUB_TOKEN`, etc.
|
||||
|
||||
### LangSmith Tracing
|
||||
|
||||
DeerFlow has built-in [LangSmith](https://smith.langchain.com) integration for observability. When enabled, all LLM calls, agent runs, tool executions, and middleware processing are traced and visible in the LangSmith dashboard.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Sign up at [smith.langchain.com](https://smith.langchain.com) and create a project.
|
||||
2. Add the following to your `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
LANGSMITH_TRACING=true
|
||||
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
||||
LANGSMITH_API_KEY=lsv2_pt_xxxxxxxxxxxxxxxx
|
||||
LANGSMITH_PROJECT=xxx
|
||||
```
|
||||
|
||||
**Legacy variables:** The `LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGCHAIN_PROJECT`, and `LANGCHAIN_ENDPOINT` variables are also supported for backward compatibility. `LANGSMITH_*` variables take precedence when both are set.
|
||||
|
||||
### Langfuse Tracing
|
||||
|
||||
DeerFlow also supports [Langfuse](https://langfuse.com) observability for LangChain-compatible runs.
|
||||
|
||||
Add the following to your `.env` file:
|
||||
|
||||
```bash
|
||||
LANGFUSE_TRACING=true
|
||||
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxx
|
||||
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxx
|
||||
LANGFUSE_BASE_URL=https://cloud.langfuse.com
|
||||
```
|
||||
|
||||
If you are using a self-hosted Langfuse deployment, set `LANGFUSE_BASE_URL` to your Langfuse host.
|
||||
|
||||
### Dual Provider Behavior
|
||||
|
||||
If both LangSmith and Langfuse are enabled, DeerFlow initializes and attaches both callbacks so the same run data is reported to both systems.
|
||||
|
||||
If a provider is explicitly enabled but required credentials are missing, or the provider callback cannot be initialized, DeerFlow raises an error when tracing is initialized during model creation instead of silently disabling tracing.
|
||||
|
||||
**Docker:** In `docker-compose.yaml`, tracing is disabled by default (`LANGSMITH_TRACING=false`). Set `LANGSMITH_TRACING=true` and/or `LANGFUSE_TRACING=true` in your `.env`, together with the required credentials, to enable tracing in containerized deployments.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
make install # Install dependencies
|
||||
make dev # Run LangGraph server (port 2024)
|
||||
make gateway # Run Gateway API (port 8001)
|
||||
make lint # Run linter (ruff)
|
||||
make format # Format code (ruff)
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- **Linter/Formatter**: `ruff`
|
||||
- **Line length**: 240 characters
|
||||
- **Python**: 3.12+ with type hints
|
||||
- **Quotes**: Double quotes
|
||||
- **Indentation**: 4 spaces
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **LangGraph** (1.0.6+) - Agent framework and multi-agent orchestration
|
||||
- **LangChain** (1.2.3+) - LLM abstractions and tool system
|
||||
- **FastAPI** (0.115.0+) - Gateway REST API
|
||||
- **langchain-mcp-adapters** - Model Context Protocol support
|
||||
- **agent-sandbox** - Sandboxed code execution
|
||||
- **markitdown** - Multi-format document conversion
|
||||
- **tavily-python** / **firecrawl-py** - Web search and scraping
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Configuration Guide](docs/CONFIGURATION.md)
|
||||
- [Architecture Details](docs/ARCHITECTURE.md)
|
||||
- [API Reference](docs/API.md)
|
||||
- [File Upload](docs/FILE_UPLOAD.md)
|
||||
- [Path Examples](docs/PATH_EXAMPLES.md)
|
||||
- [Context Summarization](docs/summarization.md)
|
||||
- [Plan Mode](docs/plan_mode_usage.md)
|
||||
- [Backend Run/Stop Guide (ZH)](docs/BACKEND_RUN_STOP_ZH.md)
|
||||
- [Setup Guide](docs/SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See the [LICENSE](../LICENSE) file in the project root.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
||||
@@ -89,6 +89,12 @@ def _current_user_id(request: Request) -> str:
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _is_admin_user(request: Request) -> bool:
|
||||
"""Return True when the request is authenticated as a system admin."""
|
||||
user = getattr(request.state, "user", None)
|
||||
return getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
def _validate_display_name(name: str) -> str:
|
||||
normalized = name.strip()
|
||||
if not normalized:
|
||||
@@ -203,6 +209,29 @@ async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) ->
|
||||
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
||||
|
||||
|
||||
async def _get_editable_or_raise(
|
||||
store: AgentStore,
|
||||
agent_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
is_admin: bool,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
||||
|
||||
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
|
||||
caller is a system admin. Custom agents owned by other users still 403.
|
||||
"""
|
||||
owned = await store.get_owned(agent_id, user_id)
|
||||
if owned is not None:
|
||||
return owned, "owner"
|
||||
visible = await store.get_visible(agent_id, user_id)
|
||||
if visible is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
if is_admin and visible.get("user_id") is None:
|
||||
return visible, "admin"
|
||||
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/agents",
|
||||
response_model=AgentsListResponse,
|
||||
@@ -322,7 +351,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
try:
|
||||
store = get_agent_store(http_request)
|
||||
user_id = _current_user_id(http_request)
|
||||
current = await _get_owned_or_raise(store, agent_id, user_id)
|
||||
is_admin = _is_admin_user(http_request)
|
||||
current, edit_mode = await _get_editable_or_raise(
|
||||
store, agent_id, user_id, is_admin=is_admin
|
||||
)
|
||||
|
||||
fields_set = request.model_fields_set
|
||||
update_data: dict[str, Any] = {}
|
||||
@@ -354,7 +386,12 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
if request.soul is not None:
|
||||
_write_agent_soul(agent_id, request.soul)
|
||||
|
||||
record = await store.update(agent_id, user_id, update_data) if update_data else current
|
||||
if not update_data:
|
||||
record = current
|
||||
elif edit_mode == "admin":
|
||||
record = await store.update_builtin(agent_id, update_data)
|
||||
else:
|
||||
record = await store.update(agent_id, user_id, update_data)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
|
||||
|
||||
@@ -1,327 +1,419 @@
|
||||
"""Scheduled task management API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user
|
||||
from deerflow.runtime.scheduler import get_scheduled_task_service
|
||||
|
||||
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||
|
||||
|
||||
class ScheduledTaskCreateRequest(BaseModel):
|
||||
name: str = Field(default="Scheduled task")
|
||||
prompt: str
|
||||
schedule: str
|
||||
timezone: str | None = None
|
||||
execution_agent_name: str | None = None
|
||||
execution_context: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ScheduledTaskUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
prompt: str | None = None
|
||||
schedule: str | None = None
|
||||
timezone: str | None = None
|
||||
enabled: bool | None = None
|
||||
execution_agent_name: str | None = None
|
||||
execution_context: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
|
||||
element_user_id: str | None = None
|
||||
|
||||
|
||||
async def _user_id(request: Request) -> str:
|
||||
user_id = await get_current_user(request)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user_id
|
||||
|
||||
|
||||
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
|
||||
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
|
||||
return name or default
|
||||
|
||||
|
||||
def _result_download_name(result: dict[str, Any], *, extension: str = "md") -> str:
|
||||
title = result.get("title") if isinstance(result, dict) else None
|
||||
return f"{_safe_filename(str(title or 'scheduled-task-result'))}.{extension}"
|
||||
|
||||
|
||||
def _result_markdown(result: dict[str, Any]) -> str:
|
||||
title = str(result.get("title") or "Scheduled Task Result")
|
||||
content = str(result.get("content") or "")
|
||||
return f"# {title}\n\n{content}".strip() + "\n"
|
||||
|
||||
|
||||
def _utf8_sig_bytes(content: str) -> bytes:
|
||||
return content.encode("utf-8-sig")
|
||||
|
||||
|
||||
def _content_disposition(filename: str) -> str:
|
||||
quoted = quote(filename)
|
||||
return f"attachment; filename*=UTF-8''{quoted}"
|
||||
|
||||
|
||||
def _run_files(run: dict[str, Any]) -> list:
|
||||
result = run.get("result") or {}
|
||||
return result.get("files") if isinstance(result, dict) and isinstance(result.get("files"), list) else []
|
||||
|
||||
|
||||
def _run_file_path(run: dict[str, Any], file_index: int) -> tuple[dict[str, Any], Path]:
|
||||
files = _run_files(run)
|
||||
if file_index < 0 or file_index >= len(files):
|
||||
raise HTTPException(status_code=404, detail="Result file not found")
|
||||
item = files[file_index]
|
||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||||
raise HTTPException(status_code=404, detail="Result file not found")
|
||||
path = Path(item["path"])
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Result file is no longer available")
|
||||
return item, path
|
||||
|
||||
|
||||
def _is_text_previewable(path: Path, mime_type: str | None) -> bool:
|
||||
if mime_type and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}):
|
||||
return True
|
||||
return path.suffix.lower() in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
|
||||
|
||||
|
||||
def _is_text_download(path: Path, mime_type: str | None) -> bool:
|
||||
return _is_text_previewable(path, mime_type)
|
||||
|
||||
|
||||
def _read_text_download_bytes(path: Path) -> bytes:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
return _utf8_sig_bytes(content)
|
||||
|
||||
|
||||
@router.get("/thread")
|
||||
async def get_scheduler_thread(request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.ensure_scheduler_thread(user_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_scheduled_tasks(request: Request) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.list_tasks(user_id)
|
||||
|
||||
|
||||
@router.get("/{task_id}/runs")
|
||||
async def list_scheduled_task_runs(task_id: str, request: Request, limit: int = 50) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
runs = await service.list_runs(user_id, task_id, limit=max(1, min(limit, 200)))
|
||||
return runs
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}")
|
||||
async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
return run
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}")
|
||||
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
deleted = await service.delete_run(user_id, run_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/files/{file_index}")
|
||||
async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
item, path = _run_file_path(run, file_index)
|
||||
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
|
||||
previewable = _is_text_previewable(path, mime_type)
|
||||
content = None
|
||||
if previewable:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
return {
|
||||
"index": file_index,
|
||||
"name": item.get("name") or path.name,
|
||||
"mime_type": mime_type,
|
||||
"kind": item.get("kind"),
|
||||
"size": path.stat().st_size,
|
||||
"previewable": previewable,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/download")
|
||||
async def download_scheduled_task_run(run_id: str, request: Request):
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
|
||||
result = run.get("result") or {}
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
files = result.get("files") if isinstance(result.get("files"), list) else []
|
||||
accessible_files: list[tuple[str, Path]] = []
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
path_value = item.get("path")
|
||||
if not isinstance(path_value, str) or not path_value:
|
||||
continue
|
||||
path = Path(path_value)
|
||||
if path.exists() and path.is_file():
|
||||
accessible_files.append((str(item.get("name") or path.name), path))
|
||||
|
||||
if not accessible_files:
|
||||
body = _utf8_sig_bytes(_result_markdown(result))
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": _content_disposition(_result_download_name(result))},
|
||||
)
|
||||
|
||||
archive = BytesIO()
|
||||
with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("result.md", _utf8_sig_bytes(_result_markdown(result)))
|
||||
for name, path in accessible_files:
|
||||
arcname = _safe_filename(name, path.name)
|
||||
mime_type = mimetypes.guess_type(path.name)[0]
|
||||
if _is_text_download(path, mime_type):
|
||||
zf.writestr(arcname, _read_text_download_bytes(path))
|
||||
else:
|
||||
zf.write(path, arcname=arcname)
|
||||
archive.seek(0)
|
||||
return StreamingResponse(
|
||||
archive,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": _content_disposition(_result_download_name(result, extension="zip"))},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/files/{file_index}/download")
|
||||
async def download_scheduled_task_run_file(run_id: str, file_index: int, request: Request):
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
item, path = _run_file_path(run, file_index)
|
||||
filename = _safe_filename(str(item.get("name") or path.name), path.name)
|
||||
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
|
||||
if _is_text_download(path, mime_type):
|
||||
return Response(
|
||||
content=_read_text_download_bytes(path),
|
||||
media_type=f"{mime_type or 'text/plain'}; charset=utf-8",
|
||||
headers={"Content-Disposition": _content_disposition(filename)},
|
||||
)
|
||||
return FileResponse(path, filename=filename, media_type=item.get("mime_type") or "application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/delivery-profile")
|
||||
async def get_delivery_profile(request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.get_delivery_profile(user_id)
|
||||
|
||||
|
||||
@router.put("/delivery-profile")
|
||||
async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.update_delivery_profile(user_id, body.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.create_task(
|
||||
user_id=user_id,
|
||||
name=body.name,
|
||||
prompt=body.prompt,
|
||||
schedule=body.schedule,
|
||||
timezone=body.timezone,
|
||||
execution_agent_name=body.execution_agent_name,
|
||||
execution_context=body.execution_context,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def delete_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
deleted = await service.delete_task(user_id, task_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/{task_id}/pause")
|
||||
async def pause_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.pause_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/resume")
|
||||
async def resume_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.resume_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/trigger")
|
||||
async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.trigger_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return {"success": True, "task": task}
|
||||
"""Scheduled task management API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user
|
||||
from deerflow.runtime.scheduler import get_scheduled_task_service
|
||||
|
||||
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||
|
||||
|
||||
class ScheduledTaskCreateRequest(BaseModel):
|
||||
name: str = Field(default="Scheduled task")
|
||||
prompt: str
|
||||
schedule: str
|
||||
timezone: str | None = None
|
||||
execution_agent_name: str | None = None
|
||||
execution_context: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ScheduledTaskUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
prompt: str | None = None
|
||||
schedule: str | None = None
|
||||
timezone: str | None = None
|
||||
enabled: bool | None = None
|
||||
execution_agent_name: str | None = None
|
||||
execution_context: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
|
||||
element_user_id: str | None = None
|
||||
|
||||
|
||||
class ScheduledTaskSubscribeRequest(BaseModel):
|
||||
notify_element: bool = False
|
||||
|
||||
|
||||
class ScheduledTaskRunFileUpdateRequest(BaseModel):
|
||||
content: str = Field(..., description="New text content to write into the run file")
|
||||
|
||||
|
||||
_MAX_RUN_FILE_EDIT_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
async def _user_id(request: Request) -> str:
|
||||
user_id = await get_current_user(request)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user_id
|
||||
|
||||
|
||||
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
|
||||
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
|
||||
return name or default
|
||||
|
||||
|
||||
def _result_download_name(result: dict[str, Any], *, extension: str = "md") -> str:
|
||||
title = result.get("title") if isinstance(result, dict) else None
|
||||
return f"{_safe_filename(str(title or 'scheduled-task-result'))}.{extension}"
|
||||
|
||||
|
||||
def _result_markdown(result: dict[str, Any]) -> str:
|
||||
title = str(result.get("title") or "Scheduled Task Result")
|
||||
content = str(result.get("content") or "")
|
||||
return f"# {title}\n\n{content}".strip() + "\n"
|
||||
|
||||
|
||||
def _utf8_sig_bytes(content: str) -> bytes:
|
||||
return content.encode("utf-8-sig")
|
||||
|
||||
|
||||
def _content_disposition(filename: str) -> str:
|
||||
quoted = quote(filename)
|
||||
return f"attachment; filename*=UTF-8''{quoted}"
|
||||
|
||||
|
||||
def _run_files(run: dict[str, Any]) -> list:
|
||||
result = run.get("result") or {}
|
||||
return result.get("files") if isinstance(result, dict) and isinstance(result.get("files"), list) else []
|
||||
|
||||
|
||||
def _run_file_path(run: dict[str, Any], file_index: int) -> tuple[dict[str, Any], Path]:
|
||||
files = _run_files(run)
|
||||
if file_index < 0 or file_index >= len(files):
|
||||
raise HTTPException(status_code=404, detail="Result file not found")
|
||||
item = files[file_index]
|
||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||||
raise HTTPException(status_code=404, detail="Result file not found")
|
||||
path = Path(item["path"])
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Result file is no longer available")
|
||||
return item, path
|
||||
|
||||
|
||||
def _is_text_previewable(path: Path, mime_type: str | None) -> bool:
|
||||
if mime_type and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}):
|
||||
return True
|
||||
return path.suffix.lower() in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
|
||||
|
||||
|
||||
def _is_text_download(path: Path, mime_type: str | None) -> bool:
|
||||
return _is_text_previewable(path, mime_type)
|
||||
|
||||
|
||||
def _read_text_download_bytes(path: Path) -> bytes:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
return _utf8_sig_bytes(content)
|
||||
|
||||
|
||||
@router.get("/thread")
|
||||
async def get_scheduler_thread(request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.ensure_scheduler_thread(user_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_scheduled_tasks(request: Request) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.list_tasks(user_id)
|
||||
|
||||
|
||||
@router.get("/{task_id}/runs")
|
||||
async def list_scheduled_task_runs(task_id: str, request: Request, limit: int = 50) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
runs = await service.list_runs(user_id, task_id, limit=max(1, min(limit, 200)))
|
||||
return runs
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}")
|
||||
async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
return run
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}")
|
||||
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
deleted = await service.delete_run(user_id, run_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/files/{file_index}")
|
||||
async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
item, path = _run_file_path(run, file_index)
|
||||
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
|
||||
previewable = _is_text_previewable(path, mime_type)
|
||||
content = None
|
||||
if previewable:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
return {
|
||||
"index": file_index,
|
||||
"name": item.get("name") or path.name,
|
||||
"mime_type": mime_type,
|
||||
"kind": item.get("kind"),
|
||||
"size": path.stat().st_size,
|
||||
"previewable": previewable,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/runs/{run_id}/files/{file_index}")
|
||||
async def update_scheduled_task_run_file(
|
||||
run_id: str,
|
||||
file_index: int,
|
||||
body: ScheduledTaskRunFileUpdateRequest,
|
||||
request: Request,
|
||||
) -> dict:
|
||||
if len(body.content.encode("utf-8")) > _MAX_RUN_FILE_EDIT_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
try:
|
||||
return await service.update_run_file(user_id, run_id, file_index, body.content)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/download")
|
||||
async def download_scheduled_task_run(run_id: str, request: Request):
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
|
||||
result = run.get("result") or {}
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
files = result.get("files") if isinstance(result.get("files"), list) else []
|
||||
accessible_files: list[tuple[str, Path]] = []
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
path_value = item.get("path")
|
||||
if not isinstance(path_value, str) or not path_value:
|
||||
continue
|
||||
path = Path(path_value)
|
||||
if path.exists() and path.is_file():
|
||||
accessible_files.append((str(item.get("name") or path.name), path))
|
||||
|
||||
if not accessible_files:
|
||||
body = _utf8_sig_bytes(_result_markdown(result))
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": _content_disposition(_result_download_name(result))},
|
||||
)
|
||||
|
||||
archive = BytesIO()
|
||||
with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("result.md", _utf8_sig_bytes(_result_markdown(result)))
|
||||
for name, path in accessible_files:
|
||||
arcname = _safe_filename(name, path.name)
|
||||
mime_type = mimetypes.guess_type(path.name)[0]
|
||||
if _is_text_download(path, mime_type):
|
||||
zf.writestr(arcname, _read_text_download_bytes(path))
|
||||
else:
|
||||
zf.write(path, arcname=arcname)
|
||||
archive.seek(0)
|
||||
return StreamingResponse(
|
||||
archive,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": _content_disposition(_result_download_name(result, extension="zip"))},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/files/{file_index}/download")
|
||||
async def download_scheduled_task_run_file(run_id: str, file_index: int, request: Request):
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
run = await service.get_run(user_id, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||
item, path = _run_file_path(run, file_index)
|
||||
filename = _safe_filename(str(item.get("name") or path.name), path.name)
|
||||
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
|
||||
if _is_text_download(path, mime_type):
|
||||
return Response(
|
||||
content=_read_text_download_bytes(path),
|
||||
media_type=f"{mime_type or 'text/plain'}; charset=utf-8",
|
||||
headers={"Content-Disposition": _content_disposition(filename)},
|
||||
)
|
||||
return FileResponse(path, filename=filename, media_type=item.get("mime_type") or "application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/delivery-profile")
|
||||
async def get_delivery_profile(request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.get_delivery_profile(user_id)
|
||||
|
||||
|
||||
@router.put("/delivery-profile")
|
||||
async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.update_delivery_profile(user_id, body.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
return await service.create_task(
|
||||
user_id=user_id,
|
||||
name=body.name,
|
||||
prompt=body.prompt,
|
||||
schedule=body.schedule,
|
||||
timezone=body.timezone,
|
||||
execution_agent_name=body.execution_agent_name,
|
||||
execution_context=body.execution_context,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def delete_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
deleted = await service.delete_task(user_id, task_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/{task_id}/pause")
|
||||
async def pause_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.pause_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/resume")
|
||||
async def resume_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.resume_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/trigger")
|
||||
async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.trigger_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return {"success": True, "task": task}
|
||||
|
||||
|
||||
# --- Publish / subscribe ---
|
||||
|
||||
|
||||
@router.get("/published")
|
||||
async def list_published_scheduled_tasks(request: Request, limit: int = 50, offset: int = 0) -> list[dict]:
|
||||
service = get_scheduled_task_service()
|
||||
await _user_id(request)
|
||||
return await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
|
||||
|
||||
|
||||
@router.post("/{task_id}/publish")
|
||||
async def publish_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.publish_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/unpublish")
|
||||
async def unpublish_scheduled_task(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
task = await service.unpublish_task(user_id, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/{task_id}/subscribe")
|
||||
async def subscribe_scheduled_task(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element)
|
||||
if sub is None:
|
||||
raise HTTPException(status_code=404, detail="Task is not available for subscription")
|
||||
return sub
|
||||
|
||||
|
||||
@router.patch("/{task_id}/subscribe")
|
||||
async def update_scheduled_task_subscription(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element)
|
||||
if sub is None:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return sub
|
||||
|
||||
|
||||
@router.delete("/{task_id}/subscribe")
|
||||
async def delete_scheduled_task_subscription(task_id: str, request: Request) -> dict:
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
deleted = await service.unsubscribe_task(user_id, task_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return {"success": True}
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_config
|
||||
@@ -56,6 +57,20 @@ class SkillInstallResponse(BaseModel):
|
||||
success: bool = Field(..., description="Whether the installation was successful")
|
||||
skill_name: str = Field(..., description="Name of the installed skill")
|
||||
message: str = Field(..., description="Installation result message")
|
||||
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
|
||||
|
||||
|
||||
class SkillUploadValidateResponse(BaseModel):
|
||||
"""Response model for the pre-install validation endpoint."""
|
||||
|
||||
ok: bool = Field(..., description="Whether the archive passed cheap validation")
|
||||
skill_name: str = Field(..., description="Skill name parsed from frontmatter")
|
||||
description: str = Field(default="", description="Skill description parsed from frontmatter")
|
||||
license: str | None = Field(default=None, description="Skill license, if declared")
|
||||
allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
|
||||
conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
|
||||
conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
|
||||
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
|
||||
|
||||
|
||||
class CustomSkillContentResponse(SkillResponse):
|
||||
@@ -100,6 +115,110 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
||||
|
||||
|
||||
_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
|
||||
_ZIP_MAGIC = b"PK\x03\x04"
|
||||
|
||||
|
||||
async def _persist_uploaded_skill(file: UploadFile) -> Path:
|
||||
"""Stream an uploaded skill archive to a temp file. Caller must unlink it.
|
||||
|
||||
Validates the leading bytes look like a ZIP archive and enforces the same
|
||||
512 MB cap that the extractor uses, so we reject obvious garbage early.
|
||||
"""
|
||||
if file.filename is None:
|
||||
raise HTTPException(status_code=400, detail="No file provided")
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in {".zip", ".skill"}:
|
||||
raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file")
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-")
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
head: bytes = b""
|
||||
try:
|
||||
with open(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
if not head:
|
||||
head = chunk[: len(_ZIP_MAGIC)]
|
||||
total += len(chunk)
|
||||
if total > _MAX_SKILL_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit")
|
||||
out.write(chunk)
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
if not head.startswith(_ZIP_MAGIC):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/validate",
|
||||
response_model=SkillUploadValidateResponse,
|
||||
summary="Validate Skill Archive (No Install)",
|
||||
description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
|
||||
)
|
||||
async def validate_skill_upload(
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillUploadValidateResponse:
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
|
||||
return SkillUploadValidateResponse(**result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate uploaded skill: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}")
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/install-upload",
|
||||
response_model=SkillInstallResponse,
|
||||
summary="Install Skill from Upload",
|
||||
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
|
||||
)
|
||||
async def install_skill_upload(
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
||||
overwrite: bool = False,
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillInstallResponse:
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return SkillInstallResponse(**result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except SkillAlreadyExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to install uploaded skill: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}")
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/install",
|
||||
response_model=SkillInstallResponse,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# 后端启动与停止说明(Windows 优先)
|
||||
|
||||
本文档用于 `offline-backend-20260512/backend` 目录下 DeerFlow 后端的日常启动、停止与排障。
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
- 已安装 Python 3.12+
|
||||
- 已安装 `uv`
|
||||
- 已在项目中准备好配置文件(通常是项目根目录 `config.yaml`)
|
||||
|
||||
## 2. 进入后端目录
|
||||
|
||||
```powershell
|
||||
cd F:\react\deeflow-code\deerflow-server-master\offline-backend-20260512\backend
|
||||
```
|
||||
|
||||
## 3. 首次安装依赖
|
||||
|
||||
### 方案 A(优先,跨平台)
|
||||
|
||||
```powershell
|
||||
uv sync
|
||||
```
|
||||
|
||||
### 方案 B(如果你的环境里 `make` 可用)
|
||||
|
||||
```powershell
|
||||
make install
|
||||
```
|
||||
|
||||
> Windows 上部分 `make.exe` 与 GNU Make 不兼容,若报错请直接使用 `uv sync`。
|
||||
|
||||
## 4. 启动后端
|
||||
|
||||
### 4.1 生产样式(无热重载)
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH='.'
|
||||
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
### 4.2 开发样式(热重载)
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH='.'
|
||||
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
|
||||
```
|
||||
|
||||
如果你的 `make` 可用,也可以:
|
||||
|
||||
```powershell
|
||||
make gateway # 无热重载
|
||||
make dev # 热重载
|
||||
```
|
||||
|
||||
## 5. 验证是否启动成功
|
||||
|
||||
启动后可访问:
|
||||
|
||||
- 文档页:`http://127.0.0.1:8001/docs`
|
||||
- OpenAPI:`http://127.0.0.1:8001/openapi.json`
|
||||
- 健康检查:`http://127.0.0.1:8001/health`
|
||||
|
||||
PowerShell 快速检查:
|
||||
|
||||
```powershell
|
||||
(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8001/health).StatusCode
|
||||
```
|
||||
|
||||
返回 `200` 即正常。
|
||||
|
||||
## 6. 停止后端
|
||||
|
||||
### 6.1 前台启动时
|
||||
|
||||
在运行窗口按 `Ctrl + C`。
|
||||
|
||||
### 6.2 后台或端口占用时(Windows)
|
||||
|
||||
按 8001 端口查进程并强制结束:
|
||||
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 8001 -State Listen | Select-Object OwningProcess
|
||||
Stop-Process -Id <PID> -Force
|
||||
```
|
||||
|
||||
一键方式(自动停止 8001 的监听进程):
|
||||
|
||||
```powershell
|
||||
$procIds = @(Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique)
|
||||
foreach ($oneId in $procIds) { Stop-Process -Id $oneId -Force -ErrorAction SilentlyContinue }
|
||||
```
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
### 7.1 `make gateway` 报 unknown action keyword
|
||||
|
||||
说明当前 `make` 不是预期版本(Windows 常见)。直接改用:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH='.'
|
||||
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
### 7.2 PowerShell 报 `&&` 语法错误
|
||||
|
||||
PowerShell 5 不支持 Bash 风格 `&&`。请改成分号:
|
||||
|
||||
```powershell
|
||||
uv sync; $env:PYTHONPATH='.'; uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
### 7.3 Git 切分支时 `.deer-flow/data/*.db-wal|*.db-shm` 无法删除
|
||||
|
||||
通常是后端进程仍占用 SQLite 文件。先停掉 8001 端口进程,再执行 Git 操作。
|
||||
|
||||
## 8. 推荐日常流程(Windows)
|
||||
|
||||
```powershell
|
||||
cd F:\react\deeflow-code\deerflow-server-master\offline-backend-20260512\backend
|
||||
uv sync
|
||||
$env:PYTHONPATH='.'
|
||||
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
|
||||
```
|
||||
|
||||
停止时按 `Ctrl + C`。
|
||||
@@ -0,0 +1,825 @@
|
||||
# DeerFlow 后端开发文档
|
||||
|
||||
> 本文档面向开发人员,梳理后端项目结构、核心模块、API 接口、配置系统和常用开发命令,方便快速上手和维护。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [项目概述](#1-项目概述)
|
||||
2. [技术栈](#2-技术栈)
|
||||
3. [目录结构](#3-目录结构)
|
||||
4. [架构分层](#4-架构分层)
|
||||
5. [快速启动](#5-快速启动)
|
||||
6. [配置系统](#6-配置系统)
|
||||
7. [核心模块详解](#7-核心模块详解)
|
||||
8. [API 接口汇总](#8-api-接口汇总)
|
||||
9. [Agent 系统](#9-agent-系统)
|
||||
10. [中间件链](#10-中间件链)
|
||||
11. [沙箱执行系统](#11-沙箱执行系统)
|
||||
12. [内存系统](#12-内存系统)
|
||||
13. [工具系统](#13-工具系统)
|
||||
14. [IM 频道集成](#14-im-频道集成)
|
||||
15. [数据持久化](#15-数据持久化)
|
||||
16. [测试](#16-测试)
|
||||
17. [常见问题与排错](#17-常见问题与排错)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
DeerFlow 是一个基于 **LangGraph** 的 AI 超级 Agent 系统。后端提供:
|
||||
|
||||
- **Lead Agent**:具备沙箱执行、持久内存、子 Agent 委托和可扩展工具集成的主 Agent
|
||||
- **Gateway API**:FastAPI REST API(端口 8001),同时内嵌 LangGraph 兼容的 Agent 运行时
|
||||
- **IM 频道集成**:飞书、Slack、Telegram、钉钉、Discord、微信、企业微信
|
||||
- **每线程隔离**:沙箱和工作目录按 `用户 / 线程` 两级隔离
|
||||
|
||||
整体入口(通过 Nginx,端口 2026):
|
||||
- `/api/langgraph/*` → 内嵌 LangGraph 运行时(转发到 Gateway `/api/*`)
|
||||
- `/api/*` → Gateway REST API(8001)
|
||||
- `/` → 前端(3000)
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层次 | 技术 |
|
||||
|------|------|
|
||||
| Web 框架 | FastAPI + Uvicorn |
|
||||
| Agent 框架 | LangGraph(状态图 + 检查点) |
|
||||
| LLM 调用 | LangChain(多模型适配) |
|
||||
| 数据库 | SQLite(默认)/ PostgreSQL |
|
||||
| ORM | SQLAlchemy + Alembic(迁移) |
|
||||
| 包管理 | uv(workspace 模式) |
|
||||
| 测试 | pytest |
|
||||
| 代码规范 | ruff(检查 + 格式化) |
|
||||
| Python 版本 | 3.12+ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── Makefile # 开发命令入口
|
||||
├── pyproject.toml # 项目配置和依赖
|
||||
├── config.yaml # 主配置文件(模型、沙箱、工具等)
|
||||
├── langgraph.json # LangGraph Studio 图配置
|
||||
├── ruff.toml # 代码风格配置(行长 240)
|
||||
│
|
||||
├── app/ # 应用层(FastAPI + IM 集成)
|
||||
│ ├── gateway/ # FastAPI 网关 API
|
||||
│ │ ├── app.py # 应用主入口,创建 FastAPI 实例,挂载所有路由
|
||||
│ │ ├── auth/ # JWT 身份认证模块
|
||||
│ │ ├── routers/ # 16 个 API 路由模块
|
||||
│ │ ├── auth_middleware.py # 认证中间件
|
||||
│ │ ├── csrf_middleware.py # CSRF 保护中间件
|
||||
│ │ └── deps.py # FastAPI 依赖注入
|
||||
│ └── channels/ # IM 平台集成
|
||||
│ ├── manager.py # 频道管理器(核心调度)
|
||||
│ ├── message_bus.py # 消息总线(pub/sub)
|
||||
│ ├── store.py # 线程持久化(chat → thread_id 映射)
|
||||
│ ├── feishu.py # 飞书集成
|
||||
│ ├── slack.py # Slack 集成
|
||||
│ ├── telegram.py # Telegram 集成
|
||||
│ ├── dingtalk.py # 钉钉集成
|
||||
│ ├── discord.py # Discord 集成
|
||||
│ ├── wechat.py # 微信集成
|
||||
│ └── wecom.py # 企业微信集成
|
||||
│
|
||||
├── packages/harness/ # deerflow-harness 核心包(可独立发布)
|
||||
│ └── deerflow/ # 导入前缀:deerflow.*
|
||||
│ ├── agents/ # Agent 系统
|
||||
│ │ ├── lead_agent/ # 主 Agent(工厂 + 提示词)
|
||||
│ │ ├── middlewares/ # 18 个中间件
|
||||
│ │ ├── memory/ # 内存提取、队列、存储
|
||||
│ │ └── thread_state.py # ThreadState 数据模型
|
||||
│ ├── config/ # 配置系统(25 个模块)
|
||||
│ ├── sandbox/ # 沙箱执行(本地 + Docker)
|
||||
│ ├── tools/ # 工具工厂 + 内置工具
|
||||
│ ├── subagents/ # 子 Agent 委托系统
|
||||
│ ├── mcp/ # Model Context Protocol 集成
|
||||
│ ├── models/ # LLM 模型工厂
|
||||
│ ├── skills/ # 技能发现和加载
|
||||
│ ├── runtime/ # 运行时管理(检查点、流、调度)
|
||||
│ ├── persistence/ # 数据库 ORM 和迁移
|
||||
│ ├── community/ # 社区工具(Tavily、Jina、Firecrawl 等)
|
||||
│ ├── guardrails/ # 护栏系统(工具调用授权)
|
||||
│ ├── reflection/ # 动态模块加载
|
||||
│ ├── uploads/ # 文件上传和文档转换
|
||||
│ ├── utils/ # 网络、可读性等工具函数
|
||||
│ └── client.py # 嵌入式 Python 客户端(无需 HTTP)
|
||||
│
|
||||
├── tests/ # 测试套件
|
||||
├── scripts/ # 辅助脚本
|
||||
├── docs/ # 文档目录
|
||||
└── skills/ # 技能目录
|
||||
├── public/ # 公开技能(提交到 git)
|
||||
└── custom/ # 自定义技能(git 忽略)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 架构分层
|
||||
|
||||
### Harness / App 分层原则
|
||||
|
||||
后端严格分为两层,依赖方向单向:
|
||||
|
||||
```
|
||||
app.* →(允许导入)→ deerflow.*
|
||||
deerflow.* ×(禁止导入)× app.*
|
||||
```
|
||||
|
||||
- **Harness**(`packages/harness/deerflow/`):可发布的 Agent 框架包,包含 Agent 编排、工具、沙箱、模型、MCP、技能、配置——一切构建和运行 Agent 所需的内容。
|
||||
- **App**(`app/`):不对外发布的应用代码,包含 FastAPI Gateway API 和 IM 频道集成。
|
||||
|
||||
此边界通过 `tests/test_harness_boundary.py` 在 CI 中强制检查。
|
||||
|
||||
### 运行时数据流
|
||||
|
||||
```
|
||||
前端 / IM 平台
|
||||
↓
|
||||
Nginx(2026)
|
||||
↓
|
||||
Gateway API(8001)— FastAPI
|
||||
↓
|
||||
RunManager + run_agent() + StreamBridge
|
||||
↓
|
||||
Lead Agent(LangGraph 状态图)
|
||||
↓
|
||||
[中间件链] → [LLM] → [工具调用] → [沙箱执行]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 快速启动
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.12+
|
||||
- [uv](https://github.com/astral-sh/uv)(包管理器)
|
||||
- 可选:Docker(用于容器化沙箱)
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 在 backend/ 目录下
|
||||
make install
|
||||
# 等价于:uv sync
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 方式一:仅启动后端 Gateway(带热重载,端口 8001)
|
||||
make dev
|
||||
|
||||
# 方式二:启动完整应用(含前端和 Nginx)
|
||||
# 在项目根目录执行
|
||||
make dev
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
- API 文档:`http://localhost:8001/docs`
|
||||
- 健康检查:`http://localhost:8001/health`
|
||||
|
||||
### 停止服务
|
||||
|
||||
```bash
|
||||
# 项目根目录
|
||||
make stop
|
||||
```
|
||||
|
||||
### 开发常用命令
|
||||
|
||||
```bash
|
||||
make lint # ruff 代码检查
|
||||
make format # ruff 自动格式化
|
||||
make test # 运行全部测试
|
||||
make gateway # 启动 Gateway(不带热重载)
|
||||
|
||||
# 运行特定测试文件
|
||||
PYTHONPATH=. uv run pytest tests/test_memory_updater.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 配置系统
|
||||
|
||||
### 主配置文件 `config.yaml`
|
||||
|
||||
**查找顺序**(优先级从高到低):
|
||||
1. 显式 `config_path` 参数
|
||||
2. 环境变量 `DEER_FLOW_CONFIG_PATH`
|
||||
3. `backend/config.yaml`
|
||||
4. 项目根目录 `config.yaml`(**推荐放置位置**)
|
||||
|
||||
**关键配置段**:
|
||||
|
||||
```yaml
|
||||
config_version: 1 # 配置版本,升级时自动提示
|
||||
|
||||
log_level: info
|
||||
|
||||
database:
|
||||
backend: sqlite # 数据库后端(sqlite / postgres)
|
||||
sqlite_dir: .deer-flow/data
|
||||
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider # 沙箱提供者类路径
|
||||
allow_host_bash: false
|
||||
|
||||
models: # LLM 模型配置列表
|
||||
- name: my-model # 模型标识名
|
||||
display_name: 我的模型
|
||||
use: langchain_openai:ChatOpenAI # 提供者类(通过反射加载)
|
||||
model: gpt-4o
|
||||
api_key: $OPENAI_API_KEY # 以 $ 开头代表读取环境变量
|
||||
base_url: https://api.openai.com/v1
|
||||
supports_vision: true
|
||||
supports_thinking: false
|
||||
|
||||
tools: # 工具配置
|
||||
- use: deerflow.community.tavily:tavily_search
|
||||
group: search
|
||||
|
||||
memory:
|
||||
enabled: true
|
||||
injection_enabled: true
|
||||
debounce_seconds: 30
|
||||
max_facts: 100
|
||||
fact_confidence_threshold: 0.7
|
||||
max_injection_tokens: 2000
|
||||
|
||||
subagents:
|
||||
enabled: true # 是否启用子 Agent 委托
|
||||
|
||||
title:
|
||||
enabled: true # 是否自动生成对话标题
|
||||
```
|
||||
|
||||
### 扩展配置 `extensions_config.json`
|
||||
|
||||
MCP 服务器和技能的启用状态存放在此文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-mcp-server": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@my/mcp-server"]
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"my-skill": { "enabled": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**查找顺序**同 `config.yaml`,环境变量为 `DEER_FLOW_EXTENSIONS_CONFIG_PATH`。
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 |
|
||||
|--------|------|
|
||||
| `DEER_FLOW_CONFIG_PATH` | 指定 config.yaml 路径 |
|
||||
| `DEER_FLOW_EXTENSIONS_CONFIG_PATH` | 指定 extensions_config.json 路径 |
|
||||
| `GATEWAY_CORS_ORIGINS` | 允许的跨域来源(逗号分隔) |
|
||||
| `GATEWAY_CORS_ALLOW_ALL` | 允许所有跨域(1/true) |
|
||||
| `GATEWAY_ENABLE_DOCS` | 是否启用 `/docs` 页面(默认开启) |
|
||||
| `AUTH_JWT_SECRET` | JWT 签名密钥 |
|
||||
| `DEER_FLOW_AUTH_DISABLED` | 禁用认证(开发用) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心模块详解
|
||||
|
||||
### 7.1 应用主入口 `app/gateway/app.py`
|
||||
|
||||
FastAPI 应用工厂,负责:
|
||||
- 创建 FastAPI 实例,配置 CORS、认证、CSRF 中间件
|
||||
- 挂载全部 16 个路由模块
|
||||
- 通过 `lifespan` 管理启动/关闭钩子:初始化数据库、运行 Alembic 迁移、启动 IM 频道服务
|
||||
- 首次启动时创建管理员用户
|
||||
|
||||
### 7.2 模型工厂 `deerflow/models/factory.py`
|
||||
|
||||
```python
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
model = create_chat_model(name="my-model", thinking_enabled=False)
|
||||
```
|
||||
|
||||
- 通过反射(`resolve_variable`)按 `config.yaml` 中的 `use` 字段实例化 LangChain 模型
|
||||
- 支持 `thinking_enabled` 标志和按模型的 `when_thinking_enabled` 覆盖
|
||||
- 支持 vLLM Qwen 推理模型的 `enable_thinking` 参数
|
||||
- 配置值以 `$` 开头时自动解析为环境变量
|
||||
- 缺少提供者模块时给出可操作的安装提示
|
||||
|
||||
### 7.3 嵌入式客户端 `deerflow/client.py`
|
||||
|
||||
无需启动 HTTP 服务即可直接调用所有 DeerFlow 能力:
|
||||
|
||||
```python
|
||||
from deerflow.client import DeerFlowClient
|
||||
|
||||
client = DeerFlowClient()
|
||||
|
||||
# 同步对话
|
||||
text = client.chat("你好", thread_id="thread-1")
|
||||
|
||||
# 流式对话
|
||||
for event in client.stream("分析这份文档", thread_id="thread-1"):
|
||||
print(event)
|
||||
|
||||
# 其他 API
|
||||
client.list_models()
|
||||
client.upload_files(thread_id, [Path("doc.pdf")])
|
||||
client.get_memory()
|
||||
```
|
||||
|
||||
返回格式与 Gateway REST API 一致,方便在两种模式下复用代码。
|
||||
|
||||
---
|
||||
|
||||
## 8. API 接口汇总
|
||||
|
||||
所有接口基础路径:`http://localhost:8001`(或通过 Nginx `http://localhost:2026`)
|
||||
|
||||
### 8.1 核心接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 健康检查 |
|
||||
| GET | `/docs` | Swagger API 文档 |
|
||||
|
||||
### 8.2 线程运行(主要对话接口)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/threads/{id}/runs/stream` | 创建运行并 SSE 流式返回 |
|
||||
| POST | `/api/threads/{id}/runs/wait` | 创建运行并阻塞等待结果 |
|
||||
| POST | `/api/threads/{id}/runs` | 创建后台运行 |
|
||||
| GET | `/api/threads/{id}/runs` | 列出线程的所有运行 |
|
||||
| GET | `/api/threads/{id}/runs/{rid}` | 获取运行详情 |
|
||||
| POST | `/api/threads/{id}/runs/{rid}/cancel` | 取消运行 |
|
||||
| GET | `/api/threads/{id}/runs/{rid}/join` | 加入 SSE 流 |
|
||||
| GET | `/api/threads/{id}/runs/{rid}/messages` | 分页获取消息 |
|
||||
| GET | `/api/threads/{id}/messages` | 线程所有消息(含反馈) |
|
||||
|
||||
### 8.3 无状态运行
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/runs/stream` | 无状态运行 + SSE 流 |
|
||||
| POST | `/api/runs/wait` | 无状态运行 + 阻塞 |
|
||||
|
||||
### 8.4 文件上传
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/threads/{id}/uploads` | 上传文件(自动转换 PDF/PPT/Excel/Word) |
|
||||
| GET | `/api/threads/{id}/uploads/list` | 列出已上传文件 |
|
||||
| DELETE | `/api/threads/{id}/uploads/{filename}` | 删除文件 |
|
||||
|
||||
### 8.5 其他接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/models` | 列出所有可用 LLM 模型 |
|
||||
| GET | `/api/models/{name}` | 模型详情 |
|
||||
| GET | `/api/mcp/config` | 获取 MCP 配置 |
|
||||
| PUT | `/api/mcp/config` | 更新 MCP 配置 |
|
||||
| GET | `/api/skills` | 列出技能 |
|
||||
| PUT | `/api/skills/{name}` | 启用/禁用技能 |
|
||||
| POST | `/api/skills/install` | 安装 .skill 包 |
|
||||
| GET | `/api/memory` | 获取个人内存数据 |
|
||||
| POST | `/api/memory/reload` | 强制重新加载内存 |
|
||||
| GET/POST/PUT/DELETE | `/api/agents` | 自定义 Agent CRUD |
|
||||
| GET | `/api/threads/{id}/artifacts/{path}` | 下载工件文件 |
|
||||
| POST | `/api/threads/{id}/suggestions` | 生成后续问题建议 |
|
||||
| DELETE | `/api/threads/{id}` | 清理线程本地数据 |
|
||||
| PUT | `/api/threads/{id}/runs/{rid}/feedback` | 提交运行反馈 |
|
||||
| POST/GET | `/api/scheduled-tasks` | 定时任务管理 |
|
||||
| POST | `/api/v1/auth/login` | 用户登录 |
|
||||
| POST | `/api/v1/auth/register` | 用户注册 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Agent 系统
|
||||
|
||||
### Lead Agent
|
||||
|
||||
**入口**:`deerflow.agents.lead_agent.agent:make_lead_agent`,注册在 `langgraph.json`。
|
||||
|
||||
```python
|
||||
from deerflow.agents import make_lead_agent
|
||||
from langgraph.types import RunnableConfig
|
||||
|
||||
config = RunnableConfig(configurable={
|
||||
"thinking_enabled": False, # 是否启用扩展思考
|
||||
"model_name": "my-model", # 指定 LLM 模型
|
||||
"is_plan_mode": False, # 是否启用计划模式(TodoList)
|
||||
"subagent_enabled": True, # 是否启用子 Agent 委托
|
||||
})
|
||||
agent = make_lead_agent(config)
|
||||
```
|
||||
|
||||
### ThreadState 数据模型
|
||||
|
||||
`deerflow/agents/thread_state.py`:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `messages` | list | LangGraph 消息列表 |
|
||||
| `sandbox` | SandboxInfo | 当前线程沙箱 |
|
||||
| `thread_data` | dict | 线程本地数据(路径等) |
|
||||
| `title` | str | 自动生成的对话标题 |
|
||||
| `artifacts` | list | 生成的工件文件 |
|
||||
| `todos` | list | 计划模式下的待办事项 |
|
||||
| `uploaded_files` | list | 已上传的文件 |
|
||||
| `viewed_images` | list | 已查看的图像(base64) |
|
||||
|
||||
### 子 Agent 系统
|
||||
|
||||
- **内置子 Agent**:`general-purpose`(所有工具,除 `task`)、`bash`(命令专家)
|
||||
- **最大并发**:3 个(`SubagentLimitMiddleware` 强制限制)
|
||||
- **超时时间**:15 分钟
|
||||
- **触发方式**:调用 `task` 工具委托任务
|
||||
- **执行流程**:`task()` → `SubagentExecutor` → 后台线程 → 每 5 秒轮询 → SSE 事件
|
||||
|
||||
```python
|
||||
# 委托子 Agent 执行任务(在 Agent 提示词中由 LLM 自动调用)
|
||||
task(
|
||||
description="...",
|
||||
prompt="分析这份数据并生成报告",
|
||||
subagent_type="general-purpose",
|
||||
max_turns=10
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 中间件链
|
||||
|
||||
Lead Agent 中间件按固定顺序组装,每个中间件负责一个横切关注点:
|
||||
|
||||
| 顺序 | 中间件 | 作用 |
|
||||
|------|--------|------|
|
||||
| 1 | `ThreadDataMiddleware` | 创建每线程隔离目录(workspace/uploads/outputs) |
|
||||
| 2 | `UploadsMiddleware` | 注入新上传的文件到对话 |
|
||||
| 3 | `SandboxMiddleware` | 获取沙箱实例,存储 sandbox_id |
|
||||
| 4 | `DanglingToolCallMiddleware` | 为中断的工具调用注入占位 ToolMessage |
|
||||
| 5 | `LLMErrorHandlingMiddleware` | 将 LLM 调用失败归一化为可恢复错误 |
|
||||
| 6 | `GuardrailMiddleware` | 工具调用前授权检查(可选) |
|
||||
| 7 | `SandboxAuditMiddleware` | 沙箱操作安全审计日志 |
|
||||
| 8 | `ToolErrorHandlingMiddleware` | 将工具异常转为错误 ToolMessage,避免中止 |
|
||||
| 9 | `SummarizationMiddleware` | 接近 Token 上限时压缩上下文(可选) |
|
||||
| 10 | `TodoListMiddleware` | 计划模式下的任务追踪(可选) |
|
||||
| 11 | `TokenUsageMiddleware` | 记录 Token 用量指标(可选) |
|
||||
| 12 | `TitleMiddleware` | 首次完整交互后自动生成标题 |
|
||||
| 13 | `MemoryMiddleware` | 将对话加入内存更新队列 |
|
||||
| 14 | `ViewImageMiddleware` | 调用 LLM 前注入 base64 图像(视觉模型) |
|
||||
| 15 | `DeferredToolFilterMiddleware` | 隐藏延迟工具 Schema(可选) |
|
||||
| 16 | `SubagentLimitMiddleware` | 限制最大并发子 Agent 数(可选) |
|
||||
| 17 | `LoopDetectionMiddleware` | 检测工具调用循环并强制终止 |
|
||||
| 18 | `ClarificationMiddleware` | 拦截澄清请求,中断到 END(必须最后) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 沙箱执行系统
|
||||
|
||||
### 概述
|
||||
|
||||
每个线程拥有独立的沙箱环境,Agent 通过沙箱执行命令和读写文件。
|
||||
|
||||
### 虚拟路径映射
|
||||
|
||||
Agent 看到的虚拟路径 → 物理路径:
|
||||
|
||||
| 虚拟路径 | 物理路径 |
|
||||
|----------|----------|
|
||||
| `/mnt/user-data/workspace/` | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/workspace/` |
|
||||
| `/mnt/user-data/uploads/` | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/uploads/` |
|
||||
| `/mnt/user-data/outputs/` | `backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/` |
|
||||
| `/mnt/skills/` | `deer-flow/skills/` |
|
||||
|
||||
### 沙箱工具
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `bash` | 执行 Shell 命令(含路径转换) |
|
||||
| `ls` | 目录列表(树形格式,最多 2 层) |
|
||||
| `read_file` | 读取文件内容(支持行范围) |
|
||||
| `write_file` | 写入/追加文件(自动创建目录) |
|
||||
| `str_replace` | 字符串替换(单次或全部) |
|
||||
|
||||
### 沙箱提供者
|
||||
|
||||
- **LocalSandboxProvider**:单例本地文件系统执行(默认)
|
||||
- **AioSandboxProvider**:基于 Docker 的隔离执行(需要 Docker)
|
||||
|
||||
在 `config.yaml` 中切换:
|
||||
```yaml
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
# 或 Docker 沙箱:
|
||||
# use: deerflow.community.aio_sandbox:AioSandboxProvider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 内存系统
|
||||
|
||||
### 功能
|
||||
|
||||
- 自动从对话中提取用户偏好、知识和上下文,存储为结构化事实
|
||||
- 下次对话时自动注入内存上下文到系统提示词
|
||||
- 每用户独立存储,按 Agent 进一步隔离
|
||||
|
||||
### 存储路径
|
||||
|
||||
```
|
||||
backend/.deer-flow/users/{user_id}/memory.json # 默认 Agent 内存
|
||||
backend/.deer-flow/users/{user_id}/agents/{agent_name}/memory.json # 自定义 Agent 内存
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"workContext": "用户是一名 Python 开发者...",
|
||||
"personalContext": "...",
|
||||
"topOfMind": "...",
|
||||
"recentMonths": "...",
|
||||
"facts": [
|
||||
{
|
||||
"id": "fact-001",
|
||||
"content": "用户偏好使用 TypeScript",
|
||||
"category": "preference",
|
||||
"confidence": 0.9,
|
||||
"createdAt": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. `MemoryMiddleware` 过滤消息(用户输入 + 最终 AI 回复),捕获 `user_id`,加入更新队列
|
||||
2. 队列去抖(默认 30 秒),按线程去重
|
||||
3. 后台线程调用 LLM 提取上下文更新和事实
|
||||
4. 原子写入(临时文件 + rename),跳过重复事实
|
||||
5. 下次交互注入前 15 条事实 + 上下文到 `<memory>` 标签
|
||||
|
||||
### 配置
|
||||
|
||||
```yaml
|
||||
memory:
|
||||
enabled: true
|
||||
injection_enabled: true
|
||||
debounce_seconds: 30 # 去抖等待时间
|
||||
model_name: null # null 表示使用默认模型
|
||||
max_facts: 100 # 最大存储事实数
|
||||
fact_confidence_threshold: 0.7 # 事实置信度阈值
|
||||
max_injection_tokens: 2000 # 注入 Token 上限
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 工具系统
|
||||
|
||||
### 工具组装
|
||||
|
||||
`get_available_tools(groups, include_mcp, model_name, subagent_enabled)` 按顺序组装:
|
||||
|
||||
1. **config.yaml 定义的工具**:通过 `resolve_variable()` 反射加载
|
||||
2. **MCP 工具**:来自已启用的 MCP 服务器(懒加载,带 mtime 失效)
|
||||
3. **内置工具**:
|
||||
- `present_files` — 将输出文件呈现给用户(仅 `/mnt/user-data/outputs`)
|
||||
- `ask_clarification` — 请求澄清(由 `ClarificationMiddleware` 拦截,中断对话)
|
||||
- `view_image` — 读取图像为 base64(仅视觉模型)
|
||||
4. **子 Agent 工具**(启用时):`task` — 委托子 Agent
|
||||
|
||||
### 社区工具
|
||||
|
||||
| 工具 | 模块 | 说明 |
|
||||
|------|------|------|
|
||||
| Tavily 搜索 | `deerflow.community.tavily` | Web 搜索(默认 5 条结果) |
|
||||
| Tavily 获取 | `deerflow.community.tavily` | Web 页面获取(4KB 限制) |
|
||||
| Jina AI | `deerflow.community.jina_ai` | 通过 Jina reader API 获取页面 |
|
||||
| Firecrawl | `deerflow.community.firecrawl` | 网页爬取 |
|
||||
| 图像搜索 | `deerflow.community.image_search` | DuckDuckGo 图像搜索 |
|
||||
|
||||
### MCP 集成
|
||||
|
||||
- 使用 `langchain-mcp-adapters` 的 `MultiServerMCPClient`
|
||||
- 懒加载:首次使用时初始化
|
||||
- 配置变更自动失效(mtime 检测)
|
||||
- 支持 stdio、SSE、HTTP 传输
|
||||
- 支持 OAuth 令牌刷新(HTTP/SSE)
|
||||
|
||||
---
|
||||
|
||||
## 14. IM 频道集成
|
||||
|
||||
### 消息流程
|
||||
|
||||
```
|
||||
外部平台消息
|
||||
↓
|
||||
频道实现(feishu.py / slack.py 等)
|
||||
↓
|
||||
MessageBus.publish_inbound()
|
||||
↓
|
||||
ChannelManager._dispatch_loop()
|
||||
↓
|
||||
查找/创建线程(通过 Gateway API)
|
||||
↓
|
||||
执行 Agent 运行(stream 或 wait)
|
||||
↓
|
||||
OutboundMessage → 平台回复
|
||||
```
|
||||
|
||||
### 各平台特性
|
||||
|
||||
| 平台 | 特性 |
|
||||
|------|------|
|
||||
| 飞书 | 增量更新,修改同一消息卡片(`update_multi=true`) |
|
||||
| Slack | 阻塞等待最终结果 |
|
||||
| Telegram | 阻塞等待最终结果 |
|
||||
| 钉钉 | 支持 AI 卡片流式更新(需配置 `card_template_id`) |
|
||||
|
||||
### 配置(`config.yaml` → `channels`)
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
langgraph_url: http://localhost:8001/api # LangGraph 兼容 API 基础 URL
|
||||
gateway_url: http://localhost:8001 # Gateway API URL
|
||||
feishu:
|
||||
app_id: $FEISHU_APP_ID
|
||||
app_secret: $FEISHU_APP_SECRET
|
||||
slack:
|
||||
bot_token: $SLACK_BOT_TOKEN
|
||||
app_token: $SLACK_APP_TOKEN
|
||||
telegram:
|
||||
bot_token: $TELEGRAM_BOT_TOKEN
|
||||
dingtalk:
|
||||
client_id: $DINGTALK_CLIENT_ID
|
||||
client_secret: $DINGTALK_CLIENT_SECRET
|
||||
card_template_id: "" # 可选,启用 AI 卡片流式
|
||||
```
|
||||
|
||||
> **Docker 部署注意**:IM 频道运行在 `gateway` 容器内,应将 `localhost` 改为服务名,例如 `langgraph_url: http://gateway:8001/api`。
|
||||
|
||||
---
|
||||
|
||||
## 15. 数据持久化
|
||||
|
||||
### 数据库
|
||||
|
||||
默认使用 SQLite,存放在 `backend/.deer-flow/data/`。可切换为 PostgreSQL:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
backend: postgres
|
||||
postgres_url: postgresql://user:pass@localhost:5432/deerflow
|
||||
```
|
||||
|
||||
### 主要数据表
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `agents` | 自定义 Agent 元数据(id、name、user_id、published) |
|
||||
| `users` | 用户账号 |
|
||||
| `thread_meta` | 线程元数据(标题、创建时间等) |
|
||||
| `feedback` | 运行反馈和评分 |
|
||||
| `scheduled_tasks` | 定时任务配置 |
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
使用 Alembic 管理 Schema 变更,应用启动时自动执行:
|
||||
|
||||
```bash
|
||||
# 查看迁移历史
|
||||
PYTHONPATH=. uv run alembic history
|
||||
|
||||
# 手动升级到最新
|
||||
PYTHONPATH=. uv run alembic upgrade head
|
||||
```
|
||||
|
||||
### 运行时文件目录
|
||||
|
||||
```
|
||||
backend/.deer-flow/
|
||||
├── data/ # SQLite 数据库
|
||||
└── users/{user_id}/
|
||||
├── memory.json # 个人内存(默认 Agent)
|
||||
├── agents/{agent_name}/memory.json # 自定义 Agent 内存
|
||||
└── threads/{thread_id}/user-data/
|
||||
├── workspace/ # Agent 工作目录
|
||||
├── uploads/ # 上传的文件
|
||||
└── outputs/ # 生成的输出文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 测试
|
||||
|
||||
### 测试套件
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
make test
|
||||
|
||||
# 运行特定测试文件
|
||||
PYTHONPATH=. uv run pytest tests/test_memory_updater.py -v
|
||||
|
||||
# 运行特定测试函数
|
||||
PYTHONPATH=. uv run pytest tests/test_client.py::TestGatewayConformance -v
|
||||
```
|
||||
|
||||
### 关键测试文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `tests/test_harness_boundary.py` | 确保 harness 层不导入 app 层(CI 必须通过) |
|
||||
| `tests/test_memory_updater.py` | 内存提取和去重逻辑 |
|
||||
| `tests/test_client.py` | 嵌入式客户端 77 个单元测试,含 Gateway 一致性验证 |
|
||||
| `tests/test_client_live.py` | 需要 config.yaml 的集成测试 |
|
||||
| `tests/test_docker_sandbox_mode_detection.py` | Docker 沙箱模式检测回归测试 |
|
||||
| `tests/test_provisioner_kubeconfig.py` | kubeconfig 处理回归测试 |
|
||||
| `tests/conftest.py` | pytest fixtures 和通用配置 |
|
||||
|
||||
### TDD 原则
|
||||
|
||||
**每个新功能或 Bug 修复必须附带单元测试**,无例外。
|
||||
|
||||
- 测试文件命名:`tests/test_<feature>.py`
|
||||
- 轻量配置/工具模块优先写不依赖外部服务的纯单元测试
|
||||
- 如果模块导致循环导入问题,在 `tests/conftest.py` 中添加 `sys.modules` mock
|
||||
|
||||
---
|
||||
|
||||
## 17. 常见问题与排错
|
||||
|
||||
### 启动失败:找不到 config.yaml
|
||||
|
||||
确认 `config.yaml` 在项目根目录或 `backend/` 目录中存在。可用环境变量指定路径:
|
||||
|
||||
```bash
|
||||
export DEER_FLOW_CONFIG_PATH=/path/to/config.yaml
|
||||
make dev
|
||||
```
|
||||
|
||||
### 模型调用失败
|
||||
|
||||
1. 确认 `config.yaml` 中对应模型的 `api_key` 环境变量已设置
|
||||
2. 确认 `base_url` 可访问
|
||||
3. 检查模型提供者包是否安装:缺少时启动日志会给出 `uv add langchain-xxx` 提示
|
||||
|
||||
### 内存功能不工作
|
||||
|
||||
检查 `config.yaml` 中:
|
||||
```yaml
|
||||
memory:
|
||||
enabled: true
|
||||
injection_enabled: true
|
||||
```
|
||||
|
||||
### 文件上传后 Agent 无法使用
|
||||
|
||||
确认文件上传到正确线程,且 `UploadsMiddleware` 处于启用状态(默认启用)。上传文件存放在 `/mnt/user-data/uploads/`,Agent 可直接访问。
|
||||
|
||||
### Windows 下端口占用
|
||||
|
||||
```bash
|
||||
# 查找占用 8001 端口的进程
|
||||
netstat -ano | findstr :8001
|
||||
|
||||
# 终止进程(替换 PID)
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
详见 `docs/BACKEND_RUN_STOP_ZH.md`(Windows 启停专项指南)。
|
||||
|
||||
### 如何新增 API 路由
|
||||
|
||||
1. 在 `app/gateway/routers/` 新建路由文件
|
||||
2. 在 `app/gateway/app.py` 中导入并挂载路由
|
||||
3. 编写对应测试文件 `tests/test_<feature>.py`
|
||||
|
||||
### 如何新增工具
|
||||
|
||||
1. 在 `packages/harness/deerflow/community/` 或 `tools/builtins/` 中实现工具函数
|
||||
2. 在 `config.yaml` 中的 `tools` 列表添加配置,指定 `use` 类路径和 `group`
|
||||
3. 工具会在下次 Agent 初始化时自动加载
|
||||
|
||||
---
|
||||
|
||||
*文档最后更新:2026-05-13*
|
||||
+7
-4
@@ -8,8 +8,9 @@ from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||
from deerflow.agents.memory.summarization_hook import memory_flush_hook
|
||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
||||
@@ -303,8 +304,10 @@ def _build_middlewares(
|
||||
if custom_middlewares:
|
||||
middlewares.extend(custom_middlewares)
|
||||
|
||||
# ClarificationMiddleware should always be last
|
||||
middlewares.append(ClarificationMiddleware())
|
||||
middlewares.append(SetupAgentApprovalMiddleware())
|
||||
|
||||
# ClarificationMiddleware should always be last
|
||||
middlewares.append(ClarificationMiddleware())
|
||||
return middlewares
|
||||
|
||||
|
||||
|
||||
+19
-21
@@ -384,7 +384,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """
|
||||
<role>
|
||||
You are {agent_name}, an open-source super agent.
|
||||
You are {agent_name}, an internal super agent.
|
||||
</role>
|
||||
|
||||
{soul}
|
||||
@@ -438,21 +438,19 @@ You are {agent_name}, an open-source super agent.
|
||||
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
|
||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
- ✅ When practical, provide 3-4 clear, mutually distinct options so the user can choose quickly
|
||||
- ✅ Keep options concise and actionable; the user can still provide a custom answer if none fit
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
|
||||
**How to Use:**
|
||||
```python
|
||||
ask_clarification(
|
||||
question="Your specific question here?",
|
||||
clarification_type="missing_info", # or other type
|
||||
context="Why you need this information", # optional but recommended
|
||||
options=["option1", "option2", "option3"] # use 3-4 options when practical
|
||||
)
|
||||
ask_clarification(
|
||||
question="Your specific question here?",
|
||||
clarification_type="missing_info", # or other type
|
||||
context="Why you need this information", # optional but recommended
|
||||
options=["option1", "option2"] # optional, for choices
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
@@ -519,10 +517,10 @@ Recent breakthroughs in language models have also accelerated progress
|
||||
```markdown
|
||||
## Executive Summary
|
||||
|
||||
DeerFlow is an open-source AI agent framework that gained significant traction in early 2026
|
||||
[citation:GitHub Repository](https://github.com/bytedance/deer-flow). The project focuses on
|
||||
providing a production-ready agent system with sandbox execution and memory management
|
||||
[citation:DeerFlow Documentation](https://deer-flow.dev/docs).
|
||||
LangGraph is an open-source workflow orchestration framework for LLM applications
|
||||
[citation:LangGraph Repository](https://github.com/langchain-ai/langgraph). The project
|
||||
focuses on providing stateful multi-agent graphs with built-in checkpointing and streaming
|
||||
[citation:LangGraph Documentation](https://langchain-ai.github.io/langgraph/).
|
||||
|
||||
## Key Analysis
|
||||
|
||||
@@ -534,8 +532,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
## Sources
|
||||
|
||||
### Primary Sources
|
||||
- [GitHub Repository](https://github.com/bytedance/deer-flow) - Official source code and documentation
|
||||
- [DeerFlow Documentation](https://deer-flow.dev/docs) - Technical specifications
|
||||
- [LangGraph Repository](https://github.com/langchain-ai/langgraph) - Official source code and documentation
|
||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Technical specifications
|
||||
|
||||
### Media Coverage
|
||||
- [AI Trends 2026](https://techcrunch.com/ai-trends) - Industry analysis
|
||||
@@ -547,7 +545,7 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
- The `[citation:Title](URL)` format is ONLY for inline citations within the report body
|
||||
- ❌ WRONG: `GitHub 仓库 - 官方源代码和文档` (no URL!)
|
||||
- ❌ WRONG in Sources: `[citation:GitHub Repository](url)` (citation prefix is for inline only!)
|
||||
- ✅ RIGHT in Sources: `[GitHub Repository](https://github.com/bytedance/deer-flow) - 官方源代码和文档`
|
||||
- ✅ RIGHT in Sources: `[LangGraph Repository](https://github.com/langchain-ai/langgraph) - 官方源代码和文档`
|
||||
|
||||
**WORKFLOW for Research Tasks:**
|
||||
1. Use web_search to find sources → Extract {{title, url, snippet}} from results
|
||||
@@ -839,7 +837,7 @@ def apply_prompt_template(
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
agent_name=agent_name or storage_agent_id or "DeerFlow 2.0",
|
||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||
soul=get_agent_soul(storage_agent_id),
|
||||
skills_section=skills_section,
|
||||
deferred_tools_section=deferred_tools_section,
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
"""Approval middleware for the custom-agent setup tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from hashlib import sha256
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.graph import END
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SetupAgentApprovalMiddlewareState(AgentState):
|
||||
"""Compatible with the DeerFlow thread state schema."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareState]):
|
||||
"""Stop before ``setup_agent`` writes files and ask the user to approve.
|
||||
|
||||
The first ``setup_agent`` call is converted into a structured tool message
|
||||
and the run ends. A subsequent request can set ``setup_agent_approved`` in
|
||||
runtime context to allow the tool to execute.
|
||||
"""
|
||||
|
||||
state_schema = SetupAgentApprovalMiddlewareState
|
||||
|
||||
def _is_approved(self, request: ToolCallRequest) -> bool:
|
||||
context = getattr(request.runtime, "context", None) or {}
|
||||
if not isinstance(context, dict):
|
||||
return False
|
||||
return context.get("setup_agent_approved") is True
|
||||
|
||||
def _stable_message_id(self, tool_call_id: str, args: dict[str, Any]) -> str:
|
||||
if tool_call_id:
|
||||
return f"setup-agent-approval:{tool_call_id}"
|
||||
digest = sha256(repr(sorted(args.items())).encode("utf-8")).hexdigest()[:16]
|
||||
return f"setup-agent-approval:{digest}"
|
||||
|
||||
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
skills = args.get("skills")
|
||||
return {
|
||||
"name": str(args.get("name") or "").strip(),
|
||||
"description": str(args.get("description") or "").strip(),
|
||||
"soul": str(args.get("soul") or "").strip(),
|
||||
"skills": skills if isinstance(skills, list) else [],
|
||||
}
|
||||
|
||||
def _handle_setup_agent(self, request: ToolCallRequest) -> Command:
|
||||
args = request.tool_call.get("args", {}) or {}
|
||||
tool_call_id = request.tool_call.get("id", "")
|
||||
payload = self._build_payload(args)
|
||||
logger.info("Setup agent approval requested before writing agent files")
|
||||
|
||||
message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, args),
|
||||
content="请确认是否创建/更新这个专家配置。",
|
||||
tool_call_id=tool_call_id,
|
||||
name="setup_agent",
|
||||
additional_kwargs={"setup_agent_approval": payload},
|
||||
)
|
||||
return Command(update={"messages": [message]}, goto=END)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request):
|
||||
return handler(request)
|
||||
return self._handle_setup_agent(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request):
|
||||
return await handler(request)
|
||||
return self._handle_setup_agent(request)
|
||||
@@ -35,6 +35,15 @@ class AgentStore(ABC):
|
||||
async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_builtin(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update a built-in agent (user_id IS NULL).
|
||||
|
||||
Caller is responsible for authorization — this method does NOT check
|
||||
any user identity and returns ``None`` if the target is not a built-in.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, agent_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
+10
@@ -91,6 +91,16 @@ class MemoryAgentStore(AgentStore):
|
||||
row["updated_at"] = _now()
|
||||
return _copy(row)
|
||||
|
||||
async def update_builtin(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None or row.get("user_id") is not None:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
return _copy(row)
|
||||
|
||||
async def delete(self, agent_id: str, user_id: str) -> bool:
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None or row.get("user_id") != user_id:
|
||||
|
||||
@@ -107,6 +107,19 @@ class AgentRepository(AgentStore):
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def update_builtin(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None or row.user_id is not None:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def delete(self, agent_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""Publish/subscribe support for scheduled tasks.
|
||||
|
||||
Revision ID: 20260513_02
|
||||
Revises: 20260513_01
|
||||
Create Date: 2026-05-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260513_02"
|
||||
down_revision = "20260513_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_column("scheduled_tasks", "published"):
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("published", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.create_index("ix_scheduled_tasks_published", "scheduled_tasks", ["published"])
|
||||
if not _has_column("scheduled_tasks", "published_at"):
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
if not _has_table("scheduled_task_subscriptions"):
|
||||
op.create_table(
|
||||
"scheduled_task_subscriptions",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("task_id", sa.String(64), nullable=False),
|
||||
sa.Column("user_id", sa.String(64), nullable=False),
|
||||
sa.Column("notify_element", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("task_id", "user_id", name="uq_scheduled_task_subscriptions_task_user"),
|
||||
)
|
||||
op.create_index("ix_scheduled_task_subscriptions_task_id", "scheduled_task_subscriptions", ["task_id"])
|
||||
op.create_index("ix_scheduled_task_subscriptions_user_id", "scheduled_task_subscriptions", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("scheduled_task_subscriptions"):
|
||||
op.drop_table("scheduled_task_subscriptions")
|
||||
if _has_column("scheduled_tasks", "published_at"):
|
||||
op.drop_column("scheduled_tasks", "published_at")
|
||||
if _has_column("scheduled_tasks", "published"):
|
||||
op.drop_index("ix_scheduled_tasks_published", table_name="scheduled_tasks")
|
||||
op.drop_column("scheduled_tasks", "published")
|
||||
+44
-37
@@ -1,37 +1,44 @@
|
||||
"""ORM model registration entry point.
|
||||
|
||||
Importing this module ensures all ORM models are registered with
|
||||
``Base.metadata`` so Alembic autogenerate detects every table.
|
||||
|
||||
The actual ORM classes have moved to entity-specific subpackages:
|
||||
- ``deerflow.persistence.thread_meta``
|
||||
- ``deerflow.persistence.run``
|
||||
- ``deerflow.persistence.feedback``
|
||||
- ``deerflow.persistence.user``
|
||||
- ``deerflow.persistence.agents``
|
||||
|
||||
``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because
|
||||
its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
||||
there is no matching entity directory.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
|
||||
__all__ = [
|
||||
"FeedbackRow",
|
||||
"AgentRow",
|
||||
"RunEventRow",
|
||||
"RunRow",
|
||||
"ScheduledTaskDeliveryProfileRow",
|
||||
"ScheduledTaskRow",
|
||||
"ScheduledTaskRunRow",
|
||||
"SchedulerThreadRow",
|
||||
"ThreadMetaRow",
|
||||
"UserRow",
|
||||
]
|
||||
"""ORM model registration entry point.
|
||||
|
||||
Importing this module ensures all ORM models are registered with
|
||||
``Base.metadata`` so Alembic autogenerate detects every table.
|
||||
|
||||
The actual ORM classes have moved to entity-specific subpackages:
|
||||
- ``deerflow.persistence.thread_meta``
|
||||
- ``deerflow.persistence.run``
|
||||
- ``deerflow.persistence.feedback``
|
||||
- ``deerflow.persistence.user``
|
||||
- ``deerflow.persistence.agents``
|
||||
|
||||
``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because
|
||||
its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
||||
there is no matching entity directory.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.scheduled_tasks.model import (
|
||||
ScheduledTaskDeliveryProfileRow,
|
||||
ScheduledTaskRow,
|
||||
ScheduledTaskRunRow,
|
||||
ScheduledTaskSubscriptionRow,
|
||||
SchedulerThreadRow,
|
||||
)
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
|
||||
__all__ = [
|
||||
"FeedbackRow",
|
||||
"AgentRow",
|
||||
"RunEventRow",
|
||||
"RunRow",
|
||||
"ScheduledTaskDeliveryProfileRow",
|
||||
"ScheduledTaskRow",
|
||||
"ScheduledTaskRunRow",
|
||||
"ScheduledTaskSubscriptionRow",
|
||||
"SchedulerThreadRow",
|
||||
"ThreadMetaRow",
|
||||
"UserRow",
|
||||
]
|
||||
+32
-25
@@ -1,25 +1,32 @@
|
||||
"""Scheduled task persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||
from deerflow.persistence.scheduled_tasks.memory import MemoryScheduledTaskStore
|
||||
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
|
||||
from deerflow.persistence.scheduled_tasks.sql import ScheduledTaskRepository
|
||||
|
||||
__all__ = [
|
||||
"ScheduledTaskRepository",
|
||||
"ScheduledTaskDeliveryProfileRow",
|
||||
"MemoryScheduledTaskStore",
|
||||
"ScheduledTaskRow",
|
||||
"ScheduledTaskRunRow",
|
||||
"ScheduledTaskStore",
|
||||
"SchedulerThreadRow",
|
||||
"make_scheduled_task_store",
|
||||
]
|
||||
|
||||
|
||||
def make_scheduled_task_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryScheduledTaskStore()
|
||||
return ScheduledTaskRepository(session_factory)
|
||||
"""Scheduled task persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||
from deerflow.persistence.scheduled_tasks.memory import MemoryScheduledTaskStore
|
||||
from deerflow.persistence.scheduled_tasks.model import (
|
||||
ScheduledTaskDeliveryProfileRow,
|
||||
ScheduledTaskRow,
|
||||
ScheduledTaskRunRow,
|
||||
ScheduledTaskSubscriptionRow,
|
||||
SchedulerThreadRow,
|
||||
)
|
||||
from deerflow.persistence.scheduled_tasks.sql import ScheduledTaskRepository
|
||||
|
||||
__all__ = [
|
||||
"ScheduledTaskRepository",
|
||||
"ScheduledTaskDeliveryProfileRow",
|
||||
"MemoryScheduledTaskStore",
|
||||
"ScheduledTaskRow",
|
||||
"ScheduledTaskRunRow",
|
||||
"ScheduledTaskStore",
|
||||
"ScheduledTaskSubscriptionRow",
|
||||
"SchedulerThreadRow",
|
||||
"make_scheduled_task_store",
|
||||
]
|
||||
|
||||
|
||||
def make_scheduled_task_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryScheduledTaskStore()
|
||||
return ScheduledTaskRepository(session_factory)
|
||||
+108
-65
@@ -1,65 +1,108 @@
|
||||
"""Abstract scheduled-task persistence API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ScheduledTaskStore(ABC):
|
||||
@abstractmethod
|
||||
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_run(self, run_id: str, data: dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_runs(self, task_id: str, user_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_run(self, run_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
"""Abstract scheduled-task persistence API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ScheduledTaskStore(ABC):
|
||||
@abstractmethod
|
||||
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_run(self, run_id: str, data: dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_runs(self, task_id: str, user_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_run(self, run_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
# --- Publish / subscribe ---
|
||||
|
||||
@abstractmethod
|
||||
async def get_task_any(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Return a task by id without restricting by owner. Used for publish/subscribe lookups."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_run_any(self, run_id: str) -> dict[str, Any] | None:
|
||||
"""Return a run by id without restricting by owner. Used for subscriber visibility."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_task_published(self, task_id: str, owner_user_id: str, *, published: bool) -> dict[str, Any] | None:
|
||||
"""Toggle published flag. Returns updated task or ``None`` if missing / not owned."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_published_tasks(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_subscribed_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Return tasks the user has subscribed to. Each item carries ``_subscription`` dict."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_subscription(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def upsert_subscription(self, task_id: str, user_id: str, *, notify_element: bool) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_subscription(self, task_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_notify_subscribers(self, task_id: str) -> list[dict[str, Any]]:
|
||||
"""Subscribers with ``notify_element=True`` for the given task."""
|
||||
raise NotImplementedError
|
||||
+179
-107
@@ -1,107 +1,179 @@
|
||||
"""In-memory scheduled-task store for memory persistence mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||
|
||||
|
||||
class MemoryScheduledTaskStore(ScheduledTaskStore):
|
||||
def __init__(self) -> None:
|
||||
self._threads: dict[str, dict[str, Any]] = {}
|
||||
self._tasks: dict[str, dict[str, Any]] = {}
|
||||
self._runs_by_key: set[tuple[str, str]] = set()
|
||||
self._runs: dict[str, dict[str, Any]] = {}
|
||||
self._profiles: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||
if user_id not in self._threads:
|
||||
self._threads[user_id] = {"user_id": user_id, "thread_id": thread_id_factory()}
|
||||
return dict(self._threads[user_id])
|
||||
|
||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
return [dict(task) for task in self._tasks.values() if task["user_id"] == user_id]
|
||||
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None or task["user_id"] != user_id:
|
||||
return None
|
||||
return dict(task)
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
self._tasks[data["task_id"]] = dict(data)
|
||||
return dict(data)
|
||||
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None or task["user_id"] != user_id:
|
||||
return None
|
||||
task.update(data)
|
||||
return dict(task)
|
||||
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None or task["user_id"] != user_id:
|
||||
return False
|
||||
self._tasks.pop(task_id, None)
|
||||
return True
|
||||
|
||||
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
due = [
|
||||
dict(task)
|
||||
for task in self._tasks.values()
|
||||
if task.get("enabled") and task.get("next_run_at") is not None and task["next_run_at"] <= now
|
||||
]
|
||||
due.sort(key=lambda task: task["next_run_at"])
|
||||
return due[:limit]
|
||||
|
||||
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
key = (data["task_id"], data["scheduled_for"].isoformat())
|
||||
if key in self._runs_by_key:
|
||||
return None
|
||||
self._runs_by_key.add(key)
|
||||
self._runs[data["id"]] = dict(data)
|
||||
return dict(data)
|
||||
|
||||
async def update_run(self, run_id: str, data: dict[str, Any]) -> None:
|
||||
if run_id in self._runs:
|
||||
self._runs[run_id].update(data)
|
||||
|
||||
async def list_runs(self, task_id: str, user_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
runs = [
|
||||
dict(run)
|
||||
for run in self._runs.values()
|
||||
if run["task_id"] == task_id and run["user_id"] == user_id
|
||||
]
|
||||
runs.sort(key=lambda run: run.get("scheduled_for"), reverse=True)
|
||||
return runs[:limit]
|
||||
|
||||
async def get_run(self, run_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
run = self._runs.get(run_id)
|
||||
if run is None or run["user_id"] != user_id:
|
||||
return None
|
||||
return dict(run)
|
||||
|
||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||
run = self._runs.get(run_id)
|
||||
if run is None or run["user_id"] != user_id:
|
||||
return False
|
||||
self._runs.pop(run_id, None)
|
||||
scheduled_for = run.get("scheduled_for")
|
||||
if hasattr(scheduled_for, "isoformat"):
|
||||
self._runs_by_key.discard((run["task_id"], scheduled_for.isoformat()))
|
||||
return True
|
||||
|
||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||
return dict(self._profiles.get(user_id) or {"user_id": user_id, "element_user_id": None})
|
||||
|
||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
profile = self._profiles.setdefault(user_id, {"user_id": user_id, "element_user_id": None})
|
||||
if "element_user_id" in data:
|
||||
element_user_id = data["element_user_id"]
|
||||
profile["element_user_id"] = element_user_id.strip() if isinstance(element_user_id, str) and element_user_id.strip() else None
|
||||
if "element_room_id" in data:
|
||||
profile["element_room_id"] = data["element_room_id"] or None
|
||||
return dict(profile)
|
||||
"""In-memory scheduled-task store for memory persistence mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||
|
||||
|
||||
class MemoryScheduledTaskStore(ScheduledTaskStore):
|
||||
def __init__(self) -> None:
|
||||
self._threads: dict[str, dict[str, Any]] = {}
|
||||
self._tasks: dict[str, dict[str, Any]] = {}
|
||||
self._runs_by_key: set[tuple[str, str]] = set()
|
||||
self._runs: dict[str, dict[str, Any]] = {}
|
||||
self._profiles: dict[str, dict[str, Any]] = {}
|
||||
self._subscriptions: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
|
||||
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||
if user_id not in self._threads:
|
||||
self._threads[user_id] = {"user_id": user_id, "thread_id": thread_id_factory()}
|
||||
return dict(self._threads[user_id])
|
||||
|
||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
return [dict(task) for task in self._tasks.values() if task["user_id"] == user_id]
|
||||
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None or task["user_id"] != user_id:
|
||||
return None
|
||||
return dict(task)
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
self._tasks[data["task_id"]] = dict(data)
|
||||
return dict(data)
|
||||
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None or task["user_id"] != user_id:
|
||||
return None
|
||||
task.update(data)
|
||||
return dict(task)
|
||||
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None or task["user_id"] != user_id:
|
||||
return False
|
||||
self._tasks.pop(task_id, None)
|
||||
return True
|
||||
|
||||
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
due = [
|
||||
dict(task)
|
||||
for task in self._tasks.values()
|
||||
if task.get("enabled") and task.get("next_run_at") is not None and task["next_run_at"] <= now
|
||||
]
|
||||
due.sort(key=lambda task: task["next_run_at"])
|
||||
return due[:limit]
|
||||
|
||||
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
key = (data["task_id"], data["scheduled_for"].isoformat())
|
||||
if key in self._runs_by_key:
|
||||
return None
|
||||
self._runs_by_key.add(key)
|
||||
self._runs[data["id"]] = dict(data)
|
||||
return dict(data)
|
||||
|
||||
async def update_run(self, run_id: str, data: dict[str, Any]) -> None:
|
||||
if run_id in self._runs:
|
||||
self._runs[run_id].update(data)
|
||||
|
||||
async def list_runs(self, task_id: str, user_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
runs = [
|
||||
dict(run)
|
||||
for run in self._runs.values()
|
||||
if run["task_id"] == task_id and run["user_id"] == user_id
|
||||
]
|
||||
runs.sort(key=lambda run: run.get("scheduled_for"), reverse=True)
|
||||
return runs[:limit]
|
||||
|
||||
async def get_run(self, run_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
run = self._runs.get(run_id)
|
||||
if run is None or run["user_id"] != user_id:
|
||||
return None
|
||||
return dict(run)
|
||||
|
||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||
run = self._runs.get(run_id)
|
||||
if run is None or run["user_id"] != user_id:
|
||||
return False
|
||||
self._runs.pop(run_id, None)
|
||||
scheduled_for = run.get("scheduled_for")
|
||||
if hasattr(scheduled_for, "isoformat"):
|
||||
self._runs_by_key.discard((run["task_id"], scheduled_for.isoformat()))
|
||||
return True
|
||||
|
||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||
return dict(self._profiles.get(user_id) or {"user_id": user_id, "element_user_id": None})
|
||||
|
||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
profile = self._profiles.setdefault(user_id, {"user_id": user_id, "element_user_id": None})
|
||||
if "element_user_id" in data:
|
||||
element_user_id = data["element_user_id"]
|
||||
profile["element_user_id"] = element_user_id.strip() if isinstance(element_user_id, str) and element_user_id.strip() else None
|
||||
if "element_room_id" in data:
|
||||
profile["element_room_id"] = data["element_room_id"] or None
|
||||
return dict(profile)
|
||||
|
||||
# --- Publish / subscribe ---
|
||||
|
||||
async def get_task_any(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self._tasks.get(task_id)
|
||||
return dict(task) if task is not None else None
|
||||
|
||||
async def get_run_any(self, run_id: str) -> dict[str, Any] | None:
|
||||
run = self._runs.get(run_id)
|
||||
return dict(run) if run is not None else None
|
||||
|
||||
async def set_task_published(self, task_id: str, owner_user_id: str, *, published: bool) -> dict[str, Any] | None:
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None or task["user_id"] != owner_user_id:
|
||||
return None
|
||||
task["published"] = bool(published)
|
||||
task["published_at"] = datetime.now(UTC) if published else None
|
||||
return dict(task)
|
||||
|
||||
async def list_published_tasks(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
||||
tasks = [dict(task) for task in self._tasks.values() if task.get("published")]
|
||||
tasks.sort(key=lambda t: (t.get("published_at") or t.get("created_at") or ""), reverse=True)
|
||||
return tasks[max(0, offset) : max(0, offset) + max(1, limit)]
|
||||
|
||||
async def list_subscribed_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for (task_id, sub_user_id), sub in self._subscriptions.items():
|
||||
if sub_user_id != user_id:
|
||||
continue
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None:
|
||||
continue
|
||||
payload = dict(task)
|
||||
payload["_subscription"] = dict(sub)
|
||||
out.append(payload)
|
||||
out.sort(key=lambda item: item["_subscription"].get("created_at") or "", reverse=True)
|
||||
return out
|
||||
|
||||
async def get_subscription(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
sub = self._subscriptions.get((task_id, user_id))
|
||||
return dict(sub) if sub is not None else None
|
||||
|
||||
async def upsert_subscription(self, task_id: str, user_id: str, *, notify_element: bool) -> dict[str, Any]:
|
||||
key = (task_id, user_id)
|
||||
existing = self._subscriptions.get(key)
|
||||
now = datetime.now(UTC)
|
||||
if existing is None:
|
||||
sub = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"task_id": task_id,
|
||||
"user_id": user_id,
|
||||
"notify_element": bool(notify_element),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._subscriptions[key] = sub
|
||||
return dict(sub)
|
||||
existing["notify_element"] = bool(notify_element)
|
||||
existing["updated_at"] = now
|
||||
return dict(existing)
|
||||
|
||||
async def delete_subscription(self, task_id: str, user_id: str) -> bool:
|
||||
return self._subscriptions.pop((task_id, user_id), None) is not None
|
||||
|
||||
async def list_notify_subscribers(self, task_id: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
dict(sub)
|
||||
for (tid, _uid), sub in self._subscriptions.items()
|
||||
if tid == task_id and sub.get("notify_element")
|
||||
]
|
||||
+86
-72
@@ -1,72 +1,86 @@
|
||||
"""ORM models for user scheduled tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class SchedulerThreadRow(Base):
|
||||
__tablename__ = "scheduler_threads"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ScheduledTaskRow(Base):
|
||||
__tablename__ = "scheduled_tasks"
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_scheduled_tasks_user_name"),)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
scheduler_thread_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source_thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
source_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
execution_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
prompt: Mapped[str] = mapped_column(Text)
|
||||
schedule_text: Mapped[str] = mapped_column(String(256))
|
||||
cron_expr: Mapped[str] = mapped_column(String(128))
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="UTC")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_status: Mapped[str | None] = mapped_column(String(32))
|
||||
execution_context_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ScheduledTaskRunRow(Base):
|
||||
__tablename__ = "scheduled_task_runs"
|
||||
__table_args__ = (UniqueConstraint("task_id", "scheduled_for", name="uq_scheduled_task_runs_task_scheduled_for"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
scheduler_thread_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
agent_run_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
result_json: Mapped[dict | None] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ScheduledTaskDeliveryProfileRow(Base):
|
||||
__tablename__ = "scheduled_task_delivery_profiles"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
element_user_id: Mapped[str | None] = mapped_column(String(256), index=True)
|
||||
element_room_id: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
"""ORM models for user scheduled tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
|
||||
|
||||
class SchedulerThreadRow(Base):
|
||||
__tablename__ = "scheduler_threads"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ScheduledTaskRow(Base):
|
||||
__tablename__ = "scheduled_tasks"
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_scheduled_tasks_user_name"),)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
scheduler_thread_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source_thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
source_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
execution_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
prompt: Mapped[str] = mapped_column(Text)
|
||||
schedule_text: Mapped[str] = mapped_column(String(256))
|
||||
cron_expr: Mapped[str] = mapped_column(String(128))
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="UTC")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_status: Mapped[str | None] = mapped_column(String(32))
|
||||
execution_context_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
published: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ScheduledTaskSubscriptionRow(Base):
|
||||
__tablename__ = "scheduled_task_subscriptions"
|
||||
__table_args__ = (UniqueConstraint("task_id", "user_id", name="uq_scheduled_task_subscriptions_task_user"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
notify_element: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ScheduledTaskRunRow(Base):
|
||||
__tablename__ = "scheduled_task_runs"
|
||||
__table_args__ = (UniqueConstraint("task_id", "scheduled_for", name="uq_scheduled_task_runs_task_scheduled_for"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
scheduler_thread_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
agent_run_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
result_json: Mapped[dict | None] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ScheduledTaskDeliveryProfileRow(Base):
|
||||
__tablename__ = "scheduled_task_delivery_profiles"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
element_user_id: Mapped[str | None] = mapped_column(String(256), index=True)
|
||||
element_room_id: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
+308
-196
@@ -1,196 +1,308 @@
|
||||
"""SQLAlchemy repository for scheduled tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
|
||||
|
||||
|
||||
def _to_dict(row) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at", "next_run_at", "last_run_at", "scheduled_for", "started_at", "finished_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
if "execution_context_json" in data:
|
||||
data["execution_context"] = data.pop("execution_context_json") or {}
|
||||
if "result_json" in data:
|
||||
data["result"] = data.pop("result_json") or None
|
||||
return data
|
||||
|
||||
|
||||
class ScheduledTaskRepository(ScheduledTaskStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SchedulerThreadRow, user_id)
|
||||
if row is not None:
|
||||
return _to_dict(row)
|
||||
row = SchedulerThreadRow(user_id=user_id, thread_id=thread_id_factory())
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
row = await session.get(SchedulerThreadRow, user_id)
|
||||
if row is None:
|
||||
raise
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(ScheduledTaskRow).where(ScheduledTaskRow.user_id == user_id).order_by(ScheduledTaskRow.created_at.desc()))
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return _to_dict(row)
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = ScheduledTaskRow(
|
||||
task_id=data["task_id"],
|
||||
user_id=data["user_id"],
|
||||
scheduler_thread_id=data["scheduler_thread_id"],
|
||||
source_thread_id=data.get("source_thread_id"),
|
||||
source_agent_name=data.get("source_agent_name"),
|
||||
execution_agent_name=data.get("execution_agent_name"),
|
||||
name=data["name"],
|
||||
prompt=data["prompt"],
|
||||
schedule_text=data["schedule_text"],
|
||||
cron_expr=data["cron_expr"],
|
||||
timezone=data["timezone"],
|
||||
enabled=data.get("enabled", True),
|
||||
next_run_at=data.get("next_run_at"),
|
||||
last_status=data.get("last_status"),
|
||||
execution_context_json=data.get("execution_context") or {},
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
attr = "execution_context_json" if key == "execution_context" else key
|
||||
if hasattr(row, attr):
|
||||
setattr(row, attr, value)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskRow)
|
||||
.where(ScheduledTaskRow.enabled.is_(True), ScheduledTaskRow.next_run_at.is_not(None), ScheduledTaskRow.next_run_at <= now)
|
||||
.order_by(ScheduledTaskRow.next_run_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = ScheduledTaskRunRow(
|
||||
id=data["id"],
|
||||
task_id=data["task_id"],
|
||||
user_id=data["user_id"],
|
||||
scheduler_thread_id=data["scheduler_thread_id"],
|
||||
scheduled_for=data["scheduled_for"],
|
||||
status=data.get("status", "pending"),
|
||||
started_at=data.get("started_at"),
|
||||
result_json=data.get("result") or None,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
return None
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_run(self, run_id: str, data: dict[str, Any]) -> None:
|
||||
async with self._sf() as session:
|
||||
values = dict(data)
|
||||
if "result" in values:
|
||||
values["result_json"] = values.pop("result")
|
||||
values["updated_at"] = datetime.now(UTC)
|
||||
await session.execute(update(ScheduledTaskRunRow).where(ScheduledTaskRunRow.id == run_id).values(**values))
|
||||
await session.commit()
|
||||
|
||||
async def list_runs(self, task_id: str, user_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskRunRow)
|
||||
.where(ScheduledTaskRunRow.task_id == task_id, ScheduledTaskRunRow.user_id == user_id)
|
||||
.order_by(ScheduledTaskRunRow.scheduled_for.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_run(self, run_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRunRow, run_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRunRow, run_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
||||
if row is None:
|
||||
return {"user_id": user_id, "element_user_id": None}
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
||||
if row is None:
|
||||
row = ScheduledTaskDeliveryProfileRow(user_id=user_id)
|
||||
session.add(row)
|
||||
if "element_user_id" in data:
|
||||
element_user_id = data["element_user_id"]
|
||||
row.element_user_id = element_user_id.strip() if isinstance(element_user_id, str) and element_user_id.strip() else None
|
||||
if "element_room_id" in data:
|
||||
row.element_room_id = data["element_room_id"] or None
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
"""SQLAlchemy repository for scheduled tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||
from deerflow.persistence.scheduled_tasks.model import (
|
||||
ScheduledTaskDeliveryProfileRow,
|
||||
ScheduledTaskRow,
|
||||
ScheduledTaskRunRow,
|
||||
ScheduledTaskSubscriptionRow,
|
||||
SchedulerThreadRow,
|
||||
)
|
||||
|
||||
|
||||
def _to_dict(row) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at", "next_run_at", "last_run_at", "scheduled_for", "started_at", "finished_at", "published_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
if "execution_context_json" in data:
|
||||
data["execution_context"] = data.pop("execution_context_json") or {}
|
||||
if "result_json" in data:
|
||||
data["result"] = data.pop("result_json") or None
|
||||
return data
|
||||
|
||||
|
||||
class ScheduledTaskRepository(ScheduledTaskStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(SchedulerThreadRow, user_id)
|
||||
if row is not None:
|
||||
return _to_dict(row)
|
||||
row = SchedulerThreadRow(user_id=user_id, thread_id=thread_id_factory())
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
row = await session.get(SchedulerThreadRow, user_id)
|
||||
if row is None:
|
||||
raise
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(select(ScheduledTaskRow).where(ScheduledTaskRow.user_id == user_id).order_by(ScheduledTaskRow.created_at.desc()))
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return _to_dict(row)
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = ScheduledTaskRow(
|
||||
task_id=data["task_id"],
|
||||
user_id=data["user_id"],
|
||||
scheduler_thread_id=data["scheduler_thread_id"],
|
||||
source_thread_id=data.get("source_thread_id"),
|
||||
source_agent_name=data.get("source_agent_name"),
|
||||
execution_agent_name=data.get("execution_agent_name"),
|
||||
name=data["name"],
|
||||
prompt=data["prompt"],
|
||||
schedule_text=data["schedule_text"],
|
||||
cron_expr=data["cron_expr"],
|
||||
timezone=data["timezone"],
|
||||
enabled=data.get("enabled", True),
|
||||
next_run_at=data.get("next_run_at"),
|
||||
last_status=data.get("last_status"),
|
||||
execution_context_json=data.get("execution_context") or {},
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
attr = "execution_context_json" if key == "execution_context" else key
|
||||
if hasattr(row, attr):
|
||||
setattr(row, attr, value)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskRow)
|
||||
.where(ScheduledTaskRow.enabled.is_(True), ScheduledTaskRow.next_run_at.is_not(None), ScheduledTaskRow.next_run_at <= now)
|
||||
.order_by(ScheduledTaskRow.next_run_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = ScheduledTaskRunRow(
|
||||
id=data["id"],
|
||||
task_id=data["task_id"],
|
||||
user_id=data["user_id"],
|
||||
scheduler_thread_id=data["scheduler_thread_id"],
|
||||
scheduled_for=data["scheduled_for"],
|
||||
status=data.get("status", "pending"),
|
||||
started_at=data.get("started_at"),
|
||||
result_json=data.get("result") or None,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
return None
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_run(self, run_id: str, data: dict[str, Any]) -> None:
|
||||
async with self._sf() as session:
|
||||
values = dict(data)
|
||||
if "result" in values:
|
||||
values["result_json"] = values.pop("result")
|
||||
values["updated_at"] = datetime.now(UTC)
|
||||
await session.execute(update(ScheduledTaskRunRow).where(ScheduledTaskRunRow.id == run_id).values(**values))
|
||||
await session.commit()
|
||||
|
||||
async def list_runs(self, task_id: str, user_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskRunRow)
|
||||
.where(ScheduledTaskRunRow.task_id == task_id, ScheduledTaskRunRow.user_id == user_id)
|
||||
.order_by(ScheduledTaskRunRow.scheduled_for.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_run(self, run_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRunRow, run_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRunRow, run_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
||||
if row is None:
|
||||
return {"user_id": user_id, "element_user_id": None}
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
||||
if row is None:
|
||||
row = ScheduledTaskDeliveryProfileRow(user_id=user_id)
|
||||
session.add(row)
|
||||
if "element_user_id" in data:
|
||||
element_user_id = data["element_user_id"]
|
||||
row.element_user_id = element_user_id.strip() if isinstance(element_user_id, str) and element_user_id.strip() else None
|
||||
if "element_room_id" in data:
|
||||
row.element_room_id = data["element_room_id"] or None
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
# --- Publish / subscribe ---
|
||||
|
||||
async def get_task_any(self, task_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def get_run_any(self, run_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRunRow, run_id)
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def set_task_published(self, task_id: str, owner_user_id: str, *, published: bool) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ScheduledTaskRow, task_id)
|
||||
if row is None or row.user_id != owner_user_id:
|
||||
return None
|
||||
row.published = bool(published)
|
||||
row.published_at = datetime.now(UTC) if published else None
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def list_published_tasks(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskRow)
|
||||
.where(ScheduledTaskRow.published.is_(True))
|
||||
.order_by(ScheduledTaskRow.published_at.desc().nullslast(), ScheduledTaskRow.created_at.desc())
|
||||
.offset(max(0, offset))
|
||||
.limit(max(1, limit))
|
||||
)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def list_subscribed_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskRow, ScheduledTaskSubscriptionRow)
|
||||
.join(ScheduledTaskSubscriptionRow, ScheduledTaskSubscriptionRow.task_id == ScheduledTaskRow.task_id)
|
||||
.where(ScheduledTaskSubscriptionRow.user_id == user_id)
|
||||
.order_by(ScheduledTaskSubscriptionRow.created_at.desc())
|
||||
)
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for task_row, sub_row in result.all():
|
||||
task = _to_dict(task_row)
|
||||
task["_subscription"] = _to_dict(sub_row)
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
||||
async def get_subscription(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskSubscriptionRow)
|
||||
.where(ScheduledTaskSubscriptionRow.task_id == task_id, ScheduledTaskSubscriptionRow.user_id == user_id)
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def upsert_subscription(self, task_id: str, user_id: str, *, notify_element: bool) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskSubscriptionRow)
|
||||
.where(ScheduledTaskSubscriptionRow.task_id == task_id, ScheduledTaskSubscriptionRow.user_id == user_id)
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
row = ScheduledTaskSubscriptionRow(
|
||||
id=str(uuid.uuid4()),
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
notify_element=bool(notify_element),
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.notify_element = bool(notify_element)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_subscription(self, task_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(ScheduledTaskSubscriptionRow).where(
|
||||
ScheduledTaskSubscriptionRow.task_id == task_id,
|
||||
ScheduledTaskSubscriptionRow.user_id == user_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
async def list_notify_subscribers(self, task_id: str) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(ScheduledTaskSubscriptionRow).where(
|
||||
ScheduledTaskSubscriptionRow.task_id == task_id,
|
||||
ScheduledTaskSubscriptionRow.notify_element.is_(True),
|
||||
)
|
||||
)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
+846
-669
File diff suppressed because it is too large.
Load diff
+113
-23
@@ -91,41 +91,48 @@ class LocalSkillStorage(SkillStorage):
|
||||
tmp_path = Path(tmp_file.name)
|
||||
tmp_path.replace(target)
|
||||
|
||||
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
|
||||
def _open_skill_archive(self, archive_path: str | Path, *, require_skill_suffix: bool):
|
||||
import zipfile
|
||||
|
||||
from deerflow.skills.installer import (
|
||||
SkillAlreadyExistsError,
|
||||
_move_staged_skill_into_reserved_target,
|
||||
_scan_skill_archive_contents_or_raise,
|
||||
resolve_skill_dir_from_archive,
|
||||
safe_extract_skill_archive,
|
||||
)
|
||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||
|
||||
logger.info("Installing skill from %s", archive_path)
|
||||
path = Path(archive_path)
|
||||
if not path.is_file():
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Skill file not found: {archive_path}")
|
||||
raise ValueError(f"Path is not a file: {archive_path}")
|
||||
if path.suffix != ".skill":
|
||||
if require_skill_suffix and path.suffix != ".skill":
|
||||
raise ValueError("File must have .skill extension")
|
||||
|
||||
custom_dir = self._host_root / "custom"
|
||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
zf = zipfile.ZipFile(path, "r")
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Skill file not found: {archive_path}") from None
|
||||
except (zipfile.BadZipFile, IsADirectoryError):
|
||||
raise ValueError("File is not a valid ZIP archive") from None
|
||||
return zf
|
||||
|
||||
def validate_skill_archive(self, archive_path: str | Path, *, require_skill_suffix: bool = False) -> dict:
|
||||
"""Inspect a skill archive WITHOUT installing it.
|
||||
|
||||
Returns a dict with ``skill_name``, ``description``, ``license``,
|
||||
``allowed_tools``, ``conflict`` (bool), ``warnings`` (list[str]).
|
||||
Raises ``ValueError`` for fatal validation problems (bad zip, bad
|
||||
frontmatter). The expensive LLM security scan is **not** invoked here.
|
||||
"""
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.skills.installer import (
|
||||
resolve_skill_dir_from_archive,
|
||||
safe_extract_skill_archive,
|
||||
)
|
||||
from deerflow.skills.types import SKILL_MD_FILE
|
||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(path, "r")
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Skill file not found: {archive_path}") from None
|
||||
except (zipfile.BadZipFile, IsADirectoryError):
|
||||
raise ValueError("File is not a valid ZIP archive") from None
|
||||
|
||||
with zf:
|
||||
with self._open_skill_archive(archive_path, require_skill_suffix=require_skill_suffix) as zf:
|
||||
safe_extract_skill_archive(zf, tmp_path)
|
||||
|
||||
skill_dir = resolve_skill_dir_from_archive(tmp_path)
|
||||
@@ -136,9 +143,91 @@ class LocalSkillStorage(SkillStorage):
|
||||
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||
raise ValueError(f"Invalid skill name: {skill_name}")
|
||||
|
||||
skill_md_text = (skill_dir / SKILL_MD_FILE).read_text(encoding="utf-8")
|
||||
frontmatter: dict = {}
|
||||
match = re.match(r"^---\n(.*?)\n---", skill_md_text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1)) or {}
|
||||
if isinstance(parsed, dict):
|
||||
frontmatter = parsed
|
||||
except yaml.YAMLError:
|
||||
frontmatter = {}
|
||||
|
||||
allowed_tools = frontmatter.get("allowed-tools") or []
|
||||
if not isinstance(allowed_tools, list):
|
||||
allowed_tools = [str(allowed_tools)]
|
||||
else:
|
||||
allowed_tools = [str(item) for item in allowed_tools]
|
||||
|
||||
warnings: list[str] = []
|
||||
script_files = [
|
||||
path for path in skill_dir.rglob("*")
|
||||
if path.is_file() and path.relative_to(skill_dir).parts and path.relative_to(skill_dir).parts[0] == "scripts"
|
||||
]
|
||||
if script_files:
|
||||
warnings.append(f"Archive contains {len(script_files)} script file(s); they will be security-scanned at install time.")
|
||||
|
||||
conflict_custom = (self._host_root / SkillCategory.CUSTOM.value / skill_name / SKILL_MD_FILE).exists()
|
||||
conflict_public = self.public_skill_exists(skill_name)
|
||||
conflict = conflict_custom or conflict_public
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"skill_name": skill_name,
|
||||
"description": str(frontmatter.get("description", "")).strip(),
|
||||
"license": (str(frontmatter["license"]).strip() if frontmatter.get("license") else None),
|
||||
"allowed_tools": allowed_tools,
|
||||
"conflict": conflict,
|
||||
"conflict_kind": ("public" if conflict_public else ("custom" if conflict_custom else None)),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
async def ainstall_skill_from_archive(
|
||||
self,
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
require_skill_suffix: bool = True,
|
||||
) -> dict:
|
||||
from deerflow.skills.installer import (
|
||||
SkillAlreadyExistsError,
|
||||
_move_staged_skill_into_reserved_target,
|
||||
_scan_skill_archive_contents_or_raise,
|
||||
resolve_skill_dir_from_archive,
|
||||
safe_extract_skill_archive,
|
||||
)
|
||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||
|
||||
logger.info("Installing skill from %s (overwrite=%s)", archive_path, overwrite)
|
||||
|
||||
custom_dir = self._host_root / "custom"
|
||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
|
||||
with self._open_skill_archive(archive_path, require_skill_suffix=require_skill_suffix) as zf:
|
||||
safe_extract_skill_archive(zf, tmp_path)
|
||||
|
||||
skill_dir = resolve_skill_dir_from_archive(tmp_path)
|
||||
|
||||
is_valid, message, skill_name = _validate_skill_frontmatter(skill_dir)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid skill: {message}")
|
||||
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||
raise ValueError(f"Invalid skill name: {skill_name}")
|
||||
|
||||
if self.public_skill_exists(skill_name):
|
||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' conflicts with a built-in public skill and cannot be overwritten")
|
||||
|
||||
target = custom_dir / skill_name
|
||||
replaced = False
|
||||
if target.exists():
|
||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
|
||||
if not overwrite:
|
||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
|
||||
shutil.rmtree(target)
|
||||
replaced = True
|
||||
|
||||
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
|
||||
|
||||
@@ -152,6 +241,7 @@ class LocalSkillStorage(SkillStorage):
|
||||
"success": True,
|
||||
"skill_name": skill_name,
|
||||
"message": f"Skill '{skill_name}' installed successfully",
|
||||
"overwritten": replaced,
|
||||
}
|
||||
|
||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
||||
|
||||
+5
-1
@@ -17,6 +17,7 @@ def setup_agent(
|
||||
soul: str,
|
||||
description: str,
|
||||
runtime: ToolRuntime,
|
||||
name: str | None = None,
|
||||
skills: list[str] | None = None,
|
||||
) -> Command:
|
||||
"""Setup the custom DeerFlow agent.
|
||||
@@ -24,6 +25,8 @@ def setup_agent(
|
||||
Args:
|
||||
soul: Full SOUL.md content defining the agent's personality and behavior.
|
||||
description: One-line description of what the agent does.
|
||||
name: Optional human-readable display name for the agent. If omitted,
|
||||
the agent's stable id is used as the display name.
|
||||
skills: Optional list of skill names this agent should use. None or empty means no skills.
|
||||
"""
|
||||
|
||||
@@ -40,7 +43,8 @@ def setup_agent(
|
||||
|
||||
if agent_name:
|
||||
# If agent_name is provided, we are creating a custom agent in the agents/ directory
|
||||
config_data: dict = {"id": agent_name, "name": agent_name}
|
||||
display_name = (name or "").strip() or agent_name
|
||||
config_data: dict = {"id": agent_name, "name": display_name}
|
||||
if description:
|
||||
config_data["description"] = description
|
||||
if skills is not None:
|
||||
|
||||
@@ -52,6 +52,63 @@ async def test_agent_store_only_owner_can_update_or_delete() -> None:
|
||||
assert await store.delete("private-a", "user-a") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_builtin_only_targets_builtins() -> None:
|
||||
store = MemoryAgentStore()
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# update_builtin succeeds on a built-in agent
|
||||
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
|
||||
assert updated is not None
|
||||
assert updated["name"] == "Renamed by admin"
|
||||
|
||||
# update_builtin refuses to touch a user-owned agent (returns None)
|
||||
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
|
||||
|
||||
# And on a missing agent
|
||||
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
|
||||
"""Route helper: admin gets edit access to built-ins; non-admin still 403."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers.agents import _get_editable_or_raise
|
||||
|
||||
store = MemoryAgentStore()
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# Owner edits their own agent → returns ('owner', record)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "owned-agent", "user-a", is_admin=False
|
||||
)
|
||||
assert mode == "owner" and record["id"] == "owned-agent"
|
||||
|
||||
# Admin edits a built-in agent → returns ('admin', record)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "builtin-agent", "user-a", is_admin=True
|
||||
)
|
||||
assert mode == "admin" and record["id"] == "builtin-agent"
|
||||
|
||||
# Non-admin tries to edit a built-in → 403
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Admin tries to edit someone else's PUBLISHED custom agent → still 403
|
||||
# (admin override only applies to built-ins, not to others' custom agents).
|
||||
await store.create(
|
||||
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_or_raise(store, "published-other", "user-a", is_admin=True)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
|
||||
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests for editing archived scheduled-task run files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config import paths as paths_module
|
||||
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
|
||||
from deerflow.runtime.scheduler.service import ScheduledTaskService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
yield tmp_path
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> MemoryScheduledTaskStore:
|
||||
return MemoryScheduledTaskStore()
|
||||
|
||||
|
||||
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
|
||||
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
|
||||
service._element_client = None # noqa: SLF001
|
||||
return service
|
||||
|
||||
|
||||
def _seed_task(store: MemoryScheduledTaskStore, *, task_id: str, user_id: str, published: bool = False) -> None:
|
||||
store._tasks[task_id] = { # noqa: SLF001
|
||||
"task_id": task_id,
|
||||
"user_id": user_id,
|
||||
"scheduler_thread_id": f"thread-{user_id}",
|
||||
"name": "demo",
|
||||
"prompt": "hi",
|
||||
"schedule_text": "0 9 * * *",
|
||||
"cron_expr": "0 9 * * *",
|
||||
"timezone": "UTC",
|
||||
"enabled": True,
|
||||
"next_run_at": datetime.now(UTC),
|
||||
"execution_context": {},
|
||||
"published": published,
|
||||
"published_at": datetime.now(UTC) if published else None,
|
||||
}
|
||||
|
||||
|
||||
async def _seed_run_with_file(
|
||||
store: MemoryScheduledTaskStore,
|
||||
isolated_paths: Path,
|
||||
*,
|
||||
run_id: str,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
file_name: str,
|
||||
file_text: str,
|
||||
mime_type: str = "text/markdown",
|
||||
) -> dict[str, Any]:
|
||||
archive_dir = isolated_paths / "scheduled-task-runs" / run_id / "files"
|
||||
archive_dir.mkdir(parents=True)
|
||||
file_path = archive_dir / file_name
|
||||
file_path.write_text(file_text, encoding="utf-8")
|
||||
|
||||
file_entry = {
|
||||
"name": file_name,
|
||||
"path": str(file_path),
|
||||
"mime_type": mime_type,
|
||||
"kind": "markdown" if file_name.endswith(".md") else "artifact",
|
||||
"size": file_path.stat().st_size,
|
||||
}
|
||||
result = {"title": "demo", "content": "ignored", "format": "markdown", "files": [file_entry]}
|
||||
|
||||
scheduled_for = datetime.now(UTC)
|
||||
await store.create_run_once(
|
||||
{
|
||||
"id": run_id,
|
||||
"task_id": task_id,
|
||||
"user_id": user_id,
|
||||
"scheduler_thread_id": f"thread-{user_id}",
|
||||
"scheduled_for": scheduled_for,
|
||||
"status": "succeeded",
|
||||
"started_at": scheduled_for,
|
||||
}
|
||||
)
|
||||
await store.update_run(run_id, {"result": result, "finished_at": scheduled_for})
|
||||
return file_entry
|
||||
|
||||
|
||||
# ---------- can_edit flag on get_run ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_marks_owner_as_can_edit(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
|
||||
run = await service.get_run("alice", "r1")
|
||||
assert run is not None
|
||||
assert run["can_edit"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_marks_subscriber_as_read_only(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
await service.subscribe_task("bob", "t1", notify_element=False)
|
||||
|
||||
run = await service.get_run("bob", "r1")
|
||||
assert run is not None
|
||||
assert run["can_edit"] is False
|
||||
|
||||
|
||||
# ---------- update_run_file ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_can_overwrite_md_file(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
file_entry = await _seed_run_with_file(
|
||||
store,
|
||||
isolated_paths,
|
||||
run_id="r1",
|
||||
task_id="t1",
|
||||
user_id="alice",
|
||||
file_name="report.md",
|
||||
file_text="# original",
|
||||
)
|
||||
service = _build_service(store)
|
||||
|
||||
new_text = "# updated\n\nA new paragraph."
|
||||
meta = await service.update_run_file("alice", "r1", 0, new_text)
|
||||
|
||||
assert meta["name"] == "report.md"
|
||||
assert meta["size"] == len(new_text.encode("utf-8"))
|
||||
|
||||
# The on-disk file is overwritten.
|
||||
assert Path(file_entry["path"]).read_text(encoding="utf-8") == new_text
|
||||
|
||||
# The size in the run row is refreshed.
|
||||
run = await service.get_run("alice", "r1")
|
||||
assert run is not None
|
||||
assert run["result"]["files"][0]["size"] == meta["size"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriber_cannot_edit(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
await service.subscribe_task("bob", "t1", notify_element=False)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await service.update_run_file("bob", "r1", 0, "# evil")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrelated_user_gets_not_found(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await service.update_run_file("eve", "r1", 0, "# evil")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_run_returns_filenotfound(store, isolated_paths):
|
||||
service = _build_service(store)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await service.update_run_file("alice", "missing-run", 0, "# x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_file_index_rejected(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await service.update_run_file("alice", "r1", 5, "# x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_text_file_rejected(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
await _seed_run_with_file(
|
||||
store,
|
||||
isolated_paths,
|
||||
run_id="r1",
|
||||
task_id="t1",
|
||||
user_id="alice",
|
||||
file_name="diagram.png",
|
||||
file_text="not really a png but stored as such",
|
||||
mime_type="image/png",
|
||||
)
|
||||
service = _build_service(store)
|
||||
|
||||
with pytest.raises(ValueError, match="text-previewable"):
|
||||
await service.update_run_file("alice", "r1", 0, "# x")
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for scheduled task publish/subscribe + mirrored Element delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
|
||||
from deerflow.runtime.scheduler.service import ScheduledTaskService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> MemoryScheduledTaskStore:
|
||||
return MemoryScheduledTaskStore()
|
||||
|
||||
|
||||
def _seed_task(
|
||||
store: MemoryScheduledTaskStore,
|
||||
*,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
name: str = "demo",
|
||||
prompt: str = "Hello world",
|
||||
published: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"user_id": user_id,
|
||||
"scheduler_thread_id": f"thread-{user_id}",
|
||||
"name": name,
|
||||
"prompt": prompt,
|
||||
"schedule_text": "0 9 * * *",
|
||||
"cron_expr": "0 9 * * *",
|
||||
"timezone": "UTC",
|
||||
"enabled": True,
|
||||
"next_run_at": datetime.now(UTC),
|
||||
"execution_context": {},
|
||||
"published": published,
|
||||
"published_at": datetime.now(UTC) if published else None,
|
||||
}
|
||||
store._tasks[task_id] = task # noqa: SLF001
|
||||
return task
|
||||
|
||||
|
||||
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
|
||||
# Build with no FastAPI app — the methods we exercise here don't touch app.state.
|
||||
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
|
||||
service._element_client = None # noqa: SLF001 — disable real delivery
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_unpublish_only_owner(store):
|
||||
task = _seed_task(store, task_id="t1", user_id="alice")
|
||||
service = _build_service(store)
|
||||
|
||||
assert await service.publish_task("bob", task["task_id"]) is None, "non-owner cannot publish"
|
||||
|
||||
published = await service.publish_task("alice", task["task_id"])
|
||||
assert published is not None
|
||||
assert published["published"] is True
|
||||
assert published["published_at"] is not None
|
||||
|
||||
listed = await service.list_published(limit=10, offset=0)
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["task_id"] == "t1"
|
||||
assert listed[0]["prompt_summary"] == "Hello world"
|
||||
assert "prompt" not in listed[0], "raw prompt must not leak through summary endpoint"
|
||||
|
||||
unpublished = await service.unpublish_task("alice", task["task_id"])
|
||||
assert unpublished["published"] is False
|
||||
assert (await service.list_published()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_requires_published_for_first_time(store):
|
||||
task = _seed_task(store, task_id="t1", user_id="alice", published=False)
|
||||
service = _build_service(store)
|
||||
|
||||
# Cannot subscribe to a private task.
|
||||
assert await service.subscribe_task("bob", task["task_id"], notify_element=False) is None
|
||||
|
||||
# Author publishes; bob subscribes.
|
||||
await service.publish_task("alice", task["task_id"])
|
||||
sub = await service.subscribe_task("bob", task["task_id"], notify_element=True)
|
||||
assert sub is not None and sub["notify_element"] is True
|
||||
|
||||
# Author cannot subscribe to their own task.
|
||||
assert await service.subscribe_task("alice", task["task_id"], notify_element=True) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unpublish_keeps_existing_subscribers(store):
|
||||
task = _seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
service = _build_service(store)
|
||||
|
||||
sub = await service.subscribe_task("bob", task["task_id"], notify_element=False)
|
||||
assert sub is not None
|
||||
|
||||
# Author un-publishes — bob keeps acquired access.
|
||||
await service.unpublish_task("alice", task["task_id"])
|
||||
assert await store.get_subscription(task["task_id"], "bob") is not None
|
||||
|
||||
# New user cannot subscribe after un-publish.
|
||||
assert await service.subscribe_task("carol", task["task_id"], notify_element=False) is None
|
||||
|
||||
# Existing subscriber can still toggle their notification preference.
|
||||
updated = await service.subscribe_task("bob", task["task_id"], notify_element=True)
|
||||
assert updated is not None and updated["notify_element"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_includes_subscribed_with_role(store):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
_seed_task(store, task_id="t2", user_id="bob")
|
||||
service = _build_service(store)
|
||||
|
||||
await service.subscribe_task("bob", "t1", notify_element=False)
|
||||
|
||||
bobs = await service.list_tasks("bob")
|
||||
by_id = {t["task_id"]: t for t in bobs}
|
||||
assert by_id["t2"]["role"] == "owner"
|
||||
assert by_id["t1"]["role"] == "subscriber"
|
||||
assert by_id["t1"]["_subscription"]["notify_element"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriber_can_read_runs_and_get_run(store):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
service = _build_service(store)
|
||||
await service.subscribe_task("bob", "t1", notify_element=False)
|
||||
|
||||
# Seed a run owned by alice.
|
||||
scheduled_for = datetime.now(UTC)
|
||||
run = await store.create_run_once(
|
||||
{
|
||||
"id": "run-1",
|
||||
"task_id": "t1",
|
||||
"user_id": "alice",
|
||||
"scheduler_thread_id": "thread-alice",
|
||||
"scheduled_for": scheduled_for,
|
||||
"status": "succeeded",
|
||||
"started_at": scheduled_for,
|
||||
}
|
||||
)
|
||||
assert run is not None
|
||||
|
||||
# Bob (subscriber) sees runs on the task.
|
||||
bob_runs = await service.list_runs("bob", "t1", limit=10)
|
||||
assert [r["id"] for r in bob_runs] == ["run-1"]
|
||||
|
||||
# Bob can fetch the run by id.
|
||||
fetched = await service.get_run("bob", "run-1")
|
||||
assert fetched is not None and fetched["id"] == "run-1"
|
||||
|
||||
# Carol (no subscription) cannot.
|
||||
assert await service.list_runs("carol", "t1", limit=10) == []
|
||||
assert await service.get_run("carol", "run-1") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_removes_visibility(store):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
service = _build_service(store)
|
||||
|
||||
await service.subscribe_task("bob", "t1", notify_element=False)
|
||||
assert await service.unsubscribe_task("bob", "t1") is True
|
||||
bob_tasks = await service.list_tasks("bob")
|
||||
assert all(t["task_id"] != "t1" for t in bob_tasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mirror_element_delivery_to_subscribers(store):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
service = _build_service(store)
|
||||
|
||||
# Author and two subscribers configure delivery profiles.
|
||||
await store.update_delivery_profile("alice", {"element_user_id": "@alice:example"})
|
||||
await store.update_delivery_profile("bob", {"element_user_id": "@bob:example"})
|
||||
await store.update_delivery_profile("carol", {"element_user_id": "@carol:example"})
|
||||
|
||||
await service.subscribe_task("bob", "t1", notify_element=True)
|
||||
await service.subscribe_task("carol", "t1", notify_element=False) # opted out
|
||||
|
||||
sent: list[tuple[str, str]] = []
|
||||
|
||||
class _FakeElement:
|
||||
async def send_direct_message(self, user_id: str, body: str, room_id: str | None = None) -> str:
|
||||
sent.append((user_id, body))
|
||||
return f"!room-{user_id}"
|
||||
|
||||
service._element_client = _FakeElement() # noqa: SLF001
|
||||
|
||||
task = await store.get_task_any("t1")
|
||||
assert task is not None
|
||||
await service._deliver_element_result(task, "result body") # noqa: SLF001
|
||||
|
||||
recipients = sorted(user for user, _ in sent)
|
||||
assert recipients == ["@alice:example", "@bob:example"], "only author + opted-in subscriber receive"
|
||||
|
||||
# All recipients get the same body content (we sent identical messages).
|
||||
bodies = {body for _, body in sent}
|
||||
assert bodies == {"【定时任务】demo\n\nresult body"}
|
||||
|
||||
# Carol is not in the recipient list.
|
||||
assert all(uid != "@carol:example" for uid, _ in sent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_published_summary_truncates_long_prompt(store):
|
||||
long_prompt = "abcdef" * 100 # 600 chars
|
||||
_seed_task(store, task_id="t1", user_id="alice", prompt=long_prompt, published=True)
|
||||
service = _build_service(store)
|
||||
|
||||
listed = await service.list_published()
|
||||
summary = listed[0]["prompt_summary"]
|
||||
assert len(summary) <= 201 # 200 chars + ellipsis
|
||||
assert summary.endswith("…")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Tests for skill upload validation + install via the storage layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.skills.installer import SkillAlreadyExistsError
|
||||
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
||||
|
||||
|
||||
def _make_skill_zip(
|
||||
*,
|
||||
skill_name: str = "demo-skill",
|
||||
description: str = "Use this skill when demoing things.",
|
||||
license_value: str | None = "MIT",
|
||||
allowed_tools: list[str] | None = None,
|
||||
extra_files: dict[str, str] | None = None,
|
||||
include_skill_md: bool = True,
|
||||
bad_yaml: bool = False,
|
||||
nest_under_root: bool = True,
|
||||
) -> bytes:
|
||||
"""Build an in-memory .skill ZIP archive for tests."""
|
||||
buf = io.BytesIO()
|
||||
root = f"{skill_name}/" if nest_under_root else ""
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if include_skill_md:
|
||||
if bad_yaml:
|
||||
content = "---\nname: ???: bad\n---\nhi"
|
||||
else:
|
||||
lines = ["---", f"name: {skill_name}", f"description: {description}"]
|
||||
if license_value:
|
||||
lines.append(f"license: {license_value}")
|
||||
if allowed_tools:
|
||||
lines.append("allowed-tools:")
|
||||
for tool in allowed_tools:
|
||||
lines.append(f" - {tool}")
|
||||
lines.extend(["---", "# " + skill_name, "Hello world."])
|
||||
content = "\n".join(lines)
|
||||
zf.writestr(f"{root}SKILL.md", content)
|
||||
for rel, body in (extra_files or {}).items():
|
||||
zf.writestr(f"{root}{rel}", body)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path: Path) -> LocalSkillStorage:
|
||||
skills_root = tmp_path / "skills"
|
||||
(skills_root / "public").mkdir(parents=True)
|
||||
(skills_root / "custom").mkdir(parents=True)
|
||||
return LocalSkillStorage(host_path=str(skills_root))
|
||||
|
||||
|
||||
def _write_archive(tmp_path: Path, data: bytes, *, suffix: str = ".skill") -> Path:
|
||||
archive = tmp_path / f"upload{suffix}"
|
||||
archive.write_bytes(data)
|
||||
return archive
|
||||
|
||||
|
||||
# ---------- validate_skill_archive ----------
|
||||
|
||||
|
||||
def test_validate_returns_metadata_and_no_conflict(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(
|
||||
tmp_path,
|
||||
_make_skill_zip(allowed_tools=["bash", "read_file"]),
|
||||
)
|
||||
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["skill_name"] == "demo-skill"
|
||||
assert result["description"] == "Use this skill when demoing things."
|
||||
assert result["license"] == "MIT"
|
||||
assert result["allowed_tools"] == ["bash", "read_file"]
|
||||
assert result["conflict"] is False
|
||||
assert result["conflict_kind"] is None
|
||||
assert result["warnings"] == []
|
||||
|
||||
|
||||
def test_validate_flags_script_files_as_warning(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(
|
||||
tmp_path,
|
||||
_make_skill_zip(extra_files={"scripts/hi.py": "print('hi')\n"}),
|
||||
)
|
||||
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["warnings"] and "script" in result["warnings"][0].lower()
|
||||
|
||||
|
||||
def test_validate_detects_custom_conflict(storage: LocalSkillStorage, tmp_path: Path):
|
||||
# Pre-install one with the same name into custom/
|
||||
target = storage.get_skills_root_path() / "custom" / "demo-skill"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(
|
||||
"---\nname: demo-skill\ndescription: existing\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["conflict"] is True
|
||||
assert result["conflict_kind"] == "custom"
|
||||
|
||||
|
||||
def test_validate_detects_public_conflict(storage: LocalSkillStorage, tmp_path: Path):
|
||||
target = storage.get_skills_root_path() / "public" / "demo-skill"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(
|
||||
"---\nname: demo-skill\ndescription: existing\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["conflict"] is True
|
||||
assert result["conflict_kind"] == "public"
|
||||
|
||||
|
||||
def test_validate_rejects_bad_zip(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(tmp_path, b"not a zip at all")
|
||||
with pytest.raises(ValueError, match="not a valid ZIP"):
|
||||
storage.validate_skill_archive(archive)
|
||||
|
||||
|
||||
def test_validate_rejects_missing_skill_md(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(tmp_path, _make_skill_zip(include_skill_md=False))
|
||||
with pytest.raises(ValueError, match="SKILL.md"):
|
||||
storage.validate_skill_archive(archive)
|
||||
|
||||
|
||||
def test_validate_rejects_bad_frontmatter(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(tmp_path, _make_skill_zip(bad_yaml=True))
|
||||
with pytest.raises(ValueError):
|
||||
storage.validate_skill_archive(archive)
|
||||
|
||||
|
||||
# ---------- ainstall_skill_from_archive (overwrite) ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_writes_to_custom_dir(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
# Bypass the LLM security scan so the test stays hermetic.
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
result = await storage.ainstall_skill_from_archive(archive, require_skill_suffix=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["skill_name"] == "demo-skill"
|
||||
target = storage.get_skills_root_path() / "custom" / "demo-skill" / "SKILL.md"
|
||||
assert target.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_conflict_without_overwrite(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
await storage.ainstall_skill_from_archive(archive, require_skill_suffix=False)
|
||||
|
||||
with pytest.raises(SkillAlreadyExistsError):
|
||||
await storage.ainstall_skill_from_archive(archive, require_skill_suffix=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_overwrite_replaces_existing(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
(tmp_path / "v1").mkdir(exist_ok=True)
|
||||
(tmp_path / "v2").mkdir(exist_ok=True)
|
||||
archive_v1 = _write_archive(tmp_path / "v1", _make_skill_zip(description="version one"))
|
||||
archive_v2 = _write_archive(tmp_path / "v2", _make_skill_zip(description="version two"))
|
||||
|
||||
await storage.ainstall_skill_from_archive(archive_v1, require_skill_suffix=False)
|
||||
await storage.ainstall_skill_from_archive(
|
||||
archive_v2, overwrite=True, require_skill_suffix=False
|
||||
)
|
||||
|
||||
skill_md = (
|
||||
storage.get_skills_root_path() / "custom" / "demo-skill" / "SKILL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "version two" in skill_md
|
||||
assert "version one" not in skill_md
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_refuses_to_overwrite_public_skill(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
public_dir = storage.get_skills_root_path() / "public" / "demo-skill"
|
||||
public_dir.mkdir(parents=True)
|
||||
(public_dir / "SKILL.md").write_text(
|
||||
"---\nname: demo-skill\ndescription: built-in\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
with pytest.raises(SkillAlreadyExistsError, match="public"):
|
||||
await storage.ainstall_skill_from_archive(
|
||||
archive, overwrite=True, require_skill_suffix=False
|
||||
)
|
||||
Reference in new issue
Block a user