diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..bdd36c0f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(grep \"\\\\.tsx$\")", + "Bash(awk '{print $NF}')", + "Bash(xargs -I {} basename {})" + ] + } +} diff --git a/frontend-web/docs/hebing-1.md b/frontend-web/docs/hebing-1.md new file mode 100644 index 00000000..e69de29b diff --git a/frontend-web/docs/hebing.md b/frontend-web/docs/hebing.md index e69de29b..4bbe4153 100644 --- a/frontend-web/docs/hebing.md +++ b/frontend-web/docs/hebing.md @@ -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={} />` + +--- + +## 五、“再建立一个页面放置导航”的落地解释 + +你说的“建立一个页面放置这些导航”,可以有两种理解: + +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`,减少硬编码路径。 diff --git a/frontend-web/docs/kaifawendnag.md b/frontend-web/docs/kaifawendnag.md new file mode 100644 index 00000000..1221753b --- /dev/null +++ b/frontend-web/docs/kaifawendnag.md @@ -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 }); + } +}); +``` + +**主要子组件:** + +| 组件 | 职责 | +|------|------| +| `` | 消息渲染(含工具调用、子任务、制品) | +| `` | 新对话欢迎屏 | +| `` | 输入框(模式选择、附件、发送) | +| `` | 线程标题(自动生成后同步更新) | +| `` | 导出对话按钮 | +| `` | 制品面板开关 | +| `` | Token 用量展示(可选) | +| `` | 任务清单 | +| `` | 追问建议 | + +**数据流:** + +``` +用户输入 (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, + options?: { isMock?: boolean } +): Promise +``` + +**内部机制:** +- **乐观更新:** 用户发送后立即本地展示消息,不等服务端响应 +- **文件上传:** 自动调用 `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 { + 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 + 应用主题 + 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 中添加路由: + } /> +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 中添加: +} /> +``` + +如外部系统有独立 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/` | diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 5cfc4851..c1122c19 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -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 ( - - - {children} - - - ); -} - -function WorkspaceLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - - ); -} - -function ChatRuntime({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); -} - -export function App() { - return ( - - - - } /> - } /> - } /> - } /> - } - /> - - - - } - /> - - - - - - } - /> - - - - } - /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - - - - - - } - /> - } /> - - - - ); -} +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 ( + + + {children} + + + ); +} + +export function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index db01ff70..a8e14376 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -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 ( -
- {/* Page header */} -
-
-

{t.agents.title}

-

- {t.agents.description} -

-
- -
- - {/* Content */} -
- {isLoading ? ( -
- {t.common.loading} -
- ) : agents.length === 0 ? ( -
-
- -
-
-

{t.agents.emptyTitle}

-

- {t.agents.emptyDescription} -

-
- -
- ) : ( -
- {agents.map((agent) => ( - - ))} -
- )} -
-
- ); -} +"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 ( +
+ {/* Page header */} +
+
+

{t.agents.title}

+

+ {t.agents.description} +

