Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts: # frontend-web/src/components/workspace/agents/agent-card.tsx # frontend-web/src/pages/NewAgentPage.tsx
This commit is contained in:
commit
50bda1ddee
19 files changed
+2562
-1583
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep \"\\\\.tsx$\")",
|
||||
"Bash(awk '{print $NF}')",
|
||||
"Bash(xargs -I {} basename {})"
|
||||
]
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,140 @@
|
||||
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
当前 `frontend-web/src/App.tsx` 同时承载了:
|
||||
|
||||
1. 登录/首页相关路由(如 `/login`、`/landing`)
|
||||
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
|
||||
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
|
||||
|
||||
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
|
||||
|
||||
---
|
||||
|
||||
## 二、目标(你提出的核心诉求)
|
||||
|
||||
你希望:
|
||||
|
||||
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
|
||||
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
|
||||
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
|
||||
|
||||
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构范围
|
||||
|
||||
### 1)保留在 `App.tsx` 的内容
|
||||
|
||||
- 全局 Provider:`HashRouter`、`AppProviders`
|
||||
- 登录/首页路由:
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/login/:username`
|
||||
- `/landing`
|
||||
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
|
||||
|
||||
### 2)迁移出 `App.tsx` 的内容
|
||||
|
||||
将以下与工作台有关的路由从 `App.tsx` 抽离:
|
||||
|
||||
- `/workspace`(重定向)
|
||||
- `/workspace/chats`
|
||||
- `/workspace/chats/:thread_id`
|
||||
- `/workspace/agents`
|
||||
- `/workspace/agents/new`
|
||||
- `/workspace/agents/:agent_id/chats`(重定向)
|
||||
- `/workspace/agents/:agent_id/chats/:thread_id`
|
||||
- `/workspace/scheduled`
|
||||
- `/workspace/scheduled/tasks`
|
||||
- `/workspace/scheduled/runs/:run_id`
|
||||
|
||||
以及配套的包装组件职责:
|
||||
|
||||
- `WorkspaceLayout`
|
||||
- `ChatRuntime`
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的新结构(方案)
|
||||
|
||||
## 方案 A(推荐):新增“业务路由容器组件”
|
||||
|
||||
在 `pages` 下新增一个路由容器组件,例如:
|
||||
|
||||
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
|
||||
|
||||
职责:
|
||||
- 专门维护 `/workspace/*` 相关路由
|
||||
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
|
||||
- 输出一个可被 `App.tsx` 挂载的路由节点
|
||||
|
||||
同时在 `App.tsx` 里只保留:
|
||||
- 登录/首页/404
|
||||
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
|
||||
|
||||
---
|
||||
|
||||
## 五、“再建立一个页面放置导航”的落地解释
|
||||
|
||||
你说的“建立一个页面放置这些导航”,可以有两种理解:
|
||||
|
||||
1. **路由容器页(推荐)**:
|
||||
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
|
||||
- 即上面的 `WorkspaceRoutes.tsx`。
|
||||
|
||||
2. **可见的导航页(可选)**:
|
||||
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
|
||||
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
|
||||
|
||||
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施步骤(开发任务拆解)
|
||||
|
||||
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
|
||||
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
|
||||
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
|
||||
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
|
||||
5. 校验以下访问路径行为一致:
|
||||
- `/workspace` 重定向
|
||||
- chats/agents/scheduled 各页面可正常进入
|
||||
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
|
||||
- 404 不受影响
|
||||
6. 运行前端并做手工回归。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
|
||||
2. `/workspace/*` 全部由新建组件统一管理。
|
||||
3. 功能行为与改造前一致(或符合新定义)。
|
||||
4. 代码可读性提升:登录入口与业务入口职责分离。
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
|
||||
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
|
||||
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
|
||||
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议的命名(可直接采用)
|
||||
|
||||
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
|
||||
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 十、后续可选优化
|
||||
|
||||
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
|
||||
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
|
||||
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
|
||||
@@ -0,0 +1,975 @@
|
||||
# DeerFlow 前端开发文档
|
||||
|
||||
## 目录
|
||||
|
||||
1. [整体目录结构](#1-整体目录结构)
|
||||
2. [路由结构](#2-路由结构)
|
||||
3. [核心数据流与状态管理](#3-核心数据流与状态管理)
|
||||
4. [主要页面组件](#4-主要页面组件)
|
||||
5. [核心业务组件](#5-核心业务组件)
|
||||
6. [核心 Hooks 与工具函数](#6-核心-hooks-与工具函数)
|
||||
7. [API 调用层](#7-api-调用层)
|
||||
8. [国际化机制](#8-国际化机制)
|
||||
9. [主题与样式体系](#9-主题与样式体系)
|
||||
10. [关键业务流程](#10-关键业务流程)
|
||||
11. [项目配置与依赖](#11-项目配置与依赖)
|
||||
12. [常见开发模式](#12-常见开发模式)
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体目录结构
|
||||
|
||||
```
|
||||
frontend-web/src/
|
||||
├── App.tsx # 应用入口,顶层路由 + Provider
|
||||
├── env.ts # 环境变量类型定义
|
||||
├── pages/ # 路由级页面组件
|
||||
│ ├── PageRoutes.tsx # /page/* 根路由容器(含外部系统预留位)
|
||||
│ ├── WorkspaceRoutes.tsx # /page/workspace/* 工作区路由
|
||||
│ ├── LoginPage.tsx # 登录页
|
||||
│ ├── LandingPage.tsx # 营销落地页(强制 dark 主题)
|
||||
│ ├── ChatsPage.tsx # 对话列表
|
||||
│ ├── ChatPage.tsx # 单条对话(核心页面)
|
||||
│ ├── AgentsPage.tsx # 智能体库
|
||||
│ ├── AgentChatPage.tsx # 与智能体对话
|
||||
│ ├── NewAgentPage.tsx # 创建智能体(两步流程)
|
||||
│ ├── ScheduledTasksPage.tsx # 定时任务管理
|
||||
│ ├── ScheduledTaskRunDetailPage.tsx # 定时任务执行详情
|
||||
│ ├── ScheduledChatPage.tsx # 定时任务对话跳转页
|
||||
│ └── NotFoundPage.tsx # 404 页面
|
||||
├── components/ # 业务组件库
|
||||
│ ├── ui/ # shadcn/ui 基础组件(Button、Card、Dialog 等 40+)
|
||||
│ ├── ai-elements/ # AI 交互可视化组件
|
||||
│ │ ├── prompt-input/ # 输入框 Context + 子组件系统
|
||||
│ │ ├── artifact.tsx # 制品展示
|
||||
│ │ ├── canvas.tsx
|
||||
│ │ ├── conversation.tsx # 虚拟滚动对话容器
|
||||
│ │ ├── message.tsx
|
||||
│ │ ├── model-selector.tsx
|
||||
│ │ ├── code-block.tsx
|
||||
│ │ └── shimmer.tsx
|
||||
│ ├── workspace/ # 工作区业务组件(详见第 5 节)
|
||||
│ └── landing/ # 落地页组件
|
||||
├── core/ # 核心业务逻辑层(非 UI)
|
||||
│ ├── api/ # API 客户端(LangGraph SDK + Fetch)
|
||||
│ ├── auth/ # 认证管理
|
||||
│ ├── config/ # 环境配置 & Base URL
|
||||
│ ├── i18n/ # 国际化(上下文、翻译文件、语言检测)
|
||||
│ ├── threads/ # 对话线程管理(最核心)
|
||||
│ ├── agents/ # 智能体 CRUD
|
||||
│ ├── models/ # 模型列表
|
||||
│ ├── settings/ # 本地设置(LocalStorage 发布订阅)
|
||||
│ ├── uploads/ # 文件上传
|
||||
│ ├── artifacts/ # 制品管理
|
||||
│ ├── memory/ # 用户记忆
|
||||
│ ├── skills/ # 技能管理
|
||||
│ ├── scheduled-tasks/ # 定时任务
|
||||
│ ├── messages/ # 消息处理工具函数
|
||||
│ ├── tasks/ # 子任务 Context
|
||||
│ ├── notification/ # 系统通知
|
||||
│ └── utils/ # 通用工具(文件下载、日期格式化等)
|
||||
└── hooks/ # 全局自定义 hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 路由结构
|
||||
|
||||
> 项目使用 **HashRouter**,所有路由访问形如 `http://host/#/xxx`
|
||||
|
||||
### 2.1 App.tsx 顶层路由
|
||||
|
||||
| 路径 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | Navigate | 重定向到 `/login` |
|
||||
| `/login` | LoginPage | 登录(自动登录,无表单) |
|
||||
| `/login/:username` | LoginPage | 带用户名登录 |
|
||||
| `/landing` | LandingPage | 营销落地页(固定 dark 主题) |
|
||||
| `/page/*` | PageRoutes | 应用内容根容器 |
|
||||
| `*` | NotFoundPage | 404 |
|
||||
|
||||
### 2.2 PageRoutes.tsx(`/page/*`)
|
||||
|
||||
```
|
||||
/page/
|
||||
├── index → Navigate to "workspace"
|
||||
├── workspace/* → WorkspaceRoutes
|
||||
└── [TODO 预留] → 外部系统路由挂载位置
|
||||
```
|
||||
|
||||
### 2.3 WorkspaceRoutes.tsx(`/page/workspace/*`)
|
||||
|
||||
| 路径 | 页面组件 | 布局包裹 |
|
||||
|------|---------|--------|
|
||||
| `index` | Navigate → `chats/new` | - |
|
||||
| `chats` | ChatsPage | WorkspaceLayout |
|
||||
| `chats/:thread_id` | ChatPage | WorkspaceLayout + ChatRuntime |
|
||||
| `agents` | AgentsPage | WorkspaceLayout |
|
||||
| `agents/new` | NewAgentPage | WorkspaceLayout |
|
||||
| `agents/:agent_id/chats` | Navigate → `/page/workspace/agents` | - |
|
||||
| `agents/:agent_id/chats/:thread_id` | AgentChatPage | WorkspaceLayout + ChatRuntime |
|
||||
| `scheduled` | Navigate → `.../scheduled/tasks` | - |
|
||||
| `scheduled/tasks` | ScheduledTasksPage | WorkspaceLayout |
|
||||
| `scheduled/runs/:run_id` | ScheduledTaskRunDetailPage | WorkspaceLayout |
|
||||
|
||||
### 2.4 布局 Provider 层级结构
|
||||
|
||||
```
|
||||
WorkspaceLayout
|
||||
├── SidebarProvider(侧边栏展开/收起状态)
|
||||
│ ├── WorkspaceSidebar
|
||||
│ │ ├── WorkspaceHeader(Logo、新建按钮)
|
||||
│ │ ├── WorkspaceNavChatList(Chats / Agents / Scheduled 导航)
|
||||
│ │ ├── RecentChatList(最近会话列表)
|
||||
│ │ └── WorkspaceNavMenu(Settings / About / GitHub)
|
||||
│ ├── SidebarInset(主内容区)
|
||||
│ │ └── [Page Content]
|
||||
│ ├── CommandPalette(快捷命令面板)
|
||||
│ └── Toaster(全局通知)
|
||||
|
||||
ChatRuntime(仅 ChatPage / AgentChatPage 使用)
|
||||
├── SubtasksProvider(子任务执行状态)
|
||||
├── ArtifactsProvider(制品选中/打开状态)
|
||||
└── PromptInputProvider(输入框内容/附件状态)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心数据流与状态管理
|
||||
|
||||
### 3.1 全局 Provider 架构
|
||||
|
||||
```
|
||||
App
|
||||
└── HashRouter
|
||||
└── AppProviders(useLocation 驱动主题判断)
|
||||
├── NextThemesProvider
|
||||
│ └── attribute="class",/landing 强制 forcedTheme="dark"
|
||||
├── I18nProvider
|
||||
│ └── initialLocale = detectLocale()(从 cookie 或浏览器语言)
|
||||
└── QueryClientProvider(TanStack React Query 全局缓存)
|
||||
```
|
||||
|
||||
### 3.2 全局 Context 列表
|
||||
|
||||
| Context | 文件 | 职责 | 消费 Hook |
|
||||
|---------|------|------|-----------|
|
||||
| `I18nContext` | `core/i18n/context.tsx` | 当前语言 + 翻译对象 | `useI18n()` |
|
||||
| `SubtaskContext` | `core/tasks/context.tsx` | 实时子任务执行状态 | `useSubtaskContext()` |
|
||||
| `ArtifactsContext` | `components/workspace/artifacts/context.tsx` | 制品选中/面板开关 | `useArtifacts()` |
|
||||
| `PromptInputContext` | `components/ai-elements/prompt-input/` | 输入框内容、附件列表 | `usePromptInputController()` |
|
||||
| `ThreadContext` | `components/workspace/messages/context.tsx` | 当前 thread 对象下传 | 直接读 context.thread |
|
||||
|
||||
### 3.3 LocalStorage 数据
|
||||
|
||||
**操作层:** `core/settings/store.ts`(发布-订阅,`useSyncExternalStore` 驱动响应式)
|
||||
|
||||
| Key | 内容 |
|
||||
|-----|------|
|
||||
| `deerflow.auth` | `{ access_token, token_type, user_id, email }` |
|
||||
| `deerflow.local-settings` | `{ notification, context: { model_name, mode, reasoning_effort } }` |
|
||||
| `deerflow.thread-model.{threadId}` | 每条线程的模型覆盖 |
|
||||
|
||||
### 3.4 SessionStorage 数据
|
||||
|
||||
| Key | 内容 |
|
||||
|-----|------|
|
||||
| `deerflow.run-id.{threadId}` | WebSocket run_id,用于断线重连 |
|
||||
|
||||
### 3.5 React Query 缓存 Key
|
||||
|
||||
| Query Key | 数据 |
|
||||
|-----------|------|
|
||||
| `["threads"]` | 线程列表 |
|
||||
| `["thread", threadId]` | 单条线程 |
|
||||
| `["agents"]` | 智能体列表 |
|
||||
| `["models"]` | 模型列表 |
|
||||
| `["skills"]` | 技能列表 |
|
||||
| `["memory"]` | 用户记忆 |
|
||||
| `["scheduled-tasks"]` | 定时任务列表 |
|
||||
| `["scheduled-runs", taskId]` | 任务执行记录 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 主要页面组件
|
||||
|
||||
### 4.1 ChatPage.tsx — 单条对话页面
|
||||
|
||||
**职责:** 管理单条聊天线程的完整交互,包括消息流、输入、工具调用展示、制品等。
|
||||
|
||||
**关键状态:**
|
||||
|
||||
```typescript
|
||||
const [threadId, setThreadId] = useState(routeThreadId || uuid());
|
||||
const [isNewThread, setIsNewThread] = useState(!routeThreadId || routeThreadId === "new");
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
```
|
||||
|
||||
**核心 Hook:**
|
||||
|
||||
```typescript
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// 不刷新页面,直接更新 URL hash
|
||||
history.replaceState(null, "", `...#/page/workspace/chats/${createdThreadId}`);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
// 后台通知
|
||||
showNotification(state.title, { body: lastMessageText });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**主要子组件:**
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `<MessageList>` | 消息渲染(含工具调用、子任务、制品) |
|
||||
| `<Welcome>` | 新对话欢迎屏 |
|
||||
| `<InputBox>` | 输入框(模式选择、附件、发送) |
|
||||
| `<ThreadTitle>` | 线程标题(自动生成后同步更新) |
|
||||
| `<ExportTrigger>` | 导出对话按钮 |
|
||||
| `<ArtifactTrigger>` | 制品面板开关 |
|
||||
| `<TokenUsageIndicator>` | Token 用量展示(可选) |
|
||||
| `<TodoList>` | 任务清单 |
|
||||
| `<Suggestions>` | 追问建议 |
|
||||
|
||||
**数据流:**
|
||||
|
||||
```
|
||||
用户输入 (InputBox)
|
||||
↓ handleSubmit(message: PromptInputMessage)
|
||||
↓ sendMessage(threadId, message)
|
||||
↓ [内部] 上传文件 → 提交到 LangGraph API
|
||||
↓ WebSocket 流返回消息
|
||||
↓ thread.messages 更新 → MessageList 重渲染
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 AgentChatPage.tsx — 与智能体对话
|
||||
|
||||
与 ChatPage 结构基本一致,差异点:
|
||||
|
||||
- 从路由获取 `agent_id`,传入 `useThreadStream({ assistantId: agent_id })`
|
||||
- Header 展示智能体名称 Badge
|
||||
- "新建对话"跳转到 `/page/workspace/agents/${agent_id}/chats/new`
|
||||
- URL 更新为 `#/page/workspace/agents/${agent_id}/chats/${threadId}`
|
||||
|
||||
---
|
||||
|
||||
### 4.3 NewAgentPage.tsx — 创建智能体(两步流程)
|
||||
|
||||
**Step 1:name(命名 + 选技能)**
|
||||
|
||||
```typescript
|
||||
// 自动预选前 3 个启用的技能
|
||||
useEffect(() => {
|
||||
if (!isSkillSelectionTouched) {
|
||||
setSelectedSkills(enabledSkills.slice(0, 3).map(s => s.name));
|
||||
}
|
||||
}, [enabledSkills]);
|
||||
|
||||
// 提交:验证名称 → 创建智能体
|
||||
await checkAgentName(name); // 检查重名
|
||||
await createAgent(request); // 创建,返回 agent.id
|
||||
setStep("chat"); // 进入 Step 2
|
||||
```
|
||||
|
||||
**Step 2:chat(初始化对话)**
|
||||
|
||||
```typescript
|
||||
// 发送 bootstrap 消息触发智能体初始化
|
||||
sendMessage(threadId, bootstrapMessage, { agent_id: agentId });
|
||||
|
||||
// 监听 setup_agent 工具完成事件
|
||||
onToolEnd: (event) => {
|
||||
if (event.name === "setup_agent") {
|
||||
// 带重试轮询加载 agent 最新信息
|
||||
retryLoadAgent(agentId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 ChatsPage.tsx — 对话列表
|
||||
|
||||
```typescript
|
||||
const { data: threads } = useThreads({ limit: 50, sortBy: "updated_at" });
|
||||
|
||||
// 客户端过滤:排除定时任务线程 + 搜索关键词
|
||||
const filteredThreads = useMemo(() =>
|
||||
threads?.filter(t =>
|
||||
t.metadata?.thread_type !== "scheduler" &&
|
||||
titleOfThread(t).toLowerCase().includes(search)
|
||||
), [threads, search]
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 LoginPage.tsx — 登录
|
||||
|
||||
```typescript
|
||||
// 自动登录,无需用户操作
|
||||
useEffect(() => {
|
||||
loginByUsername(username || "guest")
|
||||
.then(auth => {
|
||||
setStoredAuth(auth);
|
||||
router.replace("/page/workspace/chats/new");
|
||||
})
|
||||
.catch(err => setStatus("error"));
|
||||
}, [username]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.6 ScheduledTasksPage.tsx — 定时任务
|
||||
|
||||
**功能:** 创建/编辑/发布/暂停/恢复定时任务,查看执行历史。
|
||||
|
||||
**Cron 表达式构建:**
|
||||
|
||||
```typescript
|
||||
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
|
||||
|
||||
function buildCron(config): string {
|
||||
// hourly → "15 * * * *"
|
||||
// daily → "0 9 * * *"
|
||||
// weekly → "0 9 * * 1"
|
||||
// monthly → "0 9 1 * *"
|
||||
}
|
||||
```
|
||||
|
||||
**核心操作:**
|
||||
|
||||
```typescript
|
||||
const actions = useScheduledTaskActions();
|
||||
actions.create.mutateAsync(payload);
|
||||
actions.publish.mutateAsync(taskId);
|
||||
actions.pause.mutateAsync(taskId);
|
||||
actions.trigger.mutateAsync(taskId); // 立即执行一次
|
||||
actions.subscribe.mutateAsync(params); // 订阅推送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心业务组件
|
||||
|
||||
### 5.1 消息系统(`components/workspace/messages/`)
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `message-list.tsx` | 消息列表容器,调用 `groupMessages` 分组后渲染 |
|
||||
| `message-list-item.tsx` | 单条消息渲染(含 role 判断) |
|
||||
| `message-group.tsx` | 消息组(human / assistant / processing 等) |
|
||||
| `markdown-content.tsx` | Markdown 渲染(rehype 系列插件) |
|
||||
| `subtask-card.tsx` | 子任务状态卡片 |
|
||||
| `message-token-usage.tsx` | 单条消息 Token 用量 |
|
||||
| `skeleton.tsx` | 流式加载骨架屏 |
|
||||
| `context.tsx` | ThreadContext,向下传递 thread 对象 |
|
||||
|
||||
**消息分组类型(`groupMessages`):**
|
||||
|
||||
```typescript
|
||||
type MessageGroup =
|
||||
| "human" // 用户输入
|
||||
| "assistant:processing" // 推理/工具调用中
|
||||
| "assistant" // 最终响应
|
||||
| "assistant:clarification" // 澄清请求
|
||||
| "assistant:present-files" // 文件呈现
|
||||
| "assistant:subagent"; // 子智能体消息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 输入框(`components/workspace/input-box.tsx`)
|
||||
|
||||
**子组件层级:**
|
||||
|
||||
```
|
||||
InputBox
|
||||
└── PromptInput(Context Provider)
|
||||
├── PromptInputBody
|
||||
│ ├── PromptInputTextarea(文本输入)
|
||||
│ ├── PromptInputAttachments(附件列表)
|
||||
│ └── PromptInputTools(工具栏:附件上传、模式选择)
|
||||
├── PromptInputFooter
|
||||
│ ├── PromptInputSubmit(发送/停止按钮)
|
||||
│ └── Suggestions(追问建议列表)
|
||||
└── ModelSelector(模型下拉选择)
|
||||
```
|
||||
|
||||
**四种会话模式:**
|
||||
|
||||
| Mode | 说明 |
|
||||
|------|------|
|
||||
| `flash` | 快速模式,无推理步骤 |
|
||||
| `thinking` | 推理模式,开启深度思考 |
|
||||
| `pro` | 专业模式,多模态 + 规划 |
|
||||
| `ultra` | 超级模式,启用子智能体 |
|
||||
|
||||
**推理努力等级(mode ≠ flash 时可选):** `minimal` / `low` / `medium` / `high`
|
||||
|
||||
---
|
||||
|
||||
### 5.3 侧边栏(`components/workspace/workspace-sidebar.tsx`)
|
||||
|
||||
```
|
||||
WorkspaceSidebar
|
||||
├── SidebarHeader → WorkspaceHeader
|
||||
│ ├── Logo(可点击,跳转 /page/workspace/chats/new)
|
||||
│ └── 新建对话按钮
|
||||
├── SidebarContent
|
||||
│ ├── WorkspaceNavChatList
|
||||
│ │ ├── 新建对话(/page/workspace/chats/new)
|
||||
│ │ ├── 所有对话(/page/workspace/chats)
|
||||
│ │ ├── 智能体(/page/workspace/agents)
|
||||
│ │ └── 任务管理(/page/workspace/scheduled/tasks)
|
||||
│ └── RecentChatList(最近会话,含搜索)
|
||||
└── SidebarFooter → WorkspaceNavMenu
|
||||
├── Settings(打开设置 Dialog)
|
||||
├── About
|
||||
└── GitHub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 制品管理(`components/workspace/artifacts/`)
|
||||
|
||||
```typescript
|
||||
interface ArtifactsContextType {
|
||||
artifacts: string[]; // artifact ID 列表
|
||||
selectedArtifact: string | null;
|
||||
autoSelect: boolean; // 是否自动选中最新制品
|
||||
open: boolean; // 面板是否展开
|
||||
autoOpen: boolean;
|
||||
select(artifact: string, autoSelect?: boolean): void;
|
||||
deselect(): void;
|
||||
setOpen(open: boolean): void;
|
||||
}
|
||||
```
|
||||
|
||||
**文件:**
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `context.tsx` | ArtifactsContext + Provider |
|
||||
| `artifact-trigger.tsx` | 面板展开/收起按钮 |
|
||||
| `artifact-file-list.tsx` | 制品文件列表 |
|
||||
| `artifact-file-detail.tsx` | 单个制品详情(含代码编辑器) |
|
||||
|
||||
---
|
||||
|
||||
### 5.5 设置面板(`components/workspace/settings/`)
|
||||
|
||||
| 页面文件 | 内容 |
|
||||
|---------|------|
|
||||
| `appearance-settings-page.tsx` | 主题切换(light/dark/system)、语言切换 |
|
||||
| `memory-settings-page.tsx` | 用户记忆(事实列表、总结管理) |
|
||||
| `notification-settings-page.tsx` | 桌面通知开关 |
|
||||
| `skill-settings-page.tsx` | 技能启用/禁用 |
|
||||
| `tool-settings-page.tsx` | 工具配置 |
|
||||
| `about-settings-page.tsx` | 版本号、仓库链接 |
|
||||
|
||||
---
|
||||
|
||||
### 5.6 命令面板(`components/workspace/command-palette.tsx`)
|
||||
|
||||
通过 `Cmd/Ctrl + K` 触发,提供快速导航和操作入口。
|
||||
|
||||
```typescript
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push("/page/workspace/chats/new");
|
||||
setOpen(false);
|
||||
}, [router]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心 Hooks 与工具函数
|
||||
|
||||
### 6.1 useThreadStream(最核心 Hook)
|
||||
|
||||
**位置:** `core/threads/hooks.ts`
|
||||
|
||||
管理 LangGraph WebSocket 连接、消息流、乐观更新。
|
||||
|
||||
**签名:**
|
||||
|
||||
```typescript
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId?: string, // 有值则连接现有线程,无值表示新建
|
||||
context: LocalSettings["context"], // 模型、模式等
|
||||
isMock?: boolean,
|
||||
assistantId?: string, // 默认 "lead_agent"
|
||||
onStart?: (threadId: string) => void,
|
||||
onFinish?: (state: AgentThreadState) => void,
|
||||
onToolEnd?: (event: ToolEndEvent) => void,
|
||||
});
|
||||
```
|
||||
|
||||
**thread 对象关键属性:**
|
||||
|
||||
```typescript
|
||||
thread: {
|
||||
messages: Message[], // 含乐观消息的完整列表
|
||||
isLoading: boolean, // 流是否进行中
|
||||
isThreadLoading: boolean, // 初始线程数据是否加载中
|
||||
values: AgentThreadState, // 线程最终状态
|
||||
error?: Error,
|
||||
stop: async () => void, // 终止当前流
|
||||
}
|
||||
```
|
||||
|
||||
**sendMessage 签名:**
|
||||
|
||||
```typescript
|
||||
sendMessage(
|
||||
threadId: string | undefined,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Partial<AgentThreadContext>,
|
||||
options?: { isMock?: boolean }
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**内部机制:**
|
||||
- **乐观更新:** 用户发送后立即本地展示消息,不等服务端响应
|
||||
- **文件上传:** 自动调用 `uploadFiles()` 后再提交
|
||||
- **run_id 持久化:** sessionStorage 保存,支持页面刷新后断线重连
|
||||
- **消息同步:** 流结束后通过 `queryClient.invalidateQueries(["threads"])` 刷新列表
|
||||
|
||||
---
|
||||
|
||||
### 6.2 useThreadSettings
|
||||
|
||||
**位置:** `core/settings/hooks.ts`
|
||||
|
||||
```typescript
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
|
||||
// settings 结构
|
||||
{
|
||||
notification: { enabled: boolean },
|
||||
context: {
|
||||
model_name?: string,
|
||||
mode: "flash" | "thinking" | "pro" | "ultra",
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high"
|
||||
}
|
||||
}
|
||||
|
||||
// 修改(合并更新)
|
||||
setSettings("context", { mode: "pro" });
|
||||
setSettings("notification", { enabled: true });
|
||||
```
|
||||
|
||||
**工作原理:** `useSyncExternalStore` 监听 localStorage 变化,跨标签页同步。
|
||||
|
||||
---
|
||||
|
||||
### 6.3 useI18n
|
||||
|
||||
**位置:** `core/i18n/hooks.ts`
|
||||
|
||||
```typescript
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
|
||||
t.sidebar.newChat // "New Chat"
|
||||
t.agents.createPageTitle // "Create Agent"
|
||||
t.toolCalls.moreSteps(3) // "Show 3 more steps"
|
||||
|
||||
changeLocale("zh-CN"); // 切换语言,自动存 cookie
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.4 useScheduledTaskActions
|
||||
|
||||
**位置:** `core/scheduled-tasks/hooks.ts`
|
||||
|
||||
```typescript
|
||||
const actions = useScheduledTaskActions();
|
||||
|
||||
// 所有返回值均为 TanStack Query Mutation
|
||||
actions.create // 创建定时任务
|
||||
actions.update // 更新任务配置
|
||||
actions.remove // 删除任务
|
||||
actions.removeRun // 删除执行记录
|
||||
actions.pause // 暂停
|
||||
actions.resume // 恢复
|
||||
actions.trigger // 立即触发一次
|
||||
actions.publish // 发布(激活)
|
||||
actions.unpublish // 取消发布
|
||||
actions.subscribe // 订阅推送
|
||||
actions.updateSubscription
|
||||
actions.unsubscribe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.5 工具函数
|
||||
|
||||
**消息处理(`core/messages/utils.ts`):**
|
||||
|
||||
```typescript
|
||||
groupMessages(messages, mapper) // 按类型分组消息列表
|
||||
extractTextFromMessage(message) // 提取纯文本
|
||||
extractContentFromMessage(message) // 提取 Markdown/图像内容
|
||||
extractReasoningContentFromMessage(msg) // 提取推理过程
|
||||
hasToolCalls(message) // 是否含工具调用
|
||||
hasPresentFiles(message) // 是否包含文件呈现
|
||||
extractPresentFilesFromMessage(message) // 获取文件列表
|
||||
isClarificationToolMessage(message) // 是否为澄清请求
|
||||
```
|
||||
|
||||
**线程路由(`core/threads/utils.ts`):**
|
||||
|
||||
```typescript
|
||||
pathOfThread(thread, context?)
|
||||
// → "/page/workspace/agents/{agentRef}/chats/{threadId}"
|
||||
// 或 "/page/workspace/chats/{threadId}"
|
||||
|
||||
titleOfThread(thread)
|
||||
// → thread.values?.title ?? "Untitled"
|
||||
|
||||
textOfMessage(message)
|
||||
// → 消息文本内容(string 或 content 数组中第一个 text)
|
||||
```
|
||||
|
||||
**其他工具:**
|
||||
|
||||
```typescript
|
||||
// core/utils/files.tsx
|
||||
downloadFile(blob: Blob, filename: string)
|
||||
|
||||
// core/utils/datetime.ts
|
||||
formatTimeAgo(dateString: string) // → "2 小时前" / "3 days ago"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API 调用层
|
||||
|
||||
### 7.1 API 客户端架构
|
||||
|
||||
**两层设计:**
|
||||
|
||||
**第一层:LangGraph SDK Client**(`core/api/api-client.ts`)
|
||||
|
||||
```typescript
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
headers.set("Authorization", getAuthorizationHeaderValue());
|
||||
return { ...init, headers };
|
||||
}
|
||||
});
|
||||
|
||||
export function getAPIClient(isMock?: boolean): LangGraphClient
|
||||
```
|
||||
|
||||
**第二层:Fetch Client**(`core/api/fetch-client.ts`)
|
||||
|
||||
```typescript
|
||||
function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: withAuthHeaders(init?.headers)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 主要 API 模块
|
||||
|
||||
| 模块 | 文件 | 关键函数 |
|
||||
|------|------|---------|
|
||||
| **Auth** | `core/auth/api.ts` | `loginByUsername(username)` → `LoginByUsernameResponse` |
|
||||
| **Agents** | `core/agents/api.ts` | `createAgent()`, `updateAgent()`, `listAgents()`, `deleteAgent()`, `checkAgentName()` |
|
||||
| **Models** | `core/models/api.ts` | `loadModels()` → `Model[]` |
|
||||
| **Threads** | (via LangGraph SDK) | `useThreadStream()`, `useThreads()`, `useDeleteThread()`, `useRenameThread()` |
|
||||
| **Skills** | `core/skills/api.ts` | `loadSkills()`, `enableSkill(name, enabled)` |
|
||||
| **Uploads** | `core/uploads/api.ts` | `uploadFiles(threadId, files[])`, `listUploadedFiles()`, `deleteUploadedFile()` |
|
||||
| **Memory** | `core/memory/api.ts` | `loadMemory()`, `createMemoryFact()`, `updateMemoryFact()`, `deleteMemoryFact()` |
|
||||
| **Scheduled Tasks** | `core/scheduled-tasks/api.ts` | CRUD、publish、pause、resume、trigger、subscribe |
|
||||
|
||||
### 7.3 Base URL 配置
|
||||
|
||||
**文件:** `core/config/index.ts`
|
||||
|
||||
```typescript
|
||||
export function getBackendBaseURL(): string {
|
||||
// env.NEXT_PUBLIC_BACKEND_BASE_URL
|
||||
// 默认:""(当前域名根路径)
|
||||
}
|
||||
|
||||
export function getLangGraphBaseURL(isMock?: boolean): string {
|
||||
// mock 模式:${origin}/mock/api
|
||||
// 正常:${origin}/api/langgraph
|
||||
// 或读取 env.NEXT_PUBLIC_LANGGRAPH_BASE_URL
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 认证流程
|
||||
|
||||
```
|
||||
LoginPage 调用 loginByUsername(username)
|
||||
↓ POST /api/auth/login
|
||||
↓ 返回 { access_token, token_type, user_id, email }
|
||||
↓ setStoredAuth() 存入 localStorage["deerflow.auth"]
|
||||
↓
|
||||
后续所有 API 请求
|
||||
↓ getAuthorizationHeaderValue()
|
||||
↓ → "Bearer {access_token}"
|
||||
↓ 自动添加到 Authorization header
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 国际化机制
|
||||
|
||||
### 8.1 文件结构
|
||||
|
||||
```
|
||||
core/i18n/
|
||||
├── context.tsx # I18nProvider(包裹 App)
|
||||
├── hooks.ts # useI18n()
|
||||
├── locale.ts # detectLocale(),Locale = "en-US" | "zh-CN"
|
||||
├── cookies.ts # cookie 读写(持久化语言选择)
|
||||
├── translations.ts # 翻译映射表入口
|
||||
└── locales/
|
||||
├── types.ts # Translations 接口定义(所有翻译 key 的类型)
|
||||
├── en-US.ts # 英文翻译
|
||||
└── zh-CN.ts # 简体中文翻译
|
||||
```
|
||||
|
||||
### 8.2 Translations 接口分类
|
||||
|
||||
```typescript
|
||||
interface Translations {
|
||||
locale: { localName: string } // 语言自身名称
|
||||
common: { ... } // 通用词汇(确认、取消等)
|
||||
welcome: { ... } // 欢迎屏
|
||||
inputBox: { ... } // 输入框(模式、占位符)
|
||||
sidebar: { ... } // 侧边栏导航
|
||||
agents: { ... } // 智能体相关
|
||||
chats: { ... } // 对话相关
|
||||
workspace: { ... } // 工作区通用
|
||||
settings: { ... } // 设置面板
|
||||
toolCalls: { ... } // 工具调用展示
|
||||
uploads: { ... } // 文件上传
|
||||
subtasks: { ... } // 子任务
|
||||
tokenUsage: { ... } // Token 用量
|
||||
shortcuts: { ... } // 快捷键说明
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 语言检测优先级
|
||||
|
||||
1. 读取 cookie `deerflow.locale`
|
||||
2. 读取浏览器 `navigator.language`
|
||||
3. 回退到 `en-US`
|
||||
|
||||
---
|
||||
|
||||
## 9. 主题与样式体系
|
||||
|
||||
### 9.1 主题系统
|
||||
|
||||
**库:** `next-themes`
|
||||
|
||||
```typescript
|
||||
// App.tsx
|
||||
<NextThemesProvider
|
||||
attribute="class" // 通过 <html class="dark"> 应用主题
|
||||
enableSystem // 跟随系统偏好
|
||||
disableTransitionOnChange // 切换时禁用过渡动画
|
||||
forcedTheme={pathname === "/landing" ? "dark" : undefined}
|
||||
>
|
||||
```
|
||||
|
||||
**可选主题值:** `light` / `dark` / `system`
|
||||
|
||||
### 9.2 样式方案
|
||||
|
||||
**基础:** Tailwind CSS
|
||||
|
||||
**组件库:** shadcn/ui(`components/ui/` 目录,40+ 基础组件)
|
||||
|
||||
**主要 CSS 自定义属性:**
|
||||
|
||||
```css
|
||||
--container-width-sm /* 小容器宽度 */
|
||||
--container-width-md /* 中等容器宽度(对话主区域) */
|
||||
--primary /* 主色 */
|
||||
--background /* 页面背景 */
|
||||
--foreground /* 主文本色 */
|
||||
--muted-foreground /* 次要文本色 */
|
||||
--border /* 边框色 */
|
||||
--sidebar-foreground /* 侧边栏文本 */
|
||||
```
|
||||
|
||||
### 9.3 样式使用规范
|
||||
|
||||
- 工具类优先(Tailwind)
|
||||
- 动态样式用 `cn()` 工具函数合并(基于 `clsx` + `tailwind-merge`)
|
||||
- 主题感知样式使用 CSS 变量(如 `text-muted-foreground`)
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键业务流程
|
||||
|
||||
### 10.1 新建对话流程
|
||||
|
||||
```
|
||||
1. 点击"新建对话" → navigate("/page/workspace/chats/new")
|
||||
2. ChatPage 挂载,threadId = uuid(),isNewThread = true
|
||||
3. 用户输入消息,点击发送
|
||||
4. sendMessage(undefined, message) 调用
|
||||
5. useThreadStream 内部:
|
||||
a. 上传文件(若有)
|
||||
b. 创建 LangGraph 线程,后端返回 threadId
|
||||
c. onStart(threadId) 回调:
|
||||
- setIsNewThread(false)
|
||||
- history.replaceState 更新 URL hash
|
||||
6. WebSocket 流开始接收 AI 消息
|
||||
7. MessageList 实时渲染消息
|
||||
8. 流结束,onFinish 回调,可能触发桌面通知
|
||||
```
|
||||
|
||||
### 10.2 创建智能体流程
|
||||
|
||||
```
|
||||
Step 1(name 步骤)
|
||||
↓ 用户输入名称
|
||||
↓ checkAgentName(name) → 验证是否重名
|
||||
↓ createAgent({ name, skills: selectedSkills })
|
||||
↓ 后端返回 agent.id,setStep("chat")
|
||||
|
||||
Step 2(chat 步骤)
|
||||
↓ 生成临时 threadId
|
||||
↓ useThreadStream({ assistantId: agent.id })
|
||||
↓ 发送 bootstrap 消息(触发智能体自我设置)
|
||||
↓ onToolEnd 监听:event.name === "setup_agent"
|
||||
↓ 指数退避重试加载 agent 信息,确认已保存
|
||||
↓ 显示"Agent Created"完成界面
|
||||
↓ 用户可点击"开始对话"或"返回列表"
|
||||
```
|
||||
|
||||
### 10.3 文件上传流程
|
||||
|
||||
```
|
||||
1. 用户在 InputBox 选择/粘贴文件
|
||||
2. PromptInputAttachments 展示预览列表
|
||||
3. 用户点击发送
|
||||
4. sendMessage 内部调用 uploadFiles(threadId, files)
|
||||
5. POST /api/threads/{threadId}/upload
|
||||
6. 后端返回 { files: [{ filename, size, virtual_path }] }
|
||||
7. 构建 Message additional_kwargs.files
|
||||
8. 完整 Message 提交到 LangGraph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 项目配置与依赖
|
||||
|
||||
### 11.1 主要依赖
|
||||
|
||||
| 包 | 版本 | 用途 |
|
||||
|----|------|------|
|
||||
| `react` | ^18 | 核心框架 |
|
||||
| `react-router-dom` | ^6 | 客户端路由(HashRouter) |
|
||||
| `@tanstack/react-query` | - | 服务端状态缓存 |
|
||||
| `@langchain/langgraph-sdk` | - | LangGraph WebSocket 连接 |
|
||||
| `next-themes` | - | 主题管理 |
|
||||
| `sonner` | - | Toast 通知 |
|
||||
| `lucide-react` | - | 图标库 |
|
||||
| `tailwindcss` | - | CSS 框架 |
|
||||
| `rehype` 系列 | - | Markdown 渲染 |
|
||||
| `uuid` | - | 生成临时 thread ID |
|
||||
|
||||
### 11.2 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `NEXT_PUBLIC_BACKEND_BASE_URL` | 后端接口基础 URL | `""` (当前域名) |
|
||||
| `NEXT_PUBLIC_LANGGRAPH_BASE_URL` | LangGraph API URL | `/api/langgraph` |
|
||||
| `NEXT_PUBLIC_STATIC_WEBSITE_ONLY` | 纯静态演示模式 | `"false"` |
|
||||
|
||||
---
|
||||
|
||||
## 12. 常见开发模式
|
||||
|
||||
### 12.1 添加新工作区页面
|
||||
|
||||
```
|
||||
1. 创建 src/pages/MyPage.tsx
|
||||
2. 在 WorkspaceRoutes.tsx 中添加路由:
|
||||
<Route path="my-page" element={<WorkspaceLayout><MyPage /></WorkspaceLayout>} />
|
||||
3. 在 workspace-nav-chat-list.tsx 中添加导航入口(如需要)
|
||||
4. 更新相关的跳转路径
|
||||
```
|
||||
|
||||
### 12.2 添加新 API 接口
|
||||
|
||||
```
|
||||
1. 在 core/xxx/api.ts 中定义 API 函数(使用 apiFetch 或 getAPIClient())
|
||||
2. 在 core/xxx/hooks.ts 中封装 useQuery / useMutation
|
||||
3. 在页面或组件中调用 hook
|
||||
```
|
||||
|
||||
### 12.3 添加新 Context
|
||||
|
||||
```
|
||||
1. 创建 core/xxx/context.tsx:定义 Context + Provider + 默认值
|
||||
2. 创建消费 hook(如 useXxx())
|
||||
3. 在合适的组件树根部插入 Provider(WorkspaceRoutes 或 ChatRuntime)
|
||||
```
|
||||
|
||||
### 12.4 添加国际化文本
|
||||
|
||||
```
|
||||
1. 在 core/i18n/locales/types.ts 的 Translations 接口中添加字段
|
||||
2. 在 core/i18n/locales/en-US.ts 添加英文内容
|
||||
3. 在 core/i18n/locales/zh-CN.ts 添加中文内容
|
||||
4. 组件中通过 useI18n() 访问:const { t } = useI18n(); t.xxx.yyy
|
||||
```
|
||||
|
||||
### 12.5 引入外部系统路由
|
||||
|
||||
在 `src/pages/PageRoutes.tsx` 中找到 TODO 注释位置,填入:
|
||||
|
||||
```typescript
|
||||
import AnotherSystemRoutes from "外部系统路径";
|
||||
|
||||
// 在 Routes 中添加:
|
||||
<Route path="other/*" element={<AnotherSystemRoutes />} />
|
||||
```
|
||||
|
||||
如外部系统有独立 Provider 依赖,在此文件或 App.tsx 中包裹。
|
||||
|
||||
---
|
||||
|
||||
## 附:关键文件速查
|
||||
|
||||
| 需求 | 关键文件 |
|
||||
|------|---------|
|
||||
| 修改路由结构 | `App.tsx`, `PageRoutes.tsx`, `WorkspaceRoutes.tsx` |
|
||||
| 修改侧边栏导航 | `components/workspace/workspace-nav-chat-list.tsx` |
|
||||
| 修改对话逻辑 | `pages/ChatPage.tsx`, `core/threads/hooks.ts` |
|
||||
| 修改输入框 | `components/workspace/input-box.tsx`, `components/ai-elements/prompt-input/` |
|
||||
| 修改消息渲染 | `components/workspace/messages/` |
|
||||
| 添加 API 接口 | `core/xxx/api.ts` + `core/xxx/hooks.ts` |
|
||||
| 修改全局设置 | `core/settings/store.ts`, `core/settings/hooks.ts` |
|
||||
| 修改翻译文本 | `core/i18n/locales/en-US.ts`, `core/i18n/locales/zh-CN.ts` |
|
||||
| 修改主题 | `App.tsx` (NextThemesProvider) |
|
||||
| 修改认证逻辑 | `core/auth/api.ts`, `pages/LoginPage.tsx` |
|
||||
| 定时任务 | `pages/ScheduledTasksPage.tsx`, `core/scheduled-tasks/` |
|
||||
| 智能体管理 | `pages/NewAgentPage.tsx`, `core/agents/` |
|
||||
+50
-155
@@ -1,155 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { Toaster } from "sonner";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import ChatsPage from "./pages/ChatsPage";
|
||||
import ChatPage from "./pages/ChatPage";
|
||||
import AgentsPage from "./pages/AgentsPage";
|
||||
import AgentChatPage from "./pages/AgentChatPage";
|
||||
import NewAgentPage from "./pages/NewAgentPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./pages/ScheduledTasksPage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider className="h-screen" defaultOpen>
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRuntime({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route
|
||||
path="/workspace"
|
||||
element={<Navigate to="/workspace/chats/new" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/chats"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/scheduled"
|
||||
element={<Navigate to="/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents/new"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents/:agent_id/chats"
|
||||
element={<Navigate to="/workspace/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
import { useMemo } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/workspace/agents/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t.agents.emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/page/workspace/agents/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t.agents.emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
KeyboardIcon,
|
||||
MessageSquarePlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push("/workspace/chats/new");
|
||||
setOpen(false);
|
||||
}, [router]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleShowShortcuts = useCallback(() => {
|
||||
setOpen(false);
|
||||
setShortcutsOpen(true);
|
||||
}, []);
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
|
||||
{ key: "n", meta: true, shift: true, action: handleNewChat },
|
||||
{ key: ",", meta: true, action: handleOpenSettings },
|
||||
{ key: "/", meta: true, action: handleShowShortcuts },
|
||||
],
|
||||
[handleNewChat, handleOpenSettings, handleShowShortcuts],
|
||||
);
|
||||
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.userAgent.includes("Mac"));
|
||||
}, []);
|
||||
const metaKey = isMac ? "⌘" : "Ctrl+";
|
||||
const shiftKey = isMac ? "⇧" : "Shift+";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t.shortcuts.searchActions} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
<CommandItem onSelect={handleNewChat}>
|
||||
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
|
||||
{t.sidebar.newChat}
|
||||
<CommandShortcut>
|
||||
{metaKey}
|
||||
{shiftKey}N
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t.common.settings}
|
||||
<CommandShortcut>{metaKey},</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowShortcuts}>
|
||||
<KeyboardIcon className="mr-2 h-4 w-4" />
|
||||
{t.shortcuts.keyboardShortcuts}
|
||||
<CommandShortcut>{metaKey}/</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.shortcuts.keyboardShortcutsDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{[
|
||||
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
|
||||
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
|
||||
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
|
||||
{ keys: `${metaKey},`, label: t.common.settings },
|
||||
{
|
||||
keys: `${metaKey}/`,
|
||||
label: t.shortcuts.keyboardShortcuts,
|
||||
},
|
||||
].map(({ keys, label }) => (
|
||||
<div key={keys} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
|
||||
{keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
KeyboardIcon,
|
||||
MessageSquarePlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push("/page/workspace/chats/new");
|
||||
setOpen(false);
|
||||
}, [router]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleShowShortcuts = useCallback(() => {
|
||||
setOpen(false);
|
||||
setShortcutsOpen(true);
|
||||
}, []);
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() => [
|
||||
{ key: "k", meta: true, action: () => setOpen((o) => !o) },
|
||||
{ key: "n", meta: true, shift: true, action: handleNewChat },
|
||||
{ key: ",", meta: true, action: handleOpenSettings },
|
||||
{ key: "/", meta: true, action: handleShowShortcuts },
|
||||
],
|
||||
[handleNewChat, handleOpenSettings, handleShowShortcuts],
|
||||
);
|
||||
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.userAgent.includes("Mac"));
|
||||
}, []);
|
||||
const metaKey = isMac ? "⌘" : "Ctrl+";
|
||||
const shiftKey = isMac ? "⇧" : "Shift+";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t.shortcuts.searchActions} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
<CommandItem onSelect={handleNewChat}>
|
||||
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
|
||||
{t.sidebar.newChat}
|
||||
<CommandShortcut>
|
||||
{metaKey}
|
||||
{shiftKey}N
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t.common.settings}
|
||||
<CommandShortcut>{metaKey},</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowShortcuts}>
|
||||
<KeyboardIcon className="mr-2 h-4 w-4" />
|
||||
{t.shortcuts.keyboardShortcuts}
|
||||
<CommandShortcut>{metaKey}/</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.shortcuts.keyboardShortcutsDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{[
|
||||
{ keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette },
|
||||
{ keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat },
|
||||
{ keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar },
|
||||
{ keys: `${metaKey},`, label: t.common.settings },
|
||||
{
|
||||
keys: `${metaKey}/`,
|
||||
label: t.shortcuts.keyboardShortcuts,
|
||||
},
|
||||
].map(({ keys, label }) => (
|
||||
<div key={keys} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
|
||||
{keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCollapseButton({ className }: { className?: string }) {
|
||||
const { open, toggleSidebar } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
|
||||
className,
|
||||
)}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronLeft className="size-4" strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight className="size-4" strokeWidth={2} />
|
||||
)}
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const { state } = useSidebar();
|
||||
|
||||
const titleBlock = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
|
||||
{t.pages.appName}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px] leading-tight">
|
||||
{t.pages.brandSubtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/workspace-header border-sidebar-border/80 border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{state === "collapsed" ? (
|
||||
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
|
||||
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
|
||||
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
|
||||
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-3">
|
||||
<BrandMark />
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
|
||||
<Link href="/workspace/chats/new" className="min-w-0 flex-1 outline-none">
|
||||
{titleBlock}
|
||||
</Link>
|
||||
) : (
|
||||
titleBlock
|
||||
)}
|
||||
<SidebarCollapseButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-bold shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
C
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarCollapseButton({ className }: { className?: string }) {
|
||||
const { open, toggleSidebar } = useSidebar();
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-sidebar-foreground size-8 shrink-0",
|
||||
className,
|
||||
)}
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronLeft className="size-4" strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight className="size-4" strokeWidth={2} />
|
||||
)}
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const { state } = useSidebar();
|
||||
|
||||
const titleBlock = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sidebar-foreground truncate text-sm font-semibold leading-tight tracking-tight">
|
||||
{t.pages.appName}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-[11px] leading-tight">
|
||||
{t.pages.brandSubtitle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/workspace-header border-sidebar-border/80 border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{state === "collapsed" ? (
|
||||
<div className="group/workspace-header relative flex min-h-[4.25rem] items-center justify-center py-3">
|
||||
<div className="rounded-[1.15rem] bg-white/92 p-1.5 shadow-[0_16px_32px_-24px_rgba(15,23,42,0.3)] ring-1 ring-black/4 transition-opacity group-hover/workspace-header:pointer-events-none group-hover/workspace-header:opacity-0">
|
||||
<BrandMark className="size-10 rounded-[0.95rem] text-base" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover/workspace-header:opacity-100">
|
||||
<SidebarCollapseButton className="rounded-2xl bg-white/92 shadow-sm ring-1 ring-black/5" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-3">
|
||||
<BrandMark />
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? (
|
||||
<Link href="/page/workspace/chats/new" className="min-w-0 flex-1 outline-none">
|
||||
{titleBlock}
|
||||
</Link>
|
||||
) : (
|
||||
titleBlock
|
||||
)}
|
||||
<SidebarCollapseButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/workspace/chats/new"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/chats/new">
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>{t.sidebar.chats}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/workspace/scheduled/tasks"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats/new">
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={pathname === "/page/workspace/chats"} asChild>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>{t.sidebar.chats}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/agents")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/agents">
|
||||
<BotIcon />
|
||||
<span>{t.sidebar.agents}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,58 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { AgentThread, AgentThreadContext } from "./types";
|
||||
|
||||
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
|
||||
|
||||
type ThreadRouteTarget =
|
||||
| string
|
||||
| {
|
||||
thread_id: string;
|
||||
context?: ThreadAgentRef | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function pickAgentRef(
|
||||
context: ThreadAgentRef | null | undefined,
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): string | undefined {
|
||||
if (context?.agent_id) return context.agent_id;
|
||||
if (context?.agent_name) return context.agent_name;
|
||||
const metaId = metadata?.agent_id;
|
||||
if (typeof metaId === "string" && metaId) return metaId;
|
||||
const metaName = metadata?.agent_name;
|
||||
if (typeof metaName === "string" && metaName) return metaName;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: ThreadAgentRef | null,
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const agentRef =
|
||||
typeof thread === "string"
|
||||
? pickAgentRef(context, null)
|
||||
: pickAgentRef(thread.context, thread.metadata);
|
||||
|
||||
return agentRef
|
||||
? `/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
: `/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function titleOfThread(thread: AgentThread) {
|
||||
return thread.values?.title ?? "Untitled";
|
||||
}
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { AgentThread, AgentThreadContext } from "./types";
|
||||
|
||||
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
|
||||
|
||||
type ThreadRouteTarget =
|
||||
| string
|
||||
| {
|
||||
thread_id: string;
|
||||
context?: ThreadAgentRef | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function pickAgentRef(
|
||||
context: ThreadAgentRef | null | undefined,
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): string | undefined {
|
||||
if (context?.agent_id) return context.agent_id;
|
||||
if (context?.agent_name) return context.agent_name;
|
||||
const metaId = metadata?.agent_id;
|
||||
if (typeof metaId === "string" && metaId) return metaId;
|
||||
const metaName = metadata?.agent_name;
|
||||
if (typeof metaName === "string" && metaName) return metaName;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: ThreadAgentRef | null,
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const agentRef =
|
||||
typeof thread === "string"
|
||||
? pickAgentRef(context, null)
|
||||
: pickAgentRef(thread.context, thread.metadata);
|
||||
|
||||
return agentRef
|
||||
? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
: `/page/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function titleOfThread(thread: AgentThread) {
|
||||
return thread.values?.title ?? "Untitled";
|
||||
}
|
||||
@@ -1,233 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_id } = useParams<{
|
||||
agent_id: string;
|
||||
}>();
|
||||
|
||||
const { agent } = useAgent(agent_id);
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/workspace/agents/${agent_id}/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const { agent_id } = useParams<{
|
||||
agent_id: string;
|
||||
}>();
|
||||
|
||||
const { agent } = useAgent(agent_id);
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message, { agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
await sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message, { agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">
|
||||
{agent?.name ?? agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/workspace/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">
|
||||
{agent?.name ?? agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
+201
-209
@@ -1,219 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/workspace/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
await sendMessage(threadId, { text: answer, files: [] });
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{mounted ? (
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{mounted ? (
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
type LoginStatus = "loading" | "success" | "error";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
const router = useRouter();
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
const [status, setStatus] = useState<LoginStatus>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function runLogin() {
|
||||
setStatus("loading");
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const auth = await loginByUsername(resolvedUsername);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
setStatus("success");
|
||||
router.replace("/workspace/chats/new");
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Login request failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void runLogin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedUsername, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
|
||||
{status === "error" && (
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
登录失败:{errorMessage || "未知错误"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
type LoginStatus = "loading" | "success" | "error";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
const router = useRouter();
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
const [status, setStatus] = useState<LoginStatus>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function runLogin() {
|
||||
setStatus("loading");
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const auth = await loginByUsername(resolvedUsername);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
setStatus("success");
|
||||
router.replace("/page/workspace/chats/new");
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Login request failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void runLogin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedUsername, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
|
||||
{status === "error" && (
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
登录失败:{errorMessage || "未知错误"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||
<h1 className="text-2xl font-semibold">Page not found</h1>
|
||||
<p className="text-muted-foreground max-w-md text-center text-sm">
|
||||
The Vite rewrite currently focuses on the workspace chat flows first.
|
||||
Marketing, docs, blog, and Next-specific server routes still need a
|
||||
dedicated migration pass.
|
||||
</p>
|
||||
<Link
|
||||
to="/workspace/chats/new"
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||
<h1 className="text-2xl font-semibold">Page not found</h1>
|
||||
<p className="text-muted-foreground max-w-md text-center text-sm">
|
||||
The Vite rewrite currently focuses on the workspace chat flows first.
|
||||
Marketing, docs, blog, and Next-specific server routes still need a
|
||||
dedicated migration pass.
|
||||
</p>
|
||||
<Link
|
||||
to="/page/workspace/chats/new"
|
||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
|
||||
// 根路由容器,统一承接 /page/* 下的所有子系统路由
|
||||
export default function PageRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="workspace" replace />} />
|
||||
|
||||
{/* 现有系统:DeerFlow Workspace */}
|
||||
<Route path="workspace/*" element={<WorkspaceRoutes />} />
|
||||
|
||||
{/* TODO: 引入外部系统路由
|
||||
import AnotherSystemRoutes from "外部系统路径";
|
||||
<Route path="other/*" element={<AnotherSystemRoutes />} />
|
||||
*/}
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useSchedulerThread } from "@/core/scheduled-tasks";
|
||||
|
||||
export default function ScheduledChatPage() {
|
||||
const { data, isLoading, error } = useSchedulerThread();
|
||||
|
||||
if (data?.thread_id) {
|
||||
return <Navigate to={`/workspace/chats/${data.thread_id}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading
|
||||
? "正在打开定时任务助手..."
|
||||
: error
|
||||
? "无法打开定时任务助手"
|
||||
: "准备定时任务助手..."}
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useSchedulerThread } from "@/core/scheduled-tasks";
|
||||
|
||||
export default function ScheduledChatPage() {
|
||||
const { data, isLoading, error } = useSchedulerThread();
|
||||
|
||||
if (data?.thread_id) {
|
||||
return <Navigate to={`/page/workspace/chats/${data.thread_id}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading
|
||||
? "正在打开定时任务助手..."
|
||||
: error
|
||||
? "无法打开定时任务助手"
|
||||
: "准备定时任务助手..."}
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,455 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
downloadScheduledTaskRunFile,
|
||||
getScheduledTaskRunFileContent,
|
||||
type ScheduledTaskResultFileContent,
|
||||
type ScheduledTaskRun,
|
||||
updateScheduledTaskRunFile,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
function formatDuration(run?: ScheduledTaskRun | null) {
|
||||
if (!run?.started_at || !run.finished_at) return "暂无";
|
||||
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
|
||||
if (!Number.isFinite(duration) || duration < 0) return "暂无";
|
||||
return `${Math.round(duration / 1000)} 秒`;
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "等待中";
|
||||
default:
|
||||
return status || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (status === "succeeded") return "default";
|
||||
if (status === "failed") return "destructive";
|
||||
if (status === "running") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
file.kind === "markdown" ||
|
||||
file.mime_type === "text/markdown" ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
}
|
||||
|
||||
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
if (!file.previewable || !file.content) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
该文件不是文本内容,无法直接预览,请下载查看。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={file.content}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
type EditState = {
|
||||
draft: string;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export default function ScheduledTaskRunDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { run_id } = useParams();
|
||||
const actions = useScheduledTaskActions();
|
||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||
const result = run?.result ?? null;
|
||||
const canEdit = run?.can_edit ?? false;
|
||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
// Per-file-index edit state. `null` = not in edit mode.
|
||||
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
if (!result) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
title: result.title,
|
||||
format: result.format,
|
||||
created_at: result.created_at,
|
||||
files: result.files,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadFiles() {
|
||||
if (!run || !result || resultFiles.length === 0) {
|
||||
setFileContents([]);
|
||||
return;
|
||||
}
|
||||
setFilesLoading(true);
|
||||
try {
|
||||
const loaded = await Promise.all(
|
||||
resultFiles.map((f) => {
|
||||
const index = result.files.indexOf(f);
|
||||
return getScheduledTaskRunFileContent(run.id, index);
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setFileContents(loaded);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFilesLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadFiles();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run, result, resultFiles]);
|
||||
|
||||
const handleDownloadResult = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRun(run.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (index: number) => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRunFile(run.id, index);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRun = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(run.id);
|
||||
toast.success("执行记录已删除");
|
||||
navigate("/workspace/scheduled/tasks");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterEdit = useCallback(
|
||||
(file: ScheduledTaskResultFileContent) => {
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: { draft: file.content ?? "", saving: false },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCancelEdit = useCallback((index: number) => {
|
||||
setEdits((prev) => ({ ...prev, [index]: null }));
|
||||
}, []);
|
||||
|
||||
const handleSaveEdit = useCallback(
|
||||
async (file: ScheduledTaskResultFileContent) => {
|
||||
if (!run) return;
|
||||
const current = edits[file.index];
|
||||
if (!current) return;
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: { ...current, saving: true },
|
||||
}));
|
||||
try {
|
||||
const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft);
|
||||
setFileContents((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.index === file.index
|
||||
? { ...entry, content: current.draft, size: updated.size }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
setEdits((prev) => ({ ...prev, [file.index]: null }));
|
||||
toast.success(`文件 ${file.name} 已保存`);
|
||||
} catch (error) {
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: { ...current, saving: false },
|
||||
}));
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
},
|
||||
[edits, run],
|
||||
);
|
||||
|
||||
const handleDownloadAsWord = useCallback(
|
||||
async (file: ScheduledTaskResultFileContent) => {
|
||||
const draft = edits[file.index]?.draft;
|
||||
const source = draft ?? file.content ?? "";
|
||||
try {
|
||||
await downloadMarkdownAsDocx(source, file.name, result?.title);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "导出 Word 失败");
|
||||
}
|
||||
},
|
||||
[edits, result?.title],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col p-3">
|
||||
<Card className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
|
||||
</span>
|
||||
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
|
||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||
执行信息
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/workspace/scheduled/tasks")}>
|
||||
<ListChecks className="size-3" />
|
||||
返回记录
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||
<Download className="size-3" />
|
||||
下载全部
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
|
||||
<Trash2 className="size-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
{/* 执行信息(可折叠) */}
|
||||
{showMeta && run && (
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
|
||||
<div>ID:{run.id}</div>
|
||||
<div>计划:{formatDate(run.scheduled_for)}</div>
|
||||
<div>开始:{formatDate(run.started_at)}</div>
|
||||
<div>结束:{formatDate(run.finished_at)}</div>
|
||||
<div>耗时:{formatDuration(run)}</div>
|
||||
</div>
|
||||
{run.error && (
|
||||
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showMeta && !isLoading && !run && (
|
||||
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
|
||||
没有找到这条执行记录。
|
||||
</div>
|
||||
)}
|
||||
{/* 内容区 */}
|
||||
{!result && run && (
|
||||
<div className="text-muted-foreground p-4 text-sm">
|
||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={result.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{result.content || "无正文内容"}
|
||||
</pre>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="m-0 p-3">
|
||||
{filesLoading && (
|
||||
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
|
||||
)}
|
||||
{!filesLoading && fileContents.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
本次执行没有生成文件。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileContents.map((file) => {
|
||||
const isMarkdown = isMarkdownFile(file);
|
||||
const editable = canEdit && file.previewable;
|
||||
const editState = edits[file.index] ?? null;
|
||||
const isEditing = editState !== null;
|
||||
return (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isMarkdown && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadAsWord(file)}
|
||||
disabled={!file.content && !editState?.draft}
|
||||
title="将 Markdown 导出为 Word"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
{editable && !isEditing && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleEnterEdit(file)}>
|
||||
<Edit3 className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCancelEdit(file.index)}
|
||||
disabled={editState?.saving}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(file)}
|
||||
disabled={editState?.saving}
|
||||
>
|
||||
{editState?.saving ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
className="min-h-80 font-mono text-xs"
|
||||
value={editState?.draft ?? ""}
|
||||
disabled={editState?.saving}
|
||||
onChange={(event) =>
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: {
|
||||
draft: event.target.value,
|
||||
saving: prev[file.index]?.saving ?? false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RenderPreview file={file} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="meta" className="m-0 p-3">
|
||||
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
|
||||
{resultMetadata}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
downloadScheduledTaskRunFile,
|
||||
getScheduledTaskRunFileContent,
|
||||
type ScheduledTaskResultFileContent,
|
||||
type ScheduledTaskRun,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
function formatDuration(run?: ScheduledTaskRun | null) {
|
||||
if (!run?.started_at || !run.finished_at) return "暂无";
|
||||
const duration = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime();
|
||||
if (!Number.isFinite(duration) || duration < 0) return "暂无";
|
||||
return `${Math.round(duration / 1000)} 秒`;
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "等待中";
|
||||
default:
|
||||
return status || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (status === "succeeded") return "default";
|
||||
if (status === "failed") return "destructive";
|
||||
if (status === "running") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: ScheduledTaskResultFileContent) {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
file.kind === "markdown" ||
|
||||
file.mime_type === "text/markdown" ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
}
|
||||
|
||||
function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
if (!file.previewable || !file.content) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
该文件不是文本内容,无法直接预览,请下载查看。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMarkdownFile(file)) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={file.content}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
export default function ScheduledTaskRunDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { run_id } = useParams();
|
||||
const actions = useScheduledTaskActions();
|
||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||
const result = run?.result ?? null;
|
||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
if (!result) return "";
|
||||
return JSON.stringify(
|
||||
{
|
||||
title: result.title,
|
||||
format: result.format,
|
||||
created_at: result.created_at,
|
||||
files: result.files,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadFiles() {
|
||||
if (!run || !result || resultFiles.length === 0) {
|
||||
setFileContents([]);
|
||||
return;
|
||||
}
|
||||
setFilesLoading(true);
|
||||
try {
|
||||
const loaded = await Promise.all(
|
||||
resultFiles.map((f) => {
|
||||
const index = result.files.indexOf(f);
|
||||
return getScheduledTaskRunFileContent(run.id, index);
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setFileContents(loaded);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFilesLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadFiles();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run, result, resultFiles]);
|
||||
|
||||
const handleDownloadResult = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRun(run.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (index: number) => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await downloadScheduledTaskRunFile(run.id, index);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRun = async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(run.id);
|
||||
toast.success("执行记录已删除");
|
||||
navigate("/page/workspace/scheduled/tasks");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col p-3">
|
||||
<Card className="flex min-h-0 flex-1 flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
|
||||
</span>
|
||||
{run && <Badge variant={statusVariant(run.status)} className="shrink-0">{statusLabel(run.status)}</Badge>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setShowMeta((v) => !v)}>
|
||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||
执行信息
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
|
||||
<ListChecks className="size-3" />
|
||||
返回记录
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||
<Download className="size-3" />
|
||||
下载全部
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
|
||||
<Trash2 className="size-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
{/* 执行信息(可折叠) */}
|
||||
{showMeta && run && (
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
|
||||
<div>ID:{run.id}</div>
|
||||
<div>计划:{formatDate(run.scheduled_for)}</div>
|
||||
<div>开始:{formatDate(run.started_at)}</div>
|
||||
<div>结束:{formatDate(run.finished_at)}</div>
|
||||
<div>耗时:{formatDuration(run)}</div>
|
||||
</div>
|
||||
{run.error && (
|
||||
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showMeta && !isLoading && !run && (
|
||||
<div className="text-muted-foreground border-b px-3 py-2 text-xs">
|
||||
没有找到这条执行记录。
|
||||
</div>
|
||||
)}
|
||||
{/* 内容区 */}
|
||||
{!result && run && (
|
||||
<div className="text-muted-foreground p-4 text-sm">
|
||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={result.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{result.content || "无正文内容"}
|
||||
</pre>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="m-0 p-3">
|
||||
{filesLoading && (
|
||||
<div className="text-muted-foreground text-sm">正在加载文件内容...</div>
|
||||
)}
|
||||
{!filesLoading && fileContents.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
本次执行没有生成文件。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileContents.map((file) => (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<RenderPreview file={file} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="meta" className="m-0 p-3">
|
||||
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
|
||||
{resultMetadata}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -686,7 +686,7 @@ export default function ScheduledTasksPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/workspace/scheduled/runs/${run.id}`)}
|
||||
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
查看结果
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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 }: { 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 default function WorkspaceRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="chats"
|
||||
element={<WorkspaceLayout><ChatsPage /></WorkspaceLayout>}
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={<WorkspaceLayout><AgentsPage /></WorkspaceLayout>}
|
||||
/>
|
||||
<Route
|
||||
path="agents/new"
|
||||
element={<WorkspaceLayout><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>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/tasks"
|
||||
element={<WorkspaceLayout><ScheduledTasksPage /></WorkspaceLayout>}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={<WorkspaceLayout><ScheduledTaskRunDetailPage /></WorkspaceLayout>}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user