feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow

本次同步 09-12 至 09-28 的代码变更,主要内容:

新增功能
- 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService
  对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口
- 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、
  ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器
- 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator
- 新增 tools/minio-file-server MinIO 文件服务

功能增强
- 数据清洗:cleaning_task_service/scheduler/routes 重构,
  data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整
- 数据标注:annotation sync.py、project.py 接口与schema 更新
- 数据采集、算子市场、首页与菜单路由相关页面调整
- DataJuicer 算子接入(useOperatorOperations、dj_ops_schema)

结构调整
- deepflow/ 更名为 deep-flow/(419 个文件重命名)
- 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本

未纳入本次提交
- scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录)
- *.backup*、__pycache__ 产物
- zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物)
- DataX/(内嵌独立仓库,远程亦未收录其源码)
This commit is contained in:
沃巴蒂
2026-10-03 22:44:56 +08:00
parent cbb1048e18
commit ff22ed3a8b
1156 changed files with 129912 additions and 21716 deletions
+57
View File
@@ -0,0 +1,57 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Example:
# SERVERVAR="foo"
# NEXT_PUBLIC_CLIENTVAR="bar"
# API Configuration
# IMPORTANT: Set this variable when accessing the application from a network IP address
# or when the backend server is hosted on a different machine.
#
# Default value (when not set): http://localhost:8000/api
# This default works only when accessing from localhost (http://localhost:3000)
#
# Examples:
# - Local development (no change needed):
# NEXT_PUBLIC_API_URL=http://localhost:8000/api
#
# - Accessing from LAN IP (e.g., http://192.168.1.100:3000):
# NEXT_PUBLIC_API_URL=http://192.168.1.100:8000/api
#
# - Accessing from different machine on network (e.g., http://10.24.9.33:3000):
# NEXT_PUBLIC_API_URL=http://your-backend-server-ip:8000/api
#
# - Remote deployment (behind reverse proxy):
# NEXT_PUBLIC_API_URL=https://your-domain.com/api
#
# If not set, the frontend will attempt to use http://localhost:8000/api as fallback.
# If the backend is unreachable, the application will use default configuration with
# limited features (some LLM-based features may be disabled).
NEXT_PUBLIC_API_URL=http://localhost:8000/api
# Github OAuth Token (optional)
GITHUB_OAUTH_TOKEN=xxxx
# Stream Buffer Size Configuration (optional)
# Controls the maximum buffer size for SSE (Server-Sent Events) streams in bytes.
# Default: 1MB (1048576 bytes)
# Increase this if you experience buffer overflow errors during large searches.
# Use cases for increasing:
# - Performing multi-round web searches with large content results
# - Handling responses with many search results or images
# - Systems with sufficient memory to handle larger buffers
# Examples:
# - 5MB: NEXT_PUBLIC_MAX_STREAM_BUFFER_SIZE=5242880
# - 10MB: NEXT_PUBLIC_MAX_STREAM_BUFFER_SIZE=10485760
# Note: Very large buffers may increase memory usage. Start with 5-10MB if needed.
# NEXT_PUBLIC_MAX_STREAM_BUFFER_SIZE=1048576
+46
View File
@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# idea files
.idea
+2
View File
@@ -0,0 +1,2 @@
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
+56
View File
@@ -0,0 +1,56 @@
##### DEPENDENCIES
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml\* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm i; \
else echo "Lockfile not found." && exit 1; \
fi
##### BUILDER
FROM node:20-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_API_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then SKIP_ENV_VALIDATION=1 yarn build; \
elif [ -f package-lock.json ]; then SKIP_ENV_VALIDATION=1 npm run build; \
elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && SKIP_ENV_VALIDATION=1 pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
##### RUNNER
# FROM gcr.io/distroless/nodejs20-debian12 AS runner
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT=3000
CMD ["server.js"]
View File
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}
+12
View File
@@ -0,0 +1,12 @@
services:
deep-flow-web:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
image: deep-flow-web
ports:
- "3000:3000"
env_file:
- .env
@@ -0,0 +1,130 @@
# 深度思考块功能实现总结
## 🎯 实现的功能
### 核心特性
1. **智能展示逻辑**: 深度思考过程初始展开,计划内容开始时自动折叠
2. **分阶段显示**: 思考阶段只显示思考块,思考结束后才显示计划卡片
3. **动态主题**: 思考阶段使用蓝色主题,完成后切换为默认主题
4. **流式支持**: 实时展示推理内容的流式传输
5. **优雅交互**: 平滑的动画效果和状态切换
### 交互流程
```
用户发送问题 (启用深度思考)
↓
开始接收 reasoning_content
↓
思考块自动展开 + primary 主题 + 加载动画
↓
推理内容流式更新
↓
开始接收 content (计划内容)
↓
思考块自动折叠 + 主题切换
↓
计划卡片优雅出现 (动画效果)
↓
计划内容保持流式更新 (标题→思路→步骤)
↓
完成 (用户可手动展开思考块)
```
## 🔧 技术实现
### 数据结构扩展
- `Message` 接口添加 `reasoningContent` 和 `reasoningContentChunks` 字段
- `MessageChunkEvent` 接口添加 `reasoning_content` 字段
- 消息合并逻辑支持推理内容的流式处理
### 组件架构
- `ThoughtBlock`: 可折叠的思考块组件
- `PlanCard`: 更新后的计划卡片,集成思考块
- 智能状态管理和条件渲染
### 状态管理
```typescript
// 关键状态逻辑
const hasMainContent = message.content && message.content.trim() !== "";
const isThinking = reasoningContent && !hasMainContent;
const shouldShowPlan = hasMainContent; // 有内容就显示,保持流式效果
```
### 自动折叠逻辑
```typescript
React.useEffect(() => {
if (hasMainContent && !hasAutoCollapsed) {
setIsOpen(false);
setHasAutoCollapsed(true);
}
}, [hasMainContent, hasAutoCollapsed]);
```
## 🎨 视觉设计
### 统一设计语言
- **字体系统**: 使用 `font-semibold` 与 CardTitle 保持一致
- **圆角规范**: 采用 `rounded-xl` 与其他卡片组件统一
- **间距标准**: 使用 `px-6 py-4` 内边距,`mb-6` 外边距
- **图标尺寸**: 18px 大脑图标,与文字比例协调
### 思考阶段样式
- Primary 主题色边框和背景
- Primary 色图标和文字
- 标准边框样式
- 加载动画
### 完成阶段样式
- 默认 border 和 card 背景
- muted-foreground 图标
- 80% 透明度文字
- 静态图标
### 动画效果
- 展开/折叠动画
- 主题切换过渡
- 颜色变化动画
## 📁 文件更改
### 核心文件
1. `web/src/core/messages/types.ts` - 消息类型扩展
2. `web/src/core/api/types.ts` - API 事件类型扩展
3. `web/src/core/messages/merge-message.ts` - 消息合并逻辑
4. `web/src/core/store/store.ts` - 状态管理更新
5. `web/src/app/chat/components/message-list-view.tsx` - 主要组件实现
### 测试和文档
1. `web/public/mock/reasoning-example.txt` - 测试数据
2. `web/docs/thought-block-feature.md` - 功能文档
3. `web/docs/testing-thought-block.md` - 测试指南
4. `web/docs/interaction-flow-test.md` - 交互流程测试
## 🧪 测试方法
### 快速测试
```
访问: http://localhost:3000?mock=reasoning-example
发送任意消息,观察交互流程
```
### 完整测试
1. 启用深度思考模式
2. 配置 reasoning 模型
3. 发送复杂问题
4. 验证完整交互流程
## 🔄 兼容性
- ✅ 向后兼容:无推理内容时正常显示
- ✅ 渐进增强:功能仅在有推理内容时激活
- ✅ 优雅降级:推理内容为空时不显示思考块
## 🚀 使用建议
1. **启用深度思考**: 点击"Deep Thinking"按钮
2. **观察流程**: 注意思考块的自动展开和折叠
3. **手动控制**: 可随时点击思考块标题栏控制展开/折叠
4. **查看推理**: 展开思考块查看完整的推理过程
这个实现完全满足了用户的需求,提供了直观、流畅的深度思考过程展示体验。
+112
View File
@@ -0,0 +1,112 @@
# 思考块交互流程测试
## 测试场景
### 场景 1: 完整的深度思考流程
**步骤**:
1. 启用深度思考模式
2. 发送问题:"什么是 vibe coding?"
3. 观察交互流程
**预期行为**:
#### 阶段 1: 深度思考开始
- ✅ 思考块立即出现并展开
- ✅ 使用蓝色主题(边框、背景、图标、文字)
- ✅ 显示加载动画
- ✅ 不显示计划卡片
- ✅ 推理内容实时流式更新
#### 阶段 2: 思考过程中
- ✅ 思考块保持展开状态
- ✅ 蓝色主题持续显示
- ✅ 推理内容持续增加
- ✅ 加载动画持续显示
- ✅ 计划卡片仍然不显示
#### 阶段 3: 开始接收计划内容
- ✅ 思考块自动折叠
- ✅ 主题从 primary 切换为默认
- ✅ 加载动画消失
- ✅ 计划卡片以优雅动画出现(opacity: 0→1, y: 20→0)
- ✅ 计划内容保持流式更新效果
#### 阶段 4: 计划流式输出
- ✅ 标题逐步显示
- ✅ 思路内容流式更新
- ✅ 步骤列表逐项显示
- ✅ 每个步骤的标题和描述分别流式渲染
#### 阶段 5: 计划完成
- ✅ 思考块保持折叠状态
- ✅ 计划卡片完全显示
- ✅ 用户可手动展开思考块查看推理过程
### 场景 2: 手动交互测试
**步骤**:
1. 在思考完成后,手动点击思考块
2. 验证展开/折叠功能
**预期行为**:
- ✅ 点击可正常展开/折叠
- ✅ 动画效果流畅
- ✅ 内容完整显示
- ✅ 不影响计划卡片显示
### 场景 3: 边界情况测试
#### 3.1 只有推理内容,没有计划内容
**预期**: 思考块保持展开,不显示计划卡片
#### 3.2 没有推理内容,只有计划内容
**预期**: 不显示思考块,直接显示计划卡片
#### 3.3 推理内容为空
**预期**: 不显示思考块,直接显示计划卡片
## 验证要点
### 视觉效果
- [ ] Primary 主题色在思考阶段正确显示
- [ ] 主题切换动画流畅
- [ ] 字体权重与 CardTitle 保持一致 (`font-semibold`)
- [ ] 圆角设计与其他卡片统一 (`rounded-xl`)
- [ ] 图标尺寸和颜色正确变化 (18px, primary/muted-foreground)
- [ ] 内边距与设计系统一致 (`px-6 py-4`)
- [ ] 整体视觉层次与页面协调
### 交互逻辑
- [ ] 自动展开/折叠时机正确
- [ ] 手动展开/折叠功能正常
- [ ] 计划卡片显示时机正确
- [ ] 加载动画显示时机正确
### 内容渲染
- [ ] 推理内容正确流式更新
- [ ] Markdown 格式正确渲染
- [ ] 中文内容正确显示
- [ ] 内容不丢失或重复
### 性能表现
- [ ] 动画流畅,无卡顿
- [ ] 内存使用正常
- [ ] 组件重新渲染次数合理
## 故障排除
### 思考块不自动折叠
1. 检查 `hasMainContent` 逻辑
2. 验证 `useEffect` 依赖项
3. 确认 `hasAutoCollapsed` 状态管理
### 计划卡片显示时机错误
1. 检查 `shouldShowPlan` 计算逻辑
2. 验证 `isThinking` 状态判断
3. 确认消息内容解析正确
### 主题切换异常
1. 检查 `isStreaming` 状态
2. 验证 CSS 类名应用
3. 确认条件渲染逻辑
@@ -0,0 +1,125 @@
# 流式输出优化改进
## 🎯 改进目标
确保在深度思考结束后,plan block 保持流式输出效果,提供更流畅丝滑的用户体验。
## 🔧 技术改进
### 状态逻辑优化
**之前的逻辑**:
```typescript
const isThinking = reasoningContent && (!hasMainContent || message.isStreaming);
const shouldShowPlan = hasMainContent && !isThinking;
```
**优化后的逻辑**:
```typescript
const isThinking = reasoningContent && !hasMainContent;
const shouldShowPlan = hasMainContent; // 简化逻辑,有内容就显示
```
### 关键改进点
1. **简化显示逻辑**: 只要有主要内容就显示 plan,不再依赖思考状态
2. **保持流式状态**: plan 组件的 `animated` 属性直接使用 `message.isStreaming`
3. **优雅入场动画**: 添加 motion.div 包装,提供平滑的出现效果
## 🎨 用户体验提升
### 流式输出效果
#### 思考阶段
- ✅ 推理内容实时流式更新
- ✅ 思考块保持展开状态
- ✅ Primary 主题色高亮显示
#### 计划阶段
- ✅ 计划卡片优雅出现(300ms 动画)
- ✅ 标题内容流式渲染
- ✅ 思路内容流式更新
- ✅ 步骤列表逐项显示
- ✅ 每个步骤的标题和描述分别流式渲染
### 动画效果
#### 计划卡片入场动画
```typescript
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
```
#### 流式文本动画
- 所有 Markdown 组件都使用 `animated={message.isStreaming}`
- 确保文本逐字符或逐词显示效果
## 📊 性能优化
### 渲染优化
- **减少重新渲染**: 简化状态逻辑,减少不必要的组件重新挂载
- **保持组件实例**: plan 组件一旦出现就保持存在,避免重新创建
- **流式状态传递**: 直接使用消息的流式状态,避免额外的状态计算
### 内存优化
- **组件复用**: 避免频繁的组件销毁和重建
- **状态管理**: 简化状态依赖,减少内存占用
## 🧪 测试验证
### 流式效果验证
1. **思考阶段**: 推理内容应该逐步显示
2. **过渡阶段**: 计划卡片应该平滑出现
3. **计划阶段**: 所有计划内容应该保持流式效果
### 动画效果验证
1. **入场动画**: 计划卡片应该从下方滑入并淡入
2. **文本动画**: 所有文本内容应该有打字机效果
3. **状态切换**: 思考块折叠应该平滑自然
### 性能验证
1. **渲染次数**: 检查组件重新渲染频率
2. **内存使用**: 监控内存占用情况
3. **动画流畅度**: 确保 60fps 的动画效果
## 📝 使用示例
### 完整交互流程
```
1. 用户发送问题 (启用深度思考)
↓
2. 思考块展开,推理内容流式显示
↓
3. 开始接收计划内容
↓
4. 思考块自动折叠
↓
5. 计划卡片优雅出现 (动画效果)
↓
6. 计划内容流式渲染:
- 标题逐步显示
- 思路内容流式更新
- 步骤列表逐项显示
↓
7. 完成,用户可查看完整内容
```
## 🔄 兼容性
- ✅ **向后兼容**: 不影响现有的非深度思考模式
- ✅ **渐进增强**: 功能仅在有推理内容时激活
- ✅ **优雅降级**: 在不支持的环境中正常显示
## 🚀 效果总结
这次优化显著提升了用户体验:
1. **更流畅的过渡**: 从思考到计划的切换更加自然
2. **保持流式效果**: 计划内容保持了原有的流式输出特性
3. **视觉连贯性**: 整个过程的视觉效果更加连贯统一
4. **性能提升**: 减少了不必要的组件重新渲染
用户现在可以享受到完整的流式体验,从深度思考到计划展示都保持了一致的流畅感。
@@ -0,0 +1,78 @@
# 测试思考块功能
## 快速测试
### 方法 1: 使用模拟数据
1. 在浏览器中访问应用并添加 `?mock=reasoning-example` 参数
2. 发送任意消息
3. 观察计划卡片上方是否出现思考块
### 方法 2: 启用深度思考模式
1. 确保配置了 reasoning 模型(如 DeepSeek R1)
2. 在聊天界面点击"Deep Thinking"按钮
3. 发送一个需要规划的问题
4. 观察是否出现思考块
## 预期行为
### 思考块外观
- 深度思考开始时自动展开显示
- 思考阶段使用 primary 主题色(边框、背景、文字、图标)
- 带有 18px 大脑图标和"深度思考过程"标题
- 使用 `font-semibold` 字体权重,与 CardTitle 保持一致
- `rounded-xl` 圆角设计,与其他卡片组件统一
- 标准的 `px-6 py-4` 内边距
### 交互行为
- 思考阶段:自动展开,蓝色高亮,显示加载动画
- 计划阶段:自动折叠,切换为默认主题
- 用户可随时手动展开/折叠
- 平滑的展开/折叠动画和主题切换
### 分阶段显示
- 思考阶段:只显示思考块,不显示计划卡片
- 计划阶段:思考块折叠,显示完整计划卡片
### 内容渲染
- 支持 Markdown 格式
- 中文内容正确显示
- 保持原有的换行和格式
## 故障排除
### 思考块不显示
1. 检查消息是否包含 `reasoningContent` 字段
2. 确认 `reasoning_content` 事件是否正确处理
3. 验证消息合并逻辑是否正常工作
### 内容显示异常
1. 检查 Markdown 渲染是否正常
2. 确认 CSS 样式是否正确加载
3. 验证动画效果是否启用
### 流式传输问题
1. 检查 WebSocket 连接状态
2. 确认事件流格式是否正确
3. 验证消息更新逻辑
## 开发调试
### 控制台检查
```javascript
// 检查消息对象
const messages = useStore.getState().messages;
const lastMessage = Array.from(messages.values()).pop();
console.log('Reasoning content:', lastMessage?.reasoningContent);
```
### 网络面板
- 查看 SSE 事件流
- 确认 `reasoning_content` 字段存在
- 检查事件格式是否正确
### React DevTools
- 检查 ThoughtBlock 组件状态
- 验证 props 传递是否正确
- 观察组件重新渲染情况
@@ -0,0 +1,155 @@
# 思考块设计系统规范
## 🎯 设计目标
确保思考块组件与整个应用的设计语言保持完全一致,提供统一的用户体验。
## 📐 设计规范
### 字体系统
```css
/* 标题字体 - 与 CardTitle 保持一致 */
font-weight: 600; /* font-semibold */
line-height: 1; /* leading-none */
```
### 尺寸规范
```css
/* 图标尺寸 */
icon-size: 18px; /* 与文字比例协调 */
/* 内边距 */
padding: 1.5rem; /* px-6 py-4 */
/* 外边距 */
margin-bottom: 1.5rem; /* mb-6 */
/* 圆角 */
border-radius: 0.75rem; /* rounded-xl */
```
### 颜色系统
#### 思考阶段(活跃状态)
```css
/* 边框和背景 */
border-color: hsl(var(--primary) / 0.2);
background-color: hsl(var(--primary) / 0.05);
/* 图标和文字 */
color: hsl(var(--primary));
/* 阴影 */
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
```
#### 完成阶段(静态状态)
```css
/* 边框和背景 */
border-color: hsl(var(--border));
background-color: hsl(var(--card));
/* 图标 */
color: hsl(var(--muted-foreground));
/* 文字 */
color: hsl(var(--foreground));
```
#### 内容区域
```css
/* 思考阶段 */
.prose-primary {
color: hsl(var(--primary));
}
/* 完成阶段 */
.opacity-80 {
opacity: 0.8;
}
```
### 交互状态
```css
/* 悬停状态 */
.hover\:bg-accent:hover {
background-color: hsl(var(--accent));
}
.hover\:text-accent-foreground:hover {
color: hsl(var(--accent-foreground));
}
```
## 🔄 状态变化
### 状态映射
| 状态 | 边框 | 背景 | 图标颜色 | 文字颜色 | 阴影 |
|------|------|------|----------|----------|------|
| 思考中 | primary/20 | primary/5 | primary | primary | 有 |
| 已完成 | border | card | muted-foreground | foreground | 无 |
### 动画过渡
```css
transition: all 200ms ease-in-out;
```
## 📱 响应式设计
### 间距适配
- 移动端:保持相同的内边距比例
- 桌面端:标准的 `px-6 py-4` 内边距
### 字体适配
- 所有设备:保持 `font-semibold` 字体权重
- 图标尺寸:固定 18px,确保清晰度
## 🎨 与现有组件的对比
### CardTitle 对比
| 属性 | CardTitle | ThoughtBlock |
|------|-----------|--------------|
| 字体权重 | font-semibold | font-semibold ✅ |
| 行高 | leading-none | leading-none ✅ |
| 颜色 | foreground | primary/foreground |
### Card 对比
| 属性 | Card | ThoughtBlock |
|------|------|--------------|
| 圆角 | rounded-lg | rounded-xl |
| 边框 | border | border ✅ |
| 背景 | card | card/primary ✅ |
### Button 对比
| 属性 | Button | ThoughtBlock Trigger |
|------|--------|---------------------|
| 内边距 | 标准 | px-6 py-4 ✅ |
| 悬停 | hover:bg-accent | hover:bg-accent ✅ |
| 圆角 | rounded-md | rounded-xl |
## ✅ 设计检查清单
### 视觉一致性
- [ ] 字体权重与 CardTitle 一致
- [ ] 圆角设计与卡片组件统一
- [ ] 颜色使用 CSS 变量系统
- [ ] 间距符合设计规范
### 交互一致性
- [ ] 悬停状态与 Button 组件一致
- [ ] 过渡动画时长统一(200ms)
- [ ] 状态变化平滑自然
### 可访问性
- [ ] 颜色对比度符合 WCAG 标准
- [ ] 图标尺寸适合点击/触摸
- [ ] 状态变化有明确的视觉反馈
## 🔧 实现要点
1. **使用设计系统变量**: 所有颜色都使用 CSS 变量,确保主题切换正常
2. **保持组件一致性**: 与现有 Card、Button 组件的样式保持一致
3. **响应式友好**: 在不同设备上都有良好的显示效果
4. **性能优化**: 使用 CSS 过渡而非 JavaScript 动画
这个设计系统确保了思考块组件与整个应用的视觉语言完全统一,提供了一致的用户体验。
+108
View File
@@ -0,0 +1,108 @@
# 思考块功能 (Thought Block Feature)
## 概述
思考块功能允许在计划卡片之前展示 AI 的深度思考过程,以可折叠的方式呈现推理内容。这个功能特别适用于启用深度思考模式时的场景。
## 功能特性
- **智能展示逻辑**: 深度思考过程初始展开,当开始接收计划内容时自动折叠
- **分阶段显示**: 思考阶段只显示思考块,思考结束后才显示计划卡片
- **流式支持**: 支持推理内容的实时流式展示
- **视觉状态反馈**: 思考阶段使用蓝色主题突出显示
- **优雅的动画**: 包含平滑的展开/折叠动画效果
- **响应式设计**: 适配不同屏幕尺寸
## 技术实现
### 数据结构更新
1. **Message 类型扩展**:
```typescript
export interface Message {
// ... 其他字段
reasoningContent?: string;
reasoningContentChunks?: string[];
}
```
2. **API 事件类型扩展**:
```typescript
export interface MessageChunkEvent {
// ... 其他字段
reasoning_content?: string;
}
```
### 组件结构
- **ThoughtBlock**: 主要的思考块组件
- 使用 Radix UI 的 Collapsible 组件
- 支持流式内容展示
- 包含加载动画和状态指示
- **PlanCard**: 更新后的计划卡片
- 在计划内容之前展示思考块
- 自动检测是否有推理内容
### 消息处理
消息合并逻辑已更新以支持 `reasoning_content` 字段的流式处理:
```typescript
function mergeTextMessage(message: Message, event: MessageChunkEvent) {
// 处理常规内容
if (event.data.content) {
message.content += event.data.content;
message.contentChunks.push(event.data.content);
}
// 处理推理内容
if (event.data.reasoning_content) {
message.reasoningContent = (message.reasoningContent || "") + event.data.reasoning_content;
message.reasoningContentChunks = message.reasoningContentChunks || [];
message.reasoningContentChunks.push(event.data.reasoning_content);
}
}
```
## 使用方法
### 启用深度思考模式
1. 在聊天界面中,点击"Deep Thinking"按钮
2. 确保配置了支持推理的模型
3. 发送消息后,如果有推理内容,会在计划卡片上方显示思考块
### 查看推理过程
1. 深度思考开始时,思考块自动展开显示
2. 思考阶段使用 primary 主题色,突出显示正在进行的推理过程
3. 推理内容支持 Markdown 格式渲染,实时流式更新
4. 在流式传输过程中会显示加载动画
5. 当开始接收计划内容时,思考块自动折叠
6. 计划卡片以优雅的动画效果出现
7. 计划内容保持流式输出效果,逐步显示标题、思路和步骤
8. 用户可以随时点击思考块标题栏手动展开/折叠
## 样式特性
- **统一设计语言**: 与页面整体设计风格保持一致
- **字体层次**: 使用与 CardTitle 相同的 `font-semibold` 字体权重
- **圆角设计**: 采用 `rounded-xl` 与其他卡片组件保持一致
- **间距规范**: 使用标准的 `px-6 py-4` 内边距
- **动态主题**: 思考阶段使用 primary 色彩系统
- **图标尺寸**: 18px 图标尺寸,与文字比例协调
- **状态反馈**: 流式传输时显示加载动画和主题色高亮
- **交互反馈**: 标准的 hover 和 focus 状态
- **平滑过渡**: 所有状态变化都有平滑的过渡动画
## 测试数据
可以使用 `/mock/reasoning-example.txt` 文件测试思考块功能,该文件包含了模拟的推理内容和计划数据。
## 兼容性
- 向后兼容:没有推理内容的消息不会显示思考块
- 渐进增强:功能仅在有推理内容时激活
- 优雅降级:如果推理内容为空,组件不会渲染
+93
View File
@@ -0,0 +1,93 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{
ignores: [".next", "src/components"],
},
...compat.extends("next/core-web-vitals"),
{
files: ["**/*.ts", "**/*.tsx"],
extends: [
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
rules: {
"@next/next/no-img-element": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/no-redundant-type-constituents": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-return": "off",
"import/order": [
"error",
{
distinctGroup: false,
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
],
pathGroups: [
{
pattern: "~/**",
group: "internal",
},
{
pattern: "./**.css",
group: "object",
},
{
pattern: "**.md",
group: "object",
},
],
"newlines-between": "always",
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
);
+53
View File
@@ -0,0 +1,53 @@
export default {
testEnvironment: 'jsdom',
roots: ['<rootDir>'],
testMatch: [
'**/tests/**/*.test.ts',
'**/tests/**/*.test.tsx',
],
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
moduleNameMapper: {
'^~(.*)$': '<rootDir>/src$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/tests/__mocks__/fileMock.js',
'^~/core/store/store$': '<rootDir>/tests/__mocks__/store-mock.ts',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/**/index.{ts,tsx}',
],
setupFilesAfterEnv: [],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
useESM: true,
tsconfig: {
jsx: 'react-jsx',
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
}],
},
// The following packages are ESM-only or otherwise require transformation by Jest.
// If you encounter "SyntaxError: Cannot use import statement outside a module" or similar errors
// for a dependency, add it to this list. See: https://jestjs.io/docs/configuration#transformignorepatterns-arraystring
//
// Packages included:
// - framer-motion: ESM-only
// - nanoid: ESM-only
// - @tiptap: ESM-only
// - lowlight: ESM-only
// - highlight.js: ESM-only
// - zustand: ESM-only
// - sonner: ESM-only
// - next-intl: ESM-only
// - immer: ESM-only
// - use-debounce: ESM-only
// - use-stick-to-bottom: ESM-only
transformIgnorePatterns: [
'node_modules/(?!(framer-motion|nanoid|@tiptap|lowlight|highlight\\.js|zustand|sonner|next-intl|immer|use-debounce|use-stick-to-bottom)/)',
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
};
+5
View File
@@ -0,0 +1,5 @@
// Jest setup file
Object.defineProperty(globalThis, '__ESM__', {
value: true,
writable: false,
});
+241
View File
@@ -0,0 +1,241 @@
{
"common": {
"cancel": "Cancel",
"save": "Save",
"settings": "Settings",
"getStarted": "Get Started",
"learnMore": "Learn More",
"starOnGitHub": "Star on GitHub",
"send": "Send",
"stop": "Stop",
"linkNotReliable": "This link might be a hallucination from AI model and may not be reliable.",
"noResult": "No result"
},
"messageInput": {
"placeholder": "What can I do for you?",
"placeholderWithRag": "What can I do for you? \nYou may refer to RAG resources by using @."
},
"header": {
"title": "DeepData Agent"
},
"hero": {
"title": "Deep Research",
"subtitle": "at Your Fingertips",
"description": "Meet DeepFlow, your personal Deep Research assistant. With powerful tools like search engines, web crawlers, Python and MCP services, it delivers instant insights, comprehensive reports, and even captivating podcasts.",
"footnote": "* DEER stands for Deep Exploration and Efficient Research."
},
"settings": {
"title": "DeepData Settings",
"description": "Manage your DeepData settings here.",
"addServers": "Add Servers",
"cancel": "Cancel",
"addNewMCPServers": "Add New MCP Servers",
"mcpConfigDescription": "DeepData uses the standard JSON MCP config to create a new server.",
"pasteConfigBelow": "Paste your config below and click \"Add\" to add new servers.",
"add": "Add",
"general": {
"title": "General",
"autoAcceptPlan": "Allow automatic acceptance of plans",
"enableClarification": "Allow clarification",
"maxClarificationRounds": "Max clarification rounds",
"maxClarificationRoundsDescription": "Maximum number of clarification rounds when clarification is enabled (default: 3).",
"maxPlanIterations": "Max plan iterations",
"maxPlanIterationsDescription": "Set to 1 for single-step planning. Set to 2 or more to enable re-planning.",
"maxStepsOfPlan": "Max steps of a research plan",
"maxStepsDescription": "By default, each research plan has 3 steps.",
"maxSearchResults": "Max search results",
"maxSearchResultsDescription": "By default, each search step has 3 results."
},
"mcp": {
"title": "MCP Servers",
"description": "The Model Context Protocol boosts DeepData by integrating external tools for tasks like private domain searches, web browsing, food ordering, and more. Click here to",
"learnMore": "learn more about MCP.",
"enableDisable": "Enable/disable server",
"deleteServer": "Delete server",
"editServer": "Edit server",
"refreshServer": "Refresh server",
"editServerDescription": "Edit the MCP server configuration",
"editServerNote": "Update the server configuration in JSON format",
"disabled": "Disabled",
"new": "New",
"invalidJson": "Invalid JSON format",
"validationFailed": "Validation failed",
"missingServerName": "Missing server name"
},
"about": {
"title": "About"
},
"reportStyle": {
"writingStyle": "Writing Style",
"chooseTitle": "Choose Writing Style",
"chooseDesc": "Select the writing style for your research reports. Different styles are optimized for different audiences and purposes.",
"academic": "Academic",
"academicDesc": "Formal, objective, and analytical with precise terminology",
"popularScience": "Popular Science",
"popularScienceDesc": "Engaging and accessible for general audience",
"news": "News",
"newsDesc": "Factual, concise, and impartial journalistic style",
"socialMedia": "Social Media",
"socialMediaDesc": "Concise, attention-grabbing, and shareable",
"strategicInvestment": "Strategic Investment",
"strategicInvestmentDesc": "Deep, comprehensive analysis for strategic investment institutions with actionable insights"
}
},
"footer": {
"quote": "Originated from Open Source, give back to Open Source.",
"license": "Licensed under MIT License",
"copyright": "DeepFlow"
},
"chat": {
"page": {
"loading": "Loading DeepFlow...",
"welcomeUser": "Welcome, {username}",
"starOnGitHub": "Star DeepFlow on GitHub"
},
"welcome": {
"greeting": "👋 Hello, there!",
"description": "Welcome to 🦌 DeepFlow, a deep research assistant built on cutting-edge language models, helps you search on web, browse information, and handle complex tasks."
},
"conversationStarters": [
"How many times taller is the Eiffel Tower than the tallest building in the world?",
"How many years does an average Tesla battery last compared to a gasoline engine?",
"How many liters of water are required to produce 1 kg of beef?",
"How many times faster is the speed of light compared to the speed of sound?"
],
"inputBox": {
"deepThinking": "Deep Thinking",
"deepThinkingTooltip": {
"title": "Deep Thinking Mode: {status}",
"description": "When enabled, DeepFlow will use reasoning model ({model}) to generate more thoughtful plans."
},
"investigation": "Investigation",
"investigationTooltip": {
"title": "Investigation Mode: {status}",
"description": "When enabled, DeepFlow will perform a quick search before planning. This is useful for researches related to ongoing events and news."
},
"enhancePrompt": "Enhance prompt with AI",
"on": "On",
"off": "Off"
},
"research": {
"deepResearch": "Deep Research",
"researching": "Researching...",
"generatingReport": "Generating report...",
"reportGenerated": "Report generated",
"open": "Open",
"close": "Close",
"deepThinking": "Deep Thinking",
"report": "Report",
"activities": "Activities",
"generatePodcast": "Generate podcast",
"edit": "Edit",
"copy": "Copy",
"downloadReport": "Download report as markdown",
"searchingFor": "Searching for",
"reading": "Reading",
"runningPythonCode": "Running Python code",
"errorExecutingCode": "Error when executing the above code",
"executionOutput": "Execution output",
"retrievingDocuments": "Retrieving documents from RAG",
"running": "Running",
"generatingPodcast": "Generating podcast...",
"nowPlayingPodcast": "Now playing podcast...",
"podcast": "Podcast",
"errorGeneratingPodcast": "Error when generating podcast. Please try again.",
"downloadPodcast": "Download podcast"
},
"messages": {
"replaying": "Replaying",
"replayDescription": "DeepFlow is now replaying the conversation...",
"replayHasStopped": "The replay has been stopped.",
"replayModeDescription": "You're now in DeepFlow's replay mode. Click the \"Play\" button on the right to start.",
"play": "Play",
"fastForward": "Fast Forward",
"demoNotice": "* This site is for demo purposes only. If you want to try your own question, please",
"clickHere": "click here",
"cloneLocally": "to clone it locally and run it."
},
"multiAgent": {
"moveToPrevious": "Move to the previous step",
"playPause": "Play / Pause",
"moveToNext": "Move to the next step",
"toggleFullscreen": "Toggle fullscreen"
}
},
"landing": {
"caseStudies": {
"title": "Case Studies",
"description": "See DeepFlow in action through replays.",
"clickToWatch": "Click to watch replay",
"cases": [
{
"title": "How tall is Eiffel Tower compared to tallest building?",
"description": "The research compares the heights and global significance of the Eiffel Tower and Burj Khalifa, and uses Python code to calculate the multiples."
},
{
"title": "What are the top trending repositories on GitHub?",
"description": "The research utilized MCP services to identify the most popular GitHub repositories and documented them in detail using search engines."
},
{
"title": "Write an article about Nanjing's traditional dishes",
"description": "The study vividly showcases Nanjing's famous dishes through rich content and imagery, uncovering their hidden histories and cultural significance."
},
{
"title": "How to decorate a small rental apartment?",
"description": "The study provides readers with practical and straightforward methods for decorating apartments, accompanied by inspiring images."
},
{
"title": "Introduce the movie 'Léon: The Professional'",
"description": "The research provides a comprehensive introduction to the movie 'Léon: The Professional', including its plot, characters, and themes."
},
{
"title": "How do you view the takeaway war in China? (in Chinese)",
"description": "The research analyzes the intensifying competition between JD and Meituan, highlighting their strategies, technological innovations, and challenges."
},
{
"title": "Are ultra-processed foods linked to health?",
"description": "The research examines the health risks of rising ultra-processed food consumption, urging more research on long-term effects and individual differences."
},
{
"title": "Write an article on \"Would you insure your AI twin?\"",
"description": "The research explores the concept of insuring AI twins, highlighting their benefits, risks, ethical considerations, and the evolving regulatory."
}
]
},
"coreFeatures": {
"title": "Core Features",
"description": "Find out what makes DeepFlow effective.",
"features": [
{
"name": "Dive Deeper and Reach Wider",
"description": "Unlock deeper insights with advanced tools. Our powerful search + crawling and Python tools gathers comprehensive data, delivering in-depth reports to enhance your study."
},
{
"name": "Human-in-the-loop",
"description": "Refine your research plan, or adjust focus areas all through simple natural language."
},
{
"name": "Lang Stack",
"description": "Build with confidence using the LangChain and LangGraph frameworks."
},
{
"name": "MCP Integrations",
"description": "Supercharge your research workflow and expand your toolkit with seamless MCP integrations."
},
{
"name": "Podcast Generation",
"description": "Instantly generate podcasts from reports. Perfect for on-the-go learning or sharing findings effortlessly."
}
]
},
"multiAgent": {
"title": "Multi-Agent Architecture",
"description": "Experience the agent teamwork with our Supervisor + Handoffs design pattern."
},
"joinCommunity": {
"title": "Join the DeepFlow Community",
"description": "Contribute brilliant ideas to shape the future of DeepFlow. Collaborate, innovate, and make impacts.",
"contributeNow": "Contribute Now"
}
}
}
+241
View File
@@ -0,0 +1,241 @@
{
"common": {
"cancel": "取消",
"save": "保存",
"settings": "设置",
"getStarted": "开始使用",
"learnMore": "了解更多",
"starOnGitHub": "在 GitHub 上点赞",
"send": "发送",
"stop": "停止",
"linkNotReliable": "此链接可能是 AI 生成的幻觉,可能并不可靠。",
"noResult": "无结果"
},
"messageInput": {
"placeholder": "我能帮你做什么?",
"placeholderWithRag": "我能帮你做什么?\n你可以通过 @ 引用 RAG 资源。"
},
"header": {
"title": "DeepFlow深度研究助手"
},
"hero": {
"title": "深度研究",
"subtitle": "触手可及",
"description": "认识 DeepFlow,您的个人深度研究助手。凭借搜索引擎、网络爬虫、Python 和 MCP 服务等强大工具,它能提供即时洞察、全面报告,甚至制作引人入胜的播客。",
"footnote": "* DEER 代表深度探索和高效研究。"
},
"settings": {
"title": "DeepFlow 设置",
"description": "在这里管理您的 DeepFlow 设置。",
"cancel": "取消",
"addServers": "添加服务器",
"addNewMCPServers": "添加新的 MCP 服务器",
"mcpConfigDescription": "DeepFlow 使用标准 JSON MCP 配置来创建新服务器。",
"pasteConfigBelow": "将您的配置粘贴到下面,然后点击\"添加\"来添加新服务器。",
"add": "添加",
"general": {
"title": "通用",
"autoAcceptPlan": "允许自动接受计划",
"enableClarification": "允许澄清",
"maxClarificationRounds": "最大澄清回合数",
"maxClarificationRoundsDescription": "启用澄清时允许的最大澄清回合数,默认是3。",
"maxPlanIterations": "最大计划迭代次数",
"maxPlanIterationsDescription": "设置为 1 进行单步规划。设置为 2 或更多以启用重新规划。",
"maxStepsOfPlan": "研究计划的最大步骤数",
"maxStepsDescription": "默认情况下,每个研究计划有 3 个步骤。",
"maxSearchResults": "最大搜索结果数",
"maxSearchResultsDescription": "默认情况下,每个搜索步骤有 3 个结果。"
},
"mcp": {
"title": "MCP 服务器",
"description": "模型上下文协议通过集成外部工具来增强 DeepFlow,用于私域搜索、网页浏览、订餐等任务。点击这里",
"learnMore": "了解更多关于 MCP 的信息。",
"enableDisable": "启用/禁用服务器",
"deleteServer": "删除服务器",
"editServer": "编辑服务器",
"refreshServer": "刷新服务器",
"editServerDescription": "编辑 MCP 服务器配置",
"editServerNote": "以 JSON 格式更新服务器配置",
"disabled": "已禁用",
"new": "新增",
"invalidJson": "无效的 JSON 格式",
"validationFailed": "验证失败",
"missingServerName": "缺少服务器名称"
},
"about": {
"title": "关于"
},
"reportStyle": {
"writingStyle": "写作风格",
"chooseTitle": "选择写作风格",
"chooseDesc": "请选择您的研究报告的写作风格。不同风格适用于不同受众和用途。",
"academic": "学术",
"academicDesc": "正式、客观、分析性强,术语精确",
"popularScience": "科普",
"popularScienceDesc": "生动有趣,适合大众阅读",
"news": "新闻",
"newsDesc": "事实、简明、公正的新闻风格",
"socialMedia": "社交媒体",
"socialMediaDesc": "简洁有趣,易于传播",
"strategicInvestment": "战略投资",
"strategicInvestmentDesc": "面向战略投资机构的深度综合分析,提供可执行的投资洞察"
}
},
"footer": {
"quote": "源于开源,回馈开源。",
"license": "基于 MIT 许可证授权",
"copyright": "DeepFlow"
},
"chat": {
"page": {
"loading": "正在加载 DeepFlow...",
"welcomeUser": "欢迎,{username}",
"starOnGitHub": "在 GitHub 上点赞"
},
"welcome": {
"greeting": "👋 你好!",
"description": "欢迎来到 🦌 DeepFlow,一个基于前沿语言模型构建的深度研究助手,帮助您搜索网络、浏览信息并处理复杂任务。"
},
"conversationStarters": [
"埃菲尔铁塔比世界最高建筑高多少倍?",
"特斯拉电池的平均寿命比汽油发动机长多少年?",
"生产1公斤牛肉需要多少升水?",
"光速比声速快多少倍?"
],
"inputBox": {
"deepThinking": "深度思考",
"deepThinkingTooltip": {
"title": "深度思考模式:{status}",
"description": "启用后,DeepFlow 将使用推理模型({model})生成更深思熟虑的计划。"
},
"investigation": "调研",
"investigationTooltip": {
"title": "调研模式:{status}",
"description": "启用后,DeepFlow 将在规划前进行快速搜索。这对于与时事和新闻相关的研究很有用。"
},
"enhancePrompt": "用 AI 增强提示",
"on": "开启",
"off": "关闭"
},
"research": {
"deepResearch": "深度研究",
"researching": "研究中...",
"generatingReport": "生成报告中...",
"reportGenerated": "报告已生成",
"open": "打开",
"close": "关闭",
"deepThinking": "深度思考",
"report": "报告",
"activities": "活动",
"generatePodcast": "生成播客",
"edit": "编辑",
"copy": "复制",
"downloadReport": "下载报告为 Markdown",
"searchingFor": "搜索",
"reading": "阅读中",
"runningPythonCode": "运行 Python 代码",
"errorExecutingCode": "执行上述代码时出错",
"executionOutput": "执行输出",
"retrievingDocuments": "从 RAG 检索文档",
"running": "运行",
"generatingPodcast": "生成播客中...",
"nowPlayingPodcast": "正在播放播客...",
"podcast": "播客",
"errorGeneratingPodcast": "生成播客时出错。请重试。",
"downloadPodcast": "下载播客"
},
"messages": {
"replaying": "回放中",
"replayDescription": "DeepFlow 正在回放对话...",
"replayHasStopped": "回放已停止。",
"replayModeDescription": "您现在处于 DeepFlow 的回放模式。点击右侧的\"播放\"按钮开始。",
"play": "播放",
"fastForward": "快进",
"demoNotice": "* 此网站仅用于演示目的。如果您想尝试自己的问题,请",
"clickHere": "点击这里",
"cloneLocally": "在本地克隆并运行它。"
},
"multiAgent": {
"moveToPrevious": "移动到上一步",
"playPause": "播放 / 暂停",
"moveToNext": "移动到下一步",
"toggleFullscreen": "切换全屏"
}
},
"landing": {
"caseStudies": {
"title": "案例研究",
"description": "通过回放查看 DeepFlow 的实际应用。",
"clickToWatch": "点击观看回放",
"cases": [
{
"title": "埃菲尔铁塔与最高建筑相比有多高?",
"description": "该研究比较了埃菲尔铁塔和哈利法塔的高度和全球意义,并使用 Python 代码计算倍数。"
},
{
"title": "GitHub 上最热门的仓库有哪些?",
"description": "该研究利用 MCP 服务识别最受欢迎的 GitHub 仓库,并使用搜索引擎详细记录它们。"
},
{
"title": "写一篇关于南京传统菜肴的文章",
"description": "该研究通过丰富的内容和图像生动地展示了南京的著名菜肴,揭示了它们隐藏的历史和文化意义。"
},
{
"title": "如何装饰小型出租公寓?",
"description": "该研究为读者提供了实用而直接的公寓装饰方法,并配有鼓舞人心的图像。"
},
{
"title": "介绍电影《这个杀手不太冷》",
"description": "该研究全面介绍了电影《这个杀手不太冷》,包括其情节、角色和主题。"
},
{
"title": "你如何看待中国的外卖大战?(中文)",
"description": "该研究分析了京东和美团之间日益激烈的竞争,突出了它们的策略、技术创新和挑战。"
},
{
"title": "超加工食品与健康有关吗?",
"description": "该研究检查了超加工食品消费增加的健康风险,敦促对长期影响和个体差异进行更多研究。"
},
{
"title": "写一篇关于\"你会为你的 AI 双胞胎投保吗?\"的文章",
"description": "该研究探讨了为 AI 双胞胎投保的概念,突出了它们的好处、风险、伦理考虑和不断发展的监管。"
}
]
},
"coreFeatures": {
"title": "核心功能",
"description": "了解是什么让 DeepFlow 如此有效。",
"features": [
{
"name": "深入挖掘,触及更广",
"description": "使用高级工具解锁更深层的洞察。我们强大的搜索+爬取和 Python 工具收集全面的数据,提供深入的报告来增强您的研究。"
},
{
"name": "人机协作",
"description": "通过简单的自然语言完善您的研究计划或调整重点领域。"
},
{
"name": "Lang 技术栈",
"description": "使用 LangChain 和 LangGraph 框架自信地构建。"
},
{
"name": "MCP 集成",
"description": "通过无缝的 MCP 集成增强您的研究工作流程并扩展您的工具包。"
},
{
"name": "播客生成",
"description": "从报告中即时生成播客。非常适合移动学习或轻松分享发现。"
}
]
},
"multiAgent": {
"title": "多智能体架构",
"description": "通过我们的监督者 + 交接设计模式体验智能体团队合作。"
},
"joinCommunity": {
"title": "加入 DeepFlow 社区",
"description": "贡献精彩想法,塑造 DeepFlow 的未来。协作、创新并产生影响。",
"contributeNow": "立即贡献"
}
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import "./src/env.js";
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n.ts');
/** @type {import("next").NextConfig} */
// DeepFlow leverages **Turbopack** during development for faster builds and a smoother developer experience.
// However, in production, **Webpack** is used instead.
//
// This decision is based on the current recommendation to avoid using Turbopack for critical projects, as it
// is still evolving and may not yet be fully stable for production environments.
const config = {
// For development mode
turbopack: {
rules: {
"*.md": {
loaders: ["raw-loader"],
as: "*.js",
},
},
},
// For production mode
webpack: (config) => {
config.module.rules.push({
test: /\.md$/,
use: "raw-loader",
});
return config;
},
// ... rest of the configuration.
output: "standalone",
};
export default withNextIntl(config);
+127
View File
@@ -0,0 +1,127 @@
{
"name": "deep-flow-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "next lint && tsc --noEmit",
"dev": "dotenv -f ../.env -- next dev",
"scan": "next dev & npx react-scan@latest localhost:3000",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit",
"test": "jest --watch",
"test:run": "jest",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-collapsible": "^1.1.8",
"@radix-ui/react-dialog": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.11",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0",
"@rc-component/mentions": "^1.2.0",
"@t3-oss/env-nextjs": "^0.11.0",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-document": "^2.12.0",
"@tiptap/extension-mention": "^2.12.0",
"@tiptap/extension-table": "^2.11.7",
"@tiptap/extension-table-cell": "^2.11.7",
"@tiptap/extension-table-header": "^2.11.7",
"@tiptap/extension-table-row": "^2.11.7",
"@tiptap/extension-text": "^2.12.0",
"@tiptap/react": "^2.11.7",
"@types/js-cookie": "^3.0.6",
"@xyflow/react": "^12.6.0",
"best-effort-json-parser": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.6.5",
"hast": "^1.0.0",
"highlight.js": "^11.11.1",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"katex": "^0.16.21",
"lowlight": "^3.3.0",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"motion": "^12.7.4",
"nanoid": "^5.1.5",
"next": "^15.4.7",
"next-intl": "^4.3.1",
"next-themes": "^0.4.6",
"novel": "^1.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"tw-animate-css": "^1.2.5",
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4",
"use-stick-to-bottom": "^1.1.0",
"zod": "^3.24.3",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/hast": "^3.0.4",
"@types/jest": "^30.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"dotenv-cli": "^8.0.0",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"raw-loader": "^4.0.2",
"tailwindcss": "^4.0.15",
"ts-jest": "^29.4.5",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.6.5",
"pnpm": {
"ignoredBuiltDependencies": [
"sharp"
]
}
}
+11931
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export default {
plugins: ["prettier-plugin-tailwindcss"],
};
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "reasoning_content": "我需要仔细分析用户的问题。用户想了解什么是vibe coding。这是一个相对较新的概念,我需要收集相关信息来提供全面的答案。"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "reasoning_content": "\n\n首先,我应该理解vibe coding的基本定义和概念。这可能涉及编程文化、开发方法论或者特定的编程风格。"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "reasoning_content": "\n\n然后,我需要研究它的起源、核心理念,以及在实际开发中的应用。这将帮助我提供一个全面而准确的答案。"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "reasoning_content": "\n\n让我思考一下需要收集哪些具体信息:\n1. Vibe coding的定义和起源\n2. 核心理念和哲学\n3. 实际应用场景和案例\n4. 与传统编程方法的区别\n5. 社区和工具支持"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "reasoning_content": "\n\n基于这些思考,我认为需要进行深入的研究来收集足够的信息。现在我将制定一个详细的研究计划。"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "{"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"locale\": \"zh-CN\","}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"has_enough_context\": false,"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"thought\": \"用户想了解vibe coding的概念。"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "由于目前没有足够的信息来全面回答这个问题,"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "我需要收集更多相关数据。\","}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"title\": \"Vibe Coding 概念研究\","}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"steps\": ["}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n {"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"need_search\": true,"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"title\": \"Vibe Coding 基本定义和概念\","}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"description\": \"收集关于vibe coding的基本定义、"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "起源、核心概念和目标的信息。"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "查找官方定义、行业专家的解释以及相关的编程文化背景。\","}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"step_type\": \"research\""}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n },"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n {"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"need_search\": true,"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"title\": \"实际应用案例和最佳实践\","}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"description\": \"研究vibe coding在实际项目中的应用案例,"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "了解最佳实践和常见的实现方法。\","}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n \"step_type\": \"research\""}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n }"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n ]"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "content": "\n}"}
event: message_chunk
data: {"thread_id": "test-thread", "agent": "planner", "id": "test-id", "role": "assistant", "finish_reason": "stop"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { cn } from "~/lib/utils";
import { Welcome } from "./welcome";
export function ConversationStarter({
className,
onSend,
}: {
className?: string;
onSend?: (message: string) => void;
}) {
const t = useTranslations("chat");
const questions = t.raw("conversationStarters") as string[];
return (
<div className={cn("flex flex-col items-center", className)}>
<div className="pointer-events-none fixed inset-0 flex items-center justify-center">
<Welcome className="pointer-events-auto mb-15 w-[75%] -translate-y-24" />
</div>
<ul className="flex flex-wrap">
{questions.map((question, index) => (
<motion.li
key={`${index}-${question}`}
className="flex w-1/2 shrink-0 p-2 active:scale-105"
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.2,
delay: index * 0.1 + 0.5,
ease: "easeOut",
}}
>
<div
className="bg-card text-muted-foreground h-full w-full cursor-pointer rounded-2xl border px-4 py-4 opacity-75 transition-all duration-300 hover:opacity-100 hover:shadow-md"
onClick={() => {
onSend?.(question);
}}
>
{question}
</div>
</motion.li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,334 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { MagicWandIcon } from "@radix-ui/react-icons";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowUp, Lightbulb, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Detective } from "~/components/deep-flow/icons/detective";
import MessageInput, {
type MessageInputRef,
} from "~/components/deep-flow/message-input";
import { ReportStyleDialog } from "~/components/deep-flow/report-style-dialog";
import { Tooltip } from "~/components/deep-flow/tooltip";
import { BorderBeam } from "~/components/magicui/border-beam";
import { Button } from "~/components/ui/button";
import { enhancePrompt } from "~/core/api";
import { useConfig } from "~/core/api/hooks";
import type { Option, Resource } from "~/core/messages";
import {
setEnableDeepThinking,
setEnableBackgroundInvestigation,
useSettingsStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
export function InputBox({
className,
responding,
feedback,
onSend,
onCancel,
onRemoveFeedback,
}: {
className?: string;
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
onSend?: (
message: string,
options?: {
interruptFeedback?: string;
resources?: Array<Resource>;
},
) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
const t = useTranslations("chat.inputBox");
const tCommon = useTranslations("common");
const enableDeepThinking = useSettingsStore(
(state) => state.general.enableDeepThinking,
);
const backgroundInvestigation = useSettingsStore(
(state) => state.general.enableBackgroundInvestigation,
);
const { config, loading } = useConfig();
const reportStyle = useSettingsStore((state) => state.general.reportStyle);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<MessageInputRef>(null);
const feedbackRef = useRef<HTMLDivElement>(null);
// Enhancement state
const [isEnhancing, setIsEnhancing] = useState(false);
const [isEnhanceAnimating, setIsEnhanceAnimating] = useState(false);
const [currentPrompt, setCurrentPrompt] = useState("");
const handleSendMessage = useCallback(
(message: string, resources: Array<Resource>) => {
if (responding) {
onCancel?.();
} else {
if (message.trim() === "") {
return;
}
if (onSend) {
onSend(message, {
interruptFeedback: feedback?.option.value,
resources,
});
onRemoveFeedback?.();
// Clear enhancement animation after sending
setIsEnhanceAnimating(false);
}
}
},
[responding, onCancel, onSend, feedback, onRemoveFeedback],
);
const handleEnhancePrompt = useCallback(async () => {
if (currentPrompt.trim() === "" || isEnhancing) {
return;
}
setIsEnhancing(true);
setIsEnhanceAnimating(true);
try {
const enhancedPrompt = await enhancePrompt({
prompt: currentPrompt,
report_style: reportStyle.toUpperCase(),
});
// Add a small delay for better UX
await new Promise((resolve) => setTimeout(resolve, 500));
// Update the input with the enhanced prompt with animation
if (inputRef.current) {
inputRef.current.setContent(enhancedPrompt);
setCurrentPrompt(enhancedPrompt);
}
// Keep animation for a bit longer to show the effect
setTimeout(() => {
setIsEnhanceAnimating(false);
}, 1000);
} catch (error) {
console.error("Failed to enhance prompt:", error);
setIsEnhanceAnimating(false);
// Could add toast notification here
} finally {
setIsEnhancing(false);
}
}, [currentPrompt, isEnhancing, reportStyle]);
return (
<div
className={cn(
"bg-card relative flex h-full w-full flex-col rounded-[24px] border",
className,
)}
ref={containerRef}
>
<div className="w-full">
<AnimatePresence>
{feedback && (
<motion.div
ref={feedbackRef}
className="bg-background border-brand absolute top-0 left-0 mt-2 ml-4 flex items-center justify-center gap-1 rounded-2xl border px-2 py-0.5"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="text-brand flex h-full w-full items-center justify-center text-sm opacity-90">
{feedback.option.text}
</div>
<X
className="cursor-pointer opacity-60"
size={16}
onClick={onRemoveFeedback}
/>
</motion.div>
)}
{isEnhanceAnimating && (
<motion.div
className="pointer-events-none absolute inset-0 z-20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<div className="relative h-full w-full">
{/* Sparkle effect overlay */}
<motion.div
className="absolute inset-0 rounded-[24px] bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-blue-500/10"
animate={{
background: [
"linear-gradient(45deg, rgba(59, 130, 246, 0.1), rgba(147, 51, 234, 0.1), rgba(59, 130, 246, 0.1))",
"linear-gradient(225deg, rgba(147, 51, 234, 0.1), rgba(59, 130, 246, 0.1), rgba(147, 51, 234, 0.1))",
"linear-gradient(45deg, rgba(59, 130, 246, 0.1), rgba(147, 51, 234, 0.1), rgba(59, 130, 246, 0.1))",
],
}}
transition={{ duration: 2, repeat: Infinity }}
/>
{/* Floating sparkles */}
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute h-2 w-2 rounded-full bg-blue-400"
style={{
left: `${20 + i * 12}%`,
top: `${30 + (i % 2) * 40}%`,
}}
animate={{
y: [-10, -20, -10],
opacity: [0, 1, 0],
scale: [0.5, 1, 0.5],
}}
transition={{
duration: 1.5,
repeat: Infinity,
delay: i * 0.2,
}}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<MessageInput
className={cn(
"h-24 px-4 pt-5",
feedback && "pt-9",
isEnhanceAnimating && "transition-all duration-500",
)}
ref={inputRef}
loading={loading}
config={config}
onEnter={handleSendMessage}
onChange={setCurrentPrompt}
/>
</div>
<div className="flex items-center px-4 py-2">
<div className="flex grow gap-2">
{config?.models?.reasoning && config.models.reasoning.length > 0 && (
<Tooltip
className="max-w-60"
title={
<div>
<h3 className="mb-2 font-bold">
{t("deepThinkingTooltip.title", {
status: enableDeepThinking ? t("on") : t("off"),
})}
</h3>
<p>
{t("deepThinkingTooltip.description", {
model: config.models.reasoning[0] ?? "",
})}
</p>
</div>
}
>
<Button
className={cn(
"rounded-2xl",
enableDeepThinking && "!border-brand !text-brand",
)}
variant="outline"
onClick={() => {
setEnableDeepThinking(!enableDeepThinking);
}}
>
<Lightbulb /> {t("deepThinking")}
</Button>
</Tooltip>
)}
<Tooltip
className="max-w-60"
title={
<div>
<h3 className="mb-2 font-bold">
{t("investigationTooltip.title", {
status: backgroundInvestigation ? t("on") : t("off"),
})}
</h3>
<p>{t("investigationTooltip.description")}</p>
</div>
}
>
<Button
className={cn(
"rounded-2xl",
backgroundInvestigation && "!border-brand !text-brand",
)}
variant="outline"
onClick={() =>
setEnableBackgroundInvestigation(!backgroundInvestigation)
}
>
<Detective /> {t("investigation")}
</Button>
</Tooltip>
<ReportStyleDialog />
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip title={t("enhancePrompt")}>
<Button
variant="ghost"
size="icon"
className={cn(
"hover:bg-accent h-10 w-10",
isEnhancing && "animate-pulse",
)}
onClick={handleEnhancePrompt}
disabled={isEnhancing || currentPrompt.trim() === ""}
>
{isEnhancing ? (
<div className="flex h-10 w-10 items-center justify-center">
<div className="bg-foreground h-3 w-3 animate-bounce rounded-full opacity-70" />
</div>
) : (
<MagicWandIcon className="text-brand" />
)}
</Button>
</Tooltip>
<Tooltip title={responding ? tCommon("stop") : tCommon("send")}>
<Button
variant="outline"
size="icon"
className={cn("h-10 w-10 rounded-full")}
onClick={() => inputRef.current?.submit()}
>
{responding ? (
<div className="flex h-10 w-10 items-center justify-center">
<div className="bg-foreground h-4 w-4 rounded-sm opacity-70" />
</div>
) : (
<ArrowUp />
)}
</Button>
</Tooltip>
</div>
</div>
{isEnhancing && (
<>
<BorderBeam
duration={5}
size={250}
className="from-transparent via-red-500 to-transparent"
/>
<BorderBeam
duration={5}
delay={3}
size={250}
className="from-transparent via-blue-500 to-transparent"
/>
</>
)}
</div>
);
}
@@ -0,0 +1,700 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { LoadingOutlined } from "@ant-design/icons";
import { motion } from "framer-motion";
import {
Download,
Headphones,
ChevronDown,
ChevronRight,
Lightbulb,
Wrench,
} from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { LoadingAnimation } from "~/components/deep-flow/loading-animation";
import { Markdown } from "~/components/deep-flow/markdown";
import { RainbowText } from "~/components/deep-flow/rainbow-text";
import { RollingText } from "~/components/deep-flow/rolling-text";
import {
ScrollContainer,
type ScrollContainerRef,
} from "~/components/deep-flow/scroll-container";
import { Tooltip } from "~/components/deep-flow/tooltip";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import type { Message, Option } from "~/core/messages";
import {
closeResearch,
openResearch,
useLastFeedbackMessageId,
useLastInterruptMessage,
useMessage,
useRenderableMessageIds,
useResearchMessage,
useStore,
} from "~/core/store";
import { parseJSON } from "~/core/utils";
import { cn } from "~/lib/utils";
export function MessageListView({
className,
onFeedback,
onSendMessage,
}: {
className?: string;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
}) {
const scrollContainerRef = useRef<ScrollContainerRef>(null);
// Use renderable message IDs to avoid React key warnings from duplicate or non-rendering messages
const messageIds = useRenderableMessageIds();
const interruptMessage = useLastInterruptMessage();
const waitingForFeedbackMessageId = useLastFeedbackMessageId();
const responding = useStore((state) => state.responding);
const noOngoingResearch = useStore(
(state) => state.ongoingResearchId === null,
);
const ongoingResearchIsOpen = useStore(
(state) => state.ongoingResearchId === state.openResearchId,
);
const handleToggleResearch = useCallback(() => {
// Fix the issue where auto-scrolling to the bottom
// occasionally fails when toggling research.
const timer = setTimeout(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollToBottom();
}
}, 500);
return () => {
clearTimeout(timer);
};
}, []);
return (
<ScrollContainer
className={cn("flex h-full w-full flex-col overflow-hidden", className)}
scrollShadowColor="var(--app-background)"
autoScrollToBottom
ref={scrollContainerRef}
>
<ul className="flex flex-col">
{messageIds.map((messageId) => (
<MessageListItem
key={messageId}
messageId={messageId}
waitForFeedback={waitingForFeedbackMessageId === messageId}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
onToggleResearch={handleToggleResearch}
/>
))}
<div className="flex h-8 w-full shrink-0"></div>
</ul>
{responding && (noOngoingResearch || !ongoingResearchIsOpen) && (
<LoadingAnimation className="ml-4" />
)}
</ScrollContainer>
);
}
function MessageListItem({
className,
messageId,
waitForFeedback,
interruptMessage,
onFeedback,
onSendMessage,
onToggleResearch,
}: {
className?: string;
messageId: string;
waitForFeedback?: boolean;
onFeedback?: (feedback: { option: Option }) => void;
interruptMessage?: Message | null;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
onToggleResearch?: () => void;
}) {
const message = useMessage(messageId);
const researchIds = useStore((state) => state.researchIds);
const startOfResearch = useMemo(() => {
return researchIds.includes(messageId);
}, [researchIds, messageId]);
if (message) {
if (
message.role === "user" ||
message.agent === "coordinator" ||
message.agent === "planner" ||
message.agent === "podcast" ||
startOfResearch
) {
let content: React.ReactNode;
if (message.agent === "planner") {
content = (
<div className="w-full px-4">
<PlanCard
message={message}
waitForFeedback={waitForFeedback}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
onSendMessage={onSendMessage}
/>
</div>
);
} else if (message.agent === "podcast") {
content = (
<div className="w-full px-4">
<PodcastCard message={message} />
</div>
);
} else if (startOfResearch) {
content = (
<div className="w-full px-4">
<ResearchCard
researchId={message.id}
onToggleResearch={onToggleResearch}
/>
</div>
);
} else {
content = message.content ? (
<div
className={cn(
"flex w-full px-4",
message.role === "user" && "justify-end",
className,
)}
>
<MessageBubble message={message}>
<div className="flex w-full flex-col break-words">
<Markdown
className={cn(
message.role === "user" &&
"prose-invert not-dark:text-secondary dark:text-inherit",
)}
>
{message?.content}
</Markdown>
</div>
</MessageBubble>
</div>
) : null;
}
if (content) {
return (
<motion.li
className="mt-10"
key={messageId}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
style={{ transition: "all 0.2s ease-out" }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
>
{content}
</motion.li>
);
}
}
return null;
}
}
function MessageBubble({
className,
message,
children,
}: {
className?: string;
message: Message;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"group flex w-auto max-w-[90vw] flex-col rounded-2xl px-4 py-3 break-words",
message.role === "user" && "bg-brand rounded-ee-none",
message.role === "assistant" && "bg-card rounded-es-none",
className,
)}
style={{ wordBreak: "break-all" }}
>
{children}
</div>
);
}
function ResearchCard({
className,
researchId,
onToggleResearch,
}: {
className?: string;
researchId: string;
onToggleResearch?: () => void;
}) {
const t = useTranslations("chat.research");
const reportId = useStore((state) => state.researchReportIds.get(researchId));
const hasReport = reportId !== undefined;
const reportGenerating = useStore(
(state) => hasReport && state.messages.get(reportId)!.isStreaming,
);
const openResearchId = useStore((state) => state.openResearchId);
const state = useMemo(() => {
if (hasReport) {
return reportGenerating ? t("generatingReport") : t("reportGenerated");
}
return t("researching");
}, [hasReport, reportGenerating, t]);
const msg = useResearchMessage(researchId);
const title = useMemo(() => {
if (msg) {
return parseJSON(msg.content ?? "", { title: "" }).title;
}
return undefined;
}, [msg]);
const handleOpen = useCallback(() => {
if (openResearchId === researchId) {
closeResearch();
} else {
openResearch(researchId);
}
onToggleResearch?.();
}, [openResearchId, researchId, onToggleResearch]);
return (
<Card className={cn("w-full", className)}>
<CardHeader>
<CardTitle>
<RainbowText animated={state !== t("reportGenerated")}>
{title !== undefined && title !== "" ? title : t("deepResearch")}
</RainbowText>
</CardTitle>
</CardHeader>
<CardFooter>
<div className="flex w-full">
<RollingText className="text-muted-foreground flex-grow text-sm">
{state}
</RollingText>
<Button
variant={!openResearchId ? "default" : "outline"}
onClick={handleOpen}
>
{researchId !== openResearchId ? t("open") : t("close")}
</Button>
</div>
</CardFooter>
</Card>
);
}
function ThoughtBlock({
className,
content,
isStreaming,
hasMainContent,
contentChunks,
}: {
className?: string;
content: string;
isStreaming?: boolean;
hasMainContent?: boolean;
contentChunks?: string[];
}) {
const t = useTranslations("chat.research");
const [isOpen, setIsOpen] = useState(true);
const [hasAutoCollapsed, setHasAutoCollapsed] = useState(false);
React.useEffect(() => {
if (hasMainContent && !hasAutoCollapsed) {
setIsOpen(false);
setHasAutoCollapsed(true);
}
}, [hasMainContent, hasAutoCollapsed]);
if (!content || content.trim() === "") {
return null;
}
// Split content into static (previous chunks) and streaming (current chunk)
const chunks = contentChunks ?? [];
const staticContent = chunks.slice(0, -1).join("");
const streamingChunk = isStreaming && chunks.length > 0 ? (chunks[chunks.length - 1] ?? "") : "";
const hasStreamingContent = isStreaming && streamingChunk.length > 0;
return (
<div className={cn("mb-6 w-full", className)}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-auto w-full justify-start rounded-xl border px-6 py-4 text-left transition-all duration-200",
"hover:bg-accent hover:text-accent-foreground",
isStreaming
? "border-primary/20 bg-primary/5 shadow-sm"
: "border-border bg-card",
)}
>
<div className="flex w-full items-center gap-3">
<Lightbulb
size={18}
className={cn(
"shrink-0 transition-colors duration-200",
isStreaming ? "text-primary" : "text-muted-foreground",
)}
/>
<span
className={cn(
"leading-none font-semibold transition-colors duration-200",
isStreaming ? "text-primary" : "text-foreground",
)}
>
{t("deepThinking")}
</span>
{isStreaming && <LoadingAnimation className="ml-2 scale-75" />}
<div className="flex-grow" />
{isOpen ? (
<ChevronDown
size={16}
className="text-muted-foreground transition-transform duration-200"
/>
) : (
<ChevronRight
size={16}
className="text-muted-foreground transition-transform duration-200"
/>
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-up-2 data-[state=open]:slide-down-2 mt-3">
<Card
className={cn(
"transition-all duration-200",
isStreaming ? "border-primary/20 bg-primary/5" : "border-border",
)}
>
<CardContent>
<div className="flex h-40 w-full overflow-y-auto">
<ScrollContainer
className={cn(
"flex h-full w-full flex-col overflow-hidden",
className,
)}
scrollShadow={false}
autoScrollToBottom
>
{staticContent && (
<Markdown
className={cn(
"prose dark:prose-invert max-w-none transition-colors duration-200",
"opacity-80",
)}
animated={false}
>
{staticContent}
</Markdown>
)}
{hasStreamingContent && (
<Markdown
className={cn(
"prose dark:prose-invert max-w-none transition-colors duration-200",
"prose-primary",
)}
animated={true}
>
{streamingChunk}
</Markdown>
)}
{!hasStreamingContent && (
<Markdown
className={cn(
"prose dark:prose-invert max-w-none transition-colors duration-200",
isStreaming ? "prose-primary" : "opacity-80",
)}
animated={false}
>
{content}
</Markdown>
)}
</ScrollContainer>
</div>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
</div>
);
}
const GREETINGS = ["Cool", "Sounds great", "Looks good", "Great", "Awesome"];
function PlanCard({
className,
message,
interruptMessage,
onFeedback,
waitForFeedback,
onSendMessage,
}: {
className?: string;
message: Message;
interruptMessage?: Message | null;
onFeedback?: (feedback: { option: Option }) => void;
onSendMessage?: (
message: string,
options?: { interruptFeedback?: string },
) => void;
waitForFeedback?: boolean;
}) {
const t = useTranslations("chat.research");
const plan = useMemo<{
title?: string;
thought?: string;
steps?: { title?: string; description?: string; tools?: string[] }[];
}>(() => {
return parseJSON(message.content ?? "", {});
}, [message.content]);
const reasoningContent = message.reasoningContent;
const hasMainContent = Boolean(
message.content && message.content.trim() !== "",
);
// 判断是否正在思考:有推理内容但还没有主要内容
const isThinking = Boolean(reasoningContent && !hasMainContent);
// 判断是否应该显示计划:有主要内容就显示(无论是否还在流式传输)
const shouldShowPlan = hasMainContent;
const handleAccept = useCallback(async () => {
if (onSendMessage) {
onSendMessage(
`${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
{
interruptFeedback: "accepted",
},
);
}
}, [onSendMessage]);
return (
<div className={cn("w-full", className)}>
{reasoningContent && (
<ThoughtBlock
content={reasoningContent}
isStreaming={isThinking}
hasMainContent={hasMainContent}
contentChunks={message.reasoningContentChunks}
/>
)}
{shouldShowPlan && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<Card className="w-full">
<CardHeader>
<CardTitle>
<Markdown animated={false}>
{`### ${
plan.title !== undefined && plan.title !== ""
? plan.title
: t("deepResearch")
}`}
</Markdown>
</CardTitle>
</CardHeader>
<CardContent>
<div style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
<Markdown className="opacity-80" animated={false}>
{plan.thought}
</Markdown>
{plan.steps && (
<ul className="my-2 flex list-decimal flex-col gap-4 border-l-[2px] pl-8">
{plan.steps.map((step, i) => (
<li key={`step-${i}`} style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
<div className="flex items-start gap-2">
<div className="flex-1">
<h3 className="mb flex items-center gap-2 text-lg font-medium">
<Markdown animated={false}>
{step.title}
</Markdown>
{step.tools && step.tools.length > 0 && (
<Tooltip
title={`Uses ${step.tools.length} MCP tool${step.tools.length > 1 ? "s" : ""}`}
>
<div className="flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-800">
<Wrench size={12} />
<span>{step.tools.length}</span>
</div>
</Tooltip>
)}
</h3>
<div className="text-muted-foreground text-sm" style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
<Markdown animated={false}>
{step.description}
</Markdown>
</div>
{step.tools && step.tools.length > 0 && (
<ToolsDisplay tools={step.tools} />
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
</CardContent>
<CardFooter className="flex justify-end">
{!message.isStreaming && interruptMessage?.options?.length && (
<motion.div
className="flex gap-2"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
{interruptMessage?.options.map((option) => (
<Button
key={option.value}
variant={
option.value === "accepted" ? "default" : "outline"
}
disabled={!waitForFeedback}
onClick={() => {
if (option.value === "accepted") {
void handleAccept();
} else {
onFeedback?.({
option,
});
}
}}
>
{option.text}
</Button>
))}
</motion.div>
)}
</CardFooter>
</Card>
</motion.div>
)}
</div>
);
}
function PodcastCard({
className,
message,
}: {
className?: string;
message: Message;
}) {
const data = useMemo(() => {
return JSON.parse(message.content ?? "");
}, [message.content]);
const title = useMemo<string | undefined>(() => data?.title, [data]);
const audioUrl = useMemo<string | undefined>(() => data?.audioUrl, [data]);
const isGenerating = useMemo(() => {
return message.isStreaming;
}, [message.isStreaming]);
const hasError = useMemo(() => {
return data?.error !== undefined;
}, [data]);
const [isPlaying, setIsPlaying] = useState(false);
return (
<Card className={cn("w-[508px]", className)}>
<CardHeader>
<div className="text-muted-foreground flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{isGenerating ? <LoadingOutlined /> : <Headphones size={16} />}
{!hasError ? (
<RainbowText animated={isGenerating}>
{isGenerating
? "Generating podcast..."
: isPlaying
? "Now playing podcast..."
: "Podcast"}
</RainbowText>
) : (
<div className="text-red-500">
Error when generating podcast. Please try again.
</div>
)}
</div>
{!hasError && !isGenerating && (
<div className="flex">
<Tooltip title="Download podcast">
<Button variant="ghost" size="icon" asChild>
<a
href={audioUrl}
download={`${(title ?? "podcast").replaceAll(" ", "-")}.mp3`}
>
<Download size={16} />
</a>
</Button>
</Tooltip>
</div>
)}
</div>
<CardTitle>
<div className="text-lg font-medium">
<RainbowText animated={isGenerating}>{title}</RainbowText>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{audioUrl ? (
<audio
className="w-full"
src={audioUrl}
controls
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
) : (
<div className="w-full"></div>
)}
</CardContent>
</Card>
);
}
function ToolsDisplay({ tools }: { tools: string[] }) {
return (
<div className="mt-2 flex flex-wrap gap-1">
{tools.map((tool, index) => (
<span
key={index}
className="rounded-md bg-muted px-2 py-1 text-xs font-mono text-muted-foreground"
>
{tool}
</span>
))}
</div>
);
}
@@ -0,0 +1,212 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { FastForward, Play } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { RainbowText } from "~/components/deep-flow/rainbow-text";
import { Button } from "~/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { fastForwardReplay } from "~/core/api";
import { useReplayMetadata } from "~/core/api/hooks";
import type { Option, Resource } from "~/core/messages";
import { useReplay } from "~/core/replay";
import { sendMessage, useMessageIds, useStore } from "~/core/store";
import { env } from "~/env";
import { cn } from "~/lib/utils";
import { ConversationStarter } from "./conversation-starter";
import { InputBox } from "./input-box";
import { MessageListView } from "./message-list-view";
import { Welcome } from "./welcome";
export function MessagesBlock({ className }: { className?: string }) {
const t = useTranslations("chat.messages");
const messageIds = useMessageIds();
const messageCount = messageIds.length;
const responding = useStore((state) => state.responding);
const { isReplay } = useReplay();
const { title: replayTitle, hasError: replayHasError } = useReplayMetadata();
const [replayStarted, setReplayStarted] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback(
async (
message: string,
options?: {
interruptFeedback?: string;
resources?: Array<Resource>;
},
) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
await sendMessage(
message,
{
interruptFeedback:
options?.interruptFeedback ?? feedback?.option.value,
resources: options?.resources,
},
{
abortSignal: abortController.signal,
},
);
} catch {}
},
[feedback],
);
const handleCancel = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handleFeedback = useCallback(
(feedback: { option: Option }) => {
setFeedback(feedback);
},
[setFeedback],
);
const handleRemoveFeedback = useCallback(() => {
setFeedback(null);
}, [setFeedback]);
const handleStartReplay = useCallback(() => {
setReplayStarted(true);
void sendMessage();
}, [setReplayStarted]);
const [fastForwarding, setFastForwarding] = useState(false);
const handleFastForwardReplay = useCallback(() => {
setFastForwarding(!fastForwarding);
fastForwardReplay(!fastForwarding);
}, [fastForwarding]);
return (
<div className={cn("flex h-full flex-col", className)}>
<MessageListView
className="flex flex-grow"
onFeedback={handleFeedback}
onSendMessage={handleSend}
/>
{!isReplay ? (
<div className="relative flex h-42 shrink-0 pb-4">
{!responding && messageCount === 0 && (
<ConversationStarter
className="absolute top-[-218px] left-0"
onSend={handleSend}
/>
)}
<InputBox
className="h-full w-full"
responding={responding}
feedback={feedback}
onSend={handleSend}
onCancel={handleCancel}
onRemoveFeedback={handleRemoveFeedback}
/>
</div>
) : (
<>
<div
className={cn(
"fixed bottom-[calc(50vh+80px)] left-0 transition-all duration-500 ease-out",
replayStarted && "pointer-events-none scale-150 opacity-0",
)}
>
<Welcome />
</div>
<motion.div
className="mb-4 h-fit w-full items-center justify-center"
initial={{ opacity: 0, y: "20vh" }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card
className={cn(
"w-full transition-all duration-300",
!replayStarted && "translate-y-[-40vh]",
)}
>
<div className="flex items-center justify-between">
<div className="flex flex-grow items-center">
{responding && (
<motion.div
className="ml-3"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.3 }}
>
<video
// Walking deer animation, designed by @liangzhaojun. Thank you for creating it!
src="/images/walking_deer.webm"
autoPlay
loop
muted
className="h-[42px] w-[42px] object-contain"
/>
</motion.div>
)}
<CardHeader className={cn("flex-grow", responding && "pl-3")}>
<CardTitle>
<RainbowText animated={responding}>
{responding ? t("replaying") : `${replayTitle}`}
</RainbowText>
</CardTitle>
<CardDescription>
<RainbowText animated={responding}>
{responding
? t("replayDescription")
: replayStarted
? t("replayHasStopped")
: t("replayModeDescription")}
</RainbowText>
</CardDescription>
</CardHeader>
</div>
{!replayHasError && (
<div className="pr-4">
{responding && (
<Button
className={cn(fastForwarding && "animate-pulse")}
variant={fastForwarding ? "default" : "outline"}
onClick={handleFastForwardReplay}
>
<FastForward size={16} />
{t("fastForward")}
</Button>
)}
{!replayStarted && (
<Button className="w-24" onClick={handleStartReplay}>
<Play size={16} />
{t("play")}
</Button>
)}
</div>
)}
</div>
</Card>
{!replayStarted && env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<div className="text-muted-foreground w-full text-center text-xs">
{t("demoNotice")}{" "}
<a
className="underline"
href=""
target="_blank"
rel="noopener noreferrer"
>
{t("clickHere")}
</a>{" "}
{t("cloneLocally")}
</div>
)}
</motion.div>
</>
)}
</div>
);
}
@@ -0,0 +1,510 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { PythonOutlined } from "@ant-design/icons";
import { motion } from "framer-motion";
import { LRUCache } from "lru-cache";
import { BookOpenText, FileText, PencilRuler, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import React, { useMemo } from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { FavIcon } from "~/components/deep-flow/fav-icon";
import Image from "~/components/deep-flow/image";
import { LoadingAnimation } from "~/components/deep-flow/loading-animation";
import { Markdown } from "~/components/deep-flow/markdown";
import { RainbowText } from "~/components/deep-flow/rainbow-text";
import { Tooltip } from "~/components/deep-flow/tooltip";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
import { Skeleton } from "~/components/ui/skeleton";
import { findMCPTool } from "~/core/mcp";
import type { ToolCallRuntime } from "~/core/messages";
import { useMessage, useStore } from "~/core/store";
import { parseJSON } from "~/core/utils";
import { cn } from "~/lib/utils";
// Performance optimization constants
const MAX_ANIMATED_ITEMS = 10; // Only animate first 10 items
const ANIMATION_DELAY_MULTIPLIER = 0.05; // Reduced delay between animations
export function ResearchActivitiesBlock({
className,
researchId,
}: {
className?: string;
researchId: string;
}) {
const activityIds = useStore((state) =>
state.researchActivityIds.get(researchId),
)!;
const ongoing = useStore((state) => state.ongoingResearchId === researchId);
return (
<>
<ul className={cn("flex flex-col py-4", className)}>
{activityIds.map(
(activityId, i) => {
if (i === 0) return null;
// Performance optimization: limit animations for large lists
const shouldAnimate = i < MAX_ANIMATED_ITEMS;
const animationDelay = shouldAnimate ? Math.min(i * ANIMATION_DELAY_MULTIPLIER, 0.5) : 0;
return (
<motion.li
key={activityId}
style={{ transition: shouldAnimate ? "all 0.3s ease-out" : "none" }}
initial={shouldAnimate ? { opacity: 0, y: 24 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={shouldAnimate ? {
duration: 0.3, // Reduced from 0.4
delay: animationDelay,
ease: "easeOut",
} : undefined}
>
<ActivityMessage messageId={activityId} />
<ActivityListItem messageId={activityId} />
{i !== activityIds.length - 1 && <hr className="my-8" />}
</motion.li>
);
},
)}
</ul>
{ongoing && <LoadingAnimation className="mx-4 my-12" />}
</>
);
}
const ActivityMessage = React.memo(({ messageId }: { messageId: string }) => {
const message = useMessage(messageId);
if (message?.agent && message.content) {
if (message.agent !== "reporter" && message.agent !== "planner") {
return (
<div className="px-4 py-2">
<Markdown animated checkLinkCredibility>
{message.content}
</Markdown>
</div>
);
}
}
return null;
});
ActivityMessage.displayName = "ActivityMessage";
const ActivityListItem = React.memo(({ messageId }: { messageId: string }) => {
const message = useMessage(messageId);
if (message) {
if (!message.isStreaming && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
if (typeof toolCall.result === "string" && toolCall.result?.startsWith("Error")) {
return null;
}
if (toolCall.name === "web_search") {
return <WebSearchToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "crawl_tool") {
return <CrawlToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "python_repl_tool") {
return <PythonToolCall key={toolCall.id} toolCall={toolCall} />;
} else if (toolCall.name === "local_search_tool") {
return <RetrieverToolCall key={toolCall.id} toolCall={toolCall} />;
} else {
return <MCPToolCall key={toolCall.id} toolCall={toolCall} />;
}
}
}
}
return null;
});
ActivityListItem.displayName = "ActivityListItem";
const __pageCache = new LRUCache<string, string>({ max: 100 });
type SearchResult =
| {
type: "page";
title: string;
url: string;
content: string;
}
| {
type: "image";
image_url: string;
image_description: string;
};
function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const t = useTranslations("chat.research");
const searching = useMemo(() => {
return toolCall.result === undefined;
}, [toolCall.result]);
const searchResults = useMemo<SearchResult[]>(() => {
let results: SearchResult[] | undefined = undefined;
let parseError = false;
try {
if (toolCall.result) {
results = parseJSON(toolCall.result, []);
}
} catch (error) {
parseError = true;
console.warn("Failed to parse search results:", error);
results = undefined;
}
if (Array.isArray(results)) {
results.forEach((result) => {
if (result.type === "page") {
__pageCache.set(result.url, result.title);
}
});
} else {
// If parsing failed, still try to show something useful
results = [];
}
return results;
}, [toolCall.result]);
const pageResults = useMemo(
() => searchResults?.filter((result) => result.type === "page"),
[searchResults],
);
const imageResults = useMemo(
() => searchResults?.filter((result) => result.type === "image"),
[searchResults],
);
return (
<section className="mt-4 pl-4">
<div className="font-medium italic">
<RainbowText
className="flex items-center"
animated={searchResults === undefined}
>
<Search size={16} className={"mr-2"} />
<span>{t("searchingFor")}&nbsp;</span>
<span className="max-w-[500px] overflow-hidden text-ellipsis whitespace-nowrap">
{(toolCall.args as { query: string }).query}
</span>
</RainbowText>
</div>
<div className="pr-4">
{pageResults && (
<ul className="mt-2 flex flex-wrap gap-4">
{searching &&
[...Array(6)].map((_, i) => (
<li
key={`search-result-${i}`}
className="flex h-40 w-40 gap-2 rounded-md text-sm"
>
<Skeleton
className="to-accent h-full w-full rounded-md bg-gradient-to-tl from-slate-400"
style={{ animationDelay: `${i * 0.2}s` }}
/>
</li>
))}
{pageResults
.filter((result) => result.type === "page")
.slice(0, 20) // Limit displayed results for performance
.map((searchResult, i) => {
const shouldAnimate = i < 6; // Only animate first 6 results
return (
<motion.li
key={`search-result-${i}`}
className="text-muted-foreground bg-accent flex max-w-40 gap-2 rounded-md px-2 py-1 text-sm"
initial={shouldAnimate ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={shouldAnimate ? {
duration: 0.15, // Reduced from 0.2
delay: Math.min(i * 0.05, 0.3), // Cap delay at 0.3s
ease: "easeOut",
} : undefined}
>
<FavIcon
className="mt-1"
url={searchResult.url}
title={searchResult.title}
/>
<a href={searchResult.url} target="_blank">
{searchResult.title}
</a>
</motion.li>
);
})}
{imageResults
.slice(0, 10) // Limit displayed images for performance
.map((searchResult, i) => {
const shouldAnimate = i < 4; // Only animate first 4 images
return (
<motion.li
key={`search-result-${i}`}
initial={shouldAnimate ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={shouldAnimate ? {
duration: 0.15,
delay: Math.min(i * 0.05, 0.2),
ease: "easeOut",
} : undefined}
>
<a
className="flex flex-col gap-2 overflow-hidden rounded-md opacity-75 transition-opacity duration-300 hover:opacity-100"
href={searchResult.image_url}
target="_blank"
>
<Image
src={searchResult.image_url}
alt={searchResult.image_description}
className="bg-accent h-40 w-40 max-w-full rounded-md bg-cover bg-center bg-no-repeat"
imageClassName="hover:scale-110"
imageTransition
/>
</a>
</motion.li>
);
})}
</ul>
)}
</div>
</section>
);
}
function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const t = useTranslations("chat.research");
const url = useMemo(
() => (toolCall.args as { url: string }).url,
[toolCall.args],
);
const title = useMemo(() => __pageCache.get(url), [url]);
return (
<section className="mt-4 pl-4">
<div>
<RainbowText
className="flex items-center text-base font-medium italic"
animated={toolCall.result === undefined}
>
<BookOpenText size={16} className={"mr-2"} />
<span>{t("reading")}</span>
</RainbowText>
</div>
<ul className="mt-2 flex flex-wrap gap-4">
<motion.li
className="text-muted-foreground bg-accent flex h-40 w-40 gap-2 rounded-md px-2 py-1 text-sm"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.15, // Reduced for better performance
ease: "easeOut",
}}
>
<FavIcon className="mt-1" url={url} title={title} />
<a
className="h-full flex-grow overflow-hidden text-ellipsis whitespace-nowrap"
href={url}
target="_blank"
>
{title ?? url}
</a>
</motion.li>
</ul>
</section>
);
}
function RetrieverToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const t = useTranslations("chat.research");
const searching = useMemo(() => {
return toolCall.result === undefined;
}, [toolCall.result]);
const documents = useMemo<
Array<{ id: string; title: string; content: string }>
>(() => {
return toolCall.result ? parseJSON(toolCall.result, []) : [];
}, [toolCall.result]);
return (
<section className="mt-4 pl-4">
<div className="font-medium italic">
<RainbowText className="flex items-center" animated={searching}>
<Search size={16} className={"mr-2"} />
<span>{t("retrievingDocuments")}&nbsp;</span>
<span className="max-w-[500px] overflow-hidden text-ellipsis whitespace-nowrap">
{(toolCall.args as { keywords: string }).keywords}
</span>
</RainbowText>
</div>
<div className="pr-4">
{documents && (
<ul className="mt-2 flex flex-wrap gap-4">
{searching &&
[...Array(2)].map((_, i) => (
<li
key={`search-result-${i}`}
className="flex h-40 w-40 gap-2 rounded-md text-sm"
>
<Skeleton
className="to-accent h-full w-full rounded-md bg-gradient-to-tl from-slate-400"
style={{ animationDelay: `${i * 0.2}s` }}
/>
</li>
))}
{documents?.map((doc, i) => {
const shouldAnimate = i < 4; // Only animate first 4 documents
return (
<motion.li
key={`search-result-${i}`}
className="text-muted-foreground bg-accent flex max-w-40 gap-2 rounded-md px-2 py-1 text-sm"
initial={shouldAnimate ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={shouldAnimate ? {
duration: 0.15,
delay: Math.min(i * 0.05, 0.2),
ease: "easeOut",
} : undefined}
>
<FileText size={32} />
{doc.title} (chunk-{i},size-{doc.content.length})
</motion.li>
);
})}
</ul>
)}
</div>
</section>
);
}
function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const t = useTranslations("chat.research");
const code = useMemo<string | undefined>(() => {
return (toolCall.args as { code?: string }).code;
}, [toolCall.args]);
const { resolvedTheme } = useTheme();
return (
<section className="mt-4 pl-4">
<div className="flex items-center">
<PythonOutlined className={"mr-2"} />
<RainbowText
className="text-base font-medium italic"
animated={toolCall.result === undefined}
>
{t("runningPythonCode")}
</RainbowText>
</div>
<div>
<div className="bg-accent mt-2 max-h-[400px] max-w-[calc(100%-120px)] overflow-y-auto rounded-md p-2 text-sm">
<SyntaxHighlighter
language="python"
style={resolvedTheme === "dark" ? dark : docco}
customStyle={{
background: "transparent",
border: "none",
boxShadow: "none",
}}
>
{code?.trim() ?? ""}
</SyntaxHighlighter>
</div>
</div>
{toolCall.result && <PythonToolCallResult result={toolCall.result} />}
</section>
);
}
function PythonToolCallResult({ result }: { result: string }) {
const t = useTranslations("chat.research");
const { resolvedTheme } = useTheme();
const hasError = useMemo(
() => result.includes("Error executing code:\n"),
[result],
);
const error = useMemo(() => {
if (hasError) {
const parts = result.split("```\nError: ");
if (parts.length > 1) {
return parts[1]!.trim();
}
}
return null;
}, [result, hasError]);
const stdout = useMemo(() => {
if (!hasError) {
const parts = result.split("```\nStdout: ");
if (parts.length > 1) {
return parts[1]!.trim();
}
}
return null;
}, [result, hasError]);
return (
<>
<div className="mt-4 font-medium italic">
{hasError ? t("errorExecutingCode") : t("executionOutput")}
</div>
<div className="bg-accent mt-2 max-h-[400px] max-w-[calc(100%-120px)] overflow-y-auto rounded-md p-2 text-sm">
<SyntaxHighlighter
language="plaintext"
style={resolvedTheme === "dark" ? dark : docco}
customStyle={{
color: hasError ? "red" : "inherit",
background: "transparent",
border: "none",
boxShadow: "none",
}}
>
{error ?? stdout ?? "(empty)"}
</SyntaxHighlighter>
</div>
</>
);
}
function MCPToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const tool = useMemo(() => findMCPTool(toolCall.name), [toolCall.name]);
const { resolvedTheme } = useTheme();
return (
<section className="mt-4 pl-4">
<div className="w-fit overflow-y-auto rounded-md py-0">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>
<Tooltip title={tool?.description}>
<div className="flex items-center font-medium italic">
<PencilRuler size={16} className={"mr-2"} />
<RainbowText
className="pr-0.5 text-base font-medium italic"
animated={toolCall.result === undefined}
>
Running {toolCall.name ? toolCall.name + "()" : "MCP tool"}
</RainbowText>
</div>
</Tooltip>
</AccordionTrigger>
<AccordionContent>
{toolCall.result && (
<div className="bg-accent max-h-[400px] max-w-[560px] overflow-y-auto rounded-md text-sm">
<SyntaxHighlighter
language="json"
style={resolvedTheme === "dark" ? dark : docco}
customStyle={{
background: "transparent",
border: "none",
boxShadow: "none",
}}
>
{toolCall.result.trim()}
</SyntaxHighlighter>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</section>
);
}
@@ -0,0 +1,238 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Check, Copy, Headphones, Pencil, Undo2, X, Download } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { ScrollContainer } from "~/components/deep-flow/scroll-container";
import { Tooltip } from "~/components/deep-flow/tooltip";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { useReplay } from "~/core/replay";
import { closeResearch, listenToPodcast, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { ResearchActivitiesBlock } from "./research-activities-block";
import { ResearchReportBlock } from "./research-report-block";
export function ResearchBlock({
className,
researchId = null,
}: {
className?: string;
researchId: string | null;
}) {
const t = useTranslations("chat.research");
const reportId = useStore((state) =>
researchId ? state.researchReportIds.get(researchId) : undefined,
);
const [activeTab, setActiveTab] = useState("activities");
const hasReport = useStore((state) =>
researchId ? state.researchReportIds.has(researchId) : false,
);
const reportStreaming = useStore((state) =>
reportId ? (state.messages.get(reportId)?.isStreaming ?? false) : false,
);
const { isReplay } = useReplay();
useEffect(() => {
if (hasReport) {
setActiveTab("report");
}
}, [hasReport]);
const handleGeneratePodcast = useCallback(async () => {
if (!researchId) {
return;
}
await listenToPodcast(researchId);
}, [researchId]);
const [editing, setEditing] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
if (!reportId) {
return;
}
const report = useStore.getState().messages.get(reportId);
if (!report) {
return;
}
void navigator.clipboard.writeText(report.content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
}, [reportId]);
// Download report as markdown
const handleDownload = useCallback(() => {
if (!reportId) {
return;
}
const report = useStore.getState().messages.get(reportId);
if (!report) {
return;
}
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const filename = `research-report-${timestamp}.md`;
const blob = new Blob([report.content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
try {
if (a.parentNode) {
a.parentNode.removeChild(a);
}
} finally {
URL.revokeObjectURL(url);
}
}, 0);
}, [reportId]);
const handleEdit = useCallback(() => {
setEditing((editing) => !editing);
}, []);
// When the research id changes, set the active tab to activities
useEffect(() => {
if (!hasReport) {
setActiveTab("activities");
}
}, [hasReport, researchId]);
return (
<div className={cn("h-full w-full", className)}>
<Card className={cn("relative h-full w-full pt-4", className)}>
<div className="absolute right-4 flex h-9 items-center justify-center">
{hasReport && !reportStreaming && (
<>
<Tooltip title={t("generatePodcast")}>
<Button
className="text-gray-400"
size="icon"
variant="ghost"
disabled={isReplay}
onClick={handleGeneratePodcast}
>
<Headphones />
</Button>
</Tooltip>
<Tooltip title={t("edit")}>
<Button
className="text-gray-400"
size="icon"
variant="ghost"
disabled={isReplay}
onClick={handleEdit}
>
{editing ? <Undo2 /> : <Pencil />}
</Button>
</Tooltip>
<Tooltip title={t("copy")}>
<Button
className="text-gray-400"
size="icon"
variant="ghost"
onClick={handleCopy}
>
{copied ? <Check /> : <Copy />}
</Button>
</Tooltip>
<Tooltip title={t("downloadReport")}>
<Button
className="text-gray-400"
size="icon"
variant="ghost"
onClick={handleDownload}
>
<Download />
</Button>
</Tooltip>
</>
)}
<Tooltip title={t("close")}>
<Button
className="text-gray-400"
size="sm"
variant="ghost"
onClick={() => {
closeResearch();
}}
>
<X />
</Button>
</Tooltip>
</div>
<Tabs
className="flex h-full w-full flex-col"
value={activeTab}
onValueChange={(value) => setActiveTab(value)}
>
<div className="flex w-full justify-center">
<TabsList className="">
<TabsTrigger
className="px-8"
value="report"
disabled={!hasReport}
>
{t("report")}
</TabsTrigger>
<TabsTrigger className="px-8" value="activities">
{t("activities")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent
className="h-full min-h-0 flex-grow px-8"
value="report"
forceMount
hidden={activeTab !== "report"}
>
<ScrollContainer
className="px-5pb-20 h-full"
scrollShadowColor="var(--card)"
autoScrollToBottom={!hasReport || reportStreaming}
>
{reportId && researchId && (
<ResearchReportBlock
className="mt-4"
researchId={researchId}
messageId={reportId}
editing={editing}
/>
)}
</ScrollContainer>
</TabsContent>
<TabsContent
className="h-full min-h-0 flex-grow px-8"
value="activities"
forceMount
hidden={activeTab !== "activities"}
>
<ScrollContainer
className="h-full"
scrollShadowColor="var(--card)"
autoScrollToBottom={!hasReport || reportStreaming}
>
{researchId && (
<ResearchActivitiesBlock
className="mt-4"
researchId={researchId}
/>
)}
</ScrollContainer>
</TabsContent>
</Tabs>
</Card>
</div>
);
}
@@ -0,0 +1,72 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useCallback, useRef } from "react";
import { LoadingAnimation } from "~/components/deep-flow/loading-animation";
import { Markdown } from "~/components/deep-flow/markdown";
import ReportEditor from "~/components/editor";
import { useReplay } from "~/core/replay";
import { useMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
export function ResearchReportBlock({
className,
messageId,
editing,
}: {
className?: string;
researchId: string;
messageId: string;
editing: boolean;
}) {
const message = useMessage(messageId);
const { isReplay } = useReplay();
const handleMarkdownChange = useCallback(
(markdown: string) => {
if (message) {
message.content = markdown;
useStore.setState({
messages: new Map(useStore.getState().messages).set(
message.id,
message,
),
});
}
},
[message],
);
const contentRef = useRef<HTMLDivElement>(null);
const isCompleted = message?.isStreaming === false && message?.content !== "";
// TODO: scroll to top when completed, but it's not working
// useEffect(() => {
// if (isCompleted && contentRef.current) {
// setTimeout(() => {
// contentRef
// .current!.closest("[data-radix-scroll-area-viewport]")
// ?.scrollTo({
// top: 0,
// behavior: "smooth",
// });
// }, 500);
// }
// }, [isCompleted]);
return (
<div ref={contentRef} className={cn("w-full pt-4 pb-8", className)}>
{!isReplay && isCompleted && editing ? (
<ReportEditor
content={message?.content}
onMarkdownChange={handleMarkdownChange}
/>
) : (
<>
<Markdown animated checkLinkCredibility>
{message?.content}
</Markdown>
{message?.isStreaming && <LoadingAnimation className="my-12" />}
</>
)}
</div>
);
}
@@ -0,0 +1,86 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from "~/components/deep-flow/language-switcher";
import { NumberTicker } from "~/components/magicui/number-ticker";
import { Button } from "~/components/ui/button";
import { env } from "~/env";
export function SiteHeader() {
const t = useTranslations('common');
return (
<header className="supports-backdrop-blur:bg-background/80 bg-background/40 sticky top-0 left-0 z-40 flex h-15 w-full flex-col items-center backdrop-blur-lg">
<div className="container flex h-15 items-center justify-between px-3">
<div className="text-xl font-medium">
<span className="mr-1 text-2xl">🦌</span>
<span>DeepFlow</span>
</div>
<div className="relative flex items-center gap-2">
<LanguageSwitcher />
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-60 blur-2xl"
style={{
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
filter: "blur(32px)",
}}
/>
<Button
variant="outline"
size="sm"
asChild
className="group relative z-10"
>
<Link href="" target="_blank">
<GitHubLogoIcon className="size-4" />
{t('starOnGitHub')}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</Link>
</Button>
</div>
</div>
<hr className="from-border/0 via-border/70 to-border/0 m-0 h-px w-full border-none bg-gradient-to-r" />
</header>
);
}
export async function StarCounter() {
let stars = 1000; // Default value
try {
const response = await fetch(
"",
{
headers: env.GITHUB_OAUTH_TOKEN
? {
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
"Content-Type": "application/json",
}
: {},
next: {
revalidate: 3600,
},
},
);
if (response.ok) {
const data = await response.json();
stars = data.stargazers_count ?? stars; // Update stars if API response is valid
}
} catch (error) {
console.error("Error fetching GitHub stars:", error);
}
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
{stars && (
<NumberTicker className="font-mono tabular-nums" value={stars} />
)}
</>
);
}
@@ -0,0 +1,25 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { cn } from "~/lib/utils";
export function Welcome({ className }: { className?: string }) {
const t = useTranslations("chat.welcome");
return (
<motion.div
className={cn("flex flex-col", className)}
style={{ transition: "all 0.2s ease-out" }}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
>
<h3 className="mb-2 text-center text-3xl font-medium">{t("greeting")}</h3>
<div className="text-muted-foreground px-4 text-center text-lg">
{t("description")}
</div>
</motion.div>
);
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { useMemo } from "react";
import { useStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { MessagesBlock } from "./components/messages-block";
import { ResearchBlock } from "./components/research-block";
export default function Main() {
const openResearchId = useStore((state) => state.openResearchId);
const doubleColumnMode = useMemo(
() => openResearchId !== null,
[openResearchId],
);
return (
<div
className={cn(
"flex h-full w-full justify-center-safe px-4 pt-12 pb-4",
doubleColumnMode && "gap-8",
)}
>
<MessagesBlock
className={cn(
"shrink-0 transition-all duration-300 ease-out",
!doubleColumnMode &&
`w-[768px] translate-x-[min(max(calc((100vw-538px)*0.75),575px)/2,960px/2)]`,
doubleColumnMode && `w-[538px]`,
)}
/>
<ResearchBlock
className={cn(
"w-[min(max(calc((100vw-538px)*0.75),575px),960px)] pb-4 transition-all duration-300 ease-out",
!doubleColumnMode && "scale-0",
doubleColumnMode && "",
)}
researchId={openResearchId}
/>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { GithubOutlined } from "@ant-design/icons";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Suspense } from "react";
import { Button } from "~/components/ui/button";
import { Logo } from "../../components/deep-flow/logo";
import { ThemeToggle } from "../../components/deep-flow/theme-toggle";
import { Tooltip } from "../../components/deep-flow/tooltip";
import { SettingsDialog } from "../settings/dialogs/settings-dialog";
const Main = dynamic(() => import("./main"), {
ssr: false,
loading: () => (
<div className="flex h-full w-full items-center justify-center">
Loading DeepFlow深度研究助手...
</div>
),
});
export default function HomePage() {
const t = useTranslations("chat.page");
return (
<div className="flex h-screen w-screen justify-center overscroll-none">
<header className="fixed top-0 left-0 flex h-12 w-full items-center justify-between px-4">
<Logo />
<div className="flex items-center">
<ThemeToggle />
<Suspense>
<SettingsDialog />
</Suspense>
</div>
</header>
<Main />
</div>
);
}
@@ -0,0 +1,86 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { GithubFilled } from "@ant-design/icons";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { useTranslations } from 'next-intl';
import { AuroraText } from "~/components/magicui/aurora-text";
import { FlickeringGrid } from "~/components/magicui/flickering-grid";
import { Button } from "~/components/ui/button";
import { env } from "~/env";
export function Jumbotron() {
const t = useTranslations('hero');
const tCommon = useTranslations('common');
return (
<section className="flex h-[95vh] w-full flex-col items-center justify-center pb-15">
<FlickeringGrid
id="deer-hero-bg"
className={`absolute inset-0 z-0 [mask-image:radial-gradient(800px_circle_at_center,white,transparent)]`}
squareSize={4}
gridGap={4}
color="#60A5FA"
maxOpacity={0.133}
flickerChance={0.1}
/>
<FlickeringGrid
id="deer-hero"
className="absolute inset-0 z-0 translate-y-[2vh] mask-[url(/images/deer-hero.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
squareSize={3}
gridGap={6}
color="#60A5FA"
maxOpacity={0.64}
flickerChance={0.12}
/>
<div className="relative z-10 flex flex-col items-center justify-center gap-12">
<h1 className="text-center text-4xl font-bold md:text-6xl">
<span className="bg-gradient-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-transparent">
{t('title')}{" "}
</span>
<AuroraText>{t('subtitle')}</AuroraText>
</h1>
<p className="max-w-4xl p-2 text-center text-sm opacity-85 md:text-2xl">
{t('description')}
</p>
<div className="flex gap-6">
<Button className="hidden text-lg md:flex md:w-42" size="lg" asChild>
<Link
target={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY ? "_blank" : undefined
}
href={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY
? ""
: "/chat"
}
>
{tCommon('getStarted')} <ChevronRight />
</Link>
</Button>
{!env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<Button
className="w-42 text-lg"
size="lg"
variant="outline"
asChild
>
<Link
href=""
target="_blank"
>
<GithubFilled />
{tCommon('learnMore')}
</Link>
</Button>
)}
</div>
</div>
<div className="absolute bottom-8 flex text-xs opacity-50">
<p>{t('footnote')}</p>
</div>
</section>
);
}
@@ -0,0 +1,309 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import {
ReactFlow,
Background,
Handle,
Position,
type Edge,
type ReactFlowInstance,
} from "@xyflow/react";
import {
Play,
type LucideIcon,
ChevronRight,
ChevronLeft,
Pause,
Fullscreen,
Minimize,
} from "lucide-react";
import "@xyflow/react/dist/style.css";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Tooltip } from "~/components/deep-flow/tooltip";
import { ShineBorder } from "~/components/magicui/shine-border";
import { Button } from "~/components/ui/button";
import { Slider } from "~/components/ui/slider";
import { useIntersectionObserver } from "~/hooks/use-intersection-observer";
import { cn } from "~/lib/utils";
import { playbook, type GraphNode } from "../store";
import {
activateStep,
nextStep,
play,
prevStep,
togglePlay,
useMAVStore,
} from "../store/mav-store";
const nodeTypes = {
circle: CircleNode,
agent: AgentNode,
default: AgentNode,
};
export function MultiAgentVisualization({ className }: { className?: string }) {
const t = useTranslations("chat.multiAgent");
const {
graph: { nodes, edges },
activeStepIndex,
playing,
} = useMAVStore((state) => state);
const flowRef = useRef<ReactFlowInstance<GraphNode, Edge>>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [hasPlayed, setHasPlayed] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const { ref: anchorRef } = useIntersectionObserver({
rootMargin: "0%",
threshold: 0,
onChange: (isIntersecting) => {
if (isIntersecting && !playing && !hasPlayed) {
setHasPlayed(true);
void play();
}
},
});
const toggleFullscreen = useCallback(async () => {
if (containerRef.current) {
if (!document.fullscreenElement) {
setFullscreen(true);
await containerRef.current.requestFullscreen();
setTimeout(() => {
void flowRef.current?.fitView({ maxZoom: 2.5 });
}, 100);
} else {
setFullscreen(false);
await document.exitFullscreen();
setTimeout(() => {
void flowRef.current?.fitView();
}, 100);
}
}
}, []);
return (
<div
ref={containerRef}
className={cn("flex h-full w-full flex-col pb-4", className)}
>
<ReactFlow
className={cn("flex min-h-0 flex-grow")}
style={{
["--xy-background-color-default" as string]: "transparent",
}}
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
proOptions={{ hideAttribution: true }}
colorMode="dark"
panOnScroll={false}
zoomOnScroll={false}
preventScrolling={false}
panOnDrag={false}
onInit={(instance) => {
flowRef.current = instance;
}}
>
<Background
className="[mask-image:radial-gradient(800px_circle_at_center,white,transparent)]"
bgColor="var(--background)"
/>
<div
ref={anchorRef}
id="auto-run-animation-trigger"
className="absolute bottom-0 left-[50%] h-px w-px"
/>
</ReactFlow>
<div className="h-4 shrink-0"></div>
<div className="flex h-6 w-full shrink-0 items-center justify-center">
<div className="bg-muted/50 z-[200] flex rounded-3xl px-4 py-2">
<Tooltip title={t("moveToPrevious")}>
<Button variant="ghost" onClick={prevStep}>
<ChevronLeft className="size-5" />
</Button>
</Tooltip>
<Tooltip title={t("playPause")}>
<Button variant="ghost" onClick={togglePlay}>
{playing ? (
<Pause className="size-5" />
) : (
<Play className="size-5" />
)}
</Button>
</Tooltip>
<Tooltip title={t("moveToNext")}>
<Button
variant="ghost"
onClick={() => {
setHasPlayed(true);
nextStep();
}}
>
<ChevronRight className="size-5" />
</Button>
</Tooltip>
<div className="text-muted-foreground ml-2 flex items-center justify-center">
<Slider
className="w-40 sm:w-80 md:w-100 lg:w-120"
max={playbook.steps.length - 1}
min={0}
step={1}
value={[activeStepIndex >= 0 ? activeStepIndex : 0]}
onValueChange={([value]) => {
setHasPlayed(true);
activateStep(value!);
}}
/>
</div>
<Tooltip title={t("toggleFullscreen")}>
<Button
variant="ghost"
size="icon"
onClick={() => {
setHasPlayed(true);
void toggleFullscreen();
}}
>
{fullscreen ? (
<Minimize className="size-5" />
) : (
<Fullscreen className="size-5" />
)}
</Button>
</Tooltip>
</div>
</div>
</div>
);
}
function CircleNode({ data }: { data: { label: string; active: boolean } }) {
return (
<>
{data.active && (
<ShineBorder
className="rounded-full"
shineColor={["#A07CFE", "#FE8FB5", "#FFBE7B"]}
/>
)}
<div className="flex h-10 w-10 items-center justify-center rounded-full border bg-[var(--xy-node-background-color-default)]">
<p className="text-xs">{data.label}</p>
</div>
{data.label === "Start" && (
<Handle
className="invisible"
type="source"
position={Position.Right}
id="right"
/>
)}
{data.label === "End" && (
<Handle
className="invisible"
type="target"
position={Position.Top}
id="top"
/>
)}
</>
);
}
function AgentNode({
data,
id,
}: {
data: {
icon?: LucideIcon;
label: string;
active: boolean;
stepDescription?: string;
stepTooltipPosition?: "left" | "right" | "top" | "bottom";
};
id: string;
}) {
return (
<>
{data.active && (
<ShineBorder
shineColor={["#A07CFE", "#FE8FB5", "#FFBE7B"]}
className="rounded-[2px]"
/>
)}
<Tooltip
className="max-w-50 text-[15px] font-light opacity-70"
style={{
["--primary" as string]: "#333",
["--primary-foreground" as string]: "white",
}}
open={data.active && !!data.stepDescription}
title={data.stepDescription}
side={data.stepTooltipPosition}
sideOffset={20}
>
<div
id={id}
className="relative flex w-full items-center justify-center text-xs"
>
<div className="flex items-center gap-2">
{data.icon && <data.icon className="h-[1rem] w-[1rem]" />}
<span>{data.label}</span>
</div>
</div>
</Tooltip>
<Handle
className="invisible"
type="source"
position={Position.Left}
id="left"
/>
<Handle
className="invisible"
type="source"
position={Position.Right}
id="right"
/>
<Handle
className="invisible"
type="source"
position={Position.Top}
id="top"
/>
<Handle
className="invisible"
type="source"
position={Position.Bottom}
id="bottom"
/>
<Handle
className="invisible"
type="target"
position={Position.Left}
id="left"
/>
<Handle
className="invisible"
type="target"
position={Position.Right}
id="right"
/>
<Handle
className="invisible"
type="target"
position={Position.Top}
id="top"
/>
<Handle
className="invisible"
type="target"
position={Position.Bottom}
id="bottom"
/>
</>
);
}
@@ -0,0 +1,47 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export function Ray() {
return (
<svg
className="animate-spotlight pointer-events-none fixed -top-80 left-0 z-[99] h-[169%] w-[138%] opacity-0 md:-top-20 md:left-60 lg:w-[84%]"
viewBox="0 0 3787 2842"
fill="none"
>
<g filter="url(#filter)">
<ellipse
cx="1924.71"
cy="273.501"
rx="1924.71"
ry="273.501"
transform="matrix(-0.822377 -0.568943 -0.568943 0.822377 3631.88 2291.09)"
fill="white"
fillOpacity="0.21"
></ellipse>
</g>
<defs>
<filter
id="filter"
x="0.860352"
y="0.838989"
width="3785.16"
height="2840.26"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
></feBlend>
<feGaussianBlur
stdDeviation="151"
result="effect1_foregroundBlur_1065_8"
></feGaussianBlur>
</filter>
</defs>
</svg>
);
}
@@ -0,0 +1,26 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export function SectionHeader({
anchor,
title,
description,
}: {
anchor?: string;
title: React.ReactNode;
description: React.ReactNode;
}) {
return (
<>
{anchor && <a id={anchor} className="absolute -top-20" />}
<div className="mb-12 flex flex-col items-center justify-center gap-2">
<h2 className="mb-4 bg-gradient-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-5xl font-bold text-transparent">
{title}
</h2>
<p className="text-muted-foreground text-center text-xl">
{description}
</p>
</div>
</>
);
}
@@ -0,0 +1,55 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Bike, Building, Film, Github, Ham, Home, Pizza } from "lucide-react";
import { Bot } from "lucide-react";
import { useTranslations } from "next-intl";
import { BentoCard } from "~/components/magicui/bento-grid";
import { SectionHeader } from "../components/section-header";
const caseStudyIcons = [
{ id: "eiffel-tower-vs-tallest-building", icon: Building },
{ id: "github-top-trending-repo", icon: Github },
{ id: "nanjing-traditional-dishes", icon: Ham },
{ id: "rental-apartment-decoration", icon: Home },
{ id: "review-of-the-professional", icon: Film },
{ id: "china-food-delivery", icon: Bike },
{ id: "ultra-processed-foods", icon: Pizza },
{ id: "ai-twin-insurance", icon: Bot },
];
export function CaseStudySection() {
const t = useTranslations("landing.caseStudies");
const cases = t.raw("cases") as Array<{ title: string; description: string }>;
return (
<section className="relative container hidden flex-col items-center justify-center md:flex">
<SectionHeader
anchor="case-studies"
title={t("title")}
description={t("description")}
/>
<div className="grid w-3/4 grid-cols-1 gap-2 sm:w-full sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cases.map((caseStudy, index) => {
const iconData = caseStudyIcons[index];
return (
<div key={caseStudy.title} className="w-full p-2">
<BentoCard
{...{
Icon: iconData?.icon ?? Building,
name: caseStudy.title,
description: caseStudy.description,
href: `/chat?replay=${iconData?.id}`,
cta: t("clickToWatch"),
className: "w-full h-full",
}}
/>
</div>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,91 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import {
Bird,
Microscope,
Podcast,
Usb,
User,
type LucideProps,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { ForwardRefExoticComponent, RefAttributes } from "react";
import { BentoCard, BentoGrid } from "~/components/magicui/bento-grid";
import { SectionHeader } from "../components/section-header";
type FeatureIcon = {
Icon: ForwardRefExoticComponent<
Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>
>;
href: string;
className: string;
};
const featureIcons: Array<FeatureIcon> = [
{
Icon: Microscope,
href: "",
className: "lg:col-start-1 lg:col-end-2 lg:row-start-1 lg:row-end-3",
},
{
Icon: User,
href: "",
className: "lg:col-start-1 lg:col-end-2 lg:row-start-3 lg:row-end-4",
},
{
Icon: Bird,
href: "https://www.langchain.com/",
className: "lg:col-start-2 lg:col-end-3 lg:row-start-1 lg:row-end-2",
},
{
Icon: Usb,
href: "",
className: "lg:col-start-2 lg:col-end-3 lg:row-start-2 lg:row-end-3",
},
{
Icon: Podcast,
href: "",
className: "lg:col-start-2 lg:col-end-3 lg:row-start-3 lg:row-end-4",
},
];
export function CoreFeatureSection() {
const t = useTranslations("landing.coreFeatures");
const tCommon = useTranslations("common");
const features = t.raw("features") as Array<{
name: string;
description: string;
}>;
return (
<section className="relative flex w-full flex-col content-around items-center justify-center">
<SectionHeader
anchor="core-features"
title={t("title")}
description={t("description")}
/>
<BentoGrid className="w-3/4 lg:grid-cols-2 lg:grid-rows-3">
{features.map((feature, index) => {
const iconData = featureIcons[index];
return iconData ? (
<BentoCard
key={feature.name}
{...iconData}
{...feature}
background={
<img
alt="background"
className="absolute -top-20 -right-20 opacity-60"
/>
}
cta={tCommon("learnMore")}
/>
) : null;
})}
</BentoGrid>
</section>
);
}
@@ -0,0 +1,34 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { GithubFilled } from "@ant-design/icons";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { AuroraText } from "~/components/magicui/aurora-text";
import { Button } from "~/components/ui/button";
import { SectionHeader } from "../components/section-header";
export function JoinCommunitySection() {
const t = useTranslations("landing.joinCommunity");
return (
<section className="flex w-full flex-col items-center justify-center pb-12">
<SectionHeader
anchor="join-community"
title={
<AuroraText colors={["#60A5FA", "#A5FA60", "#A560FA"]}>
{t("title")}
</AuroraText>
}
description={t("description")}
/>
<Button className="text-xl" size="lg" asChild>
<Link href="" target="_blank">
<GithubFilled />
{t("contributeNow")}
</Link>
</Button>
</section>
);
}
@@ -0,0 +1,24 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useTranslations } from "next-intl";
import { MultiAgentVisualization } from "../components/multi-agent-visualization";
import { SectionHeader } from "../components/section-header";
export function MultiAgentSection() {
const t = useTranslations("landing.multiAgent");
return (
<section className="relative flex w-full flex-col items-center justify-center">
<SectionHeader
anchor="multi-agent-architecture"
title={t("title")}
description={t("description")}
/>
<div className="flex h-[70vh] w-full flex-col items-center justify-center">
<div className="h-full w-full">
<MultiAgentVisualization />
</div>
</div>
</section>
);
}
@@ -0,0 +1,183 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { Edge, Node } from "@xyflow/react";
import {
Brain,
FilePen,
MessageSquareQuote,
Microscope,
SquareTerminal,
UserCheck,
Users,
type LucideIcon,
} from "lucide-react";
export type GraphNode = Node<{
label: string;
icon?: LucideIcon;
active?: boolean;
}>;
export type Graph = {
nodes: GraphNode[];
edges: Edge[];
};
const ROW_HEIGHT = 85;
const ROW_1 = 0;
const ROW_2 = ROW_HEIGHT;
const ROW_3 = ROW_HEIGHT * 2;
const ROW_4 = ROW_HEIGHT * 2;
const ROW_5 = ROW_HEIGHT * 3;
const ROW_6 = ROW_HEIGHT * 4;
export const graph: Graph = {
nodes: [
{
id: "Start",
type: "circle",
data: { label: "Start" },
position: { x: -75, y: ROW_1 },
},
{
id: "Coordinator",
data: { icon: MessageSquareQuote, label: "Coordinator" },
position: { x: 150, y: ROW_1 },
},
{
id: "Planner",
data: { icon: Brain, label: "Planner" },
position: { x: 150, y: ROW_2 },
},
{
id: "Reporter",
data: { icon: FilePen, label: "Reporter" },
position: { x: 275, y: ROW_3 },
},
{
id: "HumanFeedback",
data: { icon: UserCheck, label: "Human Feedback" },
position: { x: 25, y: ROW_4 },
},
{
id: "ResearchTeam",
data: { icon: Users, label: "Research Team" },
position: { x: 25, y: ROW_5 },
},
{
id: "Researcher",
data: { icon: Microscope, label: "Researcher" },
position: { x: -75, y: ROW_6 },
},
{
id: "Coder",
data: { icon: SquareTerminal, label: "Coder" },
position: { x: 125, y: ROW_6 },
},
{
id: "End",
type: "circle",
data: { label: "End" },
position: { x: 330, y: ROW_6 },
},
],
edges: [
{
id: "Start->Coordinator",
source: "Start",
target: "Coordinator",
sourceHandle: "right",
targetHandle: "left",
animated: true,
},
{
id: "Coordinator->Planner",
source: "Coordinator",
target: "Planner",
sourceHandle: "bottom",
targetHandle: "top",
animated: true,
},
{
id: "Planner->Reporter",
source: "Planner",
target: "Reporter",
sourceHandle: "right",
targetHandle: "top",
animated: true,
},
{
id: "Planner->HumanFeedback",
source: "Planner",
target: "HumanFeedback",
sourceHandle: "left",
targetHandle: "top",
animated: true,
},
{
id: "HumanFeedback->Planner",
source: "HumanFeedback",
target: "Planner",
sourceHandle: "right",
targetHandle: "bottom",
animated: true,
},
{
id: "HumanFeedback->ResearchTeam",
source: "HumanFeedback",
target: "ResearchTeam",
sourceHandle: "bottom",
targetHandle: "top",
animated: true,
},
{
id: "Reporter->End",
source: "Reporter",
target: "End",
sourceHandle: "bottom",
targetHandle: "top",
animated: true,
},
{
id: "ResearchTeam->Researcher",
source: "ResearchTeam",
target: "Researcher",
sourceHandle: "left",
targetHandle: "top",
animated: true,
},
{
id: "ResearchTeam->Coder",
source: "ResearchTeam",
target: "Coder",
sourceHandle: "bottom",
targetHandle: "left",
animated: true,
},
{
id: "ResearchTeam->Planner",
source: "ResearchTeam",
target: "Planner",
sourceHandle: "right",
targetHandle: "bottom",
animated: true,
},
{
id: "Researcher->ResearchTeam",
source: "Researcher",
target: "ResearchTeam",
sourceHandle: "right",
targetHandle: "bottom",
animated: true,
},
{
id: "Coder->ResearchTeam",
source: "Coder",
target: "ResearchTeam",
sourceHandle: "top",
targetHandle: "right",
animated: true,
},
],
};
@@ -0,0 +1,5 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export * from "./graph";
export * from "./playbook";
@@ -0,0 +1,111 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { create } from "zustand";
import { sleep } from "~/core/utils";
import { graph, type Graph } from "./graph";
import { playbook } from "./playbook";
// Store for MAV(Multi-Agent Visualization)
export const useMAVStore = create<{
graph: Graph;
activeStepIndex: number;
playing: boolean;
}>(() => ({
graph,
activeStepIndex: -1,
playing: false,
}));
export function activateStep(stepIndex: number) {
const nextStep = playbook.steps[stepIndex]!;
const currentGraph = useMAVStore.getState().graph;
const nextGraph: Graph = {
nodes: currentGraph.nodes.map((node) => ({
...node,
data: {
...node.data,
active: nextStep.activeNodes.includes(node.id),
stepDescription:
nextStep.activeNodes.indexOf(node.id) ===
nextStep.activeNodes.length - 1 && nextStep.description,
stepTooltipPosition:
nextStep.activeNodes.indexOf(node.id) ===
nextStep.activeNodes.length - 1 && nextStep.tooltipPosition,
},
})),
edges: currentGraph.edges.map((edge) => ({
...edge,
animated: nextStep.activeEdges.includes(edge.id),
})),
};
useMAVStore.setState({
activeStepIndex: stepIndex,
graph: nextGraph,
});
}
export function nextStep() {
let stepIndex = useMAVStore.getState().activeStepIndex;
if (stepIndex >= playbook.steps.length - 1) {
stepIndex = 0;
} else {
stepIndex++;
}
activateStep(stepIndex);
}
export function prevStep() {
let stepIndex = useMAVStore.getState().activeStepIndex;
if (stepIndex <= 0) {
stepIndex = playbook.steps.length - 1;
} else {
stepIndex--;
}
activateStep(stepIndex);
}
export async function play() {
const state = useMAVStore.getState();
const activeStepIndex = state.activeStepIndex;
if (activeStepIndex >= playbook.steps.length - 1) {
if (state.playing) {
stop();
return;
}
}
useMAVStore.setState({
playing: true,
});
nextStep();
await sleep(3000);
const playing = useMAVStore.getState().playing;
if (playing) {
await play();
}
}
export function pause() {
useMAVStore.setState({
playing: false,
});
}
export async function togglePlay() {
const playing = useMAVStore.getState().playing;
if (playing) {
pause();
} else {
await play();
}
}
export function stop() {
useMAVStore.setState({
playing: false,
activeStepIndex: -1,
graph,
});
}
@@ -0,0 +1,79 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export const playbook = {
steps: [
{
description:
"The Coordinator is responsible for engaging with the user to understand their problem and requirements.",
activeNodes: ["Start", "Coordinator"],
activeEdges: ["Start->Coordinator"],
tooltipPosition: "right",
},
{
description:
"If the user's problem is clearly defined, the Coordinator will hand it over to the Planner.",
activeNodes: ["Coordinator", "Planner"],
activeEdges: ["Coordinator->Planner"],
tooltipPosition: "left",
},
{
description: "Awaiting human feedback to refine the plan.",
activeNodes: ["Planner", "HumanFeedback"],
activeEdges: ["Planner->HumanFeedback"],
tooltipPosition: "left",
},
{
description: "Updating the plan based on human feedback.",
activeNodes: ["HumanFeedback", "Planner"],
activeEdges: ["HumanFeedback->Planner"],
tooltipPosition: "left",
},
{
description:
"The Research Team is responsible for conducting the core research tasks.",
activeNodes: ["Planner", "HumanFeedback", "ResearchTeam"],
activeEdges: [
"Planner->HumanFeedback",
"HumanFeedback->ResearchTeam",
"ResearchTeam->HumanFeedback",
],
tooltipPosition: "left",
},
{
description:
"The Researcher is responsible for gathering information using search and crawling tools.",
activeNodes: ["ResearchTeam", "Researcher"],
activeEdges: ["ResearchTeam->Researcher", "Researcher->ResearchTeam"],
tooltipPosition: "left",
},
{
description:
"The Coder is responsible for writing Python code to solve math problems, data analysis, and more.",
tooltipPosition: "right",
activeNodes: ["ResearchTeam", "Coder"],
activeEdges: ["ResearchTeam->Coder", "Coder->ResearchTeam"],
},
{
description:
"Once the research tasks are completed, the Researcher will hand over to the Planner.",
activeNodes: ["ResearchTeam", "Planner"],
activeEdges: ["ResearchTeam->Planner"],
tooltipPosition: "left",
},
{
description:
"If no additional information is required, the Planner will handoff to the Reporter.",
activeNodes: ["Reporter", "Planner"],
activeEdges: ["Planner->Reporter"],
tooltipPosition: "right",
},
{
description:
"The Reporter will prepare a report summarizing the results.",
activeNodes: ["End", "Reporter"],
activeEdges: ["Reporter->End"],
tooltipPosition: "bottom",
},
],
};
+72
View File
@@ -0,0 +1,72 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import "~/styles/globals.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
import Script from "next/script";
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { ThemeProviderWrapper } from "~/components/deep-flow/theme-provider-wrapper";
import { env } from "~/env";
import { Toaster } from "../components/deep-flow/toaster";
export const metadata: Metadata = {
title: "DeepFlow深度研究助手",
description:
"Deep Exploration and Efficient Research, an AI tool that combines language models with specialized tools for research tasks.",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale} className={`${geist.variable}`} suppressHydrationWarning>
<head>
{/* Define isSpace function globally to fix markdown-it issues with Next.js + Turbopack
https://github.com/markdown-it/markdown-it/issues/1082#issuecomment-2749656365 */}
<Script id="markdown-it-fix" strategy="beforeInteractive">
{`
if (typeof window !== 'undefined' && typeof window.isSpace === 'undefined') {
window.isSpace = function(code) {
return code === 0x20 || code === 0x09 || code === 0x0A || code === 0x0B || code === 0x0C || code === 0x0D;
};
}
`}
</Script>
</head>
<body className="bg-app">
<NextIntlClientProvider messages={messages}>
<ThemeProviderWrapper>{children}</ThemeProviderWrapper>
<Toaster />
</NextIntlClientProvider>
{
// NO USER BEHAVIOR TRACKING OR PRIVATE DATA COLLECTION BY DEFAULT
//
// When `NEXT_PUBLIC_STATIC_WEBSITE_ONLY` is `true`, the script will be injected
// into the page only when `AMPLITUDE_API_KEY` is provided in `.env`
}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && env.AMPLITUDE_API_KEY && (
<>
<Script src="https://cdn.amplitude.com/script/d2197dd1df3f2959f26295bb0e7e849f.js"></Script>
<Script id="amplitude-init" strategy="lazyOnload">
{`window.amplitude.init('${env.AMPLITUDE_API_KEY}', {"fetchRemoteConfig":true,"autocapture":true});`}
</Script>
</>
)}
</body>
</html>
);
}
+56
View File
@@ -0,0 +1,56 @@
// 1. 必须在第一行添加此指令,声明为客户端组件
"use client";
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useTranslations } from 'next-intl';
import { useMemo } from "react";
import { SiteHeader } from "./chat/components/site-header";
import { Jumbotron } from "./landing/components/jumbotron";
import { Ray } from "./landing/components/ray";
import { CaseStudySection } from "./landing/sections/case-study-section";
import { CoreFeatureSection } from "./landing/sections/core-features-section";
import { JoinCommunitySection } from "./landing/sections/join-community-section";
import { MultiAgentSection } from "./landing/sections/multi-agent-section";
export default function HomePage() {
return (
<div className="flex flex-col items-center">
<SiteHeader />
<main className="container flex flex-col items-center justify-center gap-56">
<Jumbotron />
<CaseStudySection />
<MultiAgentSection />
<CoreFeatureSection />
<JoinCommunitySection />
</main>
<Footer />
<Ray />
</div>
);
}
function Footer() {
const t = useTranslations('footer');
const year = useMemo(() => new Date().getFullYear(), []);
return (
<footer className="container mt-32 flex flex-col items-center justify-center">
<hr className="from-border/0 via-border/70 to-border/0 m-0 h-px w-full border-none bg-gradient-to-r" />
<div className="text-muted-foreground container flex h-20 flex-col items-center justify-center text-sm">
<p className="text-center font-serif text-lg md:text-xl">
&quot;{t('quote')}&quot;
</p>
</div>
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
<p>{t('license')}</p>
<p>&copy; {year} {t('copyright')}</p>
</div>
</footer>
);
}
// 2. 删除了末尾的 export const dynamic = "force-dynamic";
// 原因:Client Component 不需要也不支持此配置
@@ -0,0 +1,196 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Textarea } from "~/components/ui/textarea";
import { queryMCPServerMetadata } from "~/core/api";
import {
MCPConfigSchema,
type MCPServerMetadata,
type SimpleMCPServerMetadata,
type SimpleSSEMCPServerMetadata,
type SimpleStdioMCPServerMetadata,
} from "~/core/mcp";
export function AddMCPServerDialog({
onAdd,
}: {
onAdd?: (servers: MCPServerMetadata[]) => void;
}) {
const t = useTranslations("settings");
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [validationError, setValidationError] = useState<string | null>("");
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const handleChange = useCallback((value: string) => {
setInput(value);
if (!value.trim()) {
setValidationError(null);
return;
}
setValidationError(null);
try {
const parsed = JSON.parse(value);
if (!("mcpServers" in parsed)) {
setValidationError("Missing `mcpServers` in JSON");
return;
}
} catch {
setValidationError(t("invalidJson"));
return;
}
const result = MCPConfigSchema.safeParse(JSON.parse(value));
if (!result.success) {
if (result.error.errors[0]) {
const error = result.error.errors[0];
if (error.code === "invalid_union") {
if (error.unionErrors[0]?.errors[0]) {
setValidationError(error.unionErrors[0].errors[0].message);
return;
}
}
}
const errorMessage =
result.error.errors[0]?.message ?? t("validationFailed");
setValidationError(errorMessage);
return;
}
const keys = Object.keys(result.data.mcpServers);
if (keys.length === 0) {
setValidationError(t("missingServerName"));
return;
}
}, [t]);
const handleAdd = useCallback(async () => {
abortControllerRef.current = new AbortController();
const config = MCPConfigSchema.parse(JSON.parse(input));
setInput(JSON.stringify(config, null, 2));
const addingServers: SimpleMCPServerMetadata[] = [];
for (const [key, server] of Object.entries(config.mcpServers)) {
if ("command" in server) {
const metadata: SimpleStdioMCPServerMetadata = {
transport: "stdio",
name: key,
command: server.command,
args: server.args,
env: server.env,
};
addingServers.push(metadata);
} else if ("url" in server) {
const metadata: SimpleSSEMCPServerMetadata = {
transport: server.transport,
name: key,
url: server.url,
};
addingServers.push(metadata);
}
}
setProcessing(true);
const results: MCPServerMetadata[] = [];
let processingServer: string | null = null;
try {
setError(null);
for (const server of addingServers) {
processingServer = server.name;
const metadata = await queryMCPServerMetadata(server, abortControllerRef.current.signal);
results.push({ ...metadata, name: server.name, enabled: true });
}
if (results.length > 0) {
onAdd?.(results);
}
setInput("");
setOpen(false);
} catch (e) {
console.error(e);
if (e instanceof Error && e.name === 'AbortError') {
setError(`Request was cancelled`);
} else {
setError(`Failed to add server: ${processingServer}`);
}
} finally {
setProcessing(false);
abortControllerRef.current = null;
}
}, [input, onAdd]);
const handleAbort = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">{t("addServers")}</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("addNewMCPServers")}</DialogTitle>
</DialogHeader>
<DialogDescription>
{t("mcpConfigDescription")}
<br />
{t("pasteConfigBelow")}
</DialogDescription>
<main>
<Textarea
className="h-[360px] break-all sm:max-w-[510px]"
placeholder={
'Example:\n\n{\n "mcpServers": {\n "My Server": {\n "command": "python",\n "args": [\n "-m", "mcp_server"\n ],\n "env": {\n "API_KEY": "YOUR_API_KEY"\n }\n }\n }\n}'
}
value={input}
onChange={(e) => handleChange(e.target.value)}
/>
</main>
<DialogFooter>
<div className="flex h-10 w-full items-center justify-between gap-2">
<div className="text-destructive flex-grow overflow-hidden text-sm">
{validationError ?? error}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
{t("cancel", { defaultValue: "Cancel" })}
</Button>
<Button
className="w-24"
type="submit"
disabled={!input.trim() || !!validationError || processing}
onClick={handleAdd}
>
{processing && <Loader2 className="animate-spin" />}
{t("add")}
</Button>
{
processing && (
<Button variant="destructive" onClick={handleAbort}>Abort</Button>
)
}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,160 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Textarea } from "~/components/ui/textarea";
import type { MCPServerMetadata } from "~/core/mcp";
import { MCPConfigSchema } from "~/core/mcp";
export function EditMCPServerDialog({
server,
onSave,
open,
onOpenChange,
}: {
server: MCPServerMetadata;
onSave: (config: string) => Promise<boolean>;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const t = useTranslations("settings.mcp");
const commonT = useTranslations("common");
const [config, setConfig] = useState(
JSON.stringify(
{
mcpServers: {
[server.name]: server.transport === 'stdio'
? {
command: server.command,
args: server.args,
env: server.env,
}
: {
transport: server.transport,
url: server.url,
headers: server.headers,
},
},
},
null,
2
)
);
const [processing, setProcessing] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const handleChange = useCallback((value: string) => {
setConfig(value);
setValidationError(null);
if (!value.trim()) {
return;
}
try {
const parsed = JSON.parse(value);
if (!("mcpServers" in parsed)) {
setValidationError("Missing `mcpServers` in JSON");
return;
}
const result = MCPConfigSchema.safeParse(parsed);
if (!result.success) {
if (result.error.errors[0]) {
const error = result.error.errors[0];
if (error.code === "invalid_union") {
if (error.unionErrors[0]?.errors[0]) {
setValidationError(error.unionErrors[0].errors[0].message);
return;
}
}
setValidationError(error.message || t("validationFailed"));
return;
}
}
const keys = Object.keys(parsed.mcpServers);
if (keys.length === 0) {
setValidationError(t("missingServerName"));
}
} catch {
setValidationError(t("invalidJson"));
}
}, [t]);
const handleSave = useCallback(async () => {
setProcessing(true);
try {
const success = await onSave(config);
if (success) {
onOpenChange(false);
}
} catch (error) {
console.error('Failed to save server configuration:', error);
} finally {
setProcessing(false);
}
}, [config, onSave, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("editServer")}</DialogTitle>
<DialogDescription>
{t("editServerDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
className="h-[360px] font-mono text-sm"
value={config}
onChange={(e) => handleChange(e.target.value)}
/>
{validationError && (
<div className="text-sm text-red-500 mt-2">
{validationError}
</div>
)}
</div>
<DialogFooter>
<div className="flex w-full justify-between items-center">
<div className="text-sm text-muted-foreground">
{t("editServerNote")}
</div>
<div className="space-x-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
{commonT("cancel")}
</Button>
<Button
onClick={handleSave}
disabled={processing || !!validationError}
>
{processing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{commonT("save")}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,172 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Settings } from "lucide-react";
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from "react";
import { Tooltip } from "~/components/deep-flow/tooltip";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent } from "~/components/ui/tabs";
import { useReplay } from "~/core/replay";
import {
type SettingsState,
changeSettings,
saveSettings,
useSettingsStore,
} from "~/core/store";
import { cn } from "~/lib/utils";
import { SETTINGS_TABS } from "../tabs";
export function SettingsDialog() {
const t = useTranslations('settings');
const tCommon = useTranslations('common');
const { isReplay } = useReplay();
const [activeTabId, setActiveTabId] = useState(SETTINGS_TABS[0]!.id);
const [open, setOpen] = useState(false);
const [settings, setSettings] = useState(useSettingsStore.getState());
const [changes, setChanges] = useState<Partial<SettingsState>>({});
const handleTabChange = useCallback(
(newChanges: Partial<SettingsState>) => {
setTimeout(() => {
if (open) {
setChanges((prev) => ({
...prev,
...newChanges,
}));
}
}, 0);
},
[open],
);
const handleSave = useCallback(() => {
if (Object.keys(changes).length > 0) {
const newSettings: SettingsState = {
...settings,
...changes,
};
setSettings(newSettings);
setChanges({});
changeSettings(newSettings);
saveSettings();
}
setOpen(false);
}, [settings, changes]);
const handleOpen = useCallback(() => {
setSettings(useSettingsStore.getState());
}, []);
const handleClose = useCallback(() => {
setChanges({});
}, []);
useEffect(() => {
if (open) {
handleOpen();
} else {
handleClose();
}
}, [open, handleOpen, handleClose]);
const mergedSettings = useMemo<SettingsState>(() => {
return {
...settings,
...changes,
};
}, [settings, changes]);
if (isReplay) {
return null;
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip title={tCommon('settings')}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon">
<Settings />
</Button>
</DialogTrigger>
</Tooltip>
<DialogContent className="sm:max-w-[850px]">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
{t('description')}
</DialogDescription>
</DialogHeader>
<Tabs value={activeTabId}>
<div className="flex h-120 w-full overflow-auto border-y">
<ul className="flex w-50 shrink-0 border-r p-1">
<div className="size-full">
{SETTINGS_TABS.map((tab) => (
<li
key={tab.id}
className={cn(
"hover:accent-foreground hover:bg-accent mb-1 flex h-8 w-full cursor-pointer items-center gap-1.5 rounded px-2",
activeTabId === tab.id &&
"!bg-primary !text-primary-foreground",
)}
onClick={() => setActiveTabId(tab.id)}
>
<tab.icon size={16} />
<span>{tab.label}</span>
{tab.badge && (
<Badge
variant="outline"
className={cn(
"border-muted-foreground text-muted-foreground ml-auto px-1 py-0 text-xs",
activeTabId === tab.id &&
"border-primary-foreground text-primary-foreground",
)}
>
{tab.badge}
</Badge>
)}
</li>
))}
</div>
</ul>
<div className="min-w-0 flex-grow">
<div
id="settings-content-scrollable"
className="size-full overflow-auto p-4"
>
{SETTINGS_TABS.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<tab.component
settings={mergedSettings}
onChange={handleTabChange}
/>
</TabsContent>
))}
</div>
</div>
</div>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{tCommon('cancel')}
</Button>
<Button className="w-24" type="submit" onClick={handleSave}>
{tCommon('save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,22 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { BadgeInfo } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { Markdown } from "~/components/deep-flow/markdown";
import aboutEn from "./about-en.md";
import aboutZh from "./about-zh.md";
import type { Tab } from "./types";
export const AboutTab: Tab = () => {
const locale = useLocale();
//const t = useTranslations("settings.about");
const aboutContent = locale === "zh" ? aboutZh : aboutEn;
return <Markdown>{aboutContent}</Markdown>;
};
AboutTab.icon = BadgeInfo;
AboutTab.displayName = "About";
@@ -0,0 +1,232 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import type { SettingsState } from "~/core/store";
import type { Tab } from "./types";
const generalFormSchema = z.object({
autoAcceptedPlan: z.boolean(),
enableClarification: z.boolean(),
maxClarificationRounds: z.number().min(1, {
message: "Max clarification rounds must be at least 1.",
}),
maxPlanIterations: z.number().min(1, {
message: "Max plan iterations must be at least 1.",
}),
maxStepNum: z.number().min(1, {
message: "Max step number must be at least 1.",
}),
maxSearchResults: z.number().min(1, {
message: "Max search results must be at least 1.",
}),
// Others
enableBackgroundInvestigation: z.boolean(),
enableDeepThinking: z.boolean(),
reportStyle: z.enum(["academic", "popular_science", "news", "social_media","strategic_investment"]),
});
export const GeneralTab: Tab = ({
settings,
onChange,
}: {
settings: SettingsState;
onChange: (changes: Partial<SettingsState>) => void;
}) => {
const t = useTranslations("settings.general");
const generalSettings = useMemo(() => settings.general, [settings]);
const form = useForm<z.infer<typeof generalFormSchema>>({
resolver: zodResolver(generalFormSchema, undefined, undefined),
defaultValues: generalSettings,
mode: "all",
reValidateMode: "onBlur",
});
const currentSettings = form.watch();
useEffect(() => {
let hasChanges = false;
for (const key in currentSettings) {
if (
currentSettings[key as keyof typeof currentSettings] !==
settings.general[key as keyof SettingsState["general"]]
) {
hasChanges = true;
break;
}
}
if (hasChanges) {
onChange({ general: currentSettings });
}
}, [currentSettings, onChange, settings]);
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="text-lg font-medium">{t("title")}</h1>
</header>
<main>
<Form {...form}>
<form className="space-y-8">
<FormField
control={form.control}
name="autoAcceptedPlan"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Switch
id="autoAcceptedPlan"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm" htmlFor="autoAcceptedPlan">
{t("autoAcceptPlan")}
</Label>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableClarification"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Switch
id="enableClarification"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm" htmlFor="enableClarification">
{t("enableClarification")} {field.value ? "(On)" : "(Off)"}
</Label>
</div>
</FormControl>
</FormItem>
)}
/>
{form.watch("enableClarification") && (
<FormField
control={form.control}
name="maxClarificationRounds"
render={({ field }) => (
<FormItem>
<FormLabel>{t("maxClarificationRounds")}</FormLabel>
<FormControl>
<Input
className="w-60"
type="number"
defaultValue={field.value}
min={1}
onChange={(event) =>
field.onChange(parseInt(event.target.value || "1"))
}
/>
</FormControl>
<FormDescription>
{t("maxClarificationRoundsDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="maxPlanIterations"
render={({ field }) => (
<FormItem>
<FormLabel>{t("maxPlanIterations")}</FormLabel>
<FormControl>
<Input
className="w-60"
type="number"
defaultValue={field.value}
min={1}
onChange={(event) =>
field.onChange(parseInt(event.target.value || "0"))
}
/>
</FormControl>
<FormDescription>
{t("maxPlanIterationsDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxStepNum"
render={({ field }) => (
<FormItem>
<FormLabel>{t("maxStepsOfPlan")}</FormLabel>
<FormControl>
<Input
className="w-60"
type="number"
defaultValue={field.value}
min={1}
onChange={(event) =>
field.onChange(parseInt(event.target.value || "0"))
}
/>
</FormControl>
<FormDescription>{t("maxStepsDescription")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxSearchResults"
render={({ field }) => (
<FormItem>
<FormLabel>{t("maxSearchResults")}</FormLabel>
<FormControl>
<Input
className="w-60"
type="number"
defaultValue={field.value}
min={1}
onChange={(event) =>
field.onChange(parseInt(event.target.value || "0"))
}
/>
</FormControl>
<FormDescription>
{t("maxSearchResultsDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</main>
</div>
);
};
GeneralTab.displayName = "General";
GeneralTab.icon = Settings;
@@ -0,0 +1,19 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Settings, type LucideIcon } from "lucide-react";
import { AboutTab } from "./about-tab";
import { GeneralTab } from "./general-tab";
import { MCPTab } from "./mcp-tab";
export const SETTINGS_TABS = [GeneralTab, MCPTab, AboutTab].map((tab) => {
const name = tab.displayName ?? tab.name;
return {
...tab,
id: name.replace(/Tab$/, "").toLocaleLowerCase(),
label: name.replace(/Tab$/, ""),
icon: (tab.icon ?? <Settings />) as LucideIcon,
component: tab,
};
});
@@ -0,0 +1,366 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { Blocks, Edit2, PencilRuler, RefreshCw, Trash } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Tooltip } from "~/components/deep-flow/tooltip";
import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch";
import { queryMCPServerMetadata } from "~/core/api";
import type { MCPServerMetadata } from "~/core/mcp";
import { cn } from "~/lib/utils";
import { AddMCPServerDialog } from "../dialogs/add-mcp-server-dialog";
import { EditMCPServerDialog } from "../dialogs/edit-mcp-server-dialog";
import type { Tab } from "./types";
export const MCPTab: Tab = ({ settings, onChange }) => {
const t = useTranslations("settings.mcp");
const [servers, setServers] = useState<MCPServerMetadata[]>(
settings.mcp.servers,
);
const [newlyAdded, setNewlyAdded] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServerMetadata | null>(null);
const handleAddServers = useCallback(
(servers: MCPServerMetadata[]) => {
const merged = mergeServers(settings.mcp.servers, servers);
setServers(merged);
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
setNewlyAdded(true);
setTimeout(() => {
setNewlyAdded(false);
}, 1000);
setTimeout(() => {
document.getElementById("settings-content-scrollable")?.scrollTo({
top: 0,
behavior: "smooth",
});
}, 100);
},
[onChange, settings],
);
const handleDeleteServer = useCallback(
(name: string) => {
const merged = settings.mcp.servers.filter(
(server) => server.name !== name,
);
setServers(merged);
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
},
[onChange, settings],
);
const handleEditServer = useCallback(async (config: string) => {
if (!editingServer) return false;
try {
const parsedConfig = JSON.parse(config) as { mcpServers?: Record<string, MCPServerMetadata> };
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
console.error('Invalid configuration format: mcpServers not found');
return false;
}
const serverEntries = Object.entries(parsedConfig.mcpServers);
if (serverEntries.length === 0) {
console.error('No server configuration found in mcpServers');
return false;
}
const firstEntry = serverEntries[0];
if (!firstEntry) {
console.error('Failed to get server configuration');
return false;
}
const [serverName, serverConfig] = firstEntry;
// Update the server configuration
const updatedServers = settings.mcp.servers.map(server =>
server.name === editingServer.name
? {
...server,
...serverConfig,
name: serverName, // Allow renaming the server
updatedAt: Date.now(),
}
: server
);
setServers(updatedServers);
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
return true;
} catch (error) {
console.error('Failed to update server configuration:', error);
return false;
}
}, [editingServer, onChange, settings]);
const handleRefreshServers = useCallback(async (serverName?: string) => {
try {
// Create a new array with the updated server
const updatedServers = await Promise.all(
settings.mcp.servers.map(async (server) => {
// Skip if this is not the server we want to refresh
if (serverName && server.name !== serverName) {
return server;
}
// Skip disabled servers unless explicitly requested
if (!server.enabled && server.name !== serverName) {
return server;
}
try {
// Get the latest metadata
const metadata = await queryMCPServerMetadata(server);
// Create a new server object with preserved properties
return {
...server, // Keep all existing properties
...metadata, // Apply metadata updates
name: server.name, // Ensure name is preserved
enabled: server.enabled, // Preserve the enabled state
createdAt: server.createdAt, // Keep the original creation time
updatedAt: Date.now(), // Update the last updated time
};
} catch (error) {
console.error(`Failed to refresh server ${server.name}:`, error);
// Return the original server if refresh fails
return server;
}
})
);
// Update the servers list
setServers(updatedServers);
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
} catch (error) {
console.error('Failed to refresh MCP servers:', error);
}
}, [onChange, settings]);
const handleToggleServer = useCallback(
async (name: string, enabled: boolean) => {
const merged = settings.mcp.servers.map((server) =>
server.name === name ? { ...server, enabled } : server,
);
setServers(merged);
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
// Refresh server metadata when enabling a server
if (enabled) {
try {
const server = merged.find(s => s.name === name);
if (server) {
const metadata = await queryMCPServerMetadata(server);
const updatedServers = merged.map(s =>
s.name === name
? {
...s,
...metadata,
name: s.name,
enabled: true,
createdAt: s.createdAt,
updatedAt: Date.now(),
}
: s
);
setServers(updatedServers);
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
}
} catch (error) {
console.error(`Failed to refresh server ${name}:`, error);
}
}
},
[onChange, settings],
);
const animationProps = {
initial: { backgroundColor: "gray" },
animate: { backgroundColor: "transparent" },
transition: { duration: 1 },
style: {
transition: "background-color 1s ease-out",
},
};
return (
<div className="flex flex-col gap-4">
<header>
<div className="flex items-center justify-between gap-2">
<h1 className="text-lg font-medium">{t("title")}</h1>
<AddMCPServerDialog onAdd={handleAddServers} />
</div>
<div className="text-muted-foreground markdown text-sm">
{t("description")}
<a
className="ml-1"
target="_blank"
href="https://modelcontextprotocol.io/"
>
{t("learnMore")}
</a>
</div>
</header>
<main>
<ul id="mcp-servers-list" className="flex flex-col gap-4">
{servers.map((server) => {
const isNew =
server.createdAt &&
server.createdAt > Date.now() - 1000 * 60 * 60 * 1;
return (
<motion.li
className={
"!bg-card group relative overflow-hidden rounded-lg border pb-2 shadow duration-300"
}
key={server.name}
{...(isNew && newlyAdded && animationProps)}
>
<div className="absolute top-3 right-2">
<Tooltip title={t("enableDisable")}>
<div className="flex items-center gap-2">
<Switch
id="airplane-mode"
checked={server.enabled}
onCheckedChange={(checked) => {
void handleToggleServer(server.name, checked);
}}
/>
</div>
</Tooltip>
</div>
<div className="absolute top-1 right-12 flex gap-1 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip title={t("editServer")}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
setEditingServer(server);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
</Tooltip>
<Tooltip title={t("refreshServer")}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
void handleRefreshServers(server.name);
}}
>
<RefreshCw className="h-4 w-4" />
</Button>
</Tooltip>
<Tooltip title={t("deleteServer")}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleDeleteServer(server.name);
}}
>
<Trash className="h-4 w-4" />
</Button>
</Tooltip>
</div>
<div
className={cn(
"flex flex-col items-start px-4 py-2",
!server.enabled && "text-muted-foreground",
)}
>
<div
className={cn(
"mb-2 flex items-center gap-2",
!server.enabled && "opacity-70",
)}
>
<div className="text-lg font-medium">{server.name}</div>
{!server.enabled && (
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
{t("disabled")}
</div>
)}
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
{server.transport}
</div>
{isNew && (
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
{t("new")}
</div>
)}
</div>
<ul
className={cn(
"flex flex-wrap items-center gap-2",
!server.enabled && "opacity-70",
)}
>
<PencilRuler size={16} />
{server.tools.map((tool) => (
<li
key={tool.name}
className="text-muted-foreground border-muted-foreground w-fit rounded-md border px-2"
>
<Tooltip key={tool.name} title={tool.description}>
<div className="w-fit text-sm">{tool.name}</div>
</Tooltip>
</li>
))}
</ul>
</div>
</motion.li>
);
})}
</ul>
</main>
{editingServer && (
<EditMCPServerDialog
server={editingServer}
open={!!editingServer}
onOpenChange={(open) => !open && setEditingServer(null)}
onSave={async (config) => {
const success = await handleEditServer(config);
if (success) {
setEditingServer(null);
}
return success;
}}
/>
)}
</div>
);
};
MCPTab.icon = Blocks;
MCPTab.displayName = "MCP";
MCPTab.badge = "Beta";
MCPTab.displayName = "MCP";
function mergeServers(
existing: MCPServerMetadata[],
added: MCPServerMetadata[],
): MCPServerMetadata[] {
const serverMap = new Map(existing.map((server) => [server.name, server]));
for (const addedServer of added) {
addedServer.createdAt = Date.now();
addedServer.updatedAt = Date.now();
serverMap.set(addedServer.name, addedServer);
}
const result = Array.from(serverMap.values());
result.sort((a, b) => b.createdAt - a.createdAt);
return result;
}
@@ -0,0 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { LucideIcon } from "lucide-react";
import type { FunctionComponent } from "react";
import type { SettingsState } from "~/core/store";
export type Tab = FunctionComponent<{
settings: SettingsState;
onChange: (changes: Partial<SettingsState>) => void;
}> & {
displayName?: string;
icon?: LucideIcon;
badge?: string;
};
@@ -0,0 +1,28 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { cn } from "~/lib/utils";
export function FavIcon({
className,
url,
title,
}: {
className?: string;
url: string;
title?: string;
}) {
return (
<img
className={cn("bg-accent h-4 w-4 rounded-full shadow-sm", className)}
width={16}
height={16}
src={new URL(url).origin + "/favicon.ico"}
alt={title}
onError={(e) => {
e.currentTarget.src =
"https://perishablepress.com/wp/wp-content/images/2021/favicon-standard.png";
}}
/>
);
}
@@ -0,0 +1,26 @@
export function Detective({ className }: { className?: string }) {
return (
<svg
className={className}
version="1.1"
width="800px"
height="800px"
viewBox="0 0 512 512"
>
<g fill="currentcolor">
<path
d="M392.692,257.322c-1.172-8.125-2.488-16.98-3.807-25.984c-5.856-39.012-12.59-81.688-14.86-87.832
c-4.318-11.715-18.371-44.723-68.217-25.984c-15.738,5.926-18.812,11.93-41.648,8.93c-17.273-2.27-28.326-15.59-52.336-24.668
c-49.844-18.883-71.584,11.711-75.902,23.422c-2.27,6.148-9.004,67.121-14.86,106.133c-1.39,8.86-2.633,17.566-3.804,25.621
c37.256,7.535,84.174,12.879,138.705,12.879C309.541,269.837,355.801,264.716,392.692,257.322z"
/>
<path
d="M443.707,306.509c-8.051-2.196-16.834-4.246-26.057-6.148c-1.83-0.805-3.66-1.535-5.49-2.27h-0.072
c-46.918,10.394-102.254,15.664-156.125,15.664c-53.652,0-108.768-5.27-155.541-15.516c-1.316,0.512-2.707,1.098-4.098,1.684
c-8.858,1.828-17.348,3.73-25.106,5.781l-0.148,0.074C27.008,317.49,0,333.372,0,350.939c0,36.012,114.549,65.289,256.035,65.289
c141.34,0,255.965-29.278,255.965-65.289C512,333.74,486.016,318.22,443.707,306.509z"
/>
</g>
</svg>
);
}
@@ -0,0 +1,42 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { SVGProps } from "react";
export function Enhance(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 2L13.09 8.26L20 9L13.09 9.74L12 16L10.91 9.74L4 9L10.91 8.26L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M19 14L19.5 16.5L22 17L19.5 17.5L19 20L18.5 17.5L16 17L18.5 16.5L19 14Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M5 6L5.5 7.5L7 8L5.5 8.5L5 10L4.5 8.5L3 8L4.5 7.5L5 6Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
}
@@ -0,0 +1,45 @@
export function ReportStyle({ className }: { className?: string }) {
return (
<svg
className={className}
version="1.1"
width="800px"
height="800px"
viewBox="0 0 24 24"
fill="none"
>
<g fill="currentcolor">
<path
d="M4 4C4 3.44772 4.44772 3 5 3H19C19.5523 3 20 3.44772 20 4V20C20 20.5523 19.5523 21 19 21H5C4.44772 21 4 20.5523 4 20V4Z"
stroke="currentColor"
strokeWidth="2"
fill="none"
/>
<path
d="M8 7H16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M8 11H16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M8 15H12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle
cx="16"
cy="15"
r="2"
fill="currentColor"
/>
</g>
</svg>
);
}
@@ -0,0 +1,68 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { memo, useCallback, useEffect, useState } from "react";
import { cn } from "~/lib/utils";
import { Tooltip } from "./tooltip";
function Image({
className,
imageClassName,
imageTransition,
src,
alt,
fallback = null,
}: {
className?: string;
imageClassName?: string;
imageTransition?: boolean;
src: string;
alt: string;
fallback?: React.ReactNode;
}) {
const [, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
setIsError(false);
setIsLoading(true);
}, [src]);
const handleLoad = useCallback(() => {
setIsError(false);
setIsLoading(false);
}, []);
const handleError = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = "none";
console.warn(`Markdown: Image "${e.currentTarget.src}" failed to load`);
setIsError(true);
},
[],
);
return (
<span className={cn("block w-fit overflow-hidden", className)}>
{isError || !src ? (
fallback
) : (
<Tooltip title={alt ?? "No caption"}>
<img
className={cn(
"size-full object-cover",
imageTransition && "transition-all duration-200 ease-out",
imageClassName,
)}
src={src}
alt={alt}
onLoad={handleLoad}
onError={handleError}
/>
</Tooltip>
)}
</span>
);
}
export default memo(Image);
@@ -0,0 +1,70 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
type LanguageOption = {
code: string;
name: string;
flag: string;
};
const languages: Array<LanguageOption> = [
{ code: "en", name: "English", flag: "🇺🇸" },
{ code: "zh", name: "中文", flag: "🇨🇳" },
];
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const currentLanguage =
languages.find((lang) => lang.code === locale) ??
(languages[0] as LanguageOption);
const handleLanguageChange = (newLocale: string) => {
startTransition(() => {
console.log(`updateing locale to ${newLocale}`)
// Set locale in cookie
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000; SameSite=lax`;
// Reload the page to apply the new locale
window.location.reload();
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" disabled={isPending}>
<span className="mr-2">{currentLanguage.flag}</span>
{currentLanguage.name}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{languages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={locale === language.code ? "bg-accent" : ""}
>
<span className="mr-2">{language.flag}</span>
{language.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,64 @@
import { useMemo } from "react";
import { useStore, useToolCalls } from "~/core/store";
import { parseJSON } from "~/core/utils/json";
import { Tooltip } from "./tooltip";
import { WarningFilled } from "@ant-design/icons";
import { useTranslations } from "next-intl";
export const Link = ({
href,
children,
checkLinkCredibility = false,
}: {
href: string | undefined;
children: React.ReactNode;
checkLinkCredibility: boolean;
}) => {
const toolCalls = useToolCalls();
const responding = useStore((state) => state.responding);
const credibleLinks = useMemo(() => {
const links = new Set<string>();
if (!checkLinkCredibility) return links;
(toolCalls || []).forEach((call) => {
if (call && call.name === "web_search" && call.result) {
try {
const result = parseJSON(call.result, []) as Array<{ url: string }>;
if (Array.isArray(result)) {
result.forEach((r) => {
if (r && typeof r.url === 'string') {
// encodeURI is used to handle the case where the link contains chinese or other special characters
links.add(encodeURI(r.url));
links.add(r.url);
}
});
}
} catch (error) {
console.warn('Failed to parse web_search result:', error);
}
}
});
return links;
}, [toolCalls]);
const isCredible = useMemo(() => {
return checkLinkCredibility && href && !responding
? credibleLinks.has(href)
: true;
}, [credibleLinks, href, responding, checkLinkCredibility]);
const t = useTranslations("common");
return (
<span className="inline-flex items-center gap-1.5">
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
{!isCredible && (
<Tooltip title={t("linkNotReliable")} delayDuration={300}>
<WarningFilled className="text-sx transition-colors hover:!text-yellow-500" />
</Tooltip>
)}
</span>
);
};
@@ -0,0 +1,34 @@
@keyframes bouncing-animation {
to {
opacity: 0.1;
transform: translateY(-8px);
}
}
.loadingAnimation {
display: flex;
}
.loadingAnimation > div {
width: 8px;
height: 8px;
margin: 2px 4px;
border-radius: 50%;
background-color: #a3a1a1;
opacity: 1;
animation: bouncing-animation 0.5s infinite alternate;
}
.loadingAnimation.sm > div {
width: 6px;
height: 6px;
margin: 1px 2px;
}
.loadingAnimation > div:nth-child(2) {
animation-delay: 0.2s;
}
.loadingAnimation > div:nth-child(3) {
animation-delay: 0.4s;
}
@@ -0,0 +1,28 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { cn } from "~/lib/utils";
import styles from "./loading-animation.module.css";
export function LoadingAnimation({
className,
size = "normal",
}: {
className?: string;
size?: "normal" | "sm";
}) {
return (
<div
className={cn(
styles.loadingAnimation,
size === "sm" && styles.sm,
className,
)}
>
<div></div>
<div></div>
<div></div>
</div>
);
}
@@ -0,0 +1,15 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import Link from "next/link";
export function Logo() {
return (
<Link
className="opacity-70 transition-opacity duration-300 hover:opacity-100"
href="/"
>
DeepFlow深度研究助手
</Link>
);
}
@@ -0,0 +1,160 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Check, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import ReactMarkdown, {
type Options as ReactMarkdownOptions,
} from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import "katex/dist/katex.min.css";
import { Button } from "~/components/ui/button";
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
import { katexOptions } from "~/core/markdown/katex";
import { autoFixMarkdown, normalizeMathForDisplay } from "~/core/utils/markdown";
import { cn } from "~/lib/utils";
import Image from "./image";
import { Tooltip } from "./tooltip";
import { Link } from "./link";
export function Markdown({
className,
children,
style,
enableCopy,
animated = false,
checkLinkCredibility = false,
...props
}: ReactMarkdownOptions & {
className?: string;
enableCopy?: boolean;
style?: React.CSSProperties;
animated?: boolean;
checkLinkCredibility?: boolean;
}) {
const components: ReactMarkdownOptions["components"] = useMemo(() => {
return {
a: ({ href, children }) => (
<Link href={href} checkLinkCredibility={checkLinkCredibility}>
{children}
</Link>
),
img: ({ src, alt }) => (
<a href={src as string} target="_blank" rel="noopener noreferrer">
<Image className="rounded" src={src as string} alt={alt ?? ""} />
</a>
),
};
}, [checkLinkCredibility]);
const rehypePlugins = useMemo<NonNullable<ReactMarkdownOptions["rehypePlugins"]>>(() => {
const plugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [[
rehypeKatex,
katexOptions,
]];
if (animated) {
plugins.push(rehypeSplitWordsIntoSpans);
}
return plugins;
}, [animated]);
return (
<div className={cn(className, "prose dark:prose-invert")} style={style}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={rehypePlugins}
components={components}
{...props}
>
{autoFixMarkdown(
dropMarkdownQuote(normalizeMathForDisplay(children ?? "")) ?? "",
)}
</ReactMarkdown>
{enableCopy && typeof children === "string" && (
<div className="flex">
<CopyButton content={children} />
</div>
)}
</div>
);
}
function CopyButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
return (
<Tooltip title="Copy">
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (error) {
console.error(error);
}
}}
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}{" "}
</Button>
</Tooltip>
);
}
function dropMarkdownQuote(markdown?: string | null): string | null {
if (!markdown) return null;
const patternsToRemove = [
{ prefix: "```markdown\n", suffix: "\n```", prefixLen: 12 },
{ prefix: "```text\n", suffix: "\n```", prefixLen: 8 },
{ prefix: "```\n", suffix: "\n```", prefixLen: 4 },
];
let result = markdown;
for (const { prefix, suffix, prefixLen } of patternsToRemove) {
if (result.startsWith(prefix) && !result.endsWith(suffix)) {
result = result.slice(prefixLen);
break; // remove prefix without suffix only once
}
}
let changed = true;
while (changed) {
changed = false;
for (const { prefix, suffix, prefixLen } of patternsToRemove) {
let startIndex = 0;
while ((startIndex = result.indexOf(prefix, startIndex)) !== -1) {
const endIndex = result.indexOf(suffix, startIndex + prefixLen);
if (endIndex !== -1) {
// only remove fully matched code blocks
const before = result.slice(0, startIndex);
const content = result.slice(startIndex + prefixLen, endIndex);
const after = result.slice(endIndex + suffix.length);
result = before + content + after;
changed = true;
startIndex = before.length + content.length;
} else {
startIndex += prefixLen;
}
}
}
}
return result;
}
@@ -0,0 +1,222 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import Mention from "@tiptap/extension-mention";
import { Editor, Extension, type Content } from "@tiptap/react";
import {
EditorContent,
type EditorInstance,
EditorRoot,
type JSONContent,
StarterKit,
Placeholder,
} from "novel";
import { Markdown } from "tiptap-markdown";
import { useDebouncedCallback } from "use-debounce";
import { useTranslations } from "next-intl";
import "~/styles/prosemirror.css";
import { resourceSuggestion } from "./resource-suggestion";
import React, { forwardRef, useEffect, useMemo, useRef } from "react";
import type { Resource } from "~/core/messages";
import { LoadingOutlined } from "@ant-design/icons";
import type { DeepFlowConfig } from "~/core/config";
export interface MessageInputRef {
focus: () => void;
submit: () => void;
setContent: (content: string) => void;
}
export interface MessageInputProps {
className?: string;
placeholder?: string;
loading?: boolean;
config?: DeepFlowConfig | null;
onChange?: (markdown: string) => void;
onEnter?: (message: string, resources: Array<Resource>) => void;
}
function formatMessage(content: JSONContent) {
if (content.content) {
const output: {
text: string;
resources: Array<Resource>;
} = {
text: "",
resources: [],
};
for (const node of content.content) {
const { text, resources } = formatMessage(node);
output.text += text;
output.resources.push(...resources);
}
return output;
} else {
return formatItem(content);
}
}
function formatItem(item: JSONContent): {
text: string;
resources: Array<Resource>;
} {
if (item.type === "text") {
return { text: item.text ?? "", resources: [] };
}
if (item.type === "mention") {
return {
text: `[${item.attrs?.label}](${item.attrs?.id})`,
resources: [
{ uri: item.attrs?.id ?? "", title: item.attrs?.label ?? "" },
],
};
}
return { text: "", resources: [] };
}
const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
(
{ className, loading, config, onChange, onEnter }: MessageInputProps,
ref,
) => {
const t = useTranslations("messageInput");
const editorRef = useRef<Editor>(null);
const handleEnterRef = useRef<
((message: string, resources: Array<Resource>) => void) | undefined
>(onEnter);
const debouncedUpdates = useDebouncedCallback(
async (editor: EditorInstance) => {
if (onChange) {
// Get the plain text content for prompt enhancement
const { text } = formatMessage(editor.getJSON() ?? []);
onChange(text);
}
},
200,
);
React.useImperativeHandle(ref, () => ({
focus: () => {
editorRef.current?.view.focus();
},
submit: () => {
if (onEnter) {
const { text, resources } = formatMessage(
editorRef.current?.getJSON() ?? [],
);
onEnter(text, resources);
}
editorRef.current?.commands.clearContent();
},
setContent: (content: string) => {
if (editorRef.current) {
editorRef.current.commands.setContent(content);
}
},
}));
useEffect(() => {
handleEnterRef.current = onEnter;
}, [onEnter]);
const extensions = useMemo(() => {
const extensions = [
StarterKit,
Markdown.configure({
html: true,
tightLists: true,
tightListClass: "tight",
bulletListMarker: "-",
linkify: false,
breaks: false,
transformPastedText: false,
transformCopiedText: false,
}),
Placeholder.configure({
showOnlyCurrent: false,
placeholder: config?.rag.provider ? t("placeholderWithRag") : t("placeholder"),
emptyEditorClass: "placeholder",
}),
Extension.create({
name: "keyboardHandler",
addKeyboardShortcuts() {
return {
Enter: () => {
if (handleEnterRef.current) {
const { text, resources } = formatMessage(
this.editor.getJSON() ?? [],
);
handleEnterRef.current(text, resources);
}
return this.editor.commands.clearContent();
},
};
},
}),
];
if (config?.rag.provider) {
extensions.push(
Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion: resourceSuggestion,
}) as Extension,
);
}
return extensions;
}, [config]);
if (loading) {
return (
<div className={className}>
<LoadingOutlined />
</div>
);
}
return (
<div className={className}>
<EditorRoot>
<EditorContent
immediatelyRender={false}
extensions={extensions}
className="border-muted h-full w-full overflow-auto break-words"
editorProps={{
attributes: {
class:
"prose prose-base dark:prose-invert inline-editor font-default focus:outline-none max-w-full",
},
transformPastedHTML: transformPastedHTML,
}}
onCreate={({ editor }) => {
editorRef.current = editor;
}}
onUpdate={({ editor }) => {
debouncedUpdates(editor);
}}
></EditorContent>
</EditorRoot>
</div>
);
},
);
function transformPastedHTML(html: string) {
try {
// Strip HTML from user-pasted content
const tempEl = document.createElement("div");
tempEl.innerHTML = html;
return tempEl.textContent || tempEl.innerText || "";
} catch (error) {
console.error("Error transforming pasted HTML", error);
return "";
}
}
export default MessageInput;
@@ -0,0 +1,24 @@
.animated {
background: linear-gradient(
to right,
rgb(from var(--card-foreground) r g b / 0.3) 15%,
rgb(from var(--card-foreground) r g b / 0.75) 35%,
rgb(from var(--card-foreground) r g b / 0.75) 65%,
rgb(from var(--card-foreground) r g b / 0.3) 85%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-fill-color: transparent;
background-size: 500% auto;
animation: textShine 2s ease-in-out infinite alternate;
}
@keyframes textShine {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
@@ -0,0 +1,22 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { cn } from "~/lib/utils";
import styles from "./rainbow-text.module.css";
export function RainbowText({
animated,
className,
children,
}: {
animated?: boolean;
className?: string;
children?: React.ReactNode;
}) {
return (
<span className={cn(animated && styles.animated, className)}>
{children}
</span>
);
}
@@ -0,0 +1,130 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Check, FileText, Newspaper, Users, GraduationCap, TrendingUp } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { setReportStyle, useSettingsStore } from "~/core/store";
import { cn } from "~/lib/utils";
import { Tooltip } from "./tooltip";
const REPORT_STYLES = [
{
value: "academic" as const,
labelKey: "academic",
descriptionKey: "academicDesc",
icon: GraduationCap,
},
{
value: "popular_science" as const,
labelKey: "popularScience",
descriptionKey: "popularScienceDesc",
icon: FileText,
},
{
value: "news" as const,
labelKey: "news",
descriptionKey: "newsDesc",
icon: Newspaper,
},
{
value: "social_media" as const,
labelKey: "socialMedia",
descriptionKey: "socialMediaDesc",
icon: Users,
},
{
value: "strategic_investment" as const,
labelKey: "strategicInvestment",
descriptionKey: "strategicInvestmentDesc",
icon: TrendingUp,
},
];
export function ReportStyleDialog() {
const t = useTranslations("settings.reportStyle");
const [open, setOpen] = useState(false);
const currentStyle = useSettingsStore((state) => state.general.reportStyle);
const handleStyleChange = (
style: "academic" | "popular_science" | "news" | "social_media" | "strategic_investment",
) => {
setReportStyle(style);
setOpen(false);
};
const currentStyleConfig =
REPORT_STYLES.find((style) => style.value === currentStyle) ||
REPORT_STYLES[0]!;
const CurrentIcon = currentStyleConfig.icon;
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip
className="max-w-60"
title={
<div>
<h3 className="mb-2 font-bold">
{t("writingStyle")}: {t(currentStyleConfig.labelKey)}
</h3>
<p>{t("chooseDesc")}</p>
</div>
}
>
<DialogTrigger asChild>
<Button
className="!border-brand !text-brand rounded-2xl"
variant="outline"
>
<CurrentIcon className="h-4 w-4" /> {t(currentStyleConfig.labelKey)}
</Button>
</DialogTrigger>
</Tooltip>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("chooseTitle")}</DialogTitle>
<DialogDescription>{t("chooseDesc")}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-4">
{REPORT_STYLES.map((style) => {
const Icon = style.icon;
const isSelected = currentStyle === style.value;
return (
<button
key={style.value}
className={cn(
"hover:bg-accent flex items-start gap-3 rounded-lg border p-4 text-left transition-colors",
isSelected && "border-primary bg-accent",
)}
onClick={() => handleStyleChange(style.value)}
>
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{t(style.labelKey)}</h4>
{isSelected && <Check className="text-primary h-4 w-4" />}
</div>
<p className="text-muted-foreground text-sm">
{t(style.descriptionKey)}
</p>
</div>
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,89 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useTranslations } from "next-intl";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import type { Resource } from "~/core/messages";
import { cn } from "~/lib/utils";
export interface ResourceMentionsProps {
items: Array<Resource>;
command: (item: { id: string; label: string }) => void;
}
export const ResourceMentions = forwardRef<
{ onKeyDown: (args: { event: KeyboardEvent }) => boolean },
ResourceMentionsProps
>((props, ref) => {
const t = useTranslations("common")
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
props.command({ id: item.uri, label: item.title });
}
};
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="bg-card border-var(--border) relative flex flex-col gap-1 overflow-auto rounded-md border p-2 shadow">
{props.items.length ? (
props.items.map((item, index) => (
<button
className={cn(
"focus-visible:ring-ring hover:bg-accent hover:text-accent-foreground inline-flex h-9 w-full items-center justify-start gap-2 rounded-md px-4 py-2 text-sm whitespace-nowrap transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
selectedIndex === index &&
"bg-secondary text-secondary-foreground",
)}
key={index}
onClick={() => selectItem(index)}
>
{item.title}
</button>
))
) : (
<div className="items-center justify-center text-gray-500">
{t("noResult")}
</div>
)}
</div>
);
});
@@ -0,0 +1,108 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { MentionOptions } from "@tiptap/extension-mention";
import { ReactRenderer } from "@tiptap/react";
import {
ResourceMentions,
type ResourceMentionsProps,
} from "./resource-mentions";
import type { Instance, Props } from "tippy.js";
import tippy from "tippy.js";
import { resolveServiceURL } from "~/core/api/resolve-service-url";
import type { Resource } from "~/core/messages";
export const resourceSuggestion: MentionOptions["suggestion"] = {
items: ({ query }) => {
return fetch(resolveServiceURL(`rag/resources?query=${query}`), {
method: "GET",
})
.then((res) => res.json())
.then((res) => {
return res.resources as Array<Resource>;
})
.catch((err) => {
return [];
});
},
render: () => {
let reactRenderer: ReactRenderer<
{ onKeyDown: (args: { event: KeyboardEvent }) => boolean },
ResourceMentionsProps
>;
let popup: Instance<Props>[] | null = null;
return {
onStart: (props) => {
reactRenderer = new ReactRenderer(ResourceMentions, {
props,
editor: props.editor,
});
const clientRect = props.clientRect || (() => {
const selection = props.editor.state.selection;
const coords = props.editor.view.coordsAtPos(selection.from);
return {
top: coords.top,
left: coords.left,
right: coords.right,
bottom: coords.bottom,
width: 0,
height: 0,
};
});
popup = tippy("body", {
getReferenceClientRect: clientRect as any,
appendTo: () => document.body,
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "top-start",
});
},
onUpdate(props) {
if (reactRenderer) {
reactRenderer.updateProps(props);
}
if (popup?.[0] && !popup[0].state.isDestroyed) {
const clientRect = props.clientRect || (() => {
const selection = props.editor.state.selection;
const coords = props.editor.view.coordsAtPos(selection.from);
return {
top: coords.top,
left: coords.left,
right: coords.right,
bottom: coords.bottom,
width: 0,
height: 0,
};
});
popup[0].setProps({
getReferenceClientRect: clientRect as any,
});
}
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
return reactRenderer.ref?.onKeyDown(props) ?? false;
},
onExit() {
popup?.[0]?.destroy();
reactRenderer.destroy();
},
};
},
};
@@ -0,0 +1,36 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "~/lib/utils";
export function RollingText({
className,
children,
}: {
className?: string;
children?: string | string[];
}) {
return (
<span
className={cn(
"relative flex h-[2em] items-center overflow-hidden",
className,
)}
>
<AnimatePresence mode="popLayout">
<motion.div
className="absolute w-fit"
style={{ transition: "all 0.3s ease-in-out" }}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
</span>
);
}
@@ -0,0 +1,97 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import {
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
type ReactNode,
type RefObject,
} from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
export interface ScrollContainerProps {
className?: string;
children?: ReactNode;
scrollShadow?: boolean;
scrollShadowColor?: string;
autoScrollToBottom?: boolean;
ref?: RefObject<ScrollContainerRef | null>;
}
export interface ScrollContainerRef {
scrollToBottom(): void;
}
export function ScrollContainer({
className,
children,
scrollShadow = true,
scrollShadowColor = "var(--background)",
autoScrollToBottom = false,
ref,
}: ScrollContainerProps) {
const { scrollRef, contentRef, scrollToBottom, isAtBottom } =
useStickToBottom({ initial: "instant" });
useImperativeHandle(ref, () => ({
scrollToBottom() {
if (isAtBottom) {
scrollToBottom();
}
},
}));
const tempScrollRef = useRef<HTMLElement>(null);
const tempContentRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!autoScrollToBottom) {
tempScrollRef.current = scrollRef.current;
tempContentRef.current = contentRef.current;
scrollRef.current = null;
contentRef.current = null;
} else if (tempScrollRef.current && tempContentRef.current) {
scrollRef.current = tempScrollRef.current;
contentRef.current = tempContentRef.current;
}
}, [autoScrollToBottom, contentRef, scrollRef]);
return (
<div className={cn("relative", className)}>
{scrollShadow && (
<>
<div
className={cn(
"absolute top-0 right-0 left-0 z-10 h-10 bg-gradient-to-t",
`from-transparent to-[var(--scroll-shadow-color)]`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
<div
className={cn(
"absolute right-0 bottom-0 left-0 z-10 h-10 bg-gradient-to-b",
`from-transparent to-[var(--scroll-shadow-color)]`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
</>
)}
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="h-fit w-full" ref={contentRef}>
{children}
</div>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,29 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "~/components/theme-provider";
export function ThemeProviderWrapper({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const isChatPage = pathname?.startsWith("/chat");
return (
<ThemeProvider
attribute="class"
defaultTheme={"light"}
enableSystem={isChatPage}
forcedTheme={isChatPage ? undefined : "light"}
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}
@@ -0,0 +1,67 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { cn } from "~/lib/utils";
import { Tooltip } from "./tooltip";
export function ThemeToggle() {
const { theme = "system", setTheme } = useTheme();
return (
<DropdownMenu>
<Tooltip title="Change theme">
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span
className={cn(
theme === "light" ? "font-bold" : "text-muted-foreground",
)}
>
Light
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span
className={cn(
theme === "dark" ? "font-bold" : "text-muted-foreground",
)}
>
Dark
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span
className={cn(
theme === "system" ? "font-bold" : "text-muted-foreground",
)}
>
System
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,33 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { resolvedTheme = "dark" } = useTheme();
return (
<Sonner
theme={resolvedTheme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
@@ -0,0 +1,48 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { CSSProperties } from "react";
import {
Tooltip as ShadcnTooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
export function Tooltip({
className,
style,
children,
title,
open,
side,
sideOffset,
delayDuration = 750,
}: {
className?: string;
style?: CSSProperties;
children: React.ReactNode;
title?: React.ReactNode;
open?: boolean;
side?: "left" | "right" | "top" | "bottom";
sideOffset?: number;
delayDuration?: number;
}) {
return (
<TooltipProvider>
<ShadcnTooltip delayDuration={delayDuration} open={open}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className={cn(className)}
style={style}
side={side}
sideOffset={sideOffset}
>
{title}
</TooltipContent>
</ShadcnTooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,189 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import {
AIHighlight,
CharacterCount,
CodeBlockLowlight,
Color,
CustomKeymap,
GlobalDragHandle,
HighlightExtension,
HorizontalRule,
Placeholder,
StarterKit,
TaskItem,
TaskList,
TextStyle,
TiptapImage,
TiptapLink,
TiptapUnderline,
Twitter,
UpdatedImage,
UploadImagesPlugin,
Youtube,
} from "novel";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableRow } from "@tiptap/extension-table-row";
import { TableCell } from "@tiptap/extension-table-cell";
import { cx } from "class-variance-authority";
import { common, createLowlight } from "lowlight";
import { MathematicsWithMarkdown } from "./math-serializer";
//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects
const aiHighlight = AIHighlight;
//You can overwrite the placeholder with your own configuration
const placeholder = Placeholder;
const tiptapLink = TiptapLink.configure({
HTMLAttributes: {
class: cx(
"text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
),
},
});
const tiptapImage = TiptapImage.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin({
imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
}),
];
},
}).configure({
allowBase64: true,
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
});
const updatedImage = UpdatedImage.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
});
const taskList = TaskList.configure({
HTMLAttributes: {
class: cx("not-prose pl-2 "),
},
});
const taskItem = TaskItem.configure({
HTMLAttributes: {
class: cx("flex gap-2 items-start my-4"),
},
nested: true,
});
const horizontalRule = HorizontalRule.configure({
HTMLAttributes: {},
});
const starterKit = StarterKit.configure({
bulletList: {
HTMLAttributes: {},
},
orderedList: {
HTMLAttributes: {
class: cx("list-decimal list-outside leading-3 -mt-2"),
},
},
listItem: {
HTMLAttributes: {},
},
blockquote: {
HTMLAttributes: {
class: cx("border-l-4 border-primary"),
},
},
codeBlock: false,
code: {
HTMLAttributes: {
spellcheck: "false",
},
},
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 4,
},
gapcursor: false,
});
const codeBlockLowlight = CodeBlockLowlight.configure({
// configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only
// common: covers 37 language grammars which should be good enough in most cases
lowlight: createLowlight(common),
});
const youtube = Youtube.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
inline: false,
});
const twitter = Twitter.configure({
HTMLAttributes: {
class: cx("not-prose"),
},
inline: false,
});
const mathematics = MathematicsWithMarkdown.configure({
HTMLAttributes: {
class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"),
},
katexOptions: {
throwOnError: false,
},
});
const characterCount = CharacterCount.configure();
const table = Table.configure();
const tableRow = TableRow.configure();
const tableCell = TableCell.configure();
const tableHeader = TableHeader.configure();
const markdownExtension = Markdown.configure({
html: true,
tightLists: true,
tightListClass: "tight",
bulletListMarker: "-",
linkify: false,
breaks: false,
transformPastedText: false,
transformCopiedText: false,
});
const globalDragHandle = GlobalDragHandle.configure({});
export const defaultExtensions = [
starterKit,
placeholder,
tiptapLink,
updatedImage,
taskList,
taskItem,
table,
tableRow,
tableCell,
tableHeader,
horizontalRule,
aiHighlight,
codeBlockLowlight,
youtube,
twitter,
mathematics,
characterCount,
TiptapUnderline,
markdownExtension,
HighlightExtension,
TextStyle,
Color,
CustomKeymap,
globalDragHandle,
];
@@ -0,0 +1,69 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { CommandGroup, CommandItem, CommandSeparator } from "../../ui/command";
import { useEditor } from "novel";
import { Check, TextQuote, TrashIcon } from "lucide-react";
const AICompletionCommands = ({
completion,
onDiscard,
}: {
completion: string;
onDiscard: () => void;
}) => {
const { editor } = useEditor();
if (!editor) return null;
return (
<>
<CommandGroup>
<CommandItem
className="gap-2 px-4"
value="replace"
onSelect={() => {
const selection = editor.view.state.selection;
editor
.chain()
.focus()
.insertContentAt(
{
from: selection.from,
to: selection.to,
},
completion,
)
.run();
}}
>
<Check className="text-muted-foreground h-4 w-4" />
Replace selection
</CommandItem>
<CommandItem
className="gap-2 px-4"
value="insert"
onSelect={() => {
const selection = editor.view.state.selection;
editor
.chain()
.focus()
.insertContentAt(selection.to + 1, completion)
.run();
}}
>
<TextQuote className="text-muted-foreground h-4 w-4" />
Insert below
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={onDiscard} value="thrash" className="gap-2 px-4">
<TrashIcon className="text-muted-foreground h-4 w-4" />
Discard
</CommandItem>
</CommandGroup>
</>
);
};
export default AICompletionCommands;
@@ -0,0 +1,85 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import {
ArrowDownWideNarrow,
CheckCheck,
RefreshCcwDot,
StepForward,
WrapText,
} from "lucide-react";
import { getPrevText, useEditor } from "novel";
import { CommandGroup, CommandItem, CommandSeparator } from "../../ui/command";
const options = [
{
value: "improve",
label: "Improve writing",
icon: RefreshCcwDot,
},
// TODO: add this back in
// {
// value: "fix",
// label: "Fix grammar",
// icon: CheckCheck,
// },
{
value: "shorter",
label: "Make shorter",
icon: ArrowDownWideNarrow,
},
{
value: "longer",
label: "Make longer",
icon: WrapText,
},
];
interface AISelectorCommandsProps {
onSelect: (value: string, option: string) => void;
}
const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => {
const { editor } = useEditor();
if (!editor) return null;
return (
<>
<CommandGroup heading="Edit or review selection">
{options.map((option) => (
<CommandItem
onSelect={(value) => {
const slice = editor.state.selection.content();
const text = editor.storage.markdown.serializer.serialize(
slice.content,
);
onSelect(text, value);
}}
className="flex gap-2 px-4"
key={option.value}
value={option.value}
>
<option.icon className="h-4 w-4 text-purple-500" />
{option.label}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Use AI to do more">
<CommandItem
onSelect={() => {
const pos = editor.state.selection.from;
const text = getPrevText(editor, pos);
onSelect(text, "continue");
}}
value="continue"
className="gap-2 px-4"
>
<StepForward className="h-4 w-4 text-purple-500" />
Continue writing
</CommandItem>
</CommandGroup>
</>
);
};
export default AISelectorCommands;
@@ -0,0 +1,195 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { Command, CommandInput } from "../../ui/command";
import { ArrowUp } from "lucide-react";
import { useEditor } from "novel";
import { addAIHighlight } from "novel";
import { useCallback, useState } from "react";
import Markdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "../../ui/button";
import Magic from "../../ui/icons/magic";
import { ScrollArea } from "../../ui/scroll-area";
import AICompletionCommands from "./ai-completion-command";
import AISelectorCommands from "./ai-selector-commands";
import { LoadingOutlined } from "@ant-design/icons";
import { resolveServiceURL } from "~/core/api/resolve-service-url";
import { fetchStream } from "~/core/sse";
//TODO: I think it makes more sense to create a custom Tiptap extension for this functionality https://tiptap.dev/docs/editor/ai/introduction
interface AISelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function useProseCompletion() {
const [completion, setCompletion] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const complete = useCallback(
async (prompt: string, options?: { body?: Record<string, any> }) => {
setIsLoading(true);
setError(null);
try {
const response = await fetchStream(
resolveServiceURL("/api/prose/generate"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt,
...options?.body,
}),
},
);
let fullText = "";
// Process the streaming response with debounced updates
let chunkBuffer = "";
let updateTimer: NodeJS.Timeout | undefined;
const scheduleUpdate = () => {
if (updateTimer) clearTimeout(updateTimer);
updateTimer = setTimeout(() => {
if (chunkBuffer) {
fullText += chunkBuffer;
setCompletion(fullText);
chunkBuffer = "";
}
}, 16); // ~60fps
};
for await (const chunk of response) {
chunkBuffer += chunk.data;
scheduleUpdate();
}
// Final update
if (chunkBuffer) {
fullText += chunkBuffer;
setCompletion(fullText);
}
setIsLoading(false);
return fullText;
} catch (e) {
const error = e instanceof Error ? e : new Error("An error occurred");
setError(error);
toast.error(error.message);
setIsLoading(false);
throw error;
}
},
[],
);
const reset = useCallback(() => {
setCompletion("");
setError(null);
setIsLoading(false);
}, []);
return {
completion,
complete,
isLoading,
error,
reset,
};
}
export function AISelector({ onOpenChange }: AISelectorProps) {
const { editor } = useEditor();
const [inputValue, setInputValue] = useState("");
const { completion, complete, isLoading } = useProseCompletion();
if (!editor) return null;
const hasCompletion = completion.length > 0;
return (
<Command className="w-[350px]">
{hasCompletion && (
<div className="flex max-h-[400px]">
<ScrollArea>
<div className="prose prose-sm dark:prose-invert p-2 px-4">
<Markdown>{completion}</Markdown>
</div>
</ScrollArea>
</div>
)}
{isLoading && (
<div className="flex h-12 w-full items-center px-4 text-sm font-medium text-purple-500">
<Magic className="mr-2 h-4 w-4 shrink-0" />
AI is thinking
<div className="mt-1 ml-2">
<LoadingOutlined />
</div>
</div>
)}
{!isLoading && (
<>
<div className="relative">
<CommandInput
value={inputValue}
onValueChange={setInputValue}
autoFocus
placeholder={
hasCompletion
? "Tell AI what to do next"
: "Ask AI to edit or generate..."
}
onFocus={() => addAIHighlight(editor)}
/>
<Button
size="icon"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 rounded-full bg-purple-500 hover:bg-purple-900"
onClick={() => {
if (completion)
return complete(completion, {
body: { option: "zap", command: inputValue },
}).then(() => setInputValue(""));
const slice = editor.state.selection.content();
const text = editor.storage.markdown.serializer.serialize(
slice.content,
);
complete(text, {
body: { option: "zap", command: inputValue },
}).then(() => setInputValue(""));
}}
>
<ArrowUp className="h-4 w-4" />
</Button>
</div>
{hasCompletion ? (
<AICompletionCommands
onDiscard={() => {
editor.chain().unsetHighlight().focus().run();
onOpenChange(false);
}}
completion={completion}
/>
) : (
<AISelectorCommands
onSelect={(value, option) =>
complete(value, { body: { option } })
}
/>
)}
</>
)}
</Command>
);
}

Some files were not shown because too many files have changed in this diff Show More