+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ {t.common.loading} +
+ ) : agents.length === 0 ? ( +
+
+ +
+
+

{t.agents.emptyTitle}

+

+ {t.agents.emptyDescription} +

+
+ +
+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend-web/src/components/workspace/command-palette.tsx b/frontend-web/src/components/workspace/command-palette.tsx index 3f3ab799..855db3f1 100644 --- a/frontend-web/src/components/workspace/command-palette.tsx +++ b/frontend-web/src/components/workspace/command-palette.tsx @@ -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 ( - <> - - - - - {t.shortcuts.noResults} - - - - {t.sidebar.newChat} - - {metaKey} - {shiftKey}N - - - - - {t.common.settings} - {metaKey}, - - - - {t.shortcuts.keyboardShortcuts} - {metaKey}/ - - - - - - - - - {t.shortcuts.keyboardShortcuts} - - {t.shortcuts.keyboardShortcutsDescription} - - -
- {[ - { 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 }) => ( -
- {label} - - {keys} - -
- ))} -
-
-
- - ); -} +"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 ( + <> + + + + + {t.shortcuts.noResults} + + + + {t.sidebar.newChat} + + {metaKey} + {shiftKey}N + + + + + {t.common.settings} + {metaKey}, + + + + {t.shortcuts.keyboardShortcuts} + {metaKey}/ + + + + + + + + + {t.shortcuts.keyboardShortcuts} + + {t.shortcuts.keyboardShortcutsDescription} + + +
+ {[ + { 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 }) => ( +
+ {label} + + {keys} + +
+ ))} +
+
+
+ + ); +} diff --git a/frontend-web/src/components/workspace/workspace-header.tsx b/frontend-web/src/components/workspace/workspace-header.tsx index 91369d0c..11eb5b31 100644 --- a/frontend-web/src/components/workspace/workspace-header.tsx +++ b/frontend-web/src/components/workspace/workspace-header.tsx @@ -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 ( -
- C -
- ); -} - -function SidebarCollapseButton({ className }: { className?: string }) { - const { open, toggleSidebar } = useSidebar(); - return ( - - ); -} - -export function WorkspaceHeader({ className }: { className?: string }) { - const { t } = useI18n(); - const { state } = useSidebar(); - - const titleBlock = ( -
-
- {t.pages.appName} -
-
- {t.pages.brandSubtitle} -
-
- ); - - return ( -
- {state === "collapsed" ? ( -
-
- -
-
- -
-
- ) : ( -
- - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? ( - - {titleBlock} - - ) : ( - titleBlock - )} - -
- )} -
- ); -} +"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 ( +
+ C +
+ ); +} + +function SidebarCollapseButton({ className }: { className?: string }) { + const { open, toggleSidebar } = useSidebar(); + return ( + + ); +} + +export function WorkspaceHeader({ className }: { className?: string }) { + const { t } = useI18n(); + const { state } = useSidebar(); + + const titleBlock = ( +
+
+ {t.pages.appName} +
+
+ {t.pages.brandSubtitle} +
+
+ ); + + return ( +
+ {state === "collapsed" ? ( +
+
+ +
+
+ +
+
+ ) : ( +
+ + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? ( + + {titleBlock} + + ) : ( + titleBlock + )} + +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 00a29a76..f6dc0858 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -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 ( - - - - - - - {t.sidebar.newChat} - - - - - - - - {t.sidebar.chats} - - - - - - - - {t.sidebar.agents} - - - - - - - - 任务管理 - - - - - - ); -} +"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 ( + + + + + + + {t.sidebar.newChat} + + + + + + + + {t.sidebar.chats} + + + + + + + + {t.sidebar.agents} + + + + + + + + 任务管理 + + + + + + ); +} diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 18a01ad9..1c2b7ab5 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -1,58 +1,58 @@ -import type { Message } from "@langchain/langgraph-sdk"; - -import type { AgentThread, AgentThreadContext } from "./types"; - -type ThreadAgentRef = Pick; - -type ThreadRouteTarget = - | string - | { - thread_id: string; - context?: ThreadAgentRef | null; - metadata?: Record | null; - }; - -function pickAgentRef( - context: ThreadAgentRef | null | undefined, - metadata: Record | 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; + +type ThreadRouteTarget = + | string + | { + thread_id: string; + context?: ThreadAgentRef | null; + metadata?: Record | null; + }; + +function pickAgentRef( + context: ThreadAgentRef | null | undefined, + metadata: Record | 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"; +} diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 88d56813..19bd0918 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -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 ( - - -
-
- {/* Agent badge */} -
- - - {agent?.name ?? agent_id} - -
- -
- -
-
- - - - - - -
-
- -
-
- + +
+
+ {/* Agent badge */} +
+ + + {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" && ( -
- {t.common.notAvailableInDemoMode} -
- )} -
-
-
-
-
- - ); -} +
+ +
+
+
+
+
+
+ + + ) + } + 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" && ( +
+ {t.common.notAvailableInDemoMode} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index d447a232..8a2cd504 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -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 ( - - -
-
-
- -
-
- - - -
-
-
-
- + +
+
+
+ +
+
+ + + +
+
+
+
+ -
-
-
-
-
-
-
- {mounted ? ( - - } - disabled={ - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || - isUploading - } - onContextChange={(context) => - setSettings("context", context) - } - onFollowupsVisibilityChange={setShowFollowups} - onSubmit={handleSubmit} - onStop={handleStop} - /> - ) : ( - -
-
-
-
- - ); -} +
+
+
+
+
+
+
+ {mounted ? ( + + } + disabled={ + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || + isUploading + } + onContextChange={(context) => + setSettings("context", context) + } + onFollowupsVisibilityChange={setShowFollowups} + onSubmit={handleSubmit} + onStop={handleStop} + /> + ) : ( + +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index fb44dd3d..fce89afa 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -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("loading"); - const [errorMessage, setErrorMessage] = useState(""); - - 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 ( -
-
-

正在进入专家模式

- {status === "error" && ( -

- 登录失败:{errorMessage || "未知错误"} -

- )} -
-
- ); -} - +"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("loading"); + const [errorMessage, setErrorMessage] = useState(""); + + 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 ( +
+
+

