Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb1dc2f34 |
No files matched your search
@@ -1 +0,0 @@
|
|||||||
remote connectivity test - 2026-05-13
|
|
||||||
@@ -2,16 +2,9 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
frontend-web/.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 local/runtime artifacts
|
||||||
frontend-web/.env
|
frontend-web/.env
|
||||||
frontend-web/node_modules/
|
frontend-web/node_modules/
|
||||||
frontend-web/.pnpm-store/
|
|
||||||
frontend-web/tsconfig.tsbuildinfo
|
frontend-web/tsconfig.tsbuildinfo
|
||||||
frontend-web/vite-dev.log
|
frontend-web/vite-dev.log
|
||||||
frontend-web/vite-dev.err.log
|
frontend-web/vite-dev.err.log
|
||||||
@@ -58,8 +51,6 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
|||||||
offline-backend-20260512/extensions_config.json
|
offline-backend-20260512/extensions_config.json
|
||||||
offline-backend-20260512/skills/
|
offline-backend-20260512/skills/
|
||||||
offline-backend-20260512/wheelhouse/
|
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/start_backend.sh
|
||||||
offline-backend-20260512/stop_backend.sh
|
offline-backend-20260512/stop_backend.sh
|
||||||
offline-backend-20260512/check_backend.sh
|
offline-backend-20260512/check_backend.sh
|
||||||
Whitespace-only changes.
@@ -1,140 +0,0 @@
|
|||||||
# 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`,减少硬编码路径。
|
|
||||||
@@ -1,975 +0,0 @@
|
|||||||
# 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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CM 助手</title>
|
<title>DeerFlow Vite</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -50,7 +50,6 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"docx": "^9.5.1",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
|
|||||||
+107
-2
@@ -3,14 +3,27 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
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 { I18nProvider } from "@/core/i18n/context";
|
||||||
import { detectLocale } from "@/core/i18n/locale";
|
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 { HashRouter, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import LandingPage from "./pages/LandingPage";
|
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 LoginPage from "./pages/LoginPage";
|
||||||
import PageRoutes from "./pages/PageRoutes";
|
import ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
|
||||||
|
import ScheduledTasksPage from "./pages/ScheduledTasksPage";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
|
|
||||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
@@ -32,6 +45,27 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
@@ -41,7 +75,78 @@ export function App() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/login/:username" element={<LoginPage />} />
|
<Route path="/login/:username" element={<LoginPage />} />
|
||||||
<Route path="/landing" element={<LandingPage />} />
|
<Route path="/landing" element={<LandingPage />} />
|
||||||
<Route path="/page/*" element={<PageRoutes />} />
|
<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 />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
|
|||||||
@@ -674,22 +674,12 @@ export const PromptInput = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!globalDrop) return;
|
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) => {
|
const onDragOver = (e: DragEvent) => {
|
||||||
if (isInsideModal(e.target)) return;
|
|
||||||
if (e.dataTransfer?.types?.includes("Files")) {
|
if (e.dataTransfer?.types?.includes("Files")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
if (isInsideModal(e.target)) return;
|
|
||||||
if (e.dataTransfer?.types?.includes("Files")) {
|
if (e.dataTransfer?.types?.includes("Files")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
||||||
<p>Licensed under MIT License</p>
|
<p>Licensed under MIT License</p>
|
||||||
<p>© {year} CM 助手</p>
|
<p>© {year} CM业务中心-问答管理</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||||
import Link from "next/link";
|
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 type { Locale } from "@/core/i18n/locale";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type HeaderProps = {
|
export type HeaderProps = {
|
||||||
@@ -25,11 +30,11 @@ export function Header({ className, homeURL }: HeaderProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<a
|
<a
|
||||||
href={homeURL ?? "/"}
|
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
||||||
target={isExternalHome ? "_blank" : "_self"}
|
target={isExternalHome ? "_blank" : "_self"}
|
||||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||||
>
|
>
|
||||||
<h1 className="font-serif text-xl">CM 助手</h1>
|
<h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
||||||
@@ -46,7 +51,77 @@ export function Header({ className, homeURL }: HeaderProps) {
|
|||||||
{t.home.blog}
|
{t.home.blog}
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</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" />
|
<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>
|
</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",
|
"Learn Anything",
|
||||||
]}
|
]}
|
||||||
/>{" "}
|
/>{" "}
|
||||||
<div>CM 助手</div>
|
<div>CM业务中心-问答管理</div>
|
||||||
</h1>
|
</h1>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ export default function ProgressiveSkillsAnimation() {
|
|||||||
<div className="border-b border-zinc-800 p-4">
|
<div className="border-b border-zinc-800 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -691,7 +691,7 @@ export default function ProgressiveSkillsAnimation() {
|
|||||||
{/* Chat Input (decorative) */}
|
{/* Chat Input (decorative) */}
|
||||||
<div className="border-t border-zinc-800 p-4">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
|||||||
<Section
|
<Section
|
||||||
className={className}
|
className={className}
|
||||||
title="Case Studies"
|
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">
|
<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) => (
|
{caseStudies.map((caseStudy) => (
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { AuroraText } from "@/components/ui/aurora-text";
|
import { AuroraText } from "@/components/ui/aurora-text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import { Section } from "../section";
|
import { Section } from "../section";
|
||||||
|
|
||||||
@@ -12,9 +16,20 @@ export function CommunitySection() {
|
|||||||
Join the Community
|
Join the Community
|
||||||
</AuroraText>
|
</AuroraText>
|
||||||
}
|
}
|
||||||
subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts."
|
subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts."
|
||||||
>
|
>
|
||||||
<div />
|
<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>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) {
|
|||||||
title="Agent Runtime Environment"
|
title="Agent Runtime Environment"
|
||||||
subtitle={
|
subtitle={
|
||||||
<p>
|
<p>
|
||||||
We give CM Assistant a "computer", which can execute commands,
|
We give DeerFlow a "computer", which can execute commands,
|
||||||
manage files, and run long tasks — all in a secure Docker-based
|
manage files, and run long tasks — all in a secure Docker-based
|
||||||
sandbox
|
sandbox
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function SkillsSection({ className }: { className?: string }) {
|
|||||||
Agent Skills are loaded progressively — only what's needed, when
|
Agent Skills are loaded progressively — only what's needed, when
|
||||||
it's needed.
|
it's needed.
|
||||||
<br />
|
<br />
|
||||||
Extend CM Assistant with your own skill files, or use our built-in
|
Extend DeerFlow with your own skill files, or use our built-in
|
||||||
library.
|
library.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export function WhatsNewSection({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
className={cn("", className)}
|
className={cn("", className)}
|
||||||
title="CM 助手"
|
title="CM业务中心-问答管理"
|
||||||
subtitle="CM 助手"
|
subtitle="CM业务中心-问答管理"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center">
|
<div className="flex w-full items-center justify-center">
|
||||||
<MagicBento data={features} />
|
<MagicBento data={features} />
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
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,17 +59,11 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
agent.skills ?? [],
|
agent.skills ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []);
|
||||||
const currentUserId = storedAuth?.user_id ?? null;
|
|
||||||
const isAdmin = storedAuth?.system_role === "admin";
|
|
||||||
const isBuiltin = agent.builtin;
|
const isBuiltin = agent.builtin;
|
||||||
const isOwned =
|
const isOwned =
|
||||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||||
const isFromOthers = !isBuiltin && !isOwned;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editOpen) {
|
if (!editOpen) {
|
||||||
@@ -241,46 +235,40 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{t.agents.chat}
|
{t.agents.chat}
|
||||||
</Button>
|
</Button>
|
||||||
{(canEdit || isOwned) && (
|
{isOwned && (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{isOwned && (
|
<Button
|
||||||
<Button
|
size="icon"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
variant="ghost"
|
className="h-8 w-8 shrink-0"
|
||||||
className="h-8 w-8 shrink-0"
|
onClick={() => void handleTogglePublished()}
|
||||||
onClick={() => void handleTogglePublished()}
|
disabled={updateAgent.isPending}
|
||||||
disabled={updateAgent.isPending}
|
title={agent.published ? "取消发布" : "发布"}
|
||||||
title={agent.published ? "取消发布" : "发布"}
|
>
|
||||||
>
|
{agent.published ? (
|
||||||
{agent.published ? (
|
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
) : (
|
||||||
) : (
|
<EyeIcon className="h-3.5 w-3.5" />
|
||||||
<EyeIcon className="h-3.5 w-3.5" />
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
)}
|
size="icon"
|
||||||
{canEdit && (
|
variant="ghost"
|
||||||
<Button
|
className="h-8 w-8 shrink-0"
|
||||||
size="icon"
|
onClick={() => setEditOpen(true)}
|
||||||
variant="ghost"
|
title="编辑智能体"
|
||||||
className="h-8 w-8 shrink-0"
|
>
|
||||||
onClick={() => setEditOpen(true)}
|
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||||
title="编辑智能体"
|
</Button>
|
||||||
>
|
<Button
|
||||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
size="icon"
|
||||||
</Button>
|
variant="ghost"
|
||||||
)}
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
{isOwned && (
|
onClick={() => setDeleteOpen(true)}
|
||||||
<Button
|
title={t.agents.delete}
|
||||||
size="icon"
|
>
|
||||||
variant="ghost"
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
</Button>
|
||||||
onClick={() => setDeleteOpen(true)}
|
|
||||||
title={t.agents.delete}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function AgentGallery() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleNewAgent = () => {
|
const handleNewAgent = () => {
|
||||||
router.push("/page/workspace/agents/new");
|
router.push("/workspace/agents/new");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function CommandPalette() {
|
|||||||
const [isMac, setIsMac] = useState(false);
|
const [isMac, setIsMac] = useState(false);
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
router.push("/page/workspace/chats/new");
|
router.push("/workspace/chats/new");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@@ -44,18 +40,12 @@ export function MessageList({
|
|||||||
thread,
|
thread,
|
||||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
onClarificationSubmit,
|
|
||||||
onSetupAgentApprove,
|
|
||||||
onSetupAgentReject,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
|
||||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
|
||||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
@@ -87,11 +77,9 @@ export function MessageList({
|
|||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
return (
|
return (
|
||||||
<div key={group.id} className="w-full">
|
<div key={group.id} className="w-full">
|
||||||
<ClarificationCard
|
<MarkdownContent
|
||||||
fallbackContent={extractContentFromMessage(message)}
|
content={extractContentFromMessage(message)}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
message={message}
|
|
||||||
onSubmit={onClarificationSubmit}
|
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
/>
|
/>
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
@@ -103,23 +91,6 @@ export function MessageList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
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") {
|
} else if (group.type === "assistant:present-files") {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
for (const message of group.messages) {
|
for (const message of group.messages) {
|
||||||
@@ -269,287 +240,3 @@ export function MessageList({
|
|||||||
</Conversation>
|
</Conversation>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,7 +117,14 @@ export function RecentChatList() {
|
|||||||
|
|
||||||
const handleShare = useCallback(
|
const handleShare = useCallback(
|
||||||
async (thread: AgentThread) => {
|
async (thread: AgentThread) => {
|
||||||
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
|
// 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)}`;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
toast.success(t.clipboard.linkCopied);
|
toast.success(t.clipboard.linkCopied);
|
||||||
|
|||||||
@@ -1,18 +1,67 @@
|
|||||||
/**
|
/**
|
||||||
* About CM Assistant markdown content. Inlined to avoid raw-loader dependency
|
* About DeerFlow markdown content. Inlined to avoid raw-loader dependency
|
||||||
* (Turbopack cannot resolve raw-loader for .md imports).
|
* (Turbopack cannot resolve raw-loader for .md imports).
|
||||||
*/
|
*/
|
||||||
export const aboutMarkdown = `# About CM Assistant
|
export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
|
||||||
|
|
||||||
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.
|
> **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**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Core Features
|
## 🚀 Core Features
|
||||||
|
|
||||||
* **Skills & Tools**: With built-in and extensible skills and tools, CM Assistant can do almost anything.
|
* **Skills & Tools**: With built-in and extensible skills and tools, DeerFlow 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.
|
* **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.
|
* **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.
|
* **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.
|
* **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,15 +1,55 @@
|
|||||||
# About CM Assistant
|
# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
|
||||||
|
|
||||||
CM Assistant is an internal super-agent platform that orchestrates sub-agents,
|
> **From Open Source, Back to Open Source**
|
||||||
memory, and sandboxes — powered by extensible skills — to research, analyze,
|
|
||||||
and create on your behalf.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Features
|
## 🌟 GitHub Repository
|
||||||
|
|
||||||
* **Skills & Tools**: Built-in and extensible skills let CM Assistant tackle a wide range of tasks.
|
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
|
||||||
* **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.
|
## 🌐 Official Website
|
||||||
* **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.
|
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.
|
||||||
@@ -389,7 +389,7 @@ export function MemorySettingsPage() {
|
|||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const exportedMemory = await exportMemory();
|
const exportedMemory = await exportMemory();
|
||||||
const fileName = `cm-assistant-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
|
const fileName = `deerflow-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
|
||||||
const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], {
|
const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
|
import { SparklesIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -32,16 +23,9 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
import { useEnableSkill, useSkills } from "@/core/skills/hooks";
|
||||||
import {
|
|
||||||
useEnableSkill,
|
|
||||||
useInstallSkillUpload,
|
|
||||||
useSkills,
|
|
||||||
useValidateSkillUpload,
|
|
||||||
} from "@/core/skills/hooks";
|
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
|
||||||
@@ -74,7 +58,6 @@ function SkillSettingsList({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filter, setFilter] = useState<string>("public");
|
const [filter, setFilter] = useState<string>("public");
|
||||||
const [installOpen, setInstallOpen] = useState(false);
|
|
||||||
const { mutate: enableSkill } = useEnableSkill();
|
const { mutate: enableSkill } = useEnableSkill();
|
||||||
const filteredSkills = useMemo(
|
const filteredSkills = useMemo(
|
||||||
() => skills.filter((skill) => skill.category === filter),
|
() => skills.filter((skill) => skill.category === filter),
|
||||||
@@ -95,16 +78,7 @@ function SkillSettingsList({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div>
|
||||||
<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}>
|
<Button size="sm" onClick={handleCreateSkill}>
|
||||||
<SparklesIcon className="size-4" />
|
<SparklesIcon className="size-4" />
|
||||||
{t.settings.skills.createSkill}
|
{t.settings.skills.createSkill}
|
||||||
@@ -136,10 +110,6 @@ function SkillSettingsList({
|
|||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
<InstallSkillDialog
|
|
||||||
open={installOpen}
|
|
||||||
onClose={() => setInstallOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,265 +133,3 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
|||||||
</Empty>
|
</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,6 +15,9 @@ import {
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { GithubIcon } from "./github-icon";
|
||||||
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
export function WorkspaceContainer({
|
export function WorkspaceContainer({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -87,6 +90,18 @@ export function WorkspaceHeader({
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
|||||||
<div className="flex items-center gap-2.5 px-2.5 py-3">
|
<div className="flex items-center gap-2.5 px-2.5 py-3">
|
||||||
<BrandMark />
|
<BrandMark />
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
|
||||||
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
|
<Link href="/workspace/chats/new" className="min-w-0 flex-1 outline-none">
|
||||||
{titleBlock}
|
{titleBlock}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -20,18 +20,18 @@ export function WorkspaceNavChatList() {
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/chats/new"}
|
isActive={pathname === "/workspace/chats/new"}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats/new">
|
<Link className="text-sidebar-foreground" href="/workspace/chats/new">
|
||||||
<MessageSquarePlus />
|
<MessageSquarePlus />
|
||||||
<span>{t.sidebar.newChat}</span>
|
<span>{t.sidebar.newChat}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton isActive={pathname === "/page/workspace/chats"} asChild>
|
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
|
||||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats">
|
<Link className="text-sidebar-foreground" href="/workspace/chats">
|
||||||
<MessagesSquare />
|
<MessagesSquare />
|
||||||
<span>{t.sidebar.chats}</span>
|
<span>{t.sidebar.chats}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -39,10 +39,10 @@ export function WorkspaceNavChatList() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname.startsWith("/page/workspace/agents")}
|
isActive={pathname.startsWith("/workspace/agents")}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link className="text-sidebar-foreground" href="/page/workspace/agents">
|
<Link className="text-sidebar-foreground" href="/workspace/agents">
|
||||||
<BotIcon />
|
<BotIcon />
|
||||||
<span>{t.sidebar.agents}</span>
|
<span>{t.sidebar.agents}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -50,10 +50,10 @@ export function WorkspaceNavChatList() {
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
isActive={pathname === "/workspace/scheduled/tasks"}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link className="text-sidebar-foreground" href="/page/workspace/scheduled/tasks">
|
<Link className="text-sidebar-foreground" href="/workspace/scheduled/tasks">
|
||||||
<ListChecks />
|
<ListChecks />
|
||||||
<span>任务管理</span>
|
<span>任务管理</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export async function checkAgentName(
|
|||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new AgentNameCheckError(
|
throw new AgentNameCheckError(
|
||||||
"Could not reach the CM Assistant backend.",
|
"Could not reach the DeerFlow backend.",
|
||||||
"backend_unreachable",
|
"backend_unreachable",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export async function checkAgentName(
|
|||||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||||
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
|
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
|
||||||
throw new AgentNameCheckError(
|
throw new AgentNameCheckError(
|
||||||
"Could not reach the CM Assistant backend.",
|
"Could not reach the DeerFlow backend.",
|
||||||
"backend_unreachable",
|
"backend_unreachable",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ export const enUS: Translations = {
|
|||||||
welcome: {
|
welcome: {
|
||||||
greeting: "Hello, again!",
|
greeting: "Hello, again!",
|
||||||
description:
|
description:
|
||||||
"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.",
|
"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.",
|
||||||
|
|
||||||
createYourOwnSkill: "Create Your Own Skill",
|
createYourOwnSkill: "Create Your Own Skill",
|
||||||
createYourOwnSkillDescription:
|
createYourOwnSkillDescription:
|
||||||
"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.",
|
"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.",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
@@ -208,13 +208,13 @@ export const enUS: Translations = {
|
|||||||
save: "Save agent",
|
save: "Save agent",
|
||||||
saving: "Saving agent...",
|
saving: "Saving agent...",
|
||||||
saveRequested:
|
saveRequested:
|
||||||
"Save requested. CM Assistant is generating and saving an initial version now.",
|
"Save requested. DeerFlow is generating and saving an initial version now.",
|
||||||
saveHint:
|
saveHint:
|
||||||
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
|
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
|
||||||
saveCommandMessage:
|
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.",
|
"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:
|
agentCreatedPendingRefresh:
|
||||||
"The agent was created, but CM Assistant could not load it yet. Please refresh this page in a moment.",
|
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
|
||||||
more: "More actions",
|
more: "More actions",
|
||||||
agentCreated: "Agent created!",
|
agentCreated: "Agent created!",
|
||||||
startChatting: "Start chatting",
|
startChatting: "Start chatting",
|
||||||
@@ -229,13 +229,13 @@ export const enUS: Translations = {
|
|||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
workspace: {
|
workspace: {
|
||||||
officialWebsite: "CM Assistant's official website",
|
officialWebsite: "DeerFlow's official website",
|
||||||
githubTooltip: "CM Assistant on Github",
|
githubTooltip: "DeerFlow on Github",
|
||||||
settingsAndMore: "Settings and more",
|
settingsAndMore: "Settings and more",
|
||||||
visitGithub: "CM Assistant on GitHub",
|
visitGithub: "DeerFlow on GitHub",
|
||||||
reportIssue: "Report a issue",
|
reportIssue: "Report a issue",
|
||||||
contactUs: "Contact us",
|
contactUs: "Contact us",
|
||||||
about: "About CM Assistant",
|
about: "About DeerFlow",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conversation
|
// Conversation
|
||||||
@@ -251,7 +251,7 @@ export const enUS: Translations = {
|
|||||||
|
|
||||||
// Page titles (document title)
|
// Page titles (document title)
|
||||||
pages: {
|
pages: {
|
||||||
appName: "CM Assistant",
|
appName: "DeerFlow",
|
||||||
brandSubtitle: "Smart CM Platform",
|
brandSubtitle: "Smart CM Platform",
|
||||||
chats: "Chats",
|
chats: "Chats",
|
||||||
newChat: "New chat",
|
newChat: "New chat",
|
||||||
@@ -278,7 +278,7 @@ export const enUS: Translations = {
|
|||||||
writeFile: "Write file",
|
writeFile: "Write file",
|
||||||
clickToViewContent: "Click to view file content",
|
clickToViewContent: "Click to view file content",
|
||||||
writeTodos: "Update to-do list",
|
writeTodos: "Update to-do list",
|
||||||
skillInstallTooltip: "Install skill and make it available to CM Assistant",
|
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Subtasks
|
// Subtasks
|
||||||
@@ -315,7 +315,7 @@ export const enUS: Translations = {
|
|||||||
actions: "Actions",
|
actions: "Actions",
|
||||||
keyboardShortcuts: "Keyboard Shortcuts",
|
keyboardShortcuts: "Keyboard Shortcuts",
|
||||||
keyboardShortcutsDescription:
|
keyboardShortcutsDescription:
|
||||||
"Navigate CM Assistant faster with keyboard shortcuts.",
|
"Navigate DeerFlow faster with keyboard shortcuts.",
|
||||||
openCommandPalette: "Open Command Palette",
|
openCommandPalette: "Open Command Palette",
|
||||||
toggleSidebar: "Toggle Sidebar",
|
toggleSidebar: "Toggle Sidebar",
|
||||||
},
|
},
|
||||||
@@ -323,7 +323,7 @@ export const enUS: Translations = {
|
|||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
description: "Adjust how CM Assistant looks and behaves for you.",
|
description: "Adjust how DeerFlow looks and behaves for you.",
|
||||||
sections: {
|
sections: {
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
memory: "Memory",
|
memory: "Memory",
|
||||||
@@ -335,7 +335,7 @@ export const enUS: Translations = {
|
|||||||
memory: {
|
memory: {
|
||||||
title: "Memory",
|
title: "Memory",
|
||||||
description:
|
description:
|
||||||
"CM Assistant automatically learns from your conversations in the background. These memories help CM Assistant understand you better and deliver a more personalized experience.",
|
"DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.",
|
||||||
empty: "No memory data to display.",
|
empty: "No memory data to display.",
|
||||||
rawJson: "Raw JSON",
|
rawJson: "Raw JSON",
|
||||||
exportButton: "Export memory",
|
exportButton: "Export memory",
|
||||||
@@ -433,43 +433,20 @@ export const enUS: Translations = {
|
|||||||
description:
|
description:
|
||||||
"Manage the configuration and enabled status of the agent skills.",
|
"Manage the configuration and enabled status of the agent skills.",
|
||||||
createSkill: "Create skill",
|
createSkill: "Create skill",
|
||||||
installSkill: "Install skill",
|
|
||||||
emptyTitle: "No agent skill yet",
|
emptyTitle: "No agent skill yet",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
"Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
|
||||||
emptyButton: "Create Your First Skill",
|
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: {
|
notification: {
|
||||||
title: "Notification",
|
title: "Notification",
|
||||||
description:
|
description:
|
||||||
"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.",
|
"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.",
|
||||||
requestPermission: "Request notification permission",
|
requestPermission: "Request notification permission",
|
||||||
deniedHint:
|
deniedHint:
|
||||||
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
|
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
|
||||||
testButton: "Send test notification",
|
testButton: "Send test notification",
|
||||||
testTitle: "CM Assistant",
|
testTitle: "DeerFlow",
|
||||||
testBody: "This is a test notification.",
|
testBody: "This is a test notification.",
|
||||||
notSupported: "Your browser does not support notifications.",
|
notSupported: "Your browser does not support notifications.",
|
||||||
disableNotification: "Disable notification",
|
disableNotification: "Disable notification",
|
||||||
|
|||||||
@@ -354,30 +354,9 @@ export interface Translations {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
createSkill: string;
|
createSkill: string;
|
||||||
installSkill: string;
|
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyButton: 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: {
|
notification: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ export const zhCN: Translations = {
|
|||||||
welcome: {
|
welcome: {
|
||||||
greeting: "你好,欢迎回来!",
|
greeting: "你好,欢迎回来!",
|
||||||
description:
|
description:
|
||||||
"欢迎使用 CM 助手",
|
"欢迎使用 CM业务中心-问答管理",
|
||||||
|
|
||||||
createYourOwnSkill: "创建你自己的 Agent SKill",
|
createYourOwnSkill: "创建你自己的 Agent SKill",
|
||||||
createYourOwnSkillDescription:
|
createYourOwnSkillDescription:
|
||||||
"创建你的 Agent Skill 来释放 CM 助手的潜力。通过自定义技能,CM 助手\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
|
"创建你的 Agent Skill 来释放 CM业务中心-问答管理的潜力。通过自定义技能,CM业务中心-问答管理\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
@@ -196,13 +196,13 @@ export const zhCN: Translations = {
|
|||||||
save: "保存智能体",
|
save: "保存智能体",
|
||||||
saving: "正在保存智能体...",
|
saving: "正在保存智能体...",
|
||||||
saveRequested:
|
saveRequested:
|
||||||
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
|
"已提交保存请求,CM业务中心-问答管理 正在根据当前对话生成并保存初版智能体。",
|
||||||
saveHint:
|
saveHint:
|
||||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
||||||
saveCommandMessage:
|
saveCommandMessage:
|
||||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||||
agentCreatedPendingRefresh:
|
agentCreatedPendingRefresh:
|
||||||
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
"智能体已创建,但 CM业务中心-问答管理 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||||
more: "更多操作",
|
more: "更多操作",
|
||||||
agentCreated: "智能体已创建!",
|
agentCreated: "智能体已创建!",
|
||||||
startChatting: "开始对话",
|
startChatting: "开始对话",
|
||||||
@@ -217,13 +217,13 @@ export const zhCN: Translations = {
|
|||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
workspace: {
|
workspace: {
|
||||||
officialWebsite: "访问 CM 助手 官方网站",
|
officialWebsite: "访问 CM业务中心-问答管理 官方网站",
|
||||||
githubTooltip: "访问 CM 助手 的 Github 仓库",
|
githubTooltip: "访问 CM业务中心-问答管理 的 Github 仓库",
|
||||||
settingsAndMore: "设置和更多",
|
settingsAndMore: "设置和更多",
|
||||||
visitGithub: "在 Github 上查看 CM 助手",
|
visitGithub: "在 Github 上查看 CM业务中心-问答管理",
|
||||||
reportIssue: "报告问题",
|
reportIssue: "报告问题",
|
||||||
contactUs: "联系我们",
|
contactUs: "联系我们",
|
||||||
about: "关于 CM 助手",
|
about: "关于 CM业务中心-问答管理",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conversation
|
// Conversation
|
||||||
@@ -239,7 +239,7 @@ export const zhCN: Translations = {
|
|||||||
|
|
||||||
// Page titles (document title)
|
// Page titles (document title)
|
||||||
pages: {
|
pages: {
|
||||||
appName: "CM 助手",
|
appName: "CM业务中心-问答管理",
|
||||||
brandSubtitle: "Smart CM Platform",
|
brandSubtitle: "Smart CM Platform",
|
||||||
chats: "对话",
|
chats: "对话",
|
||||||
newChat: "新对话",
|
newChat: "新对话",
|
||||||
@@ -265,7 +265,7 @@ export const zhCN: Translations = {
|
|||||||
writeFile: "写入文件",
|
writeFile: "写入文件",
|
||||||
clickToViewContent: "点击查看文件内容",
|
clickToViewContent: "点击查看文件内容",
|
||||||
writeTodos: "更新 To-do 列表",
|
writeTodos: "更新 To-do 列表",
|
||||||
skillInstallTooltip: "安装技能并使其可在 CM 助手 中使用",
|
skillInstallTooltip: "安装技能并使其可在 CM业务中心-问答管理 中使用",
|
||||||
},
|
},
|
||||||
|
|
||||||
uploads: {
|
uploads: {
|
||||||
@@ -300,7 +300,7 @@ export const zhCN: Translations = {
|
|||||||
noResults: "未找到结果。",
|
noResults: "未找到结果。",
|
||||||
actions: "操作",
|
actions: "操作",
|
||||||
keyboardShortcuts: "键盘快捷键",
|
keyboardShortcuts: "键盘快捷键",
|
||||||
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM 助手。",
|
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM业务中心-问答管理。",
|
||||||
openCommandPalette: "打开命令面板",
|
openCommandPalette: "打开命令面板",
|
||||||
toggleSidebar: "切换侧边栏",
|
toggleSidebar: "切换侧边栏",
|
||||||
},
|
},
|
||||||
@@ -308,7 +308,7 @@ export const zhCN: Translations = {
|
|||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: "设置",
|
title: "设置",
|
||||||
description: "根据你的偏好调整 CM 助手 的界面和行为。",
|
description: "根据你的偏好调整 CM业务中心-问答管理 的界面和行为。",
|
||||||
sections: {
|
sections: {
|
||||||
appearance: "外观",
|
appearance: "外观",
|
||||||
memory: "记忆",
|
memory: "记忆",
|
||||||
@@ -320,7 +320,7 @@ export const zhCN: Translations = {
|
|||||||
memory: {
|
memory: {
|
||||||
title: "记忆",
|
title: "记忆",
|
||||||
description:
|
description:
|
||||||
"CM 助手 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM 助手 更好地理解你,并提供更个性化的体验。",
|
"CM业务中心-问答管理 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM业务中心-问答管理 更好地理解你,并提供更个性化的体验。",
|
||||||
empty: "暂无可展示的记忆数据。",
|
empty: "暂无可展示的记忆数据。",
|
||||||
rawJson: "原始 JSON",
|
rawJson: "原始 JSON",
|
||||||
exportButton: "导出记忆",
|
exportButton: "导出记忆",
|
||||||
@@ -414,42 +414,20 @@ export const zhCN: Translations = {
|
|||||||
title: "技能",
|
title: "技能",
|
||||||
description: "管理 Agent Skill 配置和启用状态。",
|
description: "管理 Agent Skill 配置和启用状态。",
|
||||||
createSkill: "新建技能",
|
createSkill: "新建技能",
|
||||||
installSkill: "安装技能",
|
|
||||||
emptyTitle: "还没有技能",
|
emptyTitle: "还没有技能",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
"将你的 Agent Skill 文件夹放在 CM业务中心-问答管理 根目录下的 `/skills/custom` 文件夹中。",
|
||||||
emptyButton: "创建你的第一个技能",
|
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: {
|
notification: {
|
||||||
title: "通知",
|
title: "通知",
|
||||||
description:
|
description:
|
||||||
"CM 助手 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
"CM业务中心-问答管理 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
||||||
requestPermission: "请求通知权限",
|
requestPermission: "请求通知权限",
|
||||||
deniedHint:
|
deniedHint:
|
||||||
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
|
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
|
||||||
testButton: "发送测试通知",
|
testButton: "发送测试通知",
|
||||||
testTitle: "CM 助手",
|
testTitle: "CM业务中心-问答管理",
|
||||||
testBody: "这是一条测试通知。",
|
testBody: "这是一条测试通知。",
|
||||||
notSupported: "当前浏览器不支持通知功能。",
|
notSupported: "当前浏览器不支持通知功能。",
|
||||||
disableNotification: "关闭通知",
|
disableNotification: "关闭通知",
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:pres
|
|||||||
|
|
||||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||||
|
|
||||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
|
||||||
|
|
||||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||||
|
|
||||||
type MessageGroup =
|
type MessageGroup =
|
||||||
@@ -26,7 +24,6 @@ type MessageGroup =
|
|||||||
| AssistantMessageGroup
|
| AssistantMessageGroup
|
||||||
| AssistantPresentFilesGroup
|
| AssistantPresentFilesGroup
|
||||||
| AssistantClarificationGroup
|
| AssistantClarificationGroup
|
||||||
| AssistantSetupAgentApprovalGroup
|
|
||||||
| AssistantSubagentGroup;
|
| AssistantSubagentGroup;
|
||||||
|
|
||||||
export function groupMessages<T>(
|
export function groupMessages<T>(
|
||||||
@@ -47,8 +44,7 @@ export function groupMessages<T>(
|
|||||||
last &&
|
last &&
|
||||||
last.type !== "human" &&
|
last.type !== "human" &&
|
||||||
last.type !== "assistant" &&
|
last.type !== "assistant" &&
|
||||||
last.type !== "assistant:clarification" &&
|
last.type !== "assistant:clarification"
|
||||||
last.type !== "assistant:setup-agent-approval"
|
|
||||||
) {
|
) {
|
||||||
return last;
|
return last;
|
||||||
}
|
}
|
||||||
@@ -79,13 +75,6 @@ export function groupMessages<T>(
|
|||||||
type: "assistant:clarification",
|
type: "assistant:clarification",
|
||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
|
||||||
lastOpenGroup()?.messages.push(message);
|
|
||||||
groups.push({
|
|
||||||
id: message.id,
|
|
||||||
type: "assistant:setup-agent-approval",
|
|
||||||
messages: [message],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const open = lastOpenGroup();
|
const open = lastOpenGroup();
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -301,15 +290,6 @@ export function isClarificationToolMessage(message: Message) {
|
|||||||
return message.type === "tool" && message.name === "ask_clarification";
|
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) {
|
export function extractPresentFilesFromMessage(message: Message) {
|
||||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import { apiFetch } from "@/core/api";
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PublishedScheduledTask,
|
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
ScheduledTaskDeliveryProfile,
|
ScheduledTaskDeliveryProfile,
|
||||||
ScheduledTaskResultFileContent,
|
ScheduledTaskResultFileContent,
|
||||||
ScheduledTaskRun,
|
ScheduledTaskRun,
|
||||||
ScheduledTaskRunFileUpdateResponse,
|
|
||||||
ScheduledTaskSubscription,
|
|
||||||
SchedulerThread,
|
SchedulerThread,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -82,20 +79,6 @@ export async function getScheduledTaskRunFileContent(runId: string, fileIndex: n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
function filenameFromContentDisposition(header: string | null): string | null {
|
||||||
if (!header) return null;
|
if (!header) return null;
|
||||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||||
@@ -231,57 +214,3 @@ export async function triggerScheduledTask(taskId: string) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,19 +7,13 @@ import {
|
|||||||
getSchedulerThread,
|
getSchedulerThread,
|
||||||
getScheduledTaskDeliveryProfile,
|
getScheduledTaskDeliveryProfile,
|
||||||
getScheduledTaskRun,
|
getScheduledTaskRun,
|
||||||
listPublishedScheduledTasks,
|
|
||||||
listScheduledTaskRuns,
|
listScheduledTaskRuns,
|
||||||
listScheduledTasks,
|
listScheduledTasks,
|
||||||
pauseScheduledTask,
|
pauseScheduledTask,
|
||||||
publishScheduledTask,
|
|
||||||
resumeScheduledTask,
|
resumeScheduledTask,
|
||||||
subscribeScheduledTask,
|
|
||||||
triggerScheduledTask,
|
triggerScheduledTask,
|
||||||
unpublishScheduledTask,
|
|
||||||
unsubscribeScheduledTask,
|
|
||||||
updateScheduledTaskDeliveryProfile,
|
updateScheduledTaskDeliveryProfile,
|
||||||
updateScheduledTask,
|
updateScheduledTask,
|
||||||
updateScheduledTaskSubscription,
|
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
export function useSchedulerThread() {
|
export function useSchedulerThread() {
|
||||||
@@ -64,15 +58,6 @@ export function useScheduledTaskDeliveryProfile() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePublishedScheduledTasks(enabled: boolean = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["scheduled-tasks", "published"],
|
|
||||||
queryFn: () => listPublishedScheduledTasks({ limit: 100 }),
|
|
||||||
enabled,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScheduledTaskActions() {
|
export function useScheduledTaskActions() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
@@ -122,27 +107,5 @@ export function useScheduledTaskActions() {
|
|||||||
queryKey: ["scheduled-tasks", "delivery-profile"],
|
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,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,14 +1,3 @@
|
|||||||
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 = {
|
export type ScheduledTask = {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,20 +12,6 @@ export type ScheduledTask = {
|
|||||||
source_agent_name?: string | null;
|
source_agent_name?: string | null;
|
||||||
execution_agent_name?: string | null;
|
execution_agent_name?: string | null;
|
||||||
scheduler_thread_id: string;
|
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 = {
|
export type SchedulerThread = {
|
||||||
@@ -89,13 +64,4 @@ export type ScheduledTaskRun = {
|
|||||||
result?: ScheduledTaskRunResult | null;
|
result?: ScheduledTaskRunResult | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_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,18 +34,6 @@ export interface InstallSkillResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
skill_name: string;
|
skill_name: string;
|
||||||
message: 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(
|
export async function installSkill(
|
||||||
@@ -73,40 +61,3 @@ export async function installSkill(
|
|||||||
|
|
||||||
return response.json();
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
|
import { enableSkill } from "./api";
|
||||||
|
|
||||||
import { loadSkills } from ".";
|
import { loadSkills } from ".";
|
||||||
|
|
||||||
@@ -29,20 +29,3 @@ 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"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -36,8 +36,8 @@ export function pathOfThread(
|
|||||||
: pickAgentRef(thread.context, thread.metadata);
|
: pickAgentRef(thread.context, thread.metadata);
|
||||||
|
|
||||||
return agentRef
|
return agentRef
|
||||||
? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
? `/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||||
: `/page/workspace/chats/${threadId}`;
|
: `/workspace/chats/${threadId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function textOfMessage(message: Message) {
|
export function textOfMessage(message: Message) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function AgentChatPage() {
|
|||||||
history.replaceState(
|
history.replaceState(
|
||||||
null,
|
null,
|
||||||
"",
|
"",
|
||||||
`${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`,
|
`${window.location.pathname}${window.location.search}#/workspace/agents/${agent_id}/chats/${createdThreadId}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onFinish: (state) => {
|
onFinish: (state) => {
|
||||||
@@ -137,7 +137,7 @@ export default function AgentChatPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
|
router.push(`/workspace/agents/${agent_id}/chats/new`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusSquare /> {t.agents.newChat}
|
<PlusSquare /> {t.agents.newChat}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function ChatPage() {
|
|||||||
history.replaceState(
|
history.replaceState(
|
||||||
null,
|
null,
|
||||||
"",
|
"",
|
||||||
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`,
|
`${window.location.pathname}${window.location.search}#/workspace/chats/${createdThreadId}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onFinish: (state) => {
|
onFinish: (state) => {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
setStoredAuth(auth);
|
setStoredAuth(auth);
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
router.replace("/page/workspace/chats/new");
|
router.replace("/workspace/chats/new");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
|
BotIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
|
InfoIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -15,14 +18,17 @@ import {
|
|||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
PromptInputTextarea,
|
PromptInputTextarea,
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
MessageList,
|
DropdownMenu,
|
||||||
type SetupAgentApprovalPayload,
|
DropdownMenuContent,
|
||||||
} from "@/components/workspace/messages";
|
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 { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
@@ -31,15 +37,19 @@ import {
|
|||||||
checkAgentName,
|
checkAgentName,
|
||||||
createAgent,
|
createAgent,
|
||||||
getAgent,
|
getAgent,
|
||||||
updateAgent,
|
|
||||||
} from "@/core/agents/api";
|
} from "@/core/agents/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { Skill } from "@/core/skills";
|
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
|
import type { Skill } from "@/core/skills";
|
||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
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 AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||||
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
||||||
|
|
||||||
@@ -52,12 +62,14 @@ async function getAgentWithRetry(agentId: string) {
|
|||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
await wait(delay);
|
await wait(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await getAgent(agentId);
|
return await getAgent(agentId);
|
||||||
} catch {
|
} catch {
|
||||||
// Retry until the file/database write settles.
|
// Retry until the write settles or the attempts are exhausted.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,56 +90,60 @@ function getCreateAgentErrorMessage(
|
|||||||
export default function NewAgentPage() {
|
export default function NewAgentPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const threadId = useMemo(() => uuid(), []);
|
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("name");
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [descriptionInput, setDescriptionInput] = useState("");
|
const [nameError, setNameError] = useState("");
|
||||||
const [soulInput, setSoulInput] = useState("");
|
const [isCheckingName, setIsCheckingName] = useState(false);
|
||||||
const [formError, setFormError] = useState("");
|
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||||
const [agentId, setAgentId] = useState("");
|
const [agentId, setAgentId] = useState("");
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
const [showSaveHint, setShowSaveHint] = useState(false);
|
||||||
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
const [setupAgentStatus, setSetupAgentStatus] =
|
||||||
|
useState<SetupAgentStatus>("idle");
|
||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
|
|
||||||
const enabledSkills = useMemo(
|
const enabledSkills = useMemo(
|
||||||
() => skills.filter((skill) => skill.enabled),
|
() => skills.filter((skill) => skill.enabled),
|
||||||
[skills],
|
[skills],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const threadId = useMemo(() => uuid(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSkillSelectionTouched) {
|
if (isSkillSelectionTouched) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedSkills(
|
const defaults = enabledSkills
|
||||||
enabledSkills
|
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
|
||||||
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
|
.map((skill) => skill.name);
|
||||||
.map((skill) => skill.name),
|
setSelectedSkills(defaults);
|
||||||
);
|
|
||||||
}, [enabledSkills, isSkillSelectionTouched]);
|
}, [enabledSkills, isSkillSelectionTouched]);
|
||||||
|
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
threadId,
|
threadId: step === "chat" ? threadId : undefined,
|
||||||
assistantId: agentId || undefined,
|
assistantId: agentId || undefined,
|
||||||
context: {
|
context: {
|
||||||
mode: "flash",
|
mode: "flash",
|
||||||
is_bootstrap: true,
|
is_bootstrap: true,
|
||||||
},
|
},
|
||||||
onToolEnd({ name }) {
|
onFinish() {
|
||||||
if (name !== "setup_agent" || !agentId) {
|
if (!agent && setupAgentStatus === "requested") {
|
||||||
return;
|
setSetupAgentStatus("idle");
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onToolEnd({ name }) {
|
||||||
|
if (name !== "setup_agent" || !agentId) return;
|
||||||
|
setSetupAgentStatus("completed");
|
||||||
void getAgentWithRetry(agentId).then((fetched) => {
|
void getAgentWithRetry(agentId).then((fetched) => {
|
||||||
if (fetched) {
|
if (fetched) {
|
||||||
setAgent(fetched);
|
setAgent(fetched);
|
||||||
setDescriptionInput(fetched.description ?? "");
|
return;
|
||||||
setSoulInput(fetched.soul ?? "");
|
|
||||||
setSelectedSkills(fetched.skills ?? []);
|
|
||||||
toast.success("专家配置已创建");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.error(t.agents.agentCreatedPendingRefresh);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -136,339 +152,362 @@ export default function NewAgentPage() {
|
|||||||
if (threadPrepared) {
|
if (threadPrepared) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await getAPIClient().threads.create({ threadId });
|
await getAPIClient().threads.create({
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
setThreadPrepared(true);
|
setThreadPrepared(true);
|
||||||
}, [threadId, threadPrepared]);
|
}, [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;
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
|
||||||
setFormError("请至少选择一个技能");
|
setNameError("请至少选择一个技能");
|
||||||
return null;
|
return;
|
||||||
}
|
|
||||||
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) {
|
setNameError("");
|
||||||
setIsSavingDraft(true);
|
setIsCheckingName(true);
|
||||||
try {
|
try {
|
||||||
const updated = await updateAgent(agentId, {
|
await checkAgentName(trimmed);
|
||||||
name,
|
} catch (err) {
|
||||||
description: descriptionInput,
|
if (
|
||||||
soul: soulInput,
|
err instanceof AgentNameCheckError &&
|
||||||
skills: selectedSkills,
|
err.reason === "backend_unreachable"
|
||||||
});
|
) {
|
||||||
setAgent(updated);
|
setNameError(t.agents.nameStepNetworkError);
|
||||||
return updated;
|
} else {
|
||||||
} catch (error) {
|
setNameError(t.agents.nameStepCheckError);
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsSavingDraft(false);
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setIsCheckingName(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let createdId: string;
|
||||||
|
let createdName: string;
|
||||||
setIsCreatingAgent(true);
|
setIsCreatingAgent(true);
|
||||||
try {
|
try {
|
||||||
await checkAgentName(name);
|
|
||||||
const created = await createAgent({
|
const created = await createAgent({
|
||||||
name,
|
name: trimmed,
|
||||||
description: descriptionInput,
|
description: "",
|
||||||
soul: soulInput,
|
|
||||||
skills: selectedSkills,
|
skills: selectedSkills,
|
||||||
|
soul: "",
|
||||||
});
|
});
|
||||||
setAgentId(created.id);
|
createdId = created.id;
|
||||||
setAgent(created);
|
createdName = created.name;
|
||||||
return created;
|
} catch (err) {
|
||||||
} catch (error) {
|
setNameError(
|
||||||
if (
|
getCreateAgentErrorMessage(
|
||||||
error instanceof AgentNameCheckError &&
|
err,
|
||||||
error.reason === "backend_unreachable"
|
t.agents.nameStepNetworkError,
|
||||||
) {
|
t.agents.nameStepCheckError,
|
||||||
setFormError(t.agents.nameStepNetworkError);
|
),
|
||||||
} else {
|
);
|
||||||
setFormError(
|
return;
|
||||||
getCreateAgentErrorMessage(
|
|
||||||
error,
|
|
||||||
t.agents.nameStepNetworkError,
|
|
||||||
t.agents.nameStepCheckError,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingAgent(false);
|
setIsCreatingAgent(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAgentId(createdId);
|
||||||
|
setStep("chat");
|
||||||
|
await ensureThreadExists();
|
||||||
|
await sendMessage(threadId, {
|
||||||
|
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
agentId,
|
nameInput,
|
||||||
descriptionInput,
|
sendMessage,
|
||||||
selectedSkills,
|
|
||||||
soulInput,
|
|
||||||
t.agents.nameStepCheckError,
|
|
||||||
t.agents.nameStepNetworkError,
|
t.agents.nameStepNetworkError,
|
||||||
validateForm,
|
t.agents.nameStepBootstrapMessage,
|
||||||
|
t.agents.nameStepCheckError,
|
||||||
|
t.agents.nameStepInvalidError,
|
||||||
|
threadId,
|
||||||
|
enabledSkills.length,
|
||||||
|
selectedSkills,
|
||||||
|
ensureThreadExists,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSaveAgent = useCallback(async () => {
|
const toggleSkill = useCallback((skill: Skill) => {
|
||||||
const saved = await ensureAgent();
|
setIsSkillSelectionTouched(true);
|
||||||
if (!saved) {
|
setNameError("");
|
||||||
return;
|
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();
|
||||||
}
|
}
|
||||||
toast.success(`专家「${saved.name}」已保存`);
|
};
|
||||||
}, [ensureAgent]);
|
|
||||||
|
|
||||||
const handleChatSubmit = useCallback(
|
const handleChatSubmit = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || thread.isLoading) {
|
if (!trimmed || thread.isLoading) return;
|
||||||
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 ensureThreadExists();
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
threadId,
|
threadId,
|
||||||
{ text: trimmed, files: [] },
|
{ text: trimmed, files: [] },
|
||||||
{ agent_id: activeAgentId },
|
{ agent_id: agentId },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
|
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleApproveSetupAgent = useCallback(
|
const handleSaveAgent = useCallback(async () => {
|
||||||
async (payload: SetupAgentApprovalPayload) => {
|
if (
|
||||||
if (!agentId) {
|
!agentId ||
|
||||||
return;
|
agent ||
|
||||||
}
|
thread.isLoading ||
|
||||||
// Approval persists directly to the backend — no LLM round-trip. The
|
setupAgentStatus !== "idle"
|
||||||
// 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
|
return;
|
||||||
// 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
|
setSetupAgentStatus("requested");
|
||||||
// the user already typed, or the existing agent record.
|
setShowSaveHint(false);
|
||||||
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
|
try {
|
||||||
setNameInput(resolvedName);
|
await sendMessage(
|
||||||
setDescriptionInput(newDescription);
|
threadId,
|
||||||
setSoulInput(newSoul);
|
{ text: t.agents.saveCommandMessage, files: [] },
|
||||||
setSelectedSkills(newSkills);
|
{ agent_id: agentId },
|
||||||
setIsSkillSelectionTouched(true);
|
{ 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,
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
const header = (
|
||||||
const updated = await updateAgent(agentId, {
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
name: resolvedName,
|
<div className="flex items-center gap-3">
|
||||||
description: newDescription,
|
<Button
|
||||||
soul: newSoul,
|
variant="ghost"
|
||||||
skills: newSkills,
|
size="icon-sm"
|
||||||
});
|
onClick={() => router.push("/workspace/agents")}
|
||||||
setAgent(updated);
|
>
|
||||||
toast.success("专家配置已保存");
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
} catch (error) {
|
</Button>
|
||||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
|
||||||
}
|
</div>
|
||||||
},
|
|
||||||
[agent, agentId, nameInput],
|
{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>
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRejectSetupAgent = useCallback(
|
if (step === "name") {
|
||||||
async () => {
|
return (
|
||||||
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
|
<div className="flex size-full flex-col">
|
||||||
},
|
{header}
|
||||||
[handleChatSubmit],
|
<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>
|
||||||
|
|
||||||
const toggleSkill = useCallback((skill: Skill) => {
|
<div className="space-y-3">
|
||||||
setIsSkillSelectionTouched(true);
|
<Input
|
||||||
setFormError("");
|
autoFocus
|
||||||
setSelectedSkills((prev) =>
|
placeholder={t.agents.nameStepPlaceholder}
|
||||||
prev.includes(skill.name)
|
value={nameInput}
|
||||||
? prev.filter((name) => name !== skill.name)
|
onChange={(e) => {
|
||||||
: [...prev, skill.name],
|
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>
|
||||||
);
|
);
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread }}>
|
<ThreadContext.Provider value={{ thread }}>
|
||||||
<ArtifactsProvider>
|
<ArtifactsProvider>
|
||||||
<div className="flex size-full flex-col">
|
<div className="flex size-full flex-col">
|
||||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
{header}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
<main className="flex min-h-0 flex-1 flex-col">
|
||||||
variant="ghost"
|
{showSaveHint ? (
|
||||||
size="icon-sm"
|
<div className="px-4 pt-4">
|
||||||
onClick={() => router.push("/workspace/agents")}
|
<div className="mx-auto w-full max-w-(--container-width-md)">
|
||||||
>
|
<Alert>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
</Button>
|
<AlertDescription>{t.agents.saveHint}</AlertDescription>
|
||||||
<h1 className="text-sm font-semibold">创建专家</h1>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
{agent ? (
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)}
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="h-4 w-4" />
|
|
||||||
开始对话
|
|
||||||
</Button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
|
<div className="flex min-h-0 flex-1 justify-center">
|
||||||
<section className="flex min-h-0 flex-col border-r">
|
<MessageList
|
||||||
<div className="min-h-0 flex-1">
|
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
|
||||||
<MessageList
|
threadId={threadId}
|
||||||
className="size-full"
|
thread={thread}
|
||||||
threadId={threadId}
|
/>
|
||||||
thread={thread}
|
</div>
|
||||||
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="bg-background flex shrink-0 justify-center border-t px-4 py-4">
|
||||||
<div className="space-y-5 p-4">
|
<div className="w-full max-w-(--container-width-md)">
|
||||||
<div>
|
{agent ? (
|
||||||
<h2 className="text-sm font-semibold">专家配置</h2>
|
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<CheckCircleIcon className="text-primary h-10 w-10" />
|
||||||
这些内容会作为创建专家时的初始配置。
|
<p className="font-semibold">{t.agents.agentCreated}</p>
|
||||||
</p>
|
<div className="flex gap-2">
|
||||||
</div>
|
<Button
|
||||||
|
onClick={() =>
|
||||||
<div className="space-y-2">
|
router.push(
|
||||||
<label className="text-sm font-medium">名称</label>
|
`/workspace/agents/${agentId}/chats/new`,
|
||||||
<Input
|
)
|
||||||
placeholder="例如:合同审查专家"
|
}
|
||||||
value={nameInput}
|
>
|
||||||
onChange={(event) => {
|
{t.agents.startChatting}
|
||||||
setNameInput(event.currentTarget.value);
|
</Button>
|
||||||
setFormError("");
|
<Button
|
||||||
}}
|
variant="outline"
|
||||||
className={cn(formError && "border-destructive")}
|
onClick={() => router.push("/workspace/agents")}
|
||||||
/>
|
>
|
||||||
</div>
|
{t.agents.backToGallery}
|
||||||
|
</Button>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<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>
|
||||||
<div className="rounded-md border p-2">
|
) : (
|
||||||
{isSkillsLoading ? (
|
<PromptInput
|
||||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||||
) : enabledSkills.length === 0 ? (
|
>
|
||||||
<p className="text-muted-foreground text-sm">
|
<PromptInputTextarea
|
||||||
当前没有可用技能。
|
autoFocus
|
||||||
</p>
|
placeholder={t.agents.createPageSubtitle}
|
||||||
) : (
|
disabled={thread.isLoading}
|
||||||
<div className="flex flex-wrap gap-2">
|
/>
|
||||||
{enabledSkills.map((skill) => {
|
<PromptInputFooter className="justify-end">
|
||||||
const checked = selectedSkills.includes(skill.name);
|
<PromptInputSubmit disabled={thread.isLoading} />
|
||||||
return (
|
</PromptInputFooter>
|
||||||
<Button
|
</PromptInput>
|
||||||
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>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</ArtifactsProvider>
|
</ArtifactsProvider>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function NotFoundPage() {
|
|||||||
dedicated migration pass.
|
dedicated migration pass.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/page/workspace/chats/new"
|
to="/workspace/chats/new"
|
||||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
|
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Open workspace
|
Open workspace
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ export default function ScheduledChatPage() {
|
|||||||
const { data, isLoading, error } = useSchedulerThread();
|
const { data, isLoading, error } = useSchedulerThread();
|
||||||
|
|
||||||
if (data?.thread_id) {
|
if (data?.thread_id) {
|
||||||
return <Navigate to={`/page/workspace/chats/${data.thread_id}`} replace />;
|
return <Navigate to={`/workspace/chats/${data.thread_id}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await actions.removeRun.mutateAsync(run.id);
|
await actions.removeRun.mutateAsync(run.id);
|
||||||
toast.success("执行记录已删除");
|
toast.success("执行记录已删除");
|
||||||
navigate("/page/workspace/scheduled/tasks");
|
navigate("/workspace/scheduled/tasks");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||||
执行信息
|
执行信息
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/workspace/scheduled/tasks")}>
|
||||||
<ListChecks className="size-3" />
|
<ListChecks className="size-3" />
|
||||||
返回记录
|
返回记录
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,15 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BellOff,
|
|
||||||
BellRing,
|
|
||||||
Download,
|
Download,
|
||||||
Edit3,
|
Edit3,
|
||||||
Eye,
|
Eye,
|
||||||
Globe,
|
|
||||||
Heart,
|
|
||||||
HeartOff,
|
|
||||||
Lock,
|
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -27,8 +21,6 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
@@ -38,13 +30,11 @@ import {
|
|||||||
import { useAgents } from "@/core/agents";
|
import { useAgents } from "@/core/agents";
|
||||||
import {
|
import {
|
||||||
downloadScheduledTaskRun,
|
downloadScheduledTaskRun,
|
||||||
type PublishedScheduledTask,
|
|
||||||
type ScheduledTask,
|
type ScheduledTask,
|
||||||
useScheduledTaskActions,
|
useScheduledTaskActions,
|
||||||
useScheduledTaskDeliveryProfile,
|
useScheduledTaskDeliveryProfile,
|
||||||
useScheduledTaskRuns,
|
useScheduledTaskRuns,
|
||||||
useScheduledTasks,
|
useScheduledTasks,
|
||||||
usePublishedScheduledTasks,
|
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -227,9 +217,6 @@ export default function ScheduledTasksPage() {
|
|||||||
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
||||||
const { agents } = useAgents();
|
const { agents } = useAgents();
|
||||||
const actions = useScheduledTaskActions();
|
const actions = useScheduledTaskActions();
|
||||||
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
|
|
||||||
const { data: published = [], isLoading: publishedLoading } =
|
|
||||||
usePublishedScheduledTasks(activeTab === "plaza");
|
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
const [elementUserId, setElementUserId] = useState("");
|
const [elementUserId, setElementUserId] = useState("");
|
||||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||||
@@ -237,8 +224,6 @@ export default function ScheduledTasksPage() {
|
|||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
|
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
|
||||||
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
||||||
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
|
|
||||||
const [subscribeNotify, setSubscribeNotify] = useState(false);
|
|
||||||
|
|
||||||
const sortedTasks = useMemo(
|
const sortedTasks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -392,232 +377,95 @@ export default function ScheduledTasksPage() {
|
|||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
|
|
||||||
<TabsList className="w-full">
|
|
||||||
<TabsTrigger value="mine" className="flex-1">我的任务</TabsTrigger>
|
|
||||||
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="min-h-0 flex-1 p-0">
|
<CardContent className="min-h-0 flex-1 p-0">
|
||||||
<ScrollArea className="size-full">
|
<ScrollArea className="size-full">
|
||||||
{activeTab === "mine" ? (
|
<div className="flex flex-col gap-3 p-4 pt-0">
|
||||||
<div className="flex flex-col gap-3 p-4 pt-0">
|
{isLoading && (
|
||||||
{isLoading && (
|
<div className="text-muted-foreground text-sm">正在加载任务...</div>
|
||||||
<div className="text-muted-foreground text-sm">正在加载任务...</div>
|
)}
|
||||||
)}
|
{!isLoading && sortedTasks.length === 0 && (
|
||||||
{!isLoading && sortedTasks.length === 0 && (
|
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
还没有定时任务。点击新建创建第一个任务。
|
||||||
还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{sortedTasks.map((task) => (
|
||||||
{sortedTasks.map((task) => {
|
<button
|
||||||
const isOwner = task.role !== "subscriber";
|
key={task.task_id}
|
||||||
const isSubscriber = task.role === "subscriber";
|
className={cn(
|
||||||
const notifyOn = !!task._subscription?.notify_element;
|
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
||||||
return (
|
selectedTaskId === task.task_id && "border-primary bg-accent",
|
||||||
<button
|
)}
|
||||||
key={task.task_id}
|
onClick={() => setSelectedTaskId(task.task_id)}
|
||||||
className={cn(
|
>
|
||||||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
<div className="flex items-start justify-between gap-3">
|
||||||
selectedTaskId === task.task_id && "border-primary bg-accent",
|
<div className="min-w-0">
|
||||||
)}
|
<div className="truncate font-medium">{task.name}</div>
|
||||||
onClick={() => setSelectedTaskId(task.task_id)}
|
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||||||
>
|
{task.schedule_text} · {task.timezone}
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="truncate font-medium">{task.name}</div>
|
|
||||||
{isSubscriber && (
|
|
||||||
<Badge variant="outline" className="shrink-0 text-xs">
|
|
||||||
<Heart className="size-3" />收藏
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isOwner && task.published && (
|
|
||||||
<Badge variant="outline" className="shrink-0 text-xs">
|
|
||||||
<Globe className="size-3" />已发布
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
|
||||||
{task.schedule_text} · {task.timezone}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant={task.enabled ? "default" : "secondary"}>
|
|
||||||
{task.enabled ? "启用" : "暂停"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
|
|
||||||
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
|
||||||
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
|
||||||
<div>最近状态:{statusLabel(task.last_status)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{isOwner ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
title="编辑"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
openEdit(task);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit3 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
title={task.enabled ? "暂停" : "恢复"}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
task.enabled
|
|
||||||
? actions.pause.mutate(task.task_id)
|
|
||||||
: actions.resume.mutate(task.task_id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
title="立即执行"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
actions.trigger.mutate(task.task_id);
|
|
||||||
toast.success("已触发执行");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
title={task.published ? "取消发布" : "发布到广场"}
|
|
||||||
onClick={async (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
try {
|
|
||||||
if (task.published) {
|
|
||||||
await actions.unpublish.mutateAsync(task.task_id);
|
|
||||||
toast.success("已取消发布(已订阅者保留)");
|
|
||||||
} else {
|
|
||||||
await actions.publish.mutateAsync(task.task_id);
|
|
||||||
toast.success("已发布到广场");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "操作失败");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.published ? <Lock className="size-4" /> : <Globe className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="destructive"
|
|
||||||
title="删除"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
actions.remove.mutate(task.task_id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
title={notifyOn ? "关闭通知" : "开启通知"}
|
|
||||||
onClick={async (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
try {
|
|
||||||
await actions.updateSubscription.mutateAsync({
|
|
||||||
taskId: task.task_id,
|
|
||||||
notifyElement: !notifyOn,
|
|
||||||
});
|
|
||||||
toast.success(notifyOn ? "已关闭 Element 通知" : "已开启 Element 通知");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "操作失败");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notifyOn ? <BellRing className="size-4" /> : <BellOff className="size-4" />}
|
|
||||||
{notifyOn ? "通知中" : "未通知"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
title="取消收藏"
|
|
||||||
onClick={async (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
try {
|
|
||||||
await actions.unsubscribe.mutateAsync(task.task_id);
|
|
||||||
toast.success("已取消收藏");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "操作失败");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HeartOff className="size-4" />取消收藏
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3 p-4 pt-0">
|
|
||||||
{publishedLoading && (
|
|
||||||
<div className="text-muted-foreground text-sm">正在加载广场任务...</div>
|
|
||||||
)}
|
|
||||||
{!publishedLoading && published.length === 0 && (
|
|
||||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
|
||||||
广场上还没有发布的任务。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{published.map((item) => {
|
|
||||||
const subscribed = tasks.some((t) => t.task_id === item.task_id);
|
|
||||||
return (
|
|
||||||
<div key={item.task_id} className="bg-card rounded-md border p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">{item.name}</div>
|
|
||||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
|
||||||
{item.schedule_text} · {item.timezone}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{subscribed && (
|
|
||||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
|
||||||
<Heart className="size-3" />已收藏
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mt-3 line-clamp-3 text-xs whitespace-pre-wrap">
|
|
||||||
{item.prompt_summary || "(无摘要)"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={subscribed ? "outline" : "default"}
|
|
||||||
disabled={subscribed}
|
|
||||||
onClick={() => {
|
|
||||||
setSubscribeTarget(item);
|
|
||||||
setSubscribeNotify(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Heart className="size-4" />
|
|
||||||
{subscribed ? "已收藏" : "收藏到我的任务"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
<Badge variant={task.enabled ? "default" : "secondary"}>
|
||||||
})}
|
{task.enabled ? "启用" : "暂停"}
|
||||||
</div>
|
</Badge>
|
||||||
)}
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
|
||||||
|
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
||||||
|
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
||||||
|
<div>最近状态:{statusLabel(task.last_status)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
title="编辑"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openEdit(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit3 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
title={task.enabled ? "暂停" : "恢复"}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
task.enabled
|
||||||
|
? actions.pause.mutate(task.task_id)
|
||||||
|
: actions.resume.mutate(task.task_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
title="立即执行"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
actions.trigger.mutate(task.task_id);
|
||||||
|
toast.success("已触发执行");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
title="删除"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
actions.remove.mutate(task.task_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -686,7 +534,7 @@ export default function ScheduledTasksPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
|
onClick={() => navigate(`/workspace/scheduled/runs/${run.id}`)}
|
||||||
>
|
>
|
||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
查看结果
|
查看结果
|
||||||
@@ -718,75 +566,6 @@ export default function ScheduledTasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</WorkspaceBody>
|
</WorkspaceBody>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={!!subscribeTarget}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setSubscribeTarget(null);
|
|
||||||
setSubscribeNotify(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>收藏到我的任务</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{subscribeTarget?.name}</div>
|
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
|
||||||
{subscribeTarget?.schedule_text} · {subscribeTarget?.timezone}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap">
|
|
||||||
{subscribeTarget?.prompt_summary || "(无摘要)"}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between rounded-md border p-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">同步接收 Element 通知</div>
|
|
||||||
<div className="text-muted-foreground mt-1 text-xs">
|
|
||||||
开启后,每次执行结果将复制一份发送到你设置的 Element 用户。
|
|
||||||
{!deliveryProfile?.element_user_id && (
|
|
||||||
<span className="text-destructive"> 你尚未配置 Element 用户 ID。</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch checked={subscribeNotify} onCheckedChange={setSubscribeNotify} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setSubscribeTarget(null);
|
|
||||||
setSubscribeNotify(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!subscribeTarget) return;
|
|
||||||
try {
|
|
||||||
await actions.subscribe.mutateAsync({
|
|
||||||
taskId: subscribeTarget.task_id,
|
|
||||||
notifyElement: subscribeNotify,
|
|
||||||
});
|
|
||||||
toast.success("已收藏到我的任务");
|
|
||||||
setSubscribeTarget(null);
|
|
||||||
setSubscribeNotify(false);
|
|
||||||
setActiveTab("mine");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "收藏失败");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认收藏
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ static/browser_history/*.gif
|
|||||||
|
|
||||||
log/
|
log/
|
||||||
log/*
|
log/*
|
||||||
.deer-flow/users/
|
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
d
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
@@ -569,4 +569,3 @@ See `docs/` directory for detailed documentation:
|
|||||||
- [PATH_EXAMPLES.md](docs/PATH_EXAMPLES.md) - Path types and usage
|
- [PATH_EXAMPLES.md](docs/PATH_EXAMPLES.md) - Path types and usage
|
||||||
- [summarization.md](docs/summarization.md) - Context summarization
|
- [summarization.md](docs/summarization.md) - Context summarization
|
||||||
- [plan_mode_usage.md](docs/plan_mode_usage.md) - Plan mode with TodoList
|
- [plan_mode_usage.md](docs/plan_mode_usage.md) - Plan mode with TodoList
|
||||||
- [BACKEND_RUN_STOP_ZH.md](docs/BACKEND_RUN_STOP_ZH.md) - Windows-first backend start/stop and troubleshooting guide
|
|
||||||
@@ -411,7 +411,6 @@ uv run pytest
|
|||||||
- [Path Examples](docs/PATH_EXAMPLES.md)
|
- [Path Examples](docs/PATH_EXAMPLES.md)
|
||||||
- [Context Summarization](docs/summarization.md)
|
- [Context Summarization](docs/summarization.md)
|
||||||
- [Plan Mode](docs/plan_mode_usage.md)
|
- [Plan Mode](docs/plan_mode_usage.md)
|
||||||
- [Backend Run/Stop Guide (ZH)](docs/BACKEND_RUN_STOP_ZH.md)
|
|
||||||
- [Setup Guide](docs/SETUP.md)
|
- [Setup Guide](docs/SETUP.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -89,12 +89,6 @@ def _current_user_id(request: Request) -> str:
|
|||||||
return get_effective_user_id()
|
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:
|
def _validate_display_name(name: str) -> str:
|
||||||
normalized = name.strip()
|
normalized = name.strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
@@ -209,29 +203,6 @@ 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")
|
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(
|
@router.get(
|
||||||
"/agents",
|
"/agents",
|
||||||
response_model=AgentsListResponse,
|
response_model=AgentsListResponse,
|
||||||
@@ -351,10 +322,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
try:
|
try:
|
||||||
store = get_agent_store(http_request)
|
store = get_agent_store(http_request)
|
||||||
user_id = _current_user_id(http_request)
|
user_id = _current_user_id(http_request)
|
||||||
is_admin = _is_admin_user(http_request)
|
current = await _get_owned_or_raise(store, agent_id, user_id)
|
||||||
current, edit_mode = await _get_editable_or_raise(
|
|
||||||
store, agent_id, user_id, is_admin=is_admin
|
|
||||||
)
|
|
||||||
|
|
||||||
fields_set = request.model_fields_set
|
fields_set = request.model_fields_set
|
||||||
update_data: dict[str, Any] = {}
|
update_data: dict[str, Any] = {}
|
||||||
@@ -386,12 +354,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
if request.soul is not None:
|
if request.soul is not None:
|
||||||
_write_agent_soul(agent_id, request.soul)
|
_write_agent_soul(agent_id, request.soul)
|
||||||
|
|
||||||
if not update_data:
|
record = await store.update(agent_id, user_id, update_data) if update_data else current
|
||||||
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:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
|||||||
@@ -43,17 +43,6 @@ class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
|
|||||||
element_user_id: str | None = None
|
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:
|
async def _user_id(request: Request) -> str:
|
||||||
user_id = await get_current_user(request)
|
user_id = await get_current_user(request)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -191,27 +180,6 @@ async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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")
|
@router.get("/runs/{run_id}/download")
|
||||||
async def download_scheduled_task_run(run_id: str, request: Request):
|
async def download_scheduled_task_run(run_id: str, request: Request):
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
@@ -357,63 +325,3 @@ async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||||
return {"success": True, "task": task}
|
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,9 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
@@ -57,20 +56,6 @@ class SkillInstallResponse(BaseModel):
|
|||||||
success: bool = Field(..., description="Whether the installation was successful")
|
success: bool = Field(..., description="Whether the installation was successful")
|
||||||
skill_name: str = Field(..., description="Name of the installed skill")
|
skill_name: str = Field(..., description="Name of the installed skill")
|
||||||
message: str = Field(..., description="Installation result message")
|
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):
|
class CustomSkillContentResponse(SkillResponse):
|
||||||
@@ -115,110 +100,6 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
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(
|
@router.post(
|
||||||
"/skills/install",
|
"/skills/install",
|
||||||
response_model=SkillInstallResponse,
|
response_model=SkillInstallResponse,
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
# 后端启动与停止说明(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`。
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
# 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*
|
|
||||||
@@ -9,7 +9,6 @@ from deerflow.agents.memory.summarization_hook import memory_flush_hook
|
|||||||
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
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.subagent_limit_middleware import SubagentLimitMiddleware
|
||||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||||
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||||
@@ -304,8 +303,6 @@ def _build_middlewares(
|
|||||||
if custom_middlewares:
|
if custom_middlewares:
|
||||||
middlewares.extend(custom_middlewares)
|
middlewares.extend(custom_middlewares)
|
||||||
|
|
||||||
middlewares.append(SetupAgentApprovalMiddleware())
|
|
||||||
|
|
||||||
# ClarificationMiddleware should always be last
|
# ClarificationMiddleware should always be last
|
||||||
middlewares.append(ClarificationMiddleware())
|
middlewares.append(ClarificationMiddleware())
|
||||||
return middlewares
|
return middlewares
|
||||||
|
|||||||
+9
-9
@@ -384,7 +384,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
|
|||||||
|
|
||||||
SYSTEM_PROMPT_TEMPLATE = """
|
SYSTEM_PROMPT_TEMPLATE = """
|
||||||
<role>
|
<role>
|
||||||
You are {agent_name}, an internal super agent.
|
You are {agent_name}, an open-source super agent.
|
||||||
</role>
|
</role>
|
||||||
|
|
||||||
{soul}
|
{soul}
|
||||||
@@ -517,10 +517,10 @@ Recent breakthroughs in language models have also accelerated progress
|
|||||||
```markdown
|
```markdown
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
LangGraph is an open-source workflow orchestration framework for LLM applications
|
DeerFlow is an open-source AI agent framework that gained significant traction in early 2026
|
||||||
[citation:LangGraph Repository](https://github.com/langchain-ai/langgraph). The project
|
[citation:GitHub Repository](https://github.com/bytedance/deer-flow). The project focuses on
|
||||||
focuses on providing stateful multi-agent graphs with built-in checkpointing and streaming
|
providing a production-ready agent system with sandbox execution and memory management
|
||||||
[citation:LangGraph Documentation](https://langchain-ai.github.io/langgraph/).
|
[citation:DeerFlow Documentation](https://deer-flow.dev/docs).
|
||||||
|
|
||||||
## Key Analysis
|
## Key Analysis
|
||||||
|
|
||||||
@@ -532,8 +532,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
|||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
### Primary Sources
|
### Primary Sources
|
||||||
- [LangGraph Repository](https://github.com/langchain-ai/langgraph) - Official source code and documentation
|
- [GitHub Repository](https://github.com/bytedance/deer-flow) - Official source code and documentation
|
||||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Technical specifications
|
- [DeerFlow Documentation](https://deer-flow.dev/docs) - Technical specifications
|
||||||
|
|
||||||
### Media Coverage
|
### Media Coverage
|
||||||
- [AI Trends 2026](https://techcrunch.com/ai-trends) - Industry analysis
|
- [AI Trends 2026](https://techcrunch.com/ai-trends) - Industry analysis
|
||||||
@@ -545,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
|
- The `[citation:Title](URL)` format is ONLY for inline citations within the report body
|
||||||
- ❌ WRONG: `GitHub 仓库 - 官方源代码和文档` (no URL!)
|
- ❌ WRONG: `GitHub 仓库 - 官方源代码和文档` (no URL!)
|
||||||
- ❌ WRONG in Sources: `[citation:GitHub Repository](url)` (citation prefix is for inline only!)
|
- ❌ WRONG in Sources: `[citation:GitHub Repository](url)` (citation prefix is for inline only!)
|
||||||
- ✅ RIGHT in Sources: `[LangGraph Repository](https://github.com/langchain-ai/langgraph) - 官方源代码和文档`
|
- ✅ RIGHT in Sources: `[GitHub Repository](https://github.com/bytedance/deer-flow) - 官方源代码和文档`
|
||||||
|
|
||||||
**WORKFLOW for Research Tasks:**
|
**WORKFLOW for Research Tasks:**
|
||||||
1. Use web_search to find sources → Extract {{title, url, snippet}} from results
|
1. Use web_search to find sources → Extract {{title, url, snippet}} from results
|
||||||
@@ -837,7 +837,7 @@ def apply_prompt_template(
|
|||||||
|
|
||||||
# Format the prompt with dynamic skills and memory
|
# Format the prompt with dynamic skills and memory
|
||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
agent_name=agent_name or storage_agent_id or "DeerFlow 2.0",
|
||||||
soul=get_agent_soul(storage_agent_id),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
|
|||||||
+16
-55
@@ -67,7 +67,21 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
question = args.get("question", "")
|
question = args.get("question", "")
|
||||||
clarification_type = args.get("clarification_type", "missing_info")
|
clarification_type = args.get("clarification_type", "missing_info")
|
||||||
context = args.get("context")
|
context = args.get("context")
|
||||||
options = self._normalize_options(args.get("options", []))
|
options = args.get("options", [])
|
||||||
|
|
||||||
|
# Some models (e.g. Qwen3-Max) serialize array parameters as JSON strings
|
||||||
|
# instead of native arrays. Deserialize and normalize so `options`
|
||||||
|
# is always a list for the rendering logic below.
|
||||||
|
if isinstance(options, str):
|
||||||
|
try:
|
||||||
|
options = json.loads(options)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
options = [options]
|
||||||
|
|
||||||
|
if options is None:
|
||||||
|
options = []
|
||||||
|
elif not isinstance(options, list):
|
||||||
|
options = [options]
|
||||||
|
|
||||||
# Type-specific icons
|
# Type-specific icons
|
||||||
type_icons = {
|
type_icons = {
|
||||||
@@ -96,62 +110,10 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
if options and len(options) > 0:
|
if options and len(options) > 0:
|
||||||
message_parts.append("") # blank line for spacing
|
message_parts.append("") # blank line for spacing
|
||||||
for i, option in enumerate(options, 1):
|
for i, option in enumerate(options, 1):
|
||||||
label = option.get("label") or option.get("id") or ""
|
message_parts.append(f" {i}. {option}")
|
||||||
message_parts.append(f" {i}. {label}")
|
|
||||||
|
|
||||||
return "\n".join(message_parts)
|
return "\n".join(message_parts)
|
||||||
|
|
||||||
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
|
||||||
"""Normalize model-produced options into a stable UI-friendly shape."""
|
|
||||||
options = raw_options
|
|
||||||
|
|
||||||
# Some models serialize array parameters as JSON strings instead of native arrays.
|
|
||||||
if isinstance(options, str):
|
|
||||||
try:
|
|
||||||
options = json.loads(options)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
options = [options]
|
|
||||||
|
|
||||||
if options is None:
|
|
||||||
values: list[object] = []
|
|
||||||
elif isinstance(options, list):
|
|
||||||
values = options
|
|
||||||
else:
|
|
||||||
values = [options]
|
|
||||||
|
|
||||||
normalized: list[dict[str, str]] = []
|
|
||||||
for index, value in enumerate(values, 1):
|
|
||||||
if isinstance(value, dict):
|
|
||||||
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
|
||||||
label = str(raw_label).strip() if raw_label is not None else ""
|
|
||||||
if not label:
|
|
||||||
continue
|
|
||||||
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
|
||||||
option = {
|
|
||||||
"id": str(raw_id).strip() or f"option-{index}",
|
|
||||||
"label": label,
|
|
||||||
}
|
|
||||||
description = value.get("description")
|
|
||||||
if description is not None and str(description).strip():
|
|
||||||
option["description"] = str(description).strip()
|
|
||||||
normalized.append(option)
|
|
||||||
else:
|
|
||||||
label = str(value).strip()
|
|
||||||
if label:
|
|
||||||
normalized.append({"id": f"option-{index}", "label": label})
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
def _build_payload(self, args: dict) -> dict:
|
|
||||||
"""Build structured clarification data for rich clients."""
|
|
||||||
return {
|
|
||||||
"question": str(args.get("question", "")).strip(),
|
|
||||||
"clarification_type": args.get("clarification_type", "missing_info"),
|
|
||||||
"context": args.get("context"),
|
|
||||||
"options": self._normalize_options(args.get("options", [])),
|
|
||||||
"allow_custom": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||||
"""Handle clarification request and return command to interrupt execution.
|
"""Handle clarification request and return command to interrupt execution.
|
||||||
|
|
||||||
@@ -181,7 +143,6 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
content=formatted_message,
|
content=formatted_message,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
name="ask_clarification",
|
name="ask_clarification",
|
||||||
additional_kwargs={"clarification": self._build_payload(args)},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return a Command that:
|
# Return a Command that:
|
||||||
|
|||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
"""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,15 +35,6 @@ class AgentStore(ABC):
|
|||||||
async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
raise NotImplementedError
|
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
|
@abstractmethod
|
||||||
async def delete(self, agent_id: str, user_id: str) -> bool:
|
async def delete(self, agent_id: str, user_id: str) -> bool:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
-10
@@ -91,16 +91,6 @@ class MemoryAgentStore(AgentStore):
|
|||||||
row["updated_at"] = _now()
|
row["updated_at"] = _now()
|
||||||
return _copy(row)
|
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:
|
async def delete(self, agent_id: str, user_id: str) -> bool:
|
||||||
row = self._agents.get(agent_id)
|
row = self._agents.get(agent_id)
|
||||||
if row is None or row.get("user_id") != user_id:
|
if row is None or row.get("user_id") != user_id:
|
||||||
|
|||||||
@@ -107,19 +107,6 @@ class AgentRepository(AgentStore):
|
|||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return _row_to_dict(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 def delete(self, agent_id: str, user_id: str) -> bool:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(AgentRow, agent_id)
|
row = await session.get(AgentRow, agent_id)
|
||||||
|
|||||||
-66
@@ -1,66 +0,0 @@
|
|||||||
"""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")
|
|
||||||
+1
-8
@@ -19,13 +19,7 @@ from deerflow.persistence.agents.model import AgentRow
|
|||||||
from deerflow.persistence.feedback.model import FeedbackRow
|
from deerflow.persistence.feedback.model import FeedbackRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
from deerflow.persistence.run.model import RunRow
|
from deerflow.persistence.run.model import RunRow
|
||||||
from deerflow.persistence.scheduled_tasks.model import (
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
|
||||||
ScheduledTaskDeliveryProfileRow,
|
|
||||||
ScheduledTaskRow,
|
|
||||||
ScheduledTaskRunRow,
|
|
||||||
ScheduledTaskSubscriptionRow,
|
|
||||||
SchedulerThreadRow,
|
|
||||||
)
|
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.user.model import UserRow
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
|
||||||
@@ -37,7 +31,6 @@ __all__ = [
|
|||||||
"ScheduledTaskDeliveryProfileRow",
|
"ScheduledTaskDeliveryProfileRow",
|
||||||
"ScheduledTaskRow",
|
"ScheduledTaskRow",
|
||||||
"ScheduledTaskRunRow",
|
"ScheduledTaskRunRow",
|
||||||
"ScheduledTaskSubscriptionRow",
|
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"UserRow",
|
"UserRow",
|
||||||
|
|||||||
+1
-8
@@ -4,13 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||||
from deerflow.persistence.scheduled_tasks.memory import MemoryScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks.memory import MemoryScheduledTaskStore
|
||||||
from deerflow.persistence.scheduled_tasks.model import (
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
|
||||||
ScheduledTaskDeliveryProfileRow,
|
|
||||||
ScheduledTaskRow,
|
|
||||||
ScheduledTaskRunRow,
|
|
||||||
ScheduledTaskSubscriptionRow,
|
|
||||||
SchedulerThreadRow,
|
|
||||||
)
|
|
||||||
from deerflow.persistence.scheduled_tasks.sql import ScheduledTaskRepository
|
from deerflow.persistence.scheduled_tasks.sql import ScheduledTaskRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -20,7 +14,6 @@ __all__ = [
|
|||||||
"ScheduledTaskRow",
|
"ScheduledTaskRow",
|
||||||
"ScheduledTaskRunRow",
|
"ScheduledTaskRunRow",
|
||||||
"ScheduledTaskStore",
|
"ScheduledTaskStore",
|
||||||
"ScheduledTaskSubscriptionRow",
|
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"make_scheduled_task_store",
|
"make_scheduled_task_store",
|
||||||
]
|
]
|
||||||
|
|||||||
-43
@@ -63,46 +63,3 @@ class ScheduledTaskStore(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
raise NotImplementedError
|
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
|
|
||||||
+1
-73
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
from datetime import datetime
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||||
@@ -16,7 +15,6 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
|
|||||||
self._runs_by_key: set[tuple[str, str]] = set()
|
self._runs_by_key: set[tuple[str, str]] = set()
|
||||||
self._runs: dict[str, dict[str, Any]] = {}
|
self._runs: dict[str, dict[str, Any]] = {}
|
||||||
self._profiles: 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:
|
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||||
if user_id not in self._threads:
|
if user_id not in self._threads:
|
||||||
@@ -107,73 +105,3 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
|
|||||||
if "element_room_id" in data:
|
if "element_room_id" in data:
|
||||||
profile["element_room_id"] = data["element_room_id"] or None
|
profile["element_room_id"] = data["element_room_id"] or None
|
||||||
return dict(profile)
|
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")
|
|
||||||
]
|
|
||||||
-14
@@ -39,20 +39,6 @@ class ScheduledTaskRow(Base):
|
|||||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_status: Mapped[str | None] = mapped_column(String(32))
|
last_status: Mapped[str | None] = mapped_column(String(32))
|
||||||
execution_context_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
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))
|
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))
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|||||||
+3
-115
@@ -5,25 +5,17 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import uuid
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||||
from deerflow.persistence.scheduled_tasks.model import (
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
|
||||||
ScheduledTaskDeliveryProfileRow,
|
|
||||||
ScheduledTaskRow,
|
|
||||||
ScheduledTaskRunRow,
|
|
||||||
ScheduledTaskSubscriptionRow,
|
|
||||||
SchedulerThreadRow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_dict(row) -> dict[str, Any]:
|
def _to_dict(row) -> dict[str, Any]:
|
||||||
data = row.to_dict()
|
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"):
|
for key in ("created_at", "updated_at", "next_run_at", "last_run_at", "scheduled_for", "started_at", "finished_at"):
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
data[key] = value.isoformat()
|
data[key] = value.isoformat()
|
||||||
@@ -202,107 +194,3 @@ class ScheduledTaskRepository(ScheduledTaskStore):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return _to_dict(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()]
|
|
||||||
+17
-194
@@ -197,141 +197,16 @@ class ScheduledTaskService:
|
|||||||
return thread
|
return thread
|
||||||
|
|
||||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
owned = await self.store.list_tasks(user_id)
|
return await self.store.list_tasks(user_id)
|
||||||
for task in owned:
|
|
||||||
task["role"] = "owner"
|
|
||||||
subscribed = await self.store.list_subscribed_tasks(user_id)
|
|
||||||
owned_ids = {task["task_id"] for task in owned}
|
|
||||||
for task in subscribed:
|
|
||||||
if task["task_id"] in owned_ids:
|
|
||||||
continue
|
|
||||||
task["role"] = "subscriber"
|
|
||||||
owned.append(task)
|
|
||||||
return owned
|
|
||||||
|
|
||||||
async def list_published(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
|
||||||
"""Public discovery list. Strips author prompt details to a short summary."""
|
|
||||||
tasks = await self.store.list_published_tasks(limit=limit, offset=offset)
|
|
||||||
result: list[dict[str, Any]] = []
|
|
||||||
for task in tasks:
|
|
||||||
prompt = (task.get("prompt") or "").strip()
|
|
||||||
summary = prompt if len(prompt) <= 200 else prompt[:200].rstrip() + "…"
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"task_id": task["task_id"],
|
|
||||||
"name": task.get("name"),
|
|
||||||
"prompt_summary": summary,
|
|
||||||
"schedule_text": task.get("schedule_text"),
|
|
||||||
"timezone": task.get("timezone"),
|
|
||||||
"published_at": task.get("published_at"),
|
|
||||||
"owner_user_id": task.get("user_id"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _accessible_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
|
||||||
task = await self.store.get_task_any(task_id)
|
|
||||||
if task is None:
|
|
||||||
return None
|
|
||||||
if task["user_id"] == user_id:
|
|
||||||
task["role"] = "owner"
|
|
||||||
return task
|
|
||||||
sub = await self.store.get_subscription(task_id, user_id)
|
|
||||||
if sub is None:
|
|
||||||
return None
|
|
||||||
task["role"] = "subscriber"
|
|
||||||
task["_subscription"] = sub
|
|
||||||
return task
|
|
||||||
|
|
||||||
async def list_runs(self, user_id: str, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
async def list_runs(self, user_id: str, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
accessible = await self._accessible_task(user_id, task_id)
|
task = await self.store.get_task(task_id, user_id)
|
||||||
if accessible is None:
|
if task is None:
|
||||||
return []
|
return []
|
||||||
return await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
|
return await self.store.list_runs(task_id, user_id, limit=limit)
|
||||||
|
|
||||||
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
|
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
|
||||||
run = await self.store.get_run(run_id, user_id)
|
return await self.store.get_run(run_id, user_id)
|
||||||
if run is not None:
|
|
||||||
run["can_edit"] = True
|
|
||||||
return run
|
|
||||||
# Subscriber visibility: look up the run unfiltered, then verify the
|
|
||||||
# caller has a subscription on the parent task.
|
|
||||||
candidate = await self.store.get_run_any(run_id)
|
|
||||||
if candidate is None:
|
|
||||||
return None
|
|
||||||
if await self.store.get_subscription(candidate["task_id"], user_id) is None:
|
|
||||||
return None
|
|
||||||
candidate["can_edit"] = False
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
async def update_run_file(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
run_id: str,
|
|
||||||
file_index: int,
|
|
||||||
new_content: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Overwrite an archived run file in place. Owner-only.
|
|
||||||
|
|
||||||
Returns updated file metadata. Raises:
|
|
||||||
- PermissionError if the caller is not the run owner (subscribers blocked)
|
|
||||||
- FileNotFoundError if the run or file index does not exist
|
|
||||||
- ValueError if the file is not editable (binary / not previewable)
|
|
||||||
"""
|
|
||||||
run = await self.store.get_run(run_id, user_id)
|
|
||||||
if run is None:
|
|
||||||
# Either the run doesn't exist, or the caller is a subscriber, not owner.
|
|
||||||
candidate = await self.store.get_run_any(run_id)
|
|
||||||
if candidate is None:
|
|
||||||
raise FileNotFoundError(f"Run {run_id} not found")
|
|
||||||
raise PermissionError("Only the run owner may edit result files")
|
|
||||||
|
|
||||||
result = run.get("result") or {}
|
|
||||||
files = result.get("files") if isinstance(result, dict) else None
|
|
||||||
if not isinstance(files, list) or file_index < 0 or file_index >= len(files):
|
|
||||||
raise FileNotFoundError(f"File index {file_index} not found in run {run_id}")
|
|
||||||
item = files[file_index]
|
|
||||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
||||||
raise FileNotFoundError(f"File index {file_index} has no archive path")
|
|
||||||
|
|
||||||
archive_path = Path(item["path"])
|
|
||||||
if not archive_path.exists() or not archive_path.is_file():
|
|
||||||
raise FileNotFoundError(f"Archived file {archive_path} is no longer available")
|
|
||||||
|
|
||||||
# Constrain edits to text-previewable files. We mirror the same rule the
|
|
||||||
# router uses for previews so binary files cannot be silently corrupted.
|
|
||||||
suffix = archive_path.suffix.lower()
|
|
||||||
mime_type = item.get("mime_type")
|
|
||||||
is_text = (
|
|
||||||
(isinstance(mime_type, str) and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}))
|
|
||||||
or suffix in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
|
|
||||||
)
|
|
||||||
if not is_text:
|
|
||||||
raise ValueError("Only text-previewable files (markdown, txt, json, etc.) can be edited")
|
|
||||||
|
|
||||||
# Make sure we stay inside the per-run archive root before touching disk.
|
|
||||||
archive_root = (get_paths().base_dir / "scheduled-task-runs" / run_id).resolve()
|
|
||||||
try:
|
|
||||||
archive_path.resolve().relative_to(archive_root)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise PermissionError("Refusing to edit file outside the run archive") from exc
|
|
||||||
|
|
||||||
archive_path.write_text(new_content, encoding="utf-8")
|
|
||||||
new_size = archive_path.stat().st_size
|
|
||||||
|
|
||||||
# Reflect the new size in the run row so subsequent list/preview calls
|
|
||||||
# report the latest figure. Other fields stay untouched.
|
|
||||||
files[file_index] = {**item, "size": new_size}
|
|
||||||
new_result = {**result, "files": files}
|
|
||||||
await self.store.update_run(run_id, {"result": new_result})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"index": file_index,
|
|
||||||
"name": item.get("name") or archive_path.name,
|
|
||||||
"size": new_size,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"kind": item.get("kind"),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def delete_run(self, user_id: str, run_id: str) -> bool:
|
async def delete_run(self, user_id: str, run_id: str) -> bool:
|
||||||
deleted = await self.store.delete_run(run_id, user_id)
|
deleted = await self.store.delete_run(run_id, user_id)
|
||||||
@@ -459,17 +334,12 @@ class ScheduledTaskService:
|
|||||||
self._running_task_ids.discard(task["task_id"])
|
self._running_task_ids.discard(task["task_id"])
|
||||||
|
|
||||||
async def _execute_task(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
|
async def _execute_task(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
|
||||||
# Each run uses its own throwaway thread so message history cannot accumulate
|
|
||||||
# across runs and blow past the model's input window. The per-user
|
|
||||||
# task["scheduler_thread_id"] is no longer used to drive the LLM call —
|
|
||||||
# we keep it on the task row only for backwards compatibility.
|
|
||||||
runtime_thread_id = str(uuid.uuid4())
|
|
||||||
run_row = await self.store.create_run_once(
|
run_row = await self.store.create_run_once(
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"task_id": task["task_id"],
|
"task_id": task["task_id"],
|
||||||
"user_id": task["user_id"],
|
"user_id": task["user_id"],
|
||||||
"scheduler_thread_id": runtime_thread_id,
|
"scheduler_thread_id": task["scheduler_thread_id"],
|
||||||
"scheduled_for": scheduled_for,
|
"scheduled_for": scheduled_for,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"started_at": datetime.now(UTC),
|
"started_at": datetime.now(UTC),
|
||||||
@@ -483,16 +353,16 @@ class ScheduledTaskService:
|
|||||||
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
|
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
|
await self._ensure_thread_meta(task["user_id"], task["scheduler_thread_id"])
|
||||||
async with self._agent_run_lock:
|
async with self._agent_run_lock:
|
||||||
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"])
|
output_snapshot = self._snapshot_result_files(task["scheduler_thread_id"], task["user_id"])
|
||||||
record = await self._start_agent_run(task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
|
record = await self._start_agent_run(task, scheduled_for=scheduled_for)
|
||||||
await self.store.update_run(run_row["id"], {"agent_run_id": record.run_id})
|
await self.store.update_run(run_row["id"], {"agent_run_id": record.run_id})
|
||||||
if record.task is not None:
|
if record.task is not None:
|
||||||
await record.task
|
await record.task
|
||||||
result_text = await self._extract_latest_ai_text(runtime_thread_id)
|
result_text = await self._extract_latest_ai_text(task["scheduler_thread_id"])
|
||||||
result_files = await self._extract_result_files(
|
result_files = await self._extract_result_files(
|
||||||
runtime_thread_id,
|
task["scheduler_thread_id"],
|
||||||
task["user_id"],
|
task["user_id"],
|
||||||
run_id=run_row["id"],
|
run_id=run_row["id"],
|
||||||
output_snapshot=output_snapshot,
|
output_snapshot=output_snapshot,
|
||||||
@@ -547,7 +417,7 @@ class ScheduledTaskService:
|
|||||||
finally:
|
finally:
|
||||||
reset_current_user(token)
|
reset_current_user(token)
|
||||||
|
|
||||||
async def _start_agent_run(self, task: dict[str, Any], *, scheduled_for: datetime, runtime_thread_id: str):
|
async def _start_agent_run(self, task: dict[str, Any], *, scheduled_for: datetime):
|
||||||
from app.gateway.routers.thread_runs import RunCreateRequest
|
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||||
from app.gateway.services import start_run
|
from app.gateway.services import start_run
|
||||||
|
|
||||||
@@ -560,7 +430,7 @@ class ScheduledTaskService:
|
|||||||
execution_context.update(
|
execution_context.update(
|
||||||
{
|
{
|
||||||
"is_scheduled_run": True,
|
"is_scheduled_run": True,
|
||||||
"thread_id": runtime_thread_id,
|
"thread_id": task["scheduler_thread_id"],
|
||||||
"thinking_enabled": execution_context.get("thinking_enabled", True),
|
"thinking_enabled": execution_context.get("thinking_enabled", True),
|
||||||
"is_plan_mode": execution_context.get("is_plan_mode", True),
|
"is_plan_mode": execution_context.get("is_plan_mode", True),
|
||||||
}
|
}
|
||||||
@@ -577,7 +447,7 @@ class ScheduledTaskService:
|
|||||||
context=execution_context,
|
context=execution_context,
|
||||||
multitask_strategy="enqueue" if False else "reject",
|
multitask_strategy="enqueue" if False else "reject",
|
||||||
)
|
)
|
||||||
return await start_run(body, runtime_thread_id, request) # type: ignore[arg-type]
|
return await start_run(body, task["scheduler_thread_id"], request) # type: ignore[arg-type]
|
||||||
finally:
|
finally:
|
||||||
reset_current_user(token)
|
reset_current_user(token)
|
||||||
|
|
||||||
@@ -785,62 +655,15 @@ class ScheduledTaskService:
|
|||||||
async def _deliver_element_result(self, task: dict[str, Any], result_text: str) -> None:
|
async def _deliver_element_result(self, task: dict[str, Any], result_text: str) -> None:
|
||||||
if self._element_client is None:
|
if self._element_client is None:
|
||||||
return
|
return
|
||||||
body = f"【定时任务】{task['name']}\n\n{result_text}"
|
profile = await self.store.get_delivery_profile(task["user_id"])
|
||||||
# 1) Author
|
|
||||||
await self._deliver_to_user(task["user_id"], task, body)
|
|
||||||
# 2) Subscribers who opted into notifications
|
|
||||||
try:
|
|
||||||
subscribers = await self.store.list_notify_subscribers(task["task_id"])
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to list scheduled task subscribers for %s", task["task_id"])
|
|
||||||
subscribers = []
|
|
||||||
for sub in subscribers:
|
|
||||||
sub_user_id = sub.get("user_id")
|
|
||||||
if not sub_user_id or sub_user_id == task["user_id"]:
|
|
||||||
continue
|
|
||||||
await self._deliver_to_user(sub_user_id, task, body)
|
|
||||||
|
|
||||||
async def _deliver_to_user(self, user_id: str, task: dict[str, Any], body: str) -> None:
|
|
||||||
if self._element_client is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
profile = await self.store.get_delivery_profile(user_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to load delivery profile for user %s", user_id)
|
|
||||||
return
|
|
||||||
element_user_id = profile.get("element_user_id")
|
element_user_id = profile.get("element_user_id")
|
||||||
if not isinstance(element_user_id, str) or not element_user_id.strip():
|
if not isinstance(element_user_id, str) or not element_user_id.strip():
|
||||||
return
|
return
|
||||||
existing_room_id: str | None = profile.get("element_room_id") or None
|
existing_room_id: str | None = profile.get("element_room_id") or None
|
||||||
|
body = f"【定时任务】{task['name']}\n\n{result_text}"
|
||||||
try:
|
try:
|
||||||
room_id = await self._element_client.send_direct_message(element_user_id.strip(), body, room_id=existing_room_id)
|
room_id = await self._element_client.send_direct_message(element_user_id.strip(), body, room_id=existing_room_id)
|
||||||
if room_id and room_id != existing_room_id:
|
if room_id and room_id != existing_room_id:
|
||||||
await self.store.update_delivery_profile(user_id, {"element_room_id": room_id})
|
await self.store.update_delivery_profile(task["user_id"], {"element_room_id": room_id})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to deliver scheduled task %s to Element user %s", task["task_id"], element_user_id)
|
logger.exception("Failed to deliver scheduled task %s to Element user %s", task["task_id"], element_user_id)
|
||||||
|
|
||||||
# --- Publish / subscribe ---
|
|
||||||
|
|
||||||
async def publish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
|
||||||
return await self.store.set_task_published(task_id, user_id, published=True)
|
|
||||||
|
|
||||||
async def unpublish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
|
||||||
return await self.store.set_task_published(task_id, user_id, published=False)
|
|
||||||
|
|
||||||
async def subscribe_task(
|
|
||||||
self, user_id: str, task_id: str, *, notify_element: bool
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
task = await self.store.get_task_any(task_id)
|
|
||||||
if task is None:
|
|
||||||
return None
|
|
||||||
if task["user_id"] == user_id:
|
|
||||||
# Authors don't need to subscribe to their own tasks.
|
|
||||||
return None
|
|
||||||
existing = await self.store.get_subscription(task_id, user_id)
|
|
||||||
if existing is None and not task.get("published"):
|
|
||||||
# First-time subscription requires the task to currently be published.
|
|
||||||
return None
|
|
||||||
return await self.store.upsert_subscription(task_id, user_id, notify_element=notify_element)
|
|
||||||
|
|
||||||
async def unsubscribe_task(self, user_id: str, task_id: str) -> bool:
|
|
||||||
return await self.store.delete_subscription(task_id, user_id)
|
|
||||||
+18
-108
@@ -91,105 +91,9 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
tmp_path = Path(tmp_file.name)
|
tmp_path = Path(tmp_file.name)
|
||||||
tmp_path.replace(target)
|
tmp_path.replace(target)
|
||||||
|
|
||||||
def _open_skill_archive(self, archive_path: str | Path, *, require_skill_suffix: bool):
|
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
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 require_skill_suffix and path.suffix != ".skill":
|
|
||||||
raise ValueError("File must have .skill extension")
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
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 (
|
from deerflow.skills.installer import (
|
||||||
SkillAlreadyExistsError,
|
SkillAlreadyExistsError,
|
||||||
_move_staged_skill_into_reserved_target,
|
_move_staged_skill_into_reserved_target,
|
||||||
@@ -199,7 +103,14 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
)
|
)
|
||||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||||
|
|
||||||
logger.info("Installing skill from %s (overwrite=%s)", archive_path, overwrite)
|
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":
|
||||||
|
raise ValueError("File must have .skill extension")
|
||||||
|
|
||||||
custom_dir = self._host_root / "custom"
|
custom_dir = self._host_root / "custom"
|
||||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
custom_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -207,7 +118,14 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
tmp_path = Path(tmp)
|
tmp_path = Path(tmp)
|
||||||
|
|
||||||
with self._open_skill_archive(archive_path, require_skill_suffix=require_skill_suffix) as zf:
|
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:
|
||||||
safe_extract_skill_archive(zf, tmp_path)
|
safe_extract_skill_archive(zf, tmp_path)
|
||||||
|
|
||||||
skill_dir = resolve_skill_dir_from_archive(tmp_path)
|
skill_dir = resolve_skill_dir_from_archive(tmp_path)
|
||||||
@@ -218,16 +136,9 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||||
raise ValueError(f"Invalid skill name: {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
|
target = custom_dir / skill_name
|
||||||
replaced = False
|
|
||||||
if target.exists():
|
if target.exists():
|
||||||
if not overwrite:
|
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
|
||||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
|
|
||||||
shutil.rmtree(target)
|
|
||||||
replaced = True
|
|
||||||
|
|
||||||
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
|
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
|
||||||
|
|
||||||
@@ -241,7 +152,6 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
"success": True,
|
"success": True,
|
||||||
"skill_name": skill_name,
|
"skill_name": skill_name,
|
||||||
"message": f"Skill '{skill_name}' installed successfully",
|
"message": f"Skill '{skill_name}' installed successfully",
|
||||||
"overwritten": replaced,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
||||||
|
|||||||
+1
-5
@@ -17,7 +17,6 @@ def setup_agent(
|
|||||||
soul: str,
|
soul: str,
|
||||||
description: str,
|
description: str,
|
||||||
runtime: ToolRuntime,
|
runtime: ToolRuntime,
|
||||||
name: str | None = None,
|
|
||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
) -> Command:
|
) -> Command:
|
||||||
"""Setup the custom DeerFlow agent.
|
"""Setup the custom DeerFlow agent.
|
||||||
@@ -25,8 +24,6 @@ def setup_agent(
|
|||||||
Args:
|
Args:
|
||||||
soul: Full SOUL.md content defining the agent's personality and behavior.
|
soul: Full SOUL.md content defining the agent's personality and behavior.
|
||||||
description: One-line description of what the agent does.
|
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.
|
skills: Optional list of skill names this agent should use. None or empty means no skills.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -43,8 +40,7 @@ def setup_agent(
|
|||||||
|
|
||||||
if agent_name:
|
if agent_name:
|
||||||
# If agent_name is provided, we are creating a custom agent in the agents/ directory
|
# If agent_name is provided, we are creating a custom agent in the agents/ directory
|
||||||
display_name = (name or "").strip() or agent_name
|
config_data: dict = {"id": agent_name, "name": agent_name}
|
||||||
config_data: dict = {"id": agent_name, "name": display_name}
|
|
||||||
if description:
|
if description:
|
||||||
config_data["description"] = description
|
config_data["description"] = description
|
||||||
if skills is not None:
|
if skills is not None:
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
---
|
|
||||||
name: intent-clarification-policy
|
|
||||||
description: Use this skill to analyze a higher-level task directive before planning. It helps infer the superior intent, assess whether the task is clear enough to proceed, and ask concise clarification questions when key purpose, audience, effect, boundary, coordination, or evaluation details are missing.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Intent Clarification Policy
|
|
||||||
|
|
||||||
Use this skill before drafting a plan for a higher-level task directive, action requirement, special work arrangement, cognitive-domain task, public opinion/information/psychological/influence-related task, or cross-department mission.
|
|
||||||
|
|
||||||
This skill does not draft the full plan. It prepares the intent analysis and decides whether clarification is needed first.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Restate the task briefly to avoid misreading.
|
|
||||||
2. Assess clarity across seven dimensions.
|
|
||||||
3. Assign a clarity level: A, B, C, or D.
|
|
||||||
4. If level A or B, produce a concise intent analysis.
|
|
||||||
5. If level C or D, call `ask_clarification` before planning.
|
|
||||||
|
|
||||||
## Clarity Dimensions
|
|
||||||
|
|
||||||
Check whether the task clearly states:
|
|
||||||
|
|
||||||
- Goal: what higher-level purpose or end state is expected.
|
|
||||||
- Object: who or what the task is directed toward.
|
|
||||||
- Effect: what cognitive, attitude, behavior, posture, or environment change is desired.
|
|
||||||
- Time window: when results are expected.
|
|
||||||
- Boundary: legal, policy, ethical, authorization, escalation, geographic, and resource limits.
|
|
||||||
- Coordination: who leads, supports, approves, or assesses.
|
|
||||||
- Evaluation: how completion and effect will be judged.
|
|
||||||
|
|
||||||
## Clarity Levels
|
|
||||||
|
|
||||||
- **A - Highly clear**: goal, object, effect, time, and boundary are mostly clear. Proceed to intent analysis.
|
|
||||||
- **B - Basically clear**: core purpose is clear, but some boundary or metric details are missing. Provide tentative analysis and list a few follow-up questions.
|
|
||||||
- **C - Clearly vague**: only a broad direction is given; object, effect, or boundary is missing. Ask clarification first.
|
|
||||||
- **D - Not enough context**: original task text or background is absent. Ask the user to provide the task material.
|
|
||||||
|
|
||||||
## Intent Analysis Mode
|
|
||||||
|
|
||||||
Use this mode for level A or B tasks.
|
|
||||||
|
|
||||||
Start with:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
上级任务可概括为:
|
|
||||||
在【时间/阶段】内,围绕【背景/事件】,面向【目标对象】,通过【主要行动方向】,达成【期望效果】,服务于【上级目标】。
|
|
||||||
```
|
|
||||||
|
|
||||||
Then analyze only the points supported by the task text:
|
|
||||||
|
|
||||||
- Superior purpose: why the task was assigned and what higher objective it serves.
|
|
||||||
- Task nature: shaping, deterrence, stabilization, protection, weakening, exposure, support, or assessment.
|
|
||||||
- Target objects: primary objects, secondary objects, protected groups, and possible unintended affected groups.
|
|
||||||
- Expected effects: desired changes in cognition, attitude, judgment, behavior, posture, or environment.
|
|
||||||
- Action boundaries: authorization, policy, legal, ethical, timing, geography, and resource constraints.
|
|
||||||
- Evaluation method: indicators, feedback channels, acceptance criteria, or effect assessment.
|
|
||||||
|
|
||||||
If the task is cognitive-domain related, identify one or more task types:
|
|
||||||
|
|
||||||
- Shaping: influence the narrative or perception environment in advance.
|
|
||||||
- Deterrence: affect an opponent's cost-benefit judgment.
|
|
||||||
- Stabilization: reduce panic, misunderstanding, uncertainty, or loss of trust.
|
|
||||||
- Protection: resist false information, psychological pressure, or cognitive manipulation.
|
|
||||||
- Weakening: reduce an opponent's credibility, mobilization, or influence capacity.
|
|
||||||
- Exposure: reveal facts, attribute responsibility, or strengthen credibility.
|
|
||||||
- Support: support military, diplomatic, public affairs, cyber, or civil action.
|
|
||||||
- Assessment: monitor the cognitive environment or action effect.
|
|
||||||
|
|
||||||
Summarize inferred intent with:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 上级意图推断
|
|
||||||
|
|
||||||
上级希望在【时间窗口】内,围绕【事件/议题/背景】,通过【本级行动方向】,影响【主要对象】对【关键问题】的理解、判断或行为,使其从【当前状态】转向【期望状态】,从而支撑【上级战略/战役/行动/业务目标】。
|
|
||||||
|
|
||||||
本任务的核心不是单纯完成【具体动作】,而是通过该动作形成【认知效果/行为效果/环境效果】。
|
|
||||||
```
|
|
||||||
|
|
||||||
## Clarification Mode
|
|
||||||
|
|
||||||
Use this mode for level C or D tasks. Do not draft a full plan yet.
|
|
||||||
|
|
||||||
Diagnose missing information in these categories:
|
|
||||||
|
|
||||||
- Purpose unclear: higher-level objective or desired end state is missing.
|
|
||||||
- Object unclear: primary audience, affected group, area, platform, or stakeholder is missing.
|
|
||||||
- Effect unclear: expected cognitive, attitude, behavior, or environment change is missing.
|
|
||||||
- Means unclear: allowed and prohibited action methods are missing.
|
|
||||||
- Boundary unclear: legal, policy, ethical, authorization, or escalation limits are missing.
|
|
||||||
- Coordination unclear: lead, support, approval, and assessment roles are missing.
|
|
||||||
- Evaluation unclear: completion criteria and effect metrics are missing.
|
|
||||||
|
|
||||||
Ask the minimum number of questions needed to proceed. Prefer one main question with 2-4 options. If several key fields are missing, ask for the most blocking one first.
|
|
||||||
|
|
||||||
Use `ask_clarification`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
ask_clarification(
|
|
||||||
question="一个用户可以快速回答的具体问题。",
|
|
||||||
clarification_type="missing_info",
|
|
||||||
context="简要说明为什么这个信息会影响后续计划拟制。",
|
|
||||||
options=["选项一", "选项二", "选项三"],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Choose `clarification_type`:
|
|
||||||
|
|
||||||
- `missing_info`: required task material or key detail is absent.
|
|
||||||
- `ambiguous_requirement`: the directive has multiple plausible interpretations.
|
|
||||||
- `approach_choice`: several valid task directions exist and user preference matters.
|
|
||||||
- `risk_confirmation`: the action may be sensitive, irreversible, escalatory, or policy-relevant.
|
|
||||||
- `suggestion`: you recommend a framing and need user approval.
|
|
||||||
|
|
||||||
## High-Value Questions
|
|
||||||
|
|
||||||
Use or adapt these questions when needed:
|
|
||||||
|
|
||||||
- 上级下发该任务主要服务于什么目标?
|
|
||||||
- 任务完成后,上级希望看到什么状态变化?
|
|
||||||
- 本任务主要面向哪些对象?
|
|
||||||
- 是否有不能影响、不能触碰或需要特别保护的对象?
|
|
||||||
- 希望目标对象在认知、态度、判断或行为上发生什么变化?
|
|
||||||
- 该效果是短期即时效果,还是长期环境塑造效果?
|
|
||||||
- 本级在任务中是主责、支援、协同、评估,还是审批角色?
|
|
||||||
- 有哪些法律、政策、授权、伦理或升级风险边界?
|
|
||||||
- 如何判断任务完成,如何评估实际效果?
|
|
||||||
|
|
||||||
## Style Rules
|
|
||||||
|
|
||||||
- Use Chinese when the user writes Chinese.
|
|
||||||
- Be concise and structured.
|
|
||||||
- Do not invent unsupported intent; label uncertain points as assumptions.
|
|
||||||
- Do not proceed to full planning when clarity is C or D.
|
|
||||||
- For sensitive influence, public opinion, psychological, or cognitive-domain work, keep analysis lawful, ethical, defensive, and policy-bounded.
|
|
||||||
@@ -52,63 +52,6 @@ async def test_agent_store_only_owner_can_update_or_delete() -> None:
|
|||||||
assert await store.delete("private-a", "user-a") is True
|
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:
|
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
|
||||||
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
|
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
|
||||||
|
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"""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("…")
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
"""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
|
|
||||||
)
|
|
||||||
Generated
-2
@@ -745,7 +745,6 @@ version = "0.1.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "croniter" },
|
|
||||||
{ name = "deerflow-harness" },
|
{ name = "deerflow-harness" },
|
||||||
{ name = "dingtalk-stream" },
|
{ name = "dingtalk-stream" },
|
||||||
{ name = "email-validator" },
|
{ name = "email-validator" },
|
||||||
@@ -779,7 +778,6 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
{ name = "croniter", specifier = ">=6.2.2" },
|
|
||||||
{ name = "deerflow-harness", editable = "packages/harness" },
|
{ name = "deerflow-harness", editable = "packages/harness" },
|
||||||
{ name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" },
|
{ name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" },
|
||||||
{ name = "dingtalk-stream", specifier = ">=0.24.3" },
|
{ name = "dingtalk-stream", specifier = ">=0.24.3" },
|
||||||
|
|||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
BACKEND_DIR="${ROOT_DIR}/offline-backend-20260512/backend"
|
|
||||||
FRONTEND_DIR="${ROOT_DIR}/frontend-web"
|
|
||||||
RUNTIME_DIR="${ROOT_DIR}/.runtime"
|
|
||||||
LOG_DIR="${RUNTIME_DIR}/logs"
|
|
||||||
PID_DIR="${RUNTIME_DIR}/pids"
|
|
||||||
|
|
||||||
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
|
|
||||||
BACKEND_PORT="${BACKEND_PORT:-8001}"
|
|
||||||
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
|
|
||||||
FRONTEND_PORT="${FRONTEND_PORT:-5174}"
|
|
||||||
|
|
||||||
detect_host_ip() {
|
|
||||||
if command -v hostname >/dev/null 2>&1; then
|
|
||||||
hostname -I 2>/dev/null | awk '{print $1}'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
HOST_IP="${HOST_IP:-$(detect_host_ip)}"
|
|
||||||
HOST_IP="${HOST_IP:-127.0.0.1}"
|
|
||||||
|
|
||||||
BACKEND_PUBLIC_BASE_URL="${BACKEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${BACKEND_PORT}}"
|
|
||||||
FRONTEND_PUBLIC_BASE_URL="${FRONTEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${FRONTEND_PORT}}"
|
|
||||||
|
|
||||||
GATEWAY_CORS_ORIGINS="${GATEWAY_CORS_ORIGINS:-http://localhost:${FRONTEND_PORT},http://127.0.0.1:${FRONTEND_PORT},http://${HOST_IP}:${FRONTEND_PORT}}"
|
|
||||||
|
|
||||||
mkdir -p "${LOG_DIR}" "${PID_DIR}"
|
|
||||||
|
|
||||||
is_running() {
|
|
||||||
local pid_file="$1"
|
|
||||||
[[ -f "${pid_file}" ]] || return 1
|
|
||||||
|
|
||||||
local pid
|
|
||||||
pid="$(cat "${pid_file}" 2>/dev/null || true)"
|
|
||||||
[[ -n "${pid}" ]] || return 1
|
|
||||||
kill -0 "${pid}" >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_by_pid_file() {
|
|
||||||
local name="$1"
|
|
||||||
local pid_file="$2"
|
|
||||||
|
|
||||||
if ! [[ -f "${pid_file}" ]]; then
|
|
||||||
echo "${name} is not running (no pid file)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local pid
|
|
||||||
pid="$(cat "${pid_file}" 2>/dev/null || true)"
|
|
||||||
if [[ -z "${pid}" ]] || ! kill -0 "${pid}" >/dev/null 2>&1; then
|
|
||||||
echo "${name} is not running (stale pid file removed)."
|
|
||||||
rm -f "${pid_file}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Stopping ${name} (pid ${pid})..."
|
|
||||||
# Services are started with setsid, so the pid is also the process group id.
|
|
||||||
# Stop the whole group to avoid leaving Vite/esbuild/uvicorn child processes.
|
|
||||||
kill "-${pid}" >/dev/null 2>&1 || true
|
|
||||||
kill "${pid}" >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
for _ in {1..30}; do
|
|
||||||
if ! kill -0 "${pid}" >/dev/null 2>&1; then
|
|
||||||
rm -f "${pid_file}"
|
|
||||||
echo "${name} stopped."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "${name} did not stop gracefully; killing..."
|
|
||||||
kill -9 "-${pid}" >/dev/null 2>&1 || true
|
|
||||||
kill -9 "${pid}" >/dev/null 2>&1 || true
|
|
||||||
rm -f "${pid_file}"
|
|
||||||
echo "${name} stopped."
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_by_pattern() {
|
|
||||||
local name="$1"
|
|
||||||
local pattern="$2"
|
|
||||||
|
|
||||||
if ! command -v pgrep >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local pids
|
|
||||||
pids="$(pgrep -f "${pattern}" || true)"
|
|
||||||
if [[ -z "${pids}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Stopping ${name} processes matching: ${pattern}"
|
|
||||||
while IFS= read -r pid; do
|
|
||||||
[[ -n "${pid}" ]] || continue
|
|
||||||
[[ "${pid}" != "$$" ]] || continue
|
|
||||||
kill "${pid}" >/dev/null 2>&1 || true
|
|
||||||
done <<<"${pids}"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
"${SCRIPT_DIR}/stop-all.sh"
|
|
||||||
"${SCRIPT_DIR}/start-all.sh"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
"${SCRIPT_DIR}/stop-backend.sh"
|
|
||||||
"${SCRIPT_DIR}/start-backend.sh"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
"${SCRIPT_DIR}/stop-frontend.sh"
|
|
||||||
"${SCRIPT_DIR}/start-frontend.sh"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
"${SCRIPT_DIR}/start-backend.sh"
|
|
||||||
"${SCRIPT_DIR}/start-frontend.sh"
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
|
||||||
|
|
||||||
PID_FILE="${PID_DIR}/backend.pid"
|
|
||||||
LOG_FILE="${LOG_DIR}/backend.log"
|
|
||||||
|
|
||||||
if is_running "${PID_FILE}"; then
|
|
||||||
echo "Backend is already running (pid $(cat "${PID_FILE}"))."
|
|
||||||
echo "URL: http://${HOST_IP}:${BACKEND_PORT}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "${PID_FILE}"
|
|
||||||
|
|
||||||
echo "Starting backend..."
|
|
||||||
echo "Log: ${LOG_FILE}"
|
|
||||||
|
|
||||||
setsid bash -c '
|
|
||||||
cd "$0" || exit 1
|
|
||||||
exec env \
|
|
||||||
GATEWAY_CORS_ORIGINS="$1" \
|
|
||||||
PYTHONPATH=. \
|
|
||||||
uv run uvicorn app.gateway.app:app \
|
|
||||||
--host "$2" \
|
|
||||||
--port "$3" \
|
|
||||||
--reload
|
|
||||||
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" \
|
|
||||||
>"${LOG_FILE}" 2>&1 &
|
|
||||||
|
|
||||||
echo "$!" >"${PID_FILE}"
|
|
||||||
echo "Backend started (pid $(cat "${PID_FILE}"))."
|
|
||||||
echo "URL: http://${HOST_IP}:${BACKEND_PORT}"
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
|
||||||
|
|
||||||
PID_FILE="${PID_DIR}/frontend.pid"
|
|
||||||
LOG_FILE="${LOG_DIR}/frontend.log"
|
|
||||||
|
|
||||||
if is_running "${PID_FILE}"; then
|
|
||||||
echo "Frontend is already running (pid $(cat "${PID_FILE}"))."
|
|
||||||
echo "URL: ${FRONTEND_PUBLIC_BASE_URL}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "${PID_FILE}"
|
|
||||||
|
|
||||||
echo "Starting frontend..."
|
|
||||||
echo "Log: ${LOG_FILE}"
|
|
||||||
|
|
||||||
setsid bash -c '
|
|
||||||
cd "$0" || exit 1
|
|
||||||
exec env \
|
|
||||||
VITE_BACKEND_BASE_URL="$1" \
|
|
||||||
VITE_LANGGRAPH_BASE_URL="$1/api" \
|
|
||||||
pnpm exec vite \
|
|
||||||
--host "$2" \
|
|
||||||
--port "$3"
|
|
||||||
' "${FRONTEND_DIR}" "${BACKEND_PUBLIC_BASE_URL}" "${FRONTEND_HOST}" "${FRONTEND_PORT}" \
|
|
||||||
>"${LOG_FILE}" 2>&1 &
|
|
||||||
|
|
||||||
echo "$!" >"${PID_FILE}"
|
|
||||||
echo "Frontend started (pid $(cat "${PID_FILE}"))."
|
|
||||||
echo "URL: ${FRONTEND_PUBLIC_BASE_URL}"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
|
||||||
|
|
||||||
print_status() {
|
|
||||||
local name="$1"
|
|
||||||
local pid_file="$2"
|
|
||||||
local url="$3"
|
|
||||||
local pattern="$4"
|
|
||||||
|
|
||||||
if is_running "${pid_file}"; then
|
|
||||||
echo "${name}: running (pid $(cat "${pid_file}"))"
|
|
||||||
echo " ${url}"
|
|
||||||
elif command -v pgrep >/dev/null 2>&1 && pgrep -f "${pattern}" >/dev/null 2>&1; then
|
|
||||||
echo "${name}: running (unmanaged by scripts)"
|
|
||||||
echo " ${url}"
|
|
||||||
else
|
|
||||||
echo "${name}: stopped"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
print_status "Backend" "${PID_DIR}/backend.pid" "http://${HOST_IP}:${BACKEND_PORT}" "${BACKEND_DIR}.*uvicorn app.gateway.app:app.*--port ${BACKEND_PORT}"
|
|
||||||
print_status "Frontend" "${PID_DIR}/frontend.pid" "${FRONTEND_PUBLIC_BASE_URL}" "${FRONTEND_DIR}.*vite.*--port ${FRONTEND_PORT}"
|
|
||||||
echo "Logs: ${LOG_DIR}"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
"${SCRIPT_DIR}/stop-frontend.sh"
|
|
||||||
"${SCRIPT_DIR}/stop-backend.sh"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
|
||||||
|
|
||||||
stop_by_pid_file "Backend" "${PID_DIR}/backend.pid"
|
|
||||||
stop_by_pattern "Backend" "${BACKEND_DIR}.*uvicorn app.gateway.app:app.*--port ${BACKEND_PORT}"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
|
||||||
|
|
||||||
stop_by_pid_file "Frontend" "${PID_DIR}/frontend.pid"
|
|
||||||
stop_by_pattern "Frontend" "${FRONTEND_DIR}.*vite.*--port ${FRONTEND_PORT}"
|
|
||||||
Reference in new issue
Block a user