正在进入专家模式

+ {status === "error" && ( +

+ 登录失败:{errorMessage || "未知错误"} +

+ )} +
+
+ ); +} + diff --git a/frontend-web/src/pages/NotFoundPage.tsx b/frontend-web/src/pages/NotFoundPage.tsx index 14a0de75..f41e0035 100644 --- a/frontend-web/src/pages/NotFoundPage.tsx +++ b/frontend-web/src/pages/NotFoundPage.tsx @@ -1,20 +1,20 @@ -import { Link } from "react-router-dom"; - -export function NotFoundPage() { - return ( -
-

Page not found

-

- 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. -

- - Open workspace - -
- ); -} +import { Link } from "react-router-dom"; + +export function NotFoundPage() { + return ( +
+

Page not found

+

+ 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. +

+ + Open workspace + +
+ ); +} diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx new file mode 100644 index 00000000..30f53d58 --- /dev/null +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -0,0 +1,20 @@ +import { Navigate, Route, Routes } from "react-router-dom"; + +import WorkspaceRoutes from "./WorkspaceRoutes"; + +// 根路由容器,统一承接 /page/* 下的所有子系统路由 +export default function PageRoutes() { + return ( + + } /> + + {/* 现有系统:DeerFlow Workspace */} + } /> + + {/* TODO: 引入外部系统路由 + import AnotherSystemRoutes from "外部系统路径"; + } /> + */} + + ); +} diff --git a/frontend-web/src/pages/ScheduledChatPage.tsx b/frontend-web/src/pages/ScheduledChatPage.tsx index 34e75e52..84adc17f 100644 --- a/frontend-web/src/pages/ScheduledChatPage.tsx +++ b/frontend-web/src/pages/ScheduledChatPage.tsx @@ -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 ; - } - - return ( - - -
- {isLoading - ? "正在打开定时任务助手..." - : error - ? "无法打开定时任务助手" - : "准备定时任务助手..."} -
-
-
- ); -} +"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 ; + } + + return ( + + +
+ {isLoading + ? "正在打开定时任务助手..." + : error + ? "无法打开定时任务助手" + : "准备定时任务助手..."} +
+
+
+ ); +} diff --git a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx index 35e950ee..4071b1e8 100644 --- a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx +++ b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx @@ -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 ( -
- 该文件不是文本内容,无法直接预览,请下载查看。 -
- ); - } - if (isMarkdownFile(file)) { - return ( - - ); - } - return
{file.content}
; -} - -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([]); - const [filesLoading, setFilesLoading] = useState(false); - const [showMeta, setShowMeta] = useState(false); - // Per-file-index edit state. `null` = not in edit mode. - const [edits, setEdits] = useState>({}); - - 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 ( - - - -
- - {/* 工具栏 */} -
-
- - {result?.title || (isLoading ? "正在加载..." : "执行结果详情")} - - {run && {statusLabel(run.status)}} -
- - - - -
- {/* 执行信息(可折叠) */} - {showMeta && run && ( -
-
-
ID:{run.id}
-
计划:{formatDate(run.scheduled_for)}
-
开始:{formatDate(run.started_at)}
-
结束:{formatDate(run.finished_at)}
-
耗时:{formatDuration(run)}
-
- {run.error && ( -
- {run.error} -
- )} -
- )} - {showMeta && !isLoading && !run && ( -
- 没有找到这条执行记录。 -
- )} - {/* 内容区 */} - {!result && run && ( -
- 这条记录暂时还没有结果,任务可能仍在执行中。 -
- )} - {result && ( - 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col"> -
- - - - 正文 - - - 结果文件{fileCount > 0 && `(${fileCount})`} - - - 元数据 - - -
- - - {result.format === "markdown" || result.format === "mixed" ? ( - - ) : ( -
-                      {result.content || "无正文内容"}
-                    
- )} -
- - {filesLoading && ( -
正在加载文件内容...
- )} - {!filesLoading && fileContents.length === 0 && ( -
- 本次执行没有生成文件。 -
- )} -
- {fileContents.map((file) => { - const isMarkdown = isMarkdownFile(file); - const editable = canEdit && file.previewable; - const editState = edits[file.index] ?? null; - const isEditing = editState !== null; - return ( -
-
-
-
{file.name}
-
- {file.mime_type || file.kind || "附件"} - {typeof file.size === "number" ? ` · ${file.size} bytes` : ""} -
-
-
- {isMarkdown && ( - - )} - - {editable && !isEditing && ( - - )} - {isEditing && ( - <> - - - - )} -
-
-
- {isEditing ? ( -