Compare commits

...

60 Commits

Author SHA1 Message Date
zdl
9fe65f6c23 feat: 参数调整 2025-11-12 14:27:32 +08:00
zdl
7fa4a8efbc feat:修复了图片 404 错误 2025-11-12 13:51:07 +08:00
zdl
44ae479615 feat: 调整链接 2025-11-12 13:41:33 +08:00
zdl
e32a500247 fix(bytedesk): 修复路径配置,统一使用 /bytedesk/ 前缀
修复 Bytedesk 客服系统路径不匹配问题,统一前端、CRACO 和 Nginx 配置。

## 问题
- 前端配置使用 `/bytedesk-api/` 路径
- 生产 Nginx 配置使用 `/bytedesk/` 路径
- 路径不匹配导致请求 404 或被 React Router 拦截

## 解决方案
统一使用 `/bytedesk/` 路径前缀,避免 React Router 冲突

## 代码变更

### src/bytedesk-integration/config/bytedesk.config.js
- `htmlUrl`: `/bytedesk-api/chat/` → `/bytedesk/chat/`
- `apiUrl`: `/bytedesk-api/` → `/bytedesk/`
- 更新配置注释,说明代理架构

### craco.config.js
- 代理前缀:`/bytedesk-api` → `/bytedesk`
- 删除冗余代理:`/chat` 和 `/config`(Nginx 统一处理)
- 简化配置,减少代理规则数量

## 请求链路
```
浏览器 → /bytedesk/chat/
    ↓
CRACO/Nginx → location /bytedesk/ {}
    ↓
代理转发 → http://43.143.189.195/chat/ Bytedesk 聊天窗口
```

## 优势
-  前端、CRACO、Nginx 路径统一
-  避免 React Router 冲突
-  简化代理配置
-  无需修改服务器 Nginx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 13:30:39 +08:00
zdl
5524826edd feat: 切换iframe域名 2025-11-12 13:16:11 +08:00
zdl
19b03b6c91 feat: 调整配置 2025-11-12 11:54:18 +08:00
zdl
b07cb8ab51 feat: 修改 bytedesk.config.js,改为使用相对路径和动态域名 2025-11-12 11:26:05 +08:00
zdl
a1c952c619 Merge branch 'feature_bugfix/251110_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251110_event
* 'feature_bugfix/251110_event' of https://git.valuefrontier.cn/vf/vf_react:
  feat: 调整环境配置
2025-11-12 11:04:08 +08:00
zdl
fb4a18c8ec feat: 调整环境配置 2025-11-12 11:03:37 +08:00
zdl
1e9484e471 feat: 调整环境配置 2025-11-12 11:01:44 +08:00
zdl
5c60450ba1 feat: 配置调整 2025-11-12 10:43:06 +08:00
zdl
d2b6d891b2 feat: 添加UI 2025-11-11 22:47:27 +08:00
zdl
261a7bf329 fix(community): 修复 React Hooks 顺序错误
将 Alert 组件中的 useColorModeValue Hook 调用提取到组件顶层,
避免在条件渲染中调用 Hook 导致的顺序变化问题。

## 问题
- useColorModeValue 在 showNotificationBanner 条件渲染内部调用
- 当条件状态变化时,Hooks 调用顺序发生改变
- 触发 React 警告:Hooks 顺序改变(第 75 个 Hook 从 undefined 变为 useContext)

## 解决方案
- 将 alertBgColor 和 alertBorderColor 提取到组件顶层
- 确保所有 Hooks 在每次渲染时以相同顺序调用
- 符合 React Hooks 规则:只在顶层调用 Hooks

## 变更文件
src/views/Community/index.js:
- 新增 alertBgColor 常量(第 47 行)
- 新增 alertBorderColor 常量(第 48 行)
- Alert 组件使用变量替代直接调用 Hook

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 20:20:57 +08:00
zdl
a3dfa5fd06 fix(bytedesk): 修复组织 UUID 和 API URL 配置错误
回滚之前错误的提交,使用正确的组织 UUID(df_org_uid)和相对路径 API URL。

## 问题
1. **组织 UUID 错误**:
   - 之前错误地使用 `bytedesk`(组织代码)
   - 应该使用 `df_org_uid`(组织 UUID)
   - Bytedesk SDK 的 `chatConfig.org` 需要组织 UUID,不是代码

2. **API URL 默认值错误**:
   - 代码默认值使用 HTTP 绝对 URL: `http://43.143.189.195`
   - 会导致生产环境 Mixed Content 错误
   - 应该使用相对路径: `/bytedesk-api`

## 解决方案
1. 统一使用组织 UUID: `df_org_uid`
2. 修改 API URL 默认值为相对路径: `/bytedesk-api`

## 代码变更

### 1. `.env.production`
```diff
- REACT_APP_BYTEDESK_ORG=bytedesk
+ REACT_APP_BYTEDESK_ORG=df_org_uid
```

### 2. `src/bytedesk-integration/config/bytedesk.config.js`
```diff
- const BYTEDESK_API_URL = process.env.REACT_APP_BYTEDESK_API_URL || 'http://43.143.189.195';
+ const BYTEDESK_API_URL = process.env.REACT_APP_BYTEDESK_API_URL || '/bytedesk-api';

- const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'bytedesk';
+ const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
```

### 3. `src/bytedesk-integration/.env.bytedesk.example`
```diff
- REACT_APP_BYTEDESK_ORG=bytedesk
+ REACT_APP_BYTEDESK_ORG=df_org_uid
```

## 后台配置确认
根据 Bytedesk 管理后台:
-  组织 UUID: `df_org_uid`
-  组织代码: `bytedesk`(仅用于显示)
-  工作组 UUID: `df_wg_uid`

## 最终配置
所有环境的配置统一为:
```bash
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
REACT_APP_BYTEDESK_ORG=df_org_uid
REACT_APP_BYTEDESK_SID=df_wg_uid
```

## 本地开发配置
开发者需要在 `.env.local` 中手动设置(此文件不提交到 git):
```bash
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
REACT_APP_BYTEDESK_ORG=df_org_uid
REACT_APP_BYTEDESK_SID=df_wg_uid
```

## 验证
-  即使环境变量未设置,默认值也是正确的
-  不会出现 Mixed Content 错误(使用相对路径)
-  配置与后台管理界面的 UUID 一致
-  不再出现 "Failed to create thread" 错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 20:14:28 +08:00
zdl
1b7bec47ee feat: 调整api 2025-11-11 19:00:02 +08:00
zdl
ccf1d1c0a6 Merge branch 'feature_bugfix/251111_event' into feature_bugfix/251110_event
* feature_bugfix/251111_event:
  fix(socket): 保留暂存监听器,修复重连后事件监听器丢失问题
  fix(socket): 暴露 Socket 实例到 window 对象,修复生产环境事件监听器失效问题
  fix(notification): 修复 Socket 重连后通知功能失效问题(方案2)
  feat: 添加调试 API     - 我修改 NotificationContext.js,暴露 addNotification 到 window     - 或者在调试工具 (devtools/notificationDebugger.js) 中添加测试方法     - 重新构建并部署     - 可以手动触发网页通知
  feat: Service Worker 注册失败修复方案 1. 使用了 window.location.origin,但 Service Worker 环境中没有 window 对象 2. 注册逻辑缺少详细的错误处理和状态检查
  feat: 通知调试能力
2025-11-11 18:59:00 +08:00
zdl
e78f9a512f feat: 删除机器人 2025-11-11 15:51:06 +08:00
zdl
926ffa1b8f fix(socket): 保留暂存监听器,修复重连后事件监听器丢失问题
## 问题
生产环境 Socket 已连接且订阅成功,但收到事件时不触发通知:
- Socket 连接正常:`connected: true`
- 订阅成功:`已订阅 all 类型的事件推送`
- **但是 `new_event` 监听器未注册**:`_callbacks.$new_event: undefined`
- Network 面板显示后端推送的消息已到达

## 根本原因
`socketService.js` 的监听器注册机制有缺陷:

### 原始逻辑(有问题):
```javascript
// connect() 方法中
if (this.pendingListeners.length > 0) {
    this.pendingListeners.forEach(({ event, callback }) => {
        this.on(event, callback);  // 注册监听器
    });
    this.pendingListeners = [];  //  清空暂存队列
}
```

### 问题:
1. **首次连接**:监听器从 `pendingListeners` 注册到 Socket,然后清空队列
2. **Socket 重连**:`pendingListeners` 已被清空,无法重新注册监听器
3. **结果**:重连后 `new_event` 监听器丢失,事件无法触发

### 为什么会重连?
- 用户网络波动
- 服务器重启
- 浏览器从休眠恢复
- Socket.IO 底层重连机制

## 解决方案

### 修改 1:保留 `pendingListeners`(不清空)

**文件**:`src/services/socketService.js:54-69`

```javascript
// 注册所有暂存的事件监听器(保留 pendingListeners,不清空)
if (this.pendingListeners.length > 0) {
    console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
    this.pendingListeners.forEach(({ event, callback }) => {
        // 直接在 Socket.IO 实例上注册(避免递归调用 this.on())
        const wrappedCallback = (...args) => {
            console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, ...);
            callback(...args);
        };

        this.socket.on(event, wrappedCallback);
        console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
    });
    // ⚠️ 重要:不清空 pendingListeners,保留用于重连
}
```

**变更**:
-  删除:`this.pendingListeners = [];`
-  新增:直接在 `this.socket.on()` 上注册(避免递归)
-  保留:`pendingListeners` 数组,用于重连时重新注册

### 修改 2:避免重复注册

**文件**:`src/services/socketService.js:166-181`

```javascript
on(event, callback) {
    if (!this.socket) {
        // Socket 未初始化,暂存监听器(检查是否已存在,避免重复)
        const exists = this.pendingListeners.some(
            (listener) => listener.event === event && listener.callback === callback
        );

        if (!exists) {
            this.pendingListeners.push({ event, callback });
        } else {
            console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
        }
        return;
    }
    // ...
}
```

**变更**:
-  新增:检查监听器是否已存在(`event` 和 `callback` 都匹配)
-  避免:重复添加相同监听器到 `pendingListeners`

## 效果

### 修复前:
```
首次连接:  new_event 监听器注册
重连后:    new_event 监听器丢失
事件推送:  不触发通知
```

### 修复后:
```
首次连接:  new_event 监听器注册
重连后:    new_event 监听器自动重新注册
事件推送:  正常触发通知
```

## 验证步骤

部署后在浏览器 Console 执行:

```javascript
// 1. 检查监听器
window.socket.socket._callbacks.$new_event  // 应该有 1-2 个监听器

// 2. 手动断开重连
window.socket.disconnect();
setTimeout(() => window.socket.connect(), 1000);

// 3. 重连后再次检查
window.socket.socket._callbacks.$new_event  // 应该仍然有监听器

// 4. 等待后端推送事件,验证通知显示
```

## 影响范围
- 修改文件: `src/services/socketService.js`(1 个文件,2 处修改)
- 影响功能: Socket 事件监听器注册机制
- 风险等级: 低(只修改监听器管理逻辑,不改变业务代码)

## 相关 Issue
- 修复生产环境 Socket 事件不触发通知问题
- 解决 Socket 重连后监听器丢失问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 14:16:00 +08:00
zdl
eebd207276 fix(socket): 暴露 Socket 实例到 window 对象,修复生产环境事件监听器失效问题
## 问题
生产环境收到 WebSocket 消息但不触发通知:
- Network 面板显示消息已接收
- 但事件监听器未触发(事件处理函数不执行)
- 手动测试 `window.__TEST_NOTIFICATION__.testAllTypes()` 正常工作
- 诊断脚本显示 `window.socket: undefined`

## 根本原因
Socket 实例未暴露到全局作用域,导致:
1. 无法验证 NotificationContext 中的监听器是否注册在正确的 Socket 实例上
2. 可能存在多个 Socket 实例(导入的实例 vs 实际连接的实例)
3. 事件监听器注册在错误的实例上

## 解决方案
在 `src/services/socket/index.js` 中暴露 Socket 实例到 window 对象:

### 代码变更
```javascript
//  新增:暴露 Socket 实例到 window(用于调试和验证)
if (typeof window !== 'undefined') {
    window.socket = socketService;
    window.socketService = socketService;

    console.log(' Socket instance exposed to window');
    console.log('  📍 window.socket:', window.socket);
    console.log('  📍 Socket.IO instance:', window.socket?.socket);
    console.log('  📍 Connection status:', window.socket?.connected);
}
```

## 好处
1. **可调试性**: 可在浏览器 Console 直接访问 Socket 实例
2. **验证监听器**: 可检查 `window.socket.socket._callbacks` 确认监听器已注册
3. **诊断连接**: 可实时查看 `window.socket.connected` 状态
4. **手动测试**: 可通过 `window.socket.emit()` 手动触发事件

## 验证步骤
部署后在浏览器 Console 执行:
```javascript
// 1. 验证 Socket 实例已暴露
console.log(window.socket);

// 2. 检查连接状态
console.log('Connected:', window.socket.connected);

// 3. 检查监听器
console.log('Listeners:', window.socket.socket._callbacks);

// 4. 测试手动触发事件
window.socket.socket.emit('new_event', { id: 999, title: 'Test' });
```

## 影响范围
- 修改文件: `src/services/socket/index.js`(1 个文件)
- 影响范围: 仅新增调试功能,不改变业务逻辑
- 风险等级: 低(只读暴露,不修改 Socket 行为)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:59:23 +08:00
zdl
6b96744b2c fix(notification): 修复 Socket 重连后通知功能失效问题(方案2)
采用完全重构的方式解决 Socket 重连后事件监听器丢失和闭包陷阱问题。

## 核心问题
1. Socket 重连后,事件监听器被重复注册,导致监听器累积或丢失
2. 闭包陷阱:监听器捕获了过期的 addNotification 函数引用
3. 依赖循环:registerSocketEvents 依赖 addNotification,导致频繁重新创建

## 解决方案(方案2:完全重构)

### 1. 使用 Ref 存储最新函数引用
```javascript
const addNotificationRef = useRef(null);
const adaptEventToNotificationRef = useRef(null);
const isFirstConnect = useRef(true);
```

### 2. 同步最新函数到 Ref
通过 useEffect 确保 ref.current 始终指向最新的函数:
```javascript
useEffect(() => {
    addNotificationRef.current = addNotification;
}, [addNotification]);
```

### 3. 监听器只注册一次
- useEffect 依赖数组改为 `[]`(空数组)
- socket.on('new_event') 只在组件挂载时注册一次
- 监听器内部使用 `ref.current` 访问最新函数

### 4. 重连时只重新订阅
- Socket 重连后只调用 `subscribeToEvents()`
- 不再重新注册监听器(避免累积)

## 关键代码变更

### NotificationContext.js
- **新增 Ref 定义**(第 62-65 行):存储最新的回调函数引用
- **新增同步 useEffect**(第 607-615 行):保持 ref 与函数同步
- **删除 registerSocketEvents 函数**:不再需要提取事件注册逻辑
- **重构 Socket useEffect**(第 618-824 行):
  - 依赖数组: `[registerSocketEvents, toast]` → `[]`
  - 监听器注册: 只在初始化时执行一次
  - 重连处理: 只调用 `subscribeToEvents()`,不重新注册监听器
  - 防御性检查: 确保 ref 已初始化再使用

## 技术优势

### 彻底解决重复注册
-  监听器生命周期与组件绑定,只注册一次
-  Socket 重连不会触发监听器重新注册

### 避免闭包陷阱
-  `ref.current` 始终指向最新的函数
-  监听器不受 useEffect 依赖变化影响

### 简化依赖管理
-  useEffect 无依赖,不会因状态变化而重新运行
-  性能优化:减少不必要的函数创建和监听器操作

### 提升代码质量
-  逻辑更清晰:所有监听器集中在一个 useEffect
-  易于维护:依赖关系简单明了
-  详细日志:便于调试和追踪问题

## 验证测试

### 测试场景
1.  首次连接 + 接收事件 → 正常显示通知
2.  断开重连 + 接收事件 → 重连后正常接收通知
3.  多次重连 → 每次重连后通知功能正常
4.  控制台无重复注册警告

### 预期效果
- 首次连接: 显示 " 首次连接成功"
- 重连成功: 显示 "🔄 重连成功!" (不显示 "registerSocketEvents() 被调用")
- 收到事件: 根据页面可见性显示网页通知或浏览器通知

## 影响范围
- 修改文件: `src/contexts/NotificationContext.js`
- 影响功能: Socket 连接管理、事件监听、通知分发
- 兼容性: 完全向后兼容,无破坏性变更

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:35:08 +08:00
zdl
463bdbf09c feat: 添加调试 API
- 我修改 NotificationContext.js,暴露 addNotification 到 window
    - 或者在调试工具 (devtools/notificationDebugger.js) 中添加测试方法
    - 重新构建并部署
    - 可以手动触发网页通知
2025-11-11 11:45:19 +08:00
zdl
2bb8cb78e6 feat: 客服通知代码提交 2025-11-11 11:31:40 +08:00
zdl
a15585c464 feat: Service Worker 注册失败修复方案
1. 使用了 window.location.origin,但 Service Worker 环境中没有 window 对象
2. 注册逻辑缺少详细的错误处理和状态检查
2025-11-11 10:57:12 +08:00
zdl
643c3db03e feat: 通知调试能力 2025-11-10 20:05:53 +08:00
zdl
8e5623d723 feat(customer-service): 集成 Bytedesk 客服系统并优化 Dify 机器人显示
## 主要变更

### 1. Dify 机器人优化
**文件**: public/index.html
-  恢复 Dify 机器人代码
-  添加显示控制逻辑:只在 /home 页面显示
-  使用 JavaScript 监听路由变化,动态控制显示/隐藏
-  保留所有样式配置

### 2. Bytedesk 客服系统集成
**文件**: src/bytedesk-integration/config/bytedesk.config.js
-  配置开发环境使用代理路径(/bytedesk-api)
-  修复 X-Frame-Options 跨域问题
-  优化 shouldShowCustomerService 逻辑:默认所有页面显示,只在 /login 隐藏
-  保留白名单模式代码作为备用方案

**文件**: src/components/GlobalComponents.js
-  集成 BytedeskWidget 组件
-  使用 shouldShowCustomerService 控制显示

### 3. 客服显示规则

**Dify 机器人**:
-  /home 页面 → 显示
-  其他页面 → 隐藏

**Bytedesk 客服**:
-  所有页面 → 显示
-  /login 页面 → 隐藏

## 已知问题

- ⚠️ Bytedesk 服务器配置 enabled: false,需要后端修改为 true
- ⚠️ 配置接口: /config/bytedesk/properties

## 测试建议

1. 访问 /home 页面,检查 Dify 机器人是否显示
2. 访问其他页面,检查 Dify 是否隐藏
3. 等待后端修改 enabled 后,测试 Bytedesk 客服功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 19:58:36 +08:00
zdl
57b4841b4c feat: 添加客服组件 2025-11-10 19:23:25 +08:00
zdl
9e23b370fe feat: 底部UI调整 2025-11-10 14:48:28 +08:00
zdl
34bc3d1d6f feat: 调整footer间距 2025-11-10 14:48:28 +08:00
7f2a4dd36a 事件中心不提示通知修复 2025-11-10 14:20:42 +08:00
45ff13f4d0 事件中心不提示通知修复 2025-11-10 13:46:34 +08:00
a00b8bb73d 事件中心ui 2025-11-10 12:45:34 +08:00
46ba421f42 事件中心ui 2025-11-10 12:32:14 +08:00
6cd300b5ae 事件中心ui 2025-11-10 12:22:21 +08:00
617300ac8f 事件中心不提示通知修复 2025-11-10 10:47:39 +08:00
25163789ca 事件中心不提示通知修复,增加开启/关闭通知按钮。修复edge或者opera浏览器登录扫码无跳转的问题 2025-11-10 10:36:29 +08:00
fbf6813615 事件中心有引用的相关详情样式调整 2025-11-10 10:18:55 +08:00
800151771c agent功能开发增加MCP后端 2025-11-10 08:14:53 +08:00
9a723f04f1 agent功能开发增加MCP后端 2025-11-10 07:56:52 +08:00
2756e6e379 agent功能开发增加MCP后端 2025-11-08 11:32:01 +08:00
87d8b25768 agent功能开发增加MCP后端 2025-11-08 10:58:16 +08:00
6228bef5ad agent功能开发增加MCP后端 2025-11-08 10:17:48 +08:00
dff37adbbc agent功能开发增加MCP后端 2025-11-08 08:58:30 +08:00
2a228c8d6c agent功能开发增加MCP后端 2025-11-08 00:11:36 +08:00
95eb86c06a agent功能开发增加MCP后端 2025-11-07 23:51:18 +08:00
6899b9d0d2 agent功能开发增加MCP后端 2025-11-07 23:18:20 +08:00
a8edb8bde3 agent功能开发增加MCP后端 2025-11-07 23:03:22 +08:00
d8dc79d32c agent功能开发增加MCP后端 2025-11-07 22:45:46 +08:00
e29f391f10 agent功能开发增加MCP后端 2025-11-07 22:31:07 +08:00
30788648af agent功能开发增加MCP后端 2025-11-07 22:12:23 +08:00
c886d78ff6 agent功能开发增加MCP后端 2025-11-07 22:02:21 +08:00
3a058fd805 agent功能开发增加MCP后端 2025-11-07 21:46:50 +08:00
d1d8d1a25d agent功能开发增加MCP后端 2025-11-07 21:03:24 +08:00
fc5d2058c4 agent功能开发增加MCP后端 2025-11-07 20:50:16 +08:00
322b1dd845 agent功能开发增加MCP后端 2025-11-07 20:23:54 +08:00
zdl
f01eff6eb7 feat: 优化股票卡片显示
d670b0a feat: 历史股票增加相关度数据
     02c03ab feat: 修改列表默认状态
     8bdc2aa feat: 处理mock数据
2025-11-07 20:05:14 +08:00
zdl
4860cac3ca feat: 历史股票增加相关度数据 2025-11-07 20:05:14 +08:00
zdl
207701bbde feat: 修改列表默认状态 2025-11-07 20:05:14 +08:00
zdl
033f29e90c feat: 处理mock数据 2025-11-07 20:05:14 +08:00
bd9fdefdea Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event 2025-11-07 19:55:16 +08:00
4dc27a35ff agent功能开发增加MCP后端 2025-11-07 19:55:05 +08:00
59 changed files with 12564 additions and 5342 deletions

View File

@@ -16,6 +16,15 @@ NODE_ENV=production
# Mock 配置(生产环境禁用 Mock
REACT_APP_ENABLE_MOCK=false
# 🔧 调试模式(生产环境临时调试用)
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
# 使用方法:
# 1. 设置为 true 并重新构建
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
# 3. 调试完成后设置为 false 并重新构建
REACT_APP_ENABLE_DEBUG=true
# 后端 API 地址(生产环境)
REACT_APP_API_URL=http://49.232.185.254:5001
@@ -40,3 +49,20 @@ TSC_COMPILE_ON_ERROR=true
IMAGE_INLINE_SIZE_LIMIT=10000
# Node.js 内存限制(适用于大型项目)
NODE_OPTIONS=--max_old_space_size=4096
# ========================================
# Bytedesk 客服系统配置
# ========================================
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
# Nginx 配置location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
# 组织 UUID从管理后台 -> 设置 -> 组织信息 -> 组织UUID
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组 UUID从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID
REACT_APP_BYTEDESK_SID=df_wg_uid
# 客服类型2=人工客服, 1=机器人)
REACT_APP_BYTEDESK_TYPE=2

View File

@@ -0,0 +1,49 @@
# Bytedesk 客服系统集成文件
以下文件和目录属于客服系统集成功能,未提交到当前分支:
## 1. Dify 机器人控制逻辑
**位置**: public/index.html
**状态**: 已存入 stash
**Stash ID**: stash@{0}
**说明**: 根据路径控制 Dify 机器人显示(已设置为完全不显示,只使用 Bytedesk 客服)
## 2. Bytedesk 集成代码
**位置**: src/bytedesk-integration/
**状态**: 未跟踪文件(需要手动管理)
**内容**:
- .env.bytedesk.example - Bytedesk 环境变量配置示例
- App.jsx.example - 集成 Bytedesk 的示例代码
- components/ - Bytedesk 相关组件
- config/ - Bytedesk 配置文件
- 前端工程师集成手册.md - 详细集成文档
## 恢复方法
### 恢复 public/index.html 的改动:
```bash
git stash apply stash@{0}
```
### 使用 Bytedesk 集成代码:
```bash
# 查看集成手册
cat src/bytedesk-integration/前端工程师集成手册.md
# 复制示例配置
cp src/bytedesk-integration/.env.bytedesk.example .env.bytedesk
cp src/bytedesk-integration/App.jsx.example src/App.jsx
```
## 注意事项
⚠️ **重要提示:**
- `src/bytedesk-integration/` 目录中的文件是未跟踪的untracked
- 如果需要提交客服功能,需要先添加到 git:
```bash
git add src/bytedesk-integration/
git commit -m "feat: 集成 Bytedesk 客服系统"
```
- 当前分支feature_bugfix/251110_event专注于非客服功能
- 建议在单独的分支中开发客服功能

1630
CLAUDE.md

File diff suppressed because it is too large Load Diff

5958
app_vx.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,15 +22,15 @@ openai_client = None
mysql_pool = None
# 配置
ES_HOST = 'http://192.168.1.58:9200'
OPENAI_BASE_URL = "http://192.168.1.58:8000/v1"
ES_HOST = 'http://127.0.0.1:9200'
OPENAI_BASE_URL = "http://127.0.0.1:8000/v1"
OPENAI_API_KEY = "dummy"
EMBEDDING_MODEL = "qwen3-embedding-8b"
INDEX_NAME = 'concept_library'
# MySQL配置
MYSQL_CONFIG = {
'host': '192.168.1.14',
'host': '192.168.1.8',
'user': 'root',
'password': 'Zzl5588161!',
'db': 'stock',
@@ -490,7 +490,7 @@ def build_hybrid_knn_query(
"field": "description_embedding",
"query_vector": embedding,
"k": k,
"num_candidates": min(k * 2, 500),
"num_candidates": max(k + 50, min(k * 2, 10000)), # 确保 num_candidates > k最大 10000
"boost": semantic_weight
}
}
@@ -591,7 +591,7 @@ async def search_concepts(request: SearchRequest):
"field": "description_embedding",
"query_vector": embedding,
"k": effective_search_size, # 使用有效搜索大小
"num_candidates": min(effective_search_size * 2, 1000)
"num_candidates": max(effective_search_size + 50, min(effective_search_size * 2, 10000)) # 确保 num_candidates > k
},
"size": effective_search_size
}
@@ -1045,7 +1045,16 @@ async def get_concept_price_timeseries(
):
"""获取概念在指定日期范围内的涨跌幅时间序列数据"""
if not mysql_pool:
raise HTTPException(status_code=503, detail="数据库连接不可用")
logger.warning(f"[PriceTimeseries] MySQL 连接不可用,返回空时间序列数据")
# 返回空时间序列而不是 503 错误
return PriceTimeSeriesResponse(
concept_id=concept_id,
concept_name=concept_id, # 无法查询名称,使用 ID
start_date=start_date,
end_date=end_date,
data_points=0,
timeseries=[]
)
if start_date > end_date:
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
@@ -1150,11 +1159,93 @@ async def get_concept_statistics(
min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤")
):
"""获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜"""
from datetime import datetime, timedelta
# 如果 MySQL 不可用,直接返回示例数据(而不是返回 503
if not mysql_pool:
raise HTTPException(status_code=503, detail="数据库连接不可用")
logger.warning("[Statistics] MySQL 连接不可用,使用示例数据")
# 计算日期范围
if days is not None and (start_date is not None or end_date is not None):
pass # 参数冲突,但仍使用 days
if start_date is not None and end_date is not None:
pass # 使用提供的日期
elif days is not None:
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
elif start_date is not None:
end_date = datetime.now().date()
elif end_date is not None:
start_date = end_date - timedelta(days=7)
else:
end_date = datetime.now().date()
start_date = end_date - timedelta(days=7)
# 返回示例数据(与 except 块中相同)
fallback_statistics = ConceptStatistics(
hot_concepts=[
ConceptStatItem(name="小米大模型", change_pct=12.45, stock_count=24, news_count=18),
ConceptStatItem(name="人工智能", change_pct=8.76, stock_count=45, news_count=12),
ConceptStatItem(name="新能源汽车", change_pct=6.54, stock_count=38, news_count=8),
ConceptStatItem(name="芯片概念", change_pct=5.43, stock_count=52, news_count=15),
ConceptStatItem(name="生物医药", change_pct=4.21, stock_count=28, news_count=6),
],
cold_concepts=[
ConceptStatItem(name="房地产", change_pct=-5.76, stock_count=33, news_count=5),
ConceptStatItem(name="煤炭开采", change_pct=-4.32, stock_count=25, news_count=3),
ConceptStatItem(name="钢铁冶炼", change_pct=-3.21, stock_count=28, news_count=4),
ConceptStatItem(name="传统零售", change_pct=-2.98, stock_count=19, news_count=2),
ConceptStatItem(name="纺织服装", change_pct=-2.45, stock_count=15, news_count=2),
],
active_concepts=[
ConceptStatItem(name="人工智能", news_count=45, report_count=15, total_mentions=60),
ConceptStatItem(name="芯片概念", news_count=42, report_count=12, total_mentions=54),
ConceptStatItem(name="新能源汽车", news_count=38, report_count=8, total_mentions=46),
ConceptStatItem(name="生物医药", news_count=28, report_count=6, total_mentions=34),
ConceptStatItem(name="量子科技", news_count=25, report_count=5, total_mentions=30),
],
volatile_concepts=[
ConceptStatItem(name="区块链", volatility=25.6, avg_change=2.1, max_change=15.2),
ConceptStatItem(name="元宇宙", volatility=23.8, avg_change=1.8, max_change=13.9),
ConceptStatItem(name="虚拟现实", volatility=21.2, avg_change=-0.5, max_change=10.1),
ConceptStatItem(name="游戏概念", volatility=19.7, avg_change=3.2, max_change=12.8),
ConceptStatItem(name="在线教育", volatility=18.3, avg_change=-1.1, max_change=8.1),
],
momentum_concepts=[
ConceptStatItem(name="数字经济", consecutive_days=6, total_change=19.2, avg_daily=3.2),
ConceptStatItem(name="云计算", consecutive_days=5, total_change=16.8, avg_daily=3.36),
ConceptStatItem(name="物联网", consecutive_days=4, total_change=13.1, avg_daily=3.28),
ConceptStatItem(name="大数据", consecutive_days=4, total_change=12.4, avg_daily=3.1),
ConceptStatItem(name="工业互联网", consecutive_days=3, total_change=9.6, avg_daily=3.2),
],
summary={
'total_concepts': 500,
'positive_count': 320,
'negative_count': 180,
'avg_change': 1.8,
'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'date_range': f"{start_date}{end_date}",
'days': (end_date - start_date).days + 1,
'start_date': str(start_date),
'end_date': str(end_date)
}
)
return ConceptStatisticsResponse(
success=True,
data=fallback_statistics,
params={
'days': (end_date - start_date).days + 1,
'min_stock_count': min_stock_count,
'start_date': str(start_date),
'end_date': str(end_date)
},
note="MySQL 连接不可用,使用示例数据"
)
try:
from datetime import datetime, timedelta
import random
# 参数验证和日期范围计算

View File

@@ -263,6 +263,14 @@ module.exports = {
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
'/bytedesk': {
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
changeOrigin: true,
secure: false, // 开发环境禁用 HTTPS 严格验证
logLevel: 'debug',
ws: true, // 支持 WebSocket
// 不使用 pathRewrite保留 /bytedesk 前缀,让生产 Nginx 处理
},
},
}),
},

View File

@@ -48,16 +48,18 @@ npm start
### 3. 触发通知
**Mock 模式**(默认)
- 等待 60 秒,会自动推送 1-2 条通知
- 或在控制台执行:
**测试通知**
- 使用调试 API 发送测试通知
```javascript
import { mockSocketService } from './services/mockSocketService.js';
mockSocketService.sendTestNotification();
```
// 方式1: 使用调试工具(推荐)
window.__DEBUG__.notification.forceNotification({
title: '测试通知',
body: '验证暗色模式下的通知样式'
});
**Real 模式**
- 创建测试事件(运行后端测试脚本)
// 方式2: 等待后端真实推送
// 确保已连接后端,等待真实事件推送
```
### 4. 验证效果
@@ -139,61 +141,46 @@ npm start
### 手动触发各类型通知
```javascript
// 引入服务
import { mockSocketService } from './services/mockSocketService.js';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from './constants/notificationTypes.js';
> **注意**: Mock Socket 已移除,请使用调试工具或真实后端测试。
// 测试公告通知(蓝色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.IMPORTANT,
```javascript
// 使用调试工具测试不同类型的通知
// 确保已开启调试模式REACT_APP_ENABLE_DEBUG=true
// 测试公告通知
window.__DEBUG__.notification.forceNotification({
title: '测试公告通知',
content: '这是暗色模式下的蓝色通知',
timestamp: Date.now(),
body: '这是暗色模式下的蓝色通知',
tag: 'test_announcement',
autoClose: 0,
});
// 测试股票上涨(红色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.URGENT,
title: '测试股票上涨',
content: '宁德时代 +5.2%',
extra: { priceChange: '+5.2%' },
timestamp: Date.now(),
autoClose: 0,
window.__DEBUG__.notification.forceNotification({
title: '🔴 测试股票上涨',
body: '宁德时代 +5.2%',
tag: 'test_stock_up',
});
// 测试股票下跌(绿色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '测试股票下跌',
content: '比亚迪 -3.8%',
extra: { priceChange: '-3.8%' },
timestamp: Date.now(),
autoClose: 0,
window.__DEBUG__.notification.forceNotification({
title: '🟢 测试股票下跌',
body: '比亚迪 -3.8%',
tag: 'test_stock_down',
});
// 测试事件动向(橙色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '测试事件动向',
content: '央行宣布降准',
timestamp: Date.now(),
autoClose: 0,
window.__DEBUG__.notification.forceNotification({
title: '🟠 测试事件动向',
body: '央行宣布降准',
tag: 'test_event',
});
// 测试分析报告(紫色)
mockSocketService.sendTestNotification({
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '测试分析报告',
content: '医药行业深度报告',
timestamp: Date.now(),
autoClose: 0,
window.__DEBUG__.notification.forceNotification({
title: '🟣 测试分析报告',
body: '医药行业深度报告',
tag: 'test_report',
});
```

View File

@@ -330,13 +330,14 @@ if (Notification.permission === 'granted') {
### 关键文件
- `src/services/mockSocketService.js` - Mock Socket 服务
- `src/services/socketService.js` - 真实 Socket.IO 服务
- `src/services/socket/index.js` - 统一导出
- `src/contexts/NotificationContext.js` - 通知上下文(含适配器)
- `src/services/socketService.js` - Socket.IO 服务
- `src/services/socket/index.js` - Socket 服务导出
- `src/contexts/NotificationContext.js` - 通知上下文
- `src/hooks/useEventNotifications.js` - React Hook
- `src/views/Community/components/EventList.js` - 事件列表集成
> **注意**: `mockSocketService.js` 已移除2025-01-10现仅使用真实 Socket 连接。
### 数据流
```

View File

@@ -1,8 +1,10 @@
# 实时消息推送系统 - 完整技术文档
> **版本**: v2.11.0
> **更新日期**: 2025-01-07
> **更新日期**: 2025-01-10
> **文档类型**: 快速入门 + 完整技术规格
>
> ⚠️ **重要更新**: Mock Socket 已移除2025-01-10文档中关于 `mockSocketService` 的内容仅供历史参考。
---

View File

@@ -1,361 +0,0 @@
"""
Kimi API 集成示例
演示如何将MCP工具与Kimi大模型结合使用
"""
from openai import OpenAI
import json
from typing import List, Dict, Any
from mcp_client_example import MCPClient
# Kimi API配置
KIMI_API_KEY = "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5"
KIMI_BASE_URL = "https://api.moonshot.cn/v1"
KIMI_MODEL = "kimi-k2-turbpreview"
# 初始化Kimi客户端
kimi_client = OpenAI(
api_key=KIMI_API_KEY,
base_url=KIMI_BASE_URL,
)
# 初始化MCP客户端
mcp_client = MCPClient()
def convert_mcp_tools_to_kimi_format() -> tuple[List[Dict], Dict]:
"""
将MCP工具转换为Kimi API的tools格式
Returns:
tools: Kimi格式的工具列表
tool_map: 工具名称到执行函数的映射
"""
# 获取所有MCP工具
mcp_tools_response = mcp_client.list_tools()
mcp_tools = mcp_tools_response["tools"]
# 转换为Kimi格式
kimi_tools = []
tool_map = {}
for tool in mcp_tools:
# Kimi工具格式
kimi_tool = {
"type": "function",
"function": {
"name": tool["name"],
"description": tool["description"],
"parameters": tool["parameters"]
}
}
kimi_tools.append(kimi_tool)
# 创建工具执行函数
tool_name = tool["name"]
tool_map[tool_name] = lambda args, name=tool_name: execute_mcp_tool(name, args)
return kimi_tools, tool_map
def execute_mcp_tool(tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""
执行MCP工具
Args:
tool_name: 工具名称
arguments: 工具参数
Returns:
工具执行结果
"""
print(f"[工具调用] {tool_name}")
print(f"[参数] {json.dumps(arguments, ensure_ascii=False, indent=2)}")
result = mcp_client.call_tool(tool_name, arguments)
print(f"[结果] 成功: {result.get('success', False)}")
return result
def chat_with_kimi(user_message: str, verbose: bool = True) -> str:
"""
与Kimi进行对话支持工具调用
Args:
user_message: 用户消息
verbose: 是否打印详细信息
Returns:
Kimi的回复
"""
# 获取Kimi格式的工具
tools, tool_map = convert_mcp_tools_to_kimi_format()
if verbose:
print(f"\n{'='*60}")
print(f"加载了 {len(tools)} 个工具")
print(f"{'='*60}\n")
# 初始化对话
messages = [
{
"role": "system",
"content": """你是一个专业的金融数据分析助手,由 Moonshot AI 提供支持。
你可以使用各种工具来帮助用户查询和分析金融数据,包括:
- 新闻搜索(全球新闻、中国新闻、医疗新闻)
- 公司研究(路演信息、研究报告)
- 概念板块分析
- 股票分析(涨停分析、财务数据、交易数据)
- 财务报表(资产负债表、现金流量表)
请根据用户的问题,选择合适的工具来获取信息,并提供专业的分析和建议。"""
},
{
"role": "user",
"content": user_message
}
]
if verbose:
print(f"[用户]: {user_message}\n")
# 对话循环,处理工具调用
finish_reason = None
iteration = 0
max_iterations = 10 # 防止无限循环
while finish_reason is None or finish_reason == "tool_calls":
iteration += 1
if iteration > max_iterations:
print("[警告] 达到最大迭代次数")
break
if verbose and iteration > 1:
print(f"\n[轮次 {iteration}]")
# 调用Kimi API
completion = kimi_client.chat.completions.create(
model=KIMI_MODEL,
messages=messages,
temperature=0.6, # Kimi推荐的temperature值
tools=tools,
)
choice = completion.choices[0]
finish_reason = choice.finish_reason
if verbose:
print(f"[Kimi] finish_reason: {finish_reason}")
# 处理工具调用
if finish_reason == "tool_calls":
# 将Kimi的消息添加到上下文
messages.append(choice.message)
# 执行每个工具调用
for tool_call in choice.message.tool_calls:
tool_name = tool_call.function.name
tool_arguments = json.loads(tool_call.function.arguments)
# 执行工具
tool_result = tool_map[tool_name](tool_arguments)
# 将工具结果添加到消息中
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_name,
"content": json.dumps(tool_result, ensure_ascii=False),
})
if verbose:
print() # 空行分隔
# 返回最终回复
final_response = choice.message.content
if verbose:
print(f"\n[Kimi]: {final_response}\n")
print(f"{'='*60}")
return final_response
def demo_simple_query():
"""演示1: 简单查询"""
print("\n" + "="*60)
print("演示1: 简单新闻查询")
print("="*60)
response = chat_with_kimi("帮我查找关于人工智能的最新新闻")
return response
def demo_stock_analysis():
"""演示2: 股票分析"""
print("\n" + "="*60)
print("演示2: 股票财务分析")
print("="*60)
response = chat_with_kimi("帮我分析贵州茅台600519的财务状况")
return response
def demo_concept_research():
"""演示3: 概念研究"""
print("\n" + "="*60)
print("演示3: 概念板块研究")
print("="*60)
response = chat_with_kimi("查找新能源汽车相关的概念板块,并告诉我涨幅最高的是哪些")
return response
def demo_industry_comparison():
"""演示4: 行业对比"""
print("\n" + "="*60)
print("演示4: 行业内股票对比")
print("="*60)
response = chat_with_kimi("帮我找出半导体行业的龙头股票,并对比它们的财务指标")
return response
def demo_comprehensive_analysis():
"""演示5: 综合分析"""
print("\n" + "="*60)
print("演示5: 综合分析")
print("="*60)
response = chat_with_kimi("""
我想投资白酒行业,请帮我:
1. 搜索白酒行业的主要上市公司
2. 对比贵州茅台和五粮液的财务数据
3. 查看最近的行业新闻
4. 给出投资建议
""")
return response
def interactive_chat():
"""交互式对话"""
print("\n" + "="*60)
print("Kimi 金融助手 - 交互模式")
print("="*60)
print("提示:输入 'quit''exit' 退出")
print("="*60 + "\n")
while True:
try:
user_input = input("你: ").strip()
if not user_input:
continue
if user_input.lower() in ['quit', 'exit', '退出']:
print("\n再见!")
break
response = chat_with_kimi(user_input)
except KeyboardInterrupt:
print("\n\n再见!")
break
except Exception as e:
print(f"\n[错误] {str(e)}\n")
def test_kimi_connection():
"""测试Kimi API连接"""
print("\n" + "="*60)
print("测试 Kimi API 连接")
print("="*60 + "\n")
try:
# 简单的测试请求
response = kimi_client.chat.completions.create(
model=KIMI_MODEL,
messages=[
{"role": "user", "content": "你好,请介绍一下你自己"}
],
temperature=0.6
)
print("[✓] 连接成功!")
print(f"[✓] 模型: {KIMI_MODEL}")
print(f"[✓] 回复: {response.choices[0].message.content}\n")
return True
except Exception as e:
print(f"[✗] 连接失败: {str(e)}\n")
return False
def show_available_tools():
"""显示所有可用工具"""
print("\n" + "="*60)
print("可用工具列表")
print("="*60 + "\n")
tools, _ = convert_mcp_tools_to_kimi_format()
for i, tool in enumerate(tools, 1):
func = tool["function"]
print(f"{i}. {func['name']}")
print(f" 描述: {func['description'][:80]}...")
print()
print(f"总计: {len(tools)} 个工具\n")
if __name__ == "__main__":
import sys
# 首先测试连接
if not test_kimi_connection():
print("请检查API Key和网络连接")
sys.exit(1)
# 显示可用工具
show_available_tools()
# 运行演示
print("\n选择运行模式:")
print("1. 简单查询演示")
print("2. 股票分析演示")
print("3. 概念研究演示")
print("4. 行业对比演示")
print("5. 综合分析演示")
print("6. 交互式对话")
print("7. 运行所有演示")
try:
choice = input("\n请选择 (1-7): ").strip()
if choice == "1":
demo_simple_query()
elif choice == "2":
demo_stock_analysis()
elif choice == "3":
demo_concept_research()
elif choice == "4":
demo_industry_comparison()
elif choice == "5":
demo_comprehensive_analysis()
elif choice == "6":
interactive_chat()
elif choice == "7":
demo_simple_query()
demo_stock_analysis()
demo_concept_research()
demo_industry_comparison()
demo_comprehensive_analysis()
else:
print("无效选择")
except KeyboardInterrupt:
print("\n\n程序已退出")
finally:
mcp_client.close()

View File

@@ -1,470 +0,0 @@
"""
MCP Agent System - 基于 DeepResearch 逻辑的智能代理系统
三阶段流程:计划制定 → 工具执行 → 结果总结
"""
from pydantic import BaseModel
from typing import List, Dict, Any, Optional, Literal
from datetime import datetime
import json
import logging
from openai import OpenAI
import asyncio
import os
logger = logging.getLogger(__name__)
# ==================== 数据模型 ====================
class ToolCall(BaseModel):
"""工具调用"""
tool: str
arguments: Dict[str, Any]
reason: str # 为什么要调用这个工具
class ExecutionPlan(BaseModel):
"""执行计划"""
goal: str # 用户的目标
steps: List[ToolCall] # 执行步骤
reasoning: str # 规划reasoning
class StepResult(BaseModel):
"""单步执行结果"""
step_index: int
tool: str
arguments: Dict[str, Any]
status: Literal["success", "failed", "skipped"]
result: Optional[Any] = None
error: Optional[str] = None
execution_time: float = 0
class AgentResponse(BaseModel):
"""Agent响应"""
success: bool
message: str # 自然语言总结
plan: Optional[ExecutionPlan] = None # 执行计划
step_results: List[StepResult] = [] # 每步的结果
final_summary: Optional[str] = None # 最终总结
metadata: Optional[Dict[str, Any]] = None
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
conversation_history: List[Dict[str, str]] = []
stream: bool = False # 是否流式输出
# ==================== Agent 系统 ====================
class MCPAgent:
"""MCP 智能代理 - 三阶段执行"""
def __init__(self, provider: str = "qwen"):
self.provider = provider
# LLM 配置
config = {
"qwen": {
"api_key": os.getenv("DASHSCOPE_API_KEY", ""),
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
},
"deepseek": {
"api_key": os.getenv("DEEPSEEK_API_KEY", ""),
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
},
"openai": {
"api_key": os.getenv("OPENAI_API_KEY", ""),
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
},
}.get(provider)
if not config or not config["api_key"]:
raise ValueError(f"Provider '{provider}' not configured. Please set API key.")
self.client = OpenAI(
api_key=config["api_key"],
base_url=config["base_url"],
)
self.model = config["model"]
# ==================== 阶段 1: 计划制定 ====================
def get_planning_prompt(self, tools: List[dict]) -> str:
"""获取计划制定的系统提示词"""
tools_desc = "\n\n".join([
f"**{tool['name']}**\n"
f"描述:{tool['description']}\n"
f"参数:{json.dumps(tool['parameters'], ensure_ascii=False, indent=2)}"
for tool in tools
])
return f"""你是一个专业的金融研究助手。你需要根据用户的问题,制定一个详细的执行计划。
## 可用工具
{tools_desc}
## 重要知识
- 贵州茅台股票代码: 600519
- 涨停: 股价单日涨幅约10%
- 概念板块: 相同题材的股票分类
## 特殊工具说明
- **summarize_with_llm**: 这是一个特殊工具,用于让你总结和分析收集到的数据
- 当需要对多个数据源进行综合分析时使用
- 当需要生成研究报告时使用
- 参数: {{"data": "要分析的数据", "task": "分析任务描述"}}
## 任务
分析用户问题,制定执行计划。返回 JSON 格式:
```json
{{
"goal": "用户的目标(一句话概括)",
"reasoning": "你的分析思路(为什么这样规划)",
"steps": [
{{
"tool": "工具名称",
"arguments": {{"参数名": "参数值"}},
"reason": "为什么要执行这一步"
}}
]
}}
```
## 规划原则
1. **从简到繁**: 先获取基础信息,再深入分析
2. **数据先行**: 先收集数据,再总结分析
3. **合理组合**: 可以调用多个工具但不要超过5个
4. **包含总结**: 最后一步通常是 summarize_with_llm
## 示例
用户:"帮我全面分析一下贵州茅台这只股票"
你的计划:
```json
{{
"goal": "全面分析贵州茅台股票",
"reasoning": "需要获取基本信息、财务指标、交易数据,然后综合分析",
"steps": [
{{
"tool": "get_stock_basic_info",
"arguments": {{"seccode": "600519"}},
"reason": "获取股票基本信息(公司名称、行业、市值等)"
}},
{{
"tool": "get_stock_financial_index",
"arguments": {{"seccode": "600519", "limit": 5}},
"reason": "获取最近5期财务指标营收、利润、ROE等"
}},
{{
"tool": "get_stock_trade_data",
"arguments": {{"seccode": "600519", "limit": 30}},
"reason": "获取最近30天交易数据价格走势、成交量"
}},
{{
"tool": "search_china_news",
"arguments": {{"query": "贵州茅台", "top_k": 5}},
"reason": "获取最新新闻,了解市场动态"
}},
{{
"tool": "summarize_with_llm",
"arguments": {{
"data": "前面收集的所有数据",
"task": "综合分析贵州茅台的投资价值,包括基本面、财务状况、股价走势、市场情绪"
}},
"reason": "综合所有数据,生成投资分析报告"
}}
]
}}
```
只返回JSON不要额外解释。"""
async def create_plan(self, user_query: str, tools: List[dict]) -> ExecutionPlan:
"""阶段1: 创建执行计划"""
logger.info(f"[Planning] Creating plan for: {user_query}")
messages = [
{"role": "system", "content": self.get_planning_prompt(tools)},
{"role": "user", "content": user_query},
]
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.3,
max_tokens=1500,
)
plan_json = response.choices[0].message.content.strip()
logger.info(f"[Planning] Raw response: {plan_json}")
# 清理可能的代码块标记
if "```json" in plan_json:
plan_json = plan_json.split("```json")[1].split("```")[0].strip()
elif "```" in plan_json:
plan_json = plan_json.split("```")[1].split("```")[0].strip()
plan_data = json.loads(plan_json)
plan = ExecutionPlan(
goal=plan_data["goal"],
reasoning=plan_data.get("reasoning", ""),
steps=[
ToolCall(**step) for step in plan_data["steps"]
],
)
logger.info(f"[Planning] Plan created: {len(plan.steps)} steps")
return plan
# ==================== 阶段 2: 工具执行 ====================
async def execute_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
tool_handlers: Dict[str, Any],
) -> Dict[str, Any]:
"""执行单个工具"""
# 特殊处理summarize_with_llm
if tool_name == "summarize_with_llm":
return await self.summarize_with_llm(
data=arguments.get("data", ""),
task=arguments.get("task", "总结数据"),
)
# 调用 MCP 工具
handler = tool_handlers.get(tool_name)
if not handler:
raise ValueError(f"Tool '{tool_name}' not found")
result = await handler(arguments)
return result
async def execute_plan(
self,
plan: ExecutionPlan,
tool_handlers: Dict[str, Any],
) -> List[StepResult]:
"""阶段2: 执行计划中的所有步骤"""
logger.info(f"[Execution] Starting execution: {len(plan.steps)} steps")
results = []
collected_data = {} # 收集的数据,供后续步骤使用
for i, step in enumerate(plan.steps):
logger.info(f"[Execution] Step {i+1}/{len(plan.steps)}: {step.tool}")
start_time = datetime.now()
try:
# 替换 arguments 中的占位符
arguments = step.arguments.copy()
if step.tool == "summarize_with_llm" and arguments.get("data") == "前面收集的所有数据":
# 将收集的数据传递给总结工具
arguments["data"] = json.dumps(collected_data, ensure_ascii=False, indent=2)
# 执行工具
result = await self.execute_tool(step.tool, arguments, tool_handlers)
execution_time = (datetime.now() - start_time).total_seconds()
# 保存结果
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=arguments,
status="success",
result=result,
execution_time=execution_time,
)
results.append(step_result)
# 收集数据
collected_data[f"step_{i+1}_{step.tool}"] = result
logger.info(f"[Execution] Step {i+1} completed in {execution_time:.2f}s")
except Exception as e:
logger.error(f"[Execution] Step {i+1} failed: {str(e)}")
execution_time = (datetime.now() - start_time).total_seconds()
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=step.arguments,
status="failed",
error=str(e),
execution_time=execution_time,
)
results.append(step_result)
# 根据错误类型决定是否继续
if "not found" in str(e).lower():
logger.warning(f"[Execution] Stopping due to critical error")
break
else:
logger.warning(f"[Execution] Continuing despite error")
continue
logger.info(f"[Execution] Execution completed: {len(results)} steps")
return results
async def summarize_with_llm(self, data: str, task: str) -> str:
"""特殊工具:使用 LLM 总结数据"""
logger.info(f"[LLM Summary] Task: {task}")
messages = [
{
"role": "system",
"content": "你是一个专业的金融分析师。根据提供的数据,完成指定的分析任务。"
},
{
"role": "user",
"content": f"## 任务\n{task}\n\n## 数据\n{data}\n\n请根据数据完成分析任务,用专业且易懂的语言呈现。"
},
]
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7,
max_tokens=2000,
)
summary = response.choices[0].message.content
return summary
# ==================== 阶段 3: 结果总结 ====================
async def generate_final_summary(
self,
user_query: str,
plan: ExecutionPlan,
step_results: List[StepResult],
) -> str:
"""阶段3: 生成最终总结"""
logger.info("[Summary] Generating final summary")
# 收集所有成功的结果
successful_results = [r for r in step_results if r.status == "success"]
if not successful_results:
return "很抱歉,所有步骤都执行失败,无法生成分析报告。"
# 构建总结提示
results_text = "\n\n".join([
f"**步骤 {r.step_index + 1}: {r.tool}**\n"
f"结果: {json.dumps(r.result, ensure_ascii=False, indent=2)[:1000]}..."
for r in successful_results
])
messages = [
{
"role": "system",
"content": "你是一个专业的金融研究助手。根据执行结果,生成一份简洁清晰的报告。"
},
{
"role": "user",
"content": f"""
用户问题:{user_query}
执行计划:{plan.goal}
执行结果:
{results_text}
请根据以上信息生成一份专业的分析报告300字以内
"""
},
]
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7,
max_tokens=1000,
)
summary = response.choices[0].message.content
logger.info("[Summary] Final summary generated")
return summary
# ==================== 主流程 ====================
async def process_query(
self,
user_query: str,
tools: List[dict],
tool_handlers: Dict[str, Any],
) -> AgentResponse:
"""主流程:处理用户查询"""
logger.info(f"[Agent] Processing query: {user_query}")
try:
# 阶段 1: 创建计划
plan = await self.create_plan(user_query, tools)
# 阶段 2: 执行计划
step_results = await self.execute_plan(plan, tool_handlers)
# 阶段 3: 生成总结
final_summary = await self.generate_final_summary(
user_query, plan, step_results
)
return AgentResponse(
success=True,
message=final_summary,
plan=plan,
step_results=step_results,
final_summary=final_summary,
metadata={
"total_steps": len(plan.steps),
"successful_steps": len([r for r in step_results if r.status == "success"]),
"failed_steps": len([r for r in step_results if r.status == "failed"]),
"total_execution_time": sum(r.execution_time for r in step_results),
},
)
except Exception as e:
logger.error(f"[Agent] Error: {str(e)}", exc_info=True)
return AgentResponse(
success=False,
message=f"处理失败: {str(e)}",
)
# ==================== FastAPI 端点 ====================
"""
在 mcp_server.py 中添加:
from mcp_agent_system import MCPAgent, ChatRequest, AgentResponse
# 创建 Agent 实例
agent = MCPAgent(provider="qwen")
@app.post("/agent/chat", response_model=AgentResponse)
async def agent_chat(request: ChatRequest):
\"\"\"智能代理对话端点\"\"\"
logger.info(f"Agent chat: {request.message}")
# 获取工具列表和处理器
tools = [tool.dict() for tool in TOOLS]
# 处理查询
response = await agent.process_query(
user_query=request.message,
tools=tools,
tool_handlers=TOOL_HANDLERS,
)
return response
"""

View File

@@ -1,295 +0,0 @@
"""
MCP Chat Endpoint - 添加到 mcp_server.py
集成LLM实现智能对话自动调用MCP工具并总结结果
"""
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
import os
import json
from openai import OpenAI
import logging
logger = logging.getLogger(__name__)
# ==================== LLM配置 ====================
# 支持多种LLM提供商
LLM_PROVIDERS = {
"openai": {
"api_key": os.getenv("OPENAI_API_KEY", ""),
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o-mini", # 便宜且快速
},
"qwen": {
"api_key": os.getenv("DASHSCOPE_API_KEY", ""),
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
},
"deepseek": {
"api_key": os.getenv("DEEPSEEK_API_KEY", ""),
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
},
}
# 默认使用的LLM提供商
DEFAULT_PROVIDER = "qwen" # 推荐使用通义千问,价格便宜
# ==================== 数据模型 ====================
class Message(BaseModel):
"""消息"""
role: str # system, user, assistant
content: str
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
conversation_history: List[Dict[str, str]] = []
provider: Optional[str] = DEFAULT_PROVIDER
class ChatResponse(BaseModel):
"""聊天响应"""
success: bool
message: str
tool_used: Optional[str] = None
raw_data: Optional[Any] = None
error: Optional[str] = None
# ==================== LLM助手类 ====================
class MCPChatAssistant:
"""MCP聊天助手 - 集成LLM和工具调用"""
def __init__(self, provider: str = DEFAULT_PROVIDER):
self.provider = provider
config = LLM_PROVIDERS.get(provider)
if not config or not config["api_key"]:
logger.warning(f"LLM provider '{provider}' not configured, using fallback mode")
self.client = None
else:
self.client = OpenAI(
api_key=config["api_key"],
base_url=config["base_url"],
)
self.model = config["model"]
def get_system_prompt(self, tools: List[dict]) -> str:
"""构建系统提示词"""
tools_desc = "\n\n".join([
f"**{tool['name']}**\n描述:{tool['description']}\n参数:{json.dumps(tool['parameters'], ensure_ascii=False, indent=2)}"
for tool in tools
])
return f"""你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
{tools_desc}
## 工作流程
1. **理解用户意图**:分析用户问题,确定需要什么信息
2. **选择工具**:从上面的工具中选择最合适的一个或多个
3. **提取参数**:从用户输入中提取工具需要的参数
4. **返回工具调用指令**JSON格式
{{"tool": "工具名", "arguments": {{...}}}}
## 重要规则
- 贵州茅台的股票代码是 **600519**
- 如果用户提到股票名称,尝试推断股票代码
- 如果不确定需要什么信息,使用 search_china_news 搜索相关新闻
- 涨停是指股票当日涨幅达到10%左右
- 只返回工具调用指令,不要额外解释
## 示例
用户:"查询贵州茅台的股票信息"
你:{{"tool": "get_stock_basic_info", "arguments": {{"seccode": "600519"}}}}
用户:"今日涨停的股票有哪些"
你:{{"tool": "search_limit_up_stocks", "arguments": {{"query": "", "mode": "hybrid", "page_size": 10}}}}
用户:"新能源概念板块表现如何"
你:{{"tool": "search_concepts", "arguments": {{"query": "新能源", "size": 10, "sort_by": "change_pct"}}}}
"""
async def chat(self, user_message: str, conversation_history: List[Dict[str, str]], tools: List[dict]) -> ChatResponse:
"""智能对话"""
try:
if not self.client:
# 降级到简单匹配
return await self.fallback_chat(user_message)
# 1. 构建消息历史
messages = [
{"role": "system", "content": self.get_system_prompt(tools)},
]
# 添加历史对话最多保留最近10轮
for msg in conversation_history[-20:]:
messages.append({
"role": "user" if msg.get("isUser") else "assistant",
"content": msg.get("content", ""),
})
messages.append({"role": "user", "content": user_message})
# 2. 调用LLM获取工具调用指令
logger.info(f"Calling LLM with {len(messages)} messages")
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.3, # 低温度,更确定性
max_tokens=500,
)
tool_call_instruction = response.choices[0].message.content.strip()
logger.info(f"LLM response: {tool_call_instruction}")
# 3. 解析工具调用指令
try:
tool_call = json.loads(tool_call_instruction)
tool_name = tool_call.get("tool")
tool_args = tool_call.get("arguments", {})
if not tool_name:
raise ValueError("No tool specified")
# 4. 调用工具(这里需要导入 mcp_server 的工具处理器)
from mcp_server import TOOL_HANDLERS
handler = TOOL_HANDLERS.get(tool_name)
if not handler:
raise ValueError(f"Tool '{tool_name}' not found")
tool_result = await handler(tool_args)
# 5. 让LLM总结结果
summary_messages = messages + [
{"role": "assistant", "content": tool_call_instruction},
{"role": "system", "content": f"工具 {tool_name} 返回的数据:\n{json.dumps(tool_result, ensure_ascii=False, indent=2)}\n\n请用自然语言总结这些数据给用户一个简洁清晰的回复不超过200字"}
]
summary_response = self.client.chat.completions.create(
model=self.model,
messages=summary_messages,
temperature=0.7,
max_tokens=300,
)
summary = summary_response.choices[0].message.content
return ChatResponse(
success=True,
message=summary,
tool_used=tool_name,
raw_data=tool_result,
)
except json.JSONDecodeError:
# LLM没有返回JSON格式直接返回其回复
return ChatResponse(
success=True,
message=tool_call_instruction,
)
except Exception as tool_error:
logger.error(f"Tool execution error: {str(tool_error)}")
return ChatResponse(
success=False,
message="工具调用失败",
error=str(tool_error),
)
except Exception as e:
logger.error(f"Chat error: {str(e)}", exc_info=True)
return ChatResponse(
success=False,
message="对话处理失败",
error=str(e),
)
async def fallback_chat(self, user_message: str) -> ChatResponse:
"""降级方案:简单关键词匹配"""
from mcp_server import TOOL_HANDLERS
try:
# 茅台特殊处理
if "茅台" in user_message or "贵州茅台" in user_message:
handler = TOOL_HANDLERS.get("get_stock_basic_info")
result = await handler({"seccode": "600519"})
return ChatResponse(
success=True,
message="已为您查询贵州茅台(600519)的股票信息:",
tool_used="get_stock_basic_info",
raw_data=result,
)
# 涨停分析
elif "涨停" in user_message:
handler = TOOL_HANDLERS.get("search_limit_up_stocks")
query = user_message.replace("涨停", "").strip()
result = await handler({"query": query, "mode": "hybrid", "page_size": 10})
return ChatResponse(
success=True,
message="已为您查询涨停股票信息:",
tool_used="search_limit_up_stocks",
raw_data=result,
)
# 概念板块
elif "概念" in user_message or "板块" in user_message:
handler = TOOL_HANDLERS.get("search_concepts")
query = user_message.replace("概念", "").replace("板块", "").strip()
result = await handler({"query": query, "size": 10, "sort_by": "change_pct"})
return ChatResponse(
success=True,
message=f"已为您查询'{query}'相关概念板块:",
tool_used="search_concepts",
raw_data=result,
)
# 默认:搜索新闻
else:
handler = TOOL_HANDLERS.get("search_china_news")
result = await handler({"query": user_message, "top_k": 5})
return ChatResponse(
success=True,
message="已为您搜索相关新闻:",
tool_used="search_china_news",
raw_data=result,
)
except Exception as e:
logger.error(f"Fallback chat error: {str(e)}")
return ChatResponse(
success=False,
message="查询失败",
error=str(e),
)
# ==================== FastAPI端点 ====================
# 在 mcp_server.py 中添加以下代码:
"""
from mcp_chat_endpoint import MCPChatAssistant, ChatRequest, ChatResponse
# 创建聊天助手实例
chat_assistant = MCPChatAssistant(provider="qwen") # 或 "openai", "deepseek"
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(request: ChatRequest):
\"\"\"智能对话端点 - 使用LLM理解意图并调用工具\"\"\"
logger.info(f"Chat request: {request.message}")
# 获取可用工具列表
tools = [tool.dict() for tool in TOOLS]
# 调用聊天助手
response = await chat_assistant.chat(
user_message=request.message,
conversation_history=request.conversation_history,
tools=tools,
)
return response
"""

View File

@@ -1,248 +0,0 @@
"""
MCP客户端使用示例
演示如何调用MCP服务器的各种工具
"""
import httpx
import json
from typing import Dict, Any
class MCPClient:
"""MCP客户端"""
def __init__(self, base_url: str = "http://localhost:8900"):
self.base_url = base_url
self.client = httpx.Client(timeout=60.0)
def list_tools(self):
"""列出所有可用工具"""
response = self.client.get(f"{self.base_url}/tools")
response.raise_for_status()
return response.json()
def get_tool(self, tool_name: str):
"""获取特定工具的定义"""
response = self.client.get(f"{self.base_url}/tools/{tool_name}")
response.raise_for_status()
return response.json()
def call_tool(self, tool_name: str, arguments: Dict[str, Any]):
"""调用工具"""
payload = {
"tool": tool_name,
"arguments": arguments
}
response = self.client.post(f"{self.base_url}/tools/call", json=payload)
response.raise_for_status()
return response.json()
def close(self):
"""关闭客户端"""
self.client.close()
def print_result(title: str, result: Dict[str, Any]):
"""打印结果"""
print(f"\n{'=' * 60}")
print(f"{title}")
print(f"{'=' * 60}")
print(json.dumps(result, ensure_ascii=False, indent=2))
def main():
"""主函数 - 演示各种工具的使用"""
client = MCPClient()
try:
# 1. 列出所有工具
print("\n示例1: 列出所有可用工具")
tools = client.list_tools()
print(f"可用工具数量: {len(tools['tools'])}")
for tool in tools['tools']:
print(f" - {tool['name']}: {tool['description'][:50]}...")
# 2. 搜索中国新闻
print("\n示例2: 搜索中国新闻(关键词:人工智能)")
result = client.call_tool(
"search_china_news",
{
"query": "人工智能",
"top_k": 5
}
)
if result['success']:
print_result("中国新闻搜索结果", result['data'])
# 3. 搜索概念板块(按涨跌幅排序)
print("\n示例3: 搜索概念板块(关键词:新能源,按涨跌幅排序)")
result = client.call_tool(
"search_concepts",
{
"query": "新能源",
"size": 5,
"sort_by": "change_pct"
}
)
if result['success']:
print_result("概念搜索结果", result['data'])
# 4. 获取股票的相关概念
print("\n示例4: 获取股票相关概念股票代码600519")
result = client.call_tool(
"get_stock_concepts",
{
"stock_code": "600519",
"size": 10
}
)
if result['success']:
print_result("股票概念结果", result['data'])
# 5. 搜索涨停股票
print("\n示例5: 搜索涨停股票(关键词:锂电池)")
result = client.call_tool(
"search_limit_up_stocks",
{
"query": "锂电池",
"mode": "hybrid",
"page_size": 5
}
)
if result['success']:
print_result("涨停股票搜索结果", result['data'])
# 6. 搜索研究报告
print("\n示例6: 搜索研究报告(关键词:投资策略)")
result = client.call_tool(
"search_research_reports",
{
"query": "投资策略",
"mode": "hybrid",
"size": 3
}
)
if result['success']:
print_result("研究报告搜索结果", result['data'])
# 7. 获取概念统计数据
print("\n示例7: 获取概念统计最近7天")
result = client.call_tool(
"get_concept_statistics",
{
"days": 7,
"min_stock_count": 3
}
)
if result['success']:
print_result("概念统计结果", result['data'])
# 8. 搜索路演信息
print("\n示例8: 搜索路演信息(关键词:业绩)")
result = client.call_tool(
"search_roadshows",
{
"query": "业绩",
"size": 3
}
)
if result['success']:
print_result("路演搜索结果", result['data'])
# 9. 获取股票基本信息
print("\n示例9: 获取股票基本信息股票600519")
result = client.call_tool(
"get_stock_basic_info",
{
"seccode": "600519"
}
)
if result['success']:
print_result("股票基本信息", result['data'])
# 10. 获取股票财务指标
print("\n示例10: 获取股票财务指标股票600519最近5期")
result = client.call_tool(
"get_stock_financial_index",
{
"seccode": "600519",
"limit": 5
}
)
if result['success']:
print_result("财务指标", result['data'])
# 11. 获取股票交易数据
print("\n示例11: 获取股票交易数据股票600519最近10天")
result = client.call_tool(
"get_stock_trade_data",
{
"seccode": "600519",
"limit": 10
}
)
if result['success']:
print_result("交易数据", result['data'])
# 12. 按行业搜索股票
print("\n示例12: 按行业搜索股票(行业:半导体)")
result = client.call_tool(
"search_stocks_by_criteria",
{
"industry": "半导体",
"limit": 10
}
)
if result['success']:
print_result("行业股票", result['data'])
# 13. 股票对比分析
print("\n示例13: 股票对比分析600519 vs 000858")
result = client.call_tool(
"get_stock_comparison",
{
"seccodes": ["600519", "000858"],
"metric": "financial"
}
)
if result['success']:
print_result("股票对比", result['data'])
except Exception as e:
print(f"\n错误: {str(e)}")
finally:
client.close()
def test_single_tool():
"""测试单个工具(用于快速测试)"""
client = MCPClient()
try:
# 修改这里来测试不同的工具
result = client.call_tool(
"search_china_news",
{
"query": "芯片",
"exact_match": True,
"top_k": 3
}
)
print_result("测试结果", result)
except Exception as e:
print(f"错误: {str(e)}")
finally:
client.close()
if __name__ == "__main__":
# 运行完整示例
main()
# 或者测试单个工具
# test_single_tool()

View File

@@ -544,3 +544,240 @@ async def get_stock_comparison(
"comparison_type": metric,
"stocks": [convert_row(row) for row in results]
}
async def get_user_favorite_stocks(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取用户自选股列表
Args:
user_id: 用户ID
limit: 返回条数
Returns:
自选股列表(包含最新行情数据)
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 查询用户自选股(假设有 user_favorites 表)
# 如果没有此表,可以根据实际情况调整
query = """
SELECT
f.user_id,
f.stock_code,
b.SECNAME as stock_name,
b.F030V as industry,
t.F007N as current_price,
t.F010N as change_pct,
t.F012N as turnover_rate,
t.F026N as pe_ratio,
t.TRADEDATE as latest_trade_date,
f.created_at as favorite_time
FROM user_favorites f
INNER JOIN ea_baseinfo b ON f.stock_code = b.SECCODE
LEFT JOIN (
SELECT SECCODE, MAX(TRADEDATE) as max_date
FROM ea_trade
GROUP BY SECCODE
) latest ON b.SECCODE = latest.SECCODE
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
AND t.TRADEDATE = latest.max_date
WHERE f.user_id = %s AND f.is_deleted = 0
ORDER BY f.created_at DESC
LIMIT %s
"""
await cursor.execute(query, [user_id, limit])
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_user_favorite_events(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取用户自选事件列表
Args:
user_id: 用户ID
limit: 返回条数
Returns:
自选事件列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 查询用户自选事件(假设有 user_event_favorites 表)
query = """
SELECT
f.user_id,
f.event_id,
e.title,
e.description,
e.event_date,
e.importance,
e.related_stocks,
e.category,
f.created_at as favorite_time
FROM user_event_favorites f
INNER JOIN events e ON f.event_id = e.id
WHERE f.user_id = %s AND f.is_deleted = 0
ORDER BY e.event_date DESC
LIMIT %s
"""
await cursor.execute(query, [user_id, limit])
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def add_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
"""
添加自选股
Args:
user_id: 用户ID
stock_code: 股票代码
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 检查是否已存在
check_query = """
SELECT id, is_deleted
FROM user_favorites
WHERE user_id = %s AND stock_code = %s
"""
await cursor.execute(check_query, [user_id, stock_code])
existing = await cursor.fetchone()
if existing:
if existing['is_deleted'] == 1:
# 恢复已删除的记录
update_query = """
UPDATE user_favorites
SET is_deleted = 0, updated_at = NOW()
WHERE id = %s
"""
await cursor.execute(update_query, [existing['id']])
return {"success": True, "message": "已恢复自选股"}
else:
return {"success": False, "message": "该股票已在自选中"}
# 插入新记录
insert_query = """
INSERT INTO user_favorites (user_id, stock_code, created_at, updated_at, is_deleted)
VALUES (%s, %s, NOW(), NOW(), 0)
"""
await cursor.execute(insert_query, [user_id, stock_code])
return {"success": True, "message": "添加自选股成功"}
async def remove_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
"""
删除自选股
Args:
user_id: 用户ID
stock_code: 股票代码
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
UPDATE user_favorites
SET is_deleted = 1, updated_at = NOW()
WHERE user_id = %s AND stock_code = %s AND is_deleted = 0
"""
result = await cursor.execute(query, [user_id, stock_code])
if result > 0:
return {"success": True, "message": "删除自选股成功"}
else:
return {"success": False, "message": "未找到该自选股"}
async def add_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
"""
添加自选事件
Args:
user_id: 用户ID
event_id: 事件ID
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 检查是否已存在
check_query = """
SELECT id, is_deleted
FROM user_event_favorites
WHERE user_id = %s AND event_id = %s
"""
await cursor.execute(check_query, [user_id, event_id])
existing = await cursor.fetchone()
if existing:
if existing['is_deleted'] == 1:
# 恢复已删除的记录
update_query = """
UPDATE user_event_favorites
SET is_deleted = 0, updated_at = NOW()
WHERE id = %s
"""
await cursor.execute(update_query, [existing['id']])
return {"success": True, "message": "已恢复自选事件"}
else:
return {"success": False, "message": "该事件已在自选中"}
# 插入新记录
insert_query = """
INSERT INTO user_event_favorites (user_id, event_id, created_at, updated_at, is_deleted)
VALUES (%s, %s, NOW(), NOW(), 0)
"""
await cursor.execute(insert_query, [user_id, event_id])
return {"success": True, "message": "添加自选事件成功"}
async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
"""
删除自选事件
Args:
user_id: 用户ID
event_id: 事件ID
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
UPDATE user_event_favorites
SET is_deleted = 1, updated_at = NOW()
WHERE user_id = %s AND event_id = %s AND is_deleted = 0
"""
result = await cursor.execute(query, [user_id, event_id])
if result > 0:
return {"success": True, "message": "删除自选事件成功"}
else:
return {"success": False, "message": "未找到该自选事件"}

320
mcp_elasticsearch.py Normal file
View File

@@ -0,0 +1,320 @@
"""
Elasticsearch 连接和工具模块
用于聊天记录存储和向量搜索
"""
from elasticsearch import Elasticsearch, helpers
from datetime import datetime
from typing import List, Dict, Any, Optional
import logging
import json
import openai
logger = logging.getLogger(__name__)
# ==================== 配置 ====================
# ES 配置
ES_CONFIG = {
"host": "http://222.128.1.157:19200",
"index_chat_history": "agent_chat_history", # 聊天记录索引
}
# Embedding 配置
EMBEDDING_CONFIG = {
"api_key": "dummy",
"base_url": "http://222.128.1.157:18008/v1",
"model": "qwen3-embedding-8b",
"dims": 4096, # 向量维度
}
# ==================== ES 客户端 ====================
class ESClient:
"""Elasticsearch 客户端封装"""
def __init__(self):
self.es = Elasticsearch([ES_CONFIG["host"]], request_timeout=60)
self.chat_index = ES_CONFIG["index_chat_history"]
# 初始化 OpenAI 客户端用于 embedding
self.embedding_client = openai.OpenAI(
api_key=EMBEDDING_CONFIG["api_key"],
base_url=EMBEDDING_CONFIG["base_url"],
)
self.embedding_model = EMBEDDING_CONFIG["model"]
# 初始化索引
self.create_chat_history_index()
def create_chat_history_index(self):
"""创建聊天记录索引"""
if self.es.indices.exists(index=self.chat_index):
logger.info(f"索引 {self.chat_index} 已存在")
return
mappings = {
"properties": {
"session_id": {"type": "keyword"}, # 会话ID
"user_id": {"type": "keyword"}, # 用户ID
"user_nickname": {"type": "text"}, # 用户昵称
"user_avatar": {"type": "keyword"}, # 用户头像URL
"message_type": {"type": "keyword"}, # user / assistant
"message": {"type": "text"}, # 消息内容
"message_embedding": { # 消息向量
"type": "dense_vector",
"dims": EMBEDDING_CONFIG["dims"],
"index": True,
"similarity": "cosine"
},
"plan": {"type": "text"}, # 执行计划(仅 assistant
"steps": {"type": "text"}, # 执行步骤(仅 assistant
"timestamp": {"type": "date"}, # 时间戳
"created_at": {"type": "date"}, # 创建时间
}
}
self.es.indices.create(index=self.chat_index, body={"mappings": mappings})
logger.info(f"创建索引: {self.chat_index}")
def generate_embedding(self, text: str) -> List[float]:
"""生成文本向量"""
try:
if not text or len(text.strip()) == 0:
return []
# 截断过长文本
text = text[:16000] if len(text) > 16000 else text
response = self.embedding_client.embeddings.create(
model=self.embedding_model,
input=[text]
)
return response.data[0].embedding
except Exception as e:
logger.error(f"Embedding 生成失败: {e}")
return []
def save_chat_message(
self,
session_id: str,
user_id: str,
user_nickname: str,
user_avatar: str,
message_type: str, # "user" or "assistant"
message: str,
plan: Optional[str] = None,
steps: Optional[str] = None,
) -> str:
"""
保存聊天消息
Args:
session_id: 会话ID
user_id: 用户ID
user_nickname: 用户昵称
user_avatar: 用户头像URL
message_type: 消息类型 (user/assistant)
message: 消息内容
plan: 执行计划(可选)
steps: 执行步骤(可选)
Returns:
文档ID
"""
try:
# 生成向量
embedding = self.generate_embedding(message)
doc = {
"session_id": session_id,
"user_id": user_id,
"user_nickname": user_nickname,
"user_avatar": user_avatar,
"message_type": message_type,
"message": message,
"message_embedding": embedding if embedding else None,
"plan": plan,
"steps": steps,
"timestamp": datetime.now(),
"created_at": datetime.now(),
}
result = self.es.index(index=self.chat_index, body=doc)
logger.info(f"保存聊天记录: {result['_id']}")
return result["_id"]
except Exception as e:
logger.error(f"保存聊天记录失败: {e}")
raise
def get_chat_sessions(self, user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
"""
获取用户的聊天会话列表
Args:
user_id: 用户ID
limit: 返回数量
Returns:
会话列表每个会话包含session_id, last_message, last_timestamp
"""
try:
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息
query = {
"query": {
"term": {"user_id": user_id}
},
"aggs": {
"sessions": {
"terms": {
"field": "session_id",
"size": limit,
"order": {"last_message": "desc"}
},
"aggs": {
"last_message": {
"max": {"field": "timestamp"}
},
"last_message_content": {
"top_hits": {
"size": 1,
"sort": [{"timestamp": {"order": "desc"}}],
"_source": ["message", "timestamp", "message_type"]
}
}
}
}
},
"size": 0
}
result = self.es.search(index=self.chat_index, body=query)
sessions = []
for bucket in result["aggregations"]["sessions"]["buckets"]:
session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
sessions.append({
"session_id": bucket["key"],
"last_message": session_data["message"],
"last_timestamp": session_data["timestamp"],
"message_count": bucket["doc_count"],
})
return sessions
except Exception as e:
logger.error(f"获取会话列表失败: {e}")
return []
def get_chat_history(
self,
session_id: str,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
获取指定会话的聊天历史
Args:
session_id: 会话ID
limit: 返回数量
Returns:
聊天记录列表
"""
try:
query = {
"query": {
"term": {"session_id": session_id}
},
"sort": [{"timestamp": {"order": "asc"}}],
"size": limit
}
result = self.es.search(index=self.chat_index, body=query)
messages = []
for hit in result["hits"]["hits"]:
doc = hit["_source"]
messages.append({
"message_type": doc["message_type"],
"message": doc["message"],
"plan": doc.get("plan"),
"steps": doc.get("steps"),
"timestamp": doc["timestamp"],
})
return messages
except Exception as e:
logger.error(f"获取聊天历史失败: {e}")
return []
def search_chat_history(
self,
user_id: str,
query_text: str,
top_k: int = 10
) -> List[Dict[str, Any]]:
"""
向量搜索聊天历史
Args:
user_id: 用户ID
query_text: 查询文本
top_k: 返回数量
Returns:
相关聊天记录列表
"""
try:
# 生成查询向量
query_embedding = self.generate_embedding(query_text)
if not query_embedding:
return []
# 向量搜索
query = {
"query": {
"bool": {
"must": [
{"term": {"user_id": user_id}},
{
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'message_embedding') + 1.0",
"params": {"query_vector": query_embedding}
}
}
}
]
}
},
"size": top_k
}
result = self.es.search(index=self.chat_index, body=query)
messages = []
for hit in result["hits"]["hits"]:
doc = hit["_source"]
messages.append({
"session_id": doc["session_id"],
"message_type": doc["message_type"],
"message": doc["message"],
"timestamp": doc["timestamp"],
"score": hit["_score"],
})
return messages
except Exception as e:
logger.error(f"向量搜索失败: {e}")
return []
# ==================== 全局实例 ====================
# 创建全局 ES 客户端
es_client = ESClient()

File diff suppressed because it is too large Load Diff

View File

@@ -1,492 +0,0 @@
"""
集成到 mcp_server.py 的 Agent 系统
使用 Kimi (kimi-k2-thinking) 和 DeepMoney 两个模型
"""
from openai import OpenAI
from pydantic import BaseModel
from typing import List, Dict, Any, Optional, Literal
from datetime import datetime
import json
import logging
logger = logging.getLogger(__name__)
# ==================== 模型配置 ====================
# Kimi 配置 - 用于计划制定和深度推理
KIMI_CONFIG = {
"api_key": "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5",
"base_url": "https://api.moonshot.cn/v1",
"model": "kimi-k2-thinking", # 思考模型
}
# DeepMoney 配置 - 用于新闻总结
DEEPMONEY_CONFIG = {
"api_key": "", # 空值
"base_url": "http://111.62.35.50:8000/v1",
"model": "deepmoney",
}
# ==================== 数据模型 ====================
class ToolCall(BaseModel):
"""工具调用"""
tool: str
arguments: Dict[str, Any]
reason: str
class ExecutionPlan(BaseModel):
"""执行计划"""
goal: str
steps: List[ToolCall]
reasoning: str
class StepResult(BaseModel):
"""单步执行结果"""
step_index: int
tool: str
arguments: Dict[str, Any]
status: Literal["success", "failed", "skipped"]
result: Optional[Any] = None
error: Optional[str] = None
execution_time: float = 0
class AgentResponse(BaseModel):
"""Agent响应"""
success: bool
message: str
plan: Optional[ExecutionPlan] = None
step_results: List[StepResult] = []
final_summary: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
conversation_history: List[Dict[str, str]] = []
# ==================== Agent 系统 ====================
class MCPAgentIntegrated:
"""集成版 MCP Agent - 使用 Kimi 和 DeepMoney"""
def __init__(self):
# 初始化 Kimi 客户端(计划制定)
self.kimi_client = OpenAI(
api_key=KIMI_CONFIG["api_key"],
base_url=KIMI_CONFIG["base_url"],
)
self.kimi_model = KIMI_CONFIG["model"]
# 初始化 DeepMoney 客户端(新闻总结)
self.deepmoney_client = OpenAI(
api_key=DEEPMONEY_CONFIG["api_key"],
base_url=DEEPMONEY_CONFIG["base_url"],
)
self.deepmoney_model = DEEPMONEY_CONFIG["model"]
def get_planning_prompt(self, tools: List[dict]) -> str:
"""获取计划制定的系统提示词"""
tools_desc = "\n\n".join([
f"**{tool['name']}**\n"
f"描述:{tool['description']}\n"
f"参数:{json.dumps(tool['parameters'], ensure_ascii=False, indent=2)}"
for tool in tools
])
return f"""你是一个专业的金融研究助手。根据用户问题,制定详细的执行计划。
## 可用工具
{tools_desc}
## 特殊工具
- **summarize_news**: 使用 DeepMoney 模型总结新闻数据
- 参数: {{"data": "新闻列表JSON", "focus": "关注点"}}
- 适用场景: 当需要总结新闻、研报等文本数据时
## 重要知识
- 贵州茅台: 600519
- 涨停: 涨幅约10%
- 概念板块: 相同题材股票分类
## 任务
分析用户问题,制定执行计划。返回 JSON
```json
{{
"goal": "用户目标",
"reasoning": "分析思路",
"steps": [
{{
"tool": "工具名",
"arguments": {{"参数": ""}},
"reason": "原因"
}}
]
}}
```
## 规划原则
1. 先收集数据,再分析总结
2. 使用 summarize_news 总结新闻类数据
3. 不超过5个步骤
4. 最后一步通常是总结
## 示例
用户:"贵州茅台最近有什么新闻"
计划:
```json
{{
"goal": "查询并总结贵州茅台最新新闻",
"reasoning": "先搜索新闻,再用 DeepMoney 总结",
"steps": [
{{
"tool": "search_china_news",
"arguments": {{"query": "贵州茅台", "top_k": 10}},
"reason": "搜索贵州茅台相关新闻"
}},
{{
"tool": "summarize_news",
"arguments": {{
"data": "前面的新闻数据",
"focus": "贵州茅台的重要动态和市场影响"
}},
"reason": "使用DeepMoney总结新闻要点"
}}
]
}}
```
只返回JSON不要其他内容。"""
async def create_plan(self, user_query: str, tools: List[dict]) -> ExecutionPlan:
"""阶段1: 使用 Kimi 创建执行计划(带思考过程)"""
logger.info(f"[Planning] Kimi开始制定计划: {user_query}")
messages = [
{"role": "system", "content": self.get_planning_prompt(tools)},
{"role": "user", "content": user_query},
]
# 使用 Kimi 思考模型
response = self.kimi_client.chat.completions.create(
model=self.kimi_model,
messages=messages,
temperature=1.0, # Kimi 推荐
max_tokens=16000, # 足够容纳 reasoning_content
)
choice = response.choices[0]
message = choice.message
# 提取思考过程
reasoning_content = ""
if hasattr(message, "reasoning_content"):
reasoning_content = getattr(message, "reasoning_content")
logger.info(f"[Planning] Kimi思考过程: {reasoning_content[:200]}...")
# 提取计划内容
plan_json = message.content.strip()
# 清理可能的代码块标记
if "```json" in plan_json:
plan_json = plan_json.split("```json")[1].split("```")[0].strip()
elif "```" in plan_json:
plan_json = plan_json.split("```")[1].split("```")[0].strip()
plan_data = json.loads(plan_json)
plan = ExecutionPlan(
goal=plan_data["goal"],
reasoning=plan_data.get("reasoning", "") + "\n\n" + (reasoning_content[:500] if reasoning_content else ""),
steps=[ToolCall(**step) for step in plan_data["steps"]],
)
logger.info(f"[Planning] 计划制定完成: {len(plan.steps)}")
return plan
async def execute_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
tool_handlers: Dict[str, Any],
) -> Dict[str, Any]:
"""执行单个工具"""
# 特殊工具summarize_news使用 DeepMoney
if tool_name == "summarize_news":
return await self.summarize_news_with_deepmoney(
data=arguments.get("data", ""),
focus=arguments.get("focus", "关键信息"),
)
# 调用 MCP 工具
handler = tool_handlers.get(tool_name)
if not handler:
raise ValueError(f"Tool '{tool_name}' not found")
result = await handler(arguments)
return result
async def summarize_news_with_deepmoney(self, data: str, focus: str) -> str:
"""使用 DeepMoney 模型总结新闻"""
logger.info(f"[DeepMoney] 总结新闻,关注点: {focus}")
messages = [
{
"role": "system",
"content": "你是一个专业的金融新闻分析师,擅长提取关键信息并进行总结。"
},
{
"role": "user",
"content": f"请总结以下新闻数据,关注点:{focus}\n\n数据:\n{data[:3000]}"
},
]
try:
response = self.deepmoney_client.chat.completions.create(
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=1000,
)
summary = response.choices[0].message.content
logger.info(f"[DeepMoney] 总结完成")
return summary
except Exception as e:
logger.error(f"[DeepMoney] 总结失败: {str(e)}")
# 降级:返回简化摘要
return f"新闻总结失败,原始数据:{data[:500]}..."
async def execute_plan(
self,
plan: ExecutionPlan,
tool_handlers: Dict[str, Any],
) -> List[StepResult]:
"""阶段2: 执行计划"""
logger.info(f"[Execution] 开始执行: {len(plan.steps)}")
results = []
collected_data = {}
for i, step in enumerate(plan.steps):
logger.info(f"[Execution] 步骤 {i+1}/{len(plan.steps)}: {step.tool}")
start_time = datetime.now()
try:
# 替换占位符
arguments = step.arguments.copy()
# 如果参数值是 "前面的新闻数据" 或 "前面收集的所有数据"
if step.tool == "summarize_news":
if arguments.get("data") in ["前面的新闻数据", "前面收集的所有数据"]:
# 将收集的数据传递
arguments["data"] = json.dumps(collected_data, ensure_ascii=False, indent=2)
# 执行工具
result = await self.execute_tool(step.tool, arguments, tool_handlers)
execution_time = (datetime.now() - start_time).total_seconds()
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=arguments,
status="success",
result=result,
execution_time=execution_time,
)
results.append(step_result)
# 收集数据
collected_data[f"step_{i+1}_{step.tool}"] = result
logger.info(f"[Execution] 步骤 {i+1} 完成: {execution_time:.2f}s")
except Exception as e:
logger.error(f"[Execution] 步骤 {i+1} 失败: {str(e)}")
execution_time = (datetime.now() - start_time).total_seconds()
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=step.arguments,
status="failed",
error=str(e),
execution_time=execution_time,
)
results.append(step_result)
# 继续执行其他步骤
continue
logger.info(f"[Execution] 执行完成")
return results
async def generate_final_summary(
self,
user_query: str,
plan: ExecutionPlan,
step_results: List[StepResult],
) -> str:
"""阶段3: 使用 Kimi 生成最终总结"""
logger.info("[Summary] Kimi生成最终总结")
# 收集成功的结果
successful_results = [r for r in step_results if r.status == "success"]
if not successful_results:
return "很抱歉,所有步骤都执行失败,无法生成分析报告。"
# 构建结果文本(精简版)
results_text = "\n\n".join([
f"**步骤 {r.step_index + 1}: {r.tool}**\n"
f"结果: {str(r.result)[:800]}..."
for r in successful_results[:3] # 只取前3个避免超长
])
messages = [
{
"role": "system",
"content": "你是专业的金融研究助手。根据执行结果,生成简洁清晰的报告。"
},
{
"role": "user",
"content": f"""用户问题:{user_query}
执行计划:{plan.goal}
执行结果:
{results_text}
请生成专业的分析报告300字以内"""
},
]
try:
response = self.kimi_client.chat.completions.create(
model="kimi-k2-turbpreview", # 使用非思考模型,更快
messages=messages,
temperature=0.7,
max_tokens=1000,
)
summary = response.choices[0].message.content
logger.info("[Summary] 总结完成")
return summary
except Exception as e:
logger.error(f"[Summary] 总结失败: {str(e)}")
# 降级:返回最后一步的结果
if successful_results:
last_result = successful_results[-1]
if isinstance(last_result.result, str):
return last_result.result
else:
return json.dumps(last_result.result, ensure_ascii=False, indent=2)
return "总结生成失败"
async def process_query(
self,
user_query: str,
tools: List[dict],
tool_handlers: Dict[str, Any],
) -> AgentResponse:
"""主流程"""
logger.info(f"[Agent] 处理查询: {user_query}")
try:
# 阶段1: Kimi 制定计划
plan = await self.create_plan(user_query, tools)
# 阶段2: 执行工具
step_results = await self.execute_plan(plan, tool_handlers)
# 阶段3: Kimi 生成总结
final_summary = await self.generate_final_summary(
user_query, plan, step_results
)
return AgentResponse(
success=True,
message=final_summary,
plan=plan,
step_results=step_results,
final_summary=final_summary,
metadata={
"total_steps": len(plan.steps),
"successful_steps": len([r for r in step_results if r.status == "success"]),
"failed_steps": len([r for r in step_results if r.status == "failed"]),
"total_execution_time": sum(r.execution_time for r in step_results),
"model_used": {
"planning": self.kimi_model,
"summarization": "kimi-k2-turbpreview",
"news_summary": self.deepmoney_model,
},
},
)
except Exception as e:
logger.error(f"[Agent] 错误: {str(e)}", exc_info=True)
return AgentResponse(
success=False,
message=f"处理失败: {str(e)}",
)
# ==================== 添加到 mcp_server.py ====================
"""
在 mcp_server.py 中添加以下代码:
# 导入 Agent 系统
from mcp_server_agent_integration import MCPAgentIntegrated, ChatRequest, AgentResponse
# 创建 Agent 实例(全局)
agent = MCPAgentIntegrated()
# 添加端点
@app.post("/agent/chat", response_model=AgentResponse)
async def agent_chat(request: ChatRequest):
\"\"\"智能代理对话端点\"\"\"
logger.info(f"Agent chat: {request.message}")
# 获取工具列表
tools = [tool.dict() for tool in TOOLS]
# 添加特殊工具summarize_news
tools.append({
"name": "summarize_news",
"description": "使用 DeepMoney 模型总结新闻数据,提取关键信息",
"parameters": {
"type": "object",
"properties": {
"data": {
"type": "string",
"description": "要总结的新闻数据JSON格式"
},
"focus": {
"type": "string",
"description": "关注点,例如:'市场影响''投资机会'"
}
},
"required": ["data"]
}
})
# 处理查询
response = await agent.process_query(
user_query=request.message,
tools=tools,
tool_handlers=TOOL_HANDLERS,
)
return response
"""

View File

@@ -95,10 +95,10 @@
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
"prestart:dev": "kill-port 3000",
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start",
"start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
"start:test": "concurrently \"python app.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
"dev": "npm start",
"backend": "python app_2.py",
"backend": "python app.py",
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
"test": "craco test --env=jsdom",

View File

@@ -1,21 +1,3 @@
<!--
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
-->
<!DOCTYPE html>
<html lang="en" dir="ltr" layout="admin">
<head>
@@ -25,10 +7,6 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
<link
@@ -36,142 +14,11 @@
sizes="76x76"
href="%PUBLIC_URL%/apple-icon.png"
/>
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.png" or "favicon.png", "%PUBLIC_URL%/favicon.png" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>价值前沿——LLM赋能的分析平台</title>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script>
window.difyChatbotConfig = {
token: 'DwN8qAKtYFQtWskM',
baseUrl: 'https://app.valuefrontier.cn',
inputs: {
// You can define the inputs from the Start node here
// key is the variable name
// e.g.
// name: "NAME"
},
systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
},
userVariables: {
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
// name: 'YOU CAN DEFINE USER NAME HERE',
},
}
</script>
<script
src="https://app.valuefrontier.cn/embed.min.js"
id="DwN8qAKtYFQtWskM"
defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: #1C64F2 !important;
width: 60px !important;
height: 60px !important;
box-shadow: 0 4px 12px rgba(28, 100, 242, 0.3) !important;
transition: all 0.3s ease !important;
}
#dify-chatbot-bubble-button:hover {
transform: scale(1.1) !important;
box-shadow: 0 6px 16px rgba(28, 100, 242, 0.4) !important;
}
#dify-chatbot-bubble-window {
width: 42rem !important;
height: 80vh !important;
max-height: calc(100vh - 2rem) !important;
position: fixed !important;
bottom: 100px !important;
right: 20px !important;
border-radius: 16px !important;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15) !important;
border: 1px solid rgba(28, 100, 242, 0.1) !important;
z-index: 9999 !important;
}
/* 确保Dify聊天窗口中的超链接正确显示 */
#dify-chatbot-bubble-window a,
#dify-chatbot-bubble-window a:link,
#dify-chatbot-bubble-window a:visited,
#dify-chatbot-bubble-window a:hover,
#dify-chatbot-bubble-window a:active {
color: #1C64F2 !important;
text-decoration: underline !important;
cursor: pointer !important;
pointer-events: auto !important;
}
/* 确保超链接在Dify消息区域中可见 */
#dify-chatbot-bubble-window .message-content a,
#dify-chatbot-bubble-window .markdown-content a,
#dify-chatbot-bubble-window [class*="message"] a {
color: #0066cc !important;
text-decoration: underline !important;
font-weight: 500 !important;
}
/* 桌面端大屏优化 */
@media (min-width: 1440px) {
#dify-chatbot-bubble-window {
width: 45rem !important;
height: 85vh !important;
}
}
/* 平板端适配 */
@media (max-width: 1024px) and (min-width: 641px) {
#dify-chatbot-bubble-window {
width: 38rem !important;
height: 75vh !important;
right: 15px !important;
bottom: 90px !important;
}
}
/* 移动端适配 */
@media (max-width: 640px) {
#dify-chatbot-bubble-window {
width: calc(100vw - 20px) !important;
height: 85vh !important;
max-height: 85vh !important;
right: 10px !important;
bottom: 80px !important;
left: 10px !important;
}
#dify-chatbot-bubble-button {
width: 56px !important;
height: 56px !important;
}
}
</style>
</body>
</html>

View File

@@ -2,10 +2,10 @@
/**
* Service Worker for Browser Notifications
* 主要功能:支持浏览器通知的稳定运行
*
* 注意:此 Service Worker 仅用于通知功能,不拦截任何 HTTP 请求
*/
const CACHE_NAME = 'valuefrontier-v1';
// Service Worker 安装事件
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
@@ -35,7 +35,7 @@ self.addEventListener('notificationclick', (event) => {
.then((windowClients) => {
// 查找是否已有打开的窗口
for (let client of windowClients) {
if (client.url.includes(window.location.origin) && 'focus' in client) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
// 聚焦现有窗口并导航到目标页面
return client.focus().then(client => {
return client.navigate(urlToOpen);
@@ -56,18 +56,6 @@ self.addEventListener('notificationclose', (event) => {
console.log('[Service Worker] Notification closed:', event.notification.tag);
});
// Fetch 事件 - 基础的网络优先策略
self.addEventListener('fetch', (event) => {
// 对于通知相关的资源,使用网络优先策略
event.respondWith(
fetch(event.request)
.catch(() => {
// 网络失败时,尝试从缓存获取
return caches.match(event.request);
})
);
});
// 推送消息事件(预留,用于未来的 Push API 集成)
self.addEventListener('push', (event) => {
console.log('[Service Worker] Push message received:', event);

View File

@@ -0,0 +1,157 @@
################################################################################
# Bytedesk客服系统环境变量配置示例
#
# 使用方法:
# 1. 复制本文件到vf_react项目根目录与package.json同级
# cp bytedesk-integration/.env.bytedesk.example .env.local
#
# 2. 根据实际部署环境修改配置值
#
# 3. 重启开发服务器使配置生效
# npm start
#
# 注意事项:
# - .env.local文件不应提交到Git已在.gitignore中
# - 开发环境和生产环境应使用不同的配置文件
# - 所有以REACT_APP_开头的变量会被打包到前端代码中
################################################################################
# ============================================================================
# Bytedesk服务器配置必需
# ============================================================================
# Bytedesk后端服务地址生产环境
# 格式: http://IP地址 或 https://域名
# 示例: http://43.143.189.195 或 https://kefu.yourdomain.com
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# ============================================================================
# Bytedesk组织和工作组配置必需
# ============================================================================
# 组织 UUIDOrganization UUID
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 组织UUID
# 注意: 不是"组织代码",是"组织UUID"df_org_uid
# 当前配置: df_org_uid默认组织
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组 UUIDWorkgroup UUID
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 工作组UUID
# 当前配置: df_wg_uid默认工作组
REACT_APP_BYTEDESK_SID=df_wg_uid
# ============================================================================
# 可选配置
# ============================================================================
# 客服类型
# 2 = 人工客服(默认)
# 1 = 机器人客服
# REACT_APP_BYTEDESK_TYPE=2
# 语言设置
# zh-cn = 简体中文(默认)
# en = 英语
# ja = 日语
# ko = 韩语
# REACT_APP_BYTEDESK_LOCALE=zh-cn
# 客服图标位置
# bottom-right = 右下角(默认)
# bottom-left = 左下角
# top-right = 右上角
# top-left = 左上角
# REACT_APP_BYTEDESK_PLACEMENT=bottom-right
# 客服图标边距(像素)
# REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
# REACT_APP_BYTEDESK_MARGIN_SIDE=20
# 主题模式
# system = 跟随系统(默认)
# light = 亮色模式
# dark = 暗色模式
# REACT_APP_BYTEDESK_THEME_MODE=system
# 主题色(十六进制颜色)
# REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
# 是否自动弹出客服窗口(不推荐)
# true = 页面加载后自动弹出
# false = 需用户点击图标弹出(默认)
# REACT_APP_BYTEDESK_AUTO_POPUP=false
# ============================================================================
# 开发环境专用配置
# ============================================================================
# 开发环境可以使用不同的服务器地址
# 取消注释以下行使用本地或测试服务器
# REACT_APP_BYTEDESK_API_URL_DEV=http://localhost:9003
# ============================================================================
# 配置示例 - 不同部署场景
# ============================================================================
# ---------- 示例1: 生产环境(域名访问) ----------
# REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
# REACT_APP_BYTEDESK_ORG=prod_org_12345
# REACT_APP_BYTEDESK_SID=prod_wg_sales
# ---------- 示例2: 测试环境IP访问 ----------
# REACT_APP_BYTEDESK_API_URL=http://192.168.1.100
# REACT_APP_BYTEDESK_ORG=test_org_abc
# REACT_APP_BYTEDESK_SID=test_wg_support
# ---------- 示例3: 本地开发环境 ----------
# REACT_APP_BYTEDESK_API_URL=http://localhost:9003
# REACT_APP_BYTEDESK_ORG=dev_org_local
# REACT_APP_BYTEDESK_SID=dev_wg_test
# ============================================================================
# 故障排查
# ============================================================================
# 问题1: 客服图标不显示
# 解决方案:
# - 检查REACT_APP_BYTEDESK_API_URL是否可访问
# - 确认.env文件在项目根目录
# - 重启开发服务器npm start
# - 查看浏览器控制台是否有错误
# 问题2: 连接不上后端服务
# 解决方案:
# - 确认后端服务已启动docker ps查看bytedesk-prod容器
# - 检查CORS配置后端.env.production中的BYTEDESK_CORS_ALLOWED_ORIGINS
# - 确认防火墙未阻止80/443端口
# 问题3: ORG或SID配置错误
# 解决方案:
# - 登录管理后台http://43.143.189.195/admin/
# - 导航到"设置" -> "组织信息"获取ORG
# - 导航到"客服管理" -> "工作组"获取SID
# - 确保复制的ID没有多余空格
# 问题4: 多工作组场景
# 解决方案:
# - 可以为不同页面配置不同的SID
# - 在bytedesk.config.js中使用条件判断
# - 示例: 售后页面用售后组SID销售页面用销售组SID
# ============================================================================
# 安全提示
# ============================================================================
# 1. 不要在代码中硬编码API地址和ID
# 2. .env.local文件不应提交到Git仓库
# 3. 生产环境建议使用HTTPS
# 4. 定期更新后端服务器的安全补丁
# 5. 不要在公开的代码库中暴露组织ID和工作组ID
# ============================================================================
# 更多信息
# ============================================================================
# Bytedesk官方文档: https://docs.bytedesk.com
# 技术支持: 访问http://43.143.189.195/chat/联系在线客服
# GitHub: https://github.com/Bytedesk/bytedesk

View File

@@ -0,0 +1,237 @@
/**
* vf_react App.jsx集成示例
*
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
*
* 集成步骤:
* 1. 将bytedesk-integration文件夹复制到src/目录
* 2. 在App.jsx中导入BytedeskWidget和配置
* 3. 添加BytedeskWidget组件代码如下
* 4. 配置.env文件参考.env.bytedesk.example
*/
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; // 如果使用react-router
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
// ============================================================================
// 方案一: 全局集成(推荐)
// 适用场景: 客服系统需要在所有页面显示
// ============================================================================
function App() {
// ========== vf_react原有代码保持不变 ==========
// 这里是您原有的App.jsx代码
// 例如: const [user, setUser] = useState(null);
// 例如: const [theme, setTheme] = useState('light');
// ... 保持原有逻辑不变 ...
// ========== Bytedesk集成代码开始 ==========
const location = useLocation(); // 获取当前路径
const [showBytedesk, setShowBytedesk] = useState(false);
// 根据页面路径决定是否显示客服
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 获取Bytedesk配置
const bytedeskConfig = getBytedeskConfig();
// 客服加载成功回调
const handleBytedeskLoad = (bytedesk) => {
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
};
// 客服加载失败回调
const handleBytedeskError = (error) => {
console.error('[App] Bytedesk客服系统加载失败', error);
};
// ========== Bytedesk集成代码结束 ==========
return (
<div className="App">
{/* ========== vf_react原有内容保持不变 ========== */}
{/* 这里是您原有的App.jsx JSX代码 */}
{/* 例如: <Header /> */}
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
{/* ... 保持原有结构不变 ... */}
{/* ========== Bytedesk客服Widget ========== */}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
onLoad={handleBytedeskLoad}
onError={handleBytedeskError}
/>
)}
</div>
);
}
export default App;
// ============================================================================
// 方案二: 带用户信息集成
// 适用场景: 需要将登录用户信息传递给客服端
// ============================================================================
/*
import React, { useState, useEffect, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
function App() {
// 获取登录用户信息
const { user } = useContext(AuthContext);
const location = useLocation();
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 根据用户信息生成配置
const bytedeskConfig = user
? getBytedeskConfigWithUser(user)
: getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案三: 条件性加载
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
// ============================================================================
/*
import React, { useState, useEffect } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [user, setUser] = useState(null);
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
// 只有在用户登录且为普通用户时显示客服
if (user && user.role === 'customer') {
setShowBytedesk(true);
} else {
setShowBytedesk(false);
}
}, [user]);
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案四: 动态控制显示/隐藏
// 适用场景: 需要通过按钮或其他交互控制客服显示
// ============================================================================
/*
import React, { useState } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [showBytedesk, setShowBytedesk] = useState(false);
const bytedeskConfig = getBytedeskConfig();
const toggleBytedesk = () => {
setShowBytedesk(prev => !prev);
};
return (
<div className="App">
// ... 您的原有代码 ...
{/* 自定义客服按钮 *\/}
<button onClick={toggleBytedesk} className="custom-service-button">
{showBytedesk ? '关闭客服' : '联系客服'}
</button>
{/* 客服Widget *\/}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 重要提示
// ============================================================================
/**
* 1. CSS样式兼容性
* - Bytedesk Widget使用Shadow DOM不会影响您的全局样式
* - Widget的样式可通过config中的theme配置调整
*
* 2. 性能优化
* - Widget脚本采用异步加载不会阻塞页面渲染
* - 建议在非关键页面(如登录、支付页)隐藏客服
*
* 3. 错误处理
* - 如果客服脚本加载失败,不会影响主应用
* - 建议添加onError回调进行错误监控
*
* 4. 调试模式
* - 查看浏览器控制台的[Bytedesk]前缀日志
* - 检查Network面板确认脚本加载成功
*
* 5. 生产部署
* - 确保.env文件配置正确特别是REACT_APP_BYTEDESK_API_URL
* - 确保CORS已在后端配置允许您的前端域名
* - 在管理后台配置正确的工作组IDsid
*/

View File

@@ -0,0 +1,140 @@
/**
* Bytedesk客服Widget组件
* 用于vf_react项目集成
*
* 使用方法:
* import BytedeskWidget from './components/BytedeskWidget';
* import { getBytedeskConfig } from './config/bytedesk.config';
*
* <BytedeskWidget
* config={getBytedeskConfig()}
* autoLoad={true}
* />
*/
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
const BytedeskWidget = ({
config,
autoLoad = true,
onLoad,
onError
}) => {
const scriptRef = useRef(null);
const widgetRef = useRef(null);
useEffect(() => {
// 如果不自动加载或配置未设置,跳过
if (!autoLoad || !config) {
if (!config) {
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
}
return;
}
console.log('[Bytedesk] 开始加载客服Widget...', config);
// 加载Bytedesk Widget脚本
const script = document.createElement('script');
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
script.async = true;
script.id = 'bytedesk-web-script';
script.onload = () => {
console.log('[Bytedesk] Widget脚本加载成功');
try {
if (window.BytedeskWeb) {
console.log('[Bytedesk] 初始化Widget');
const bytedesk = new window.BytedeskWeb(config);
bytedesk.init();
widgetRef.current = bytedesk;
console.log('[Bytedesk] Widget初始化成功');
if (onLoad) {
onLoad(bytedesk);
}
} else {
throw new Error('BytedeskWeb对象未定义');
}
} catch (error) {
console.error('[Bytedesk] Widget初始化失败:', error);
if (onError) {
onError(error);
}
}
};
script.onerror = (error) => {
console.error('[Bytedesk] Widget脚本加载失败:', error);
if (onError) {
onError(error);
}
};
// 添加脚本到页面
document.body.appendChild(script);
scriptRef.current = script;
// 清理函数
return () => {
console.log('[Bytedesk] 清理Widget');
// 移除脚本
if (scriptRef.current && document.body.contains(scriptRef.current)) {
document.body.removeChild(scriptRef.current);
}
// 移除Widget DOM元素
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
widgetElements.forEach(el => {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
});
// 清理全局对象
if (window.BytedeskWeb) {
delete window.BytedeskWeb;
}
};
}, [config, autoLoad, onLoad, onError]);
// 不渲染任何可见元素Widget会自动插入到body
return <div id="bytedesk-widget-container" style={{ display: 'none' }} />;
};
BytedeskWidget.propTypes = {
config: PropTypes.shape({
apiUrl: PropTypes.string.isRequired,
htmlUrl: PropTypes.string.isRequired,
placement: PropTypes.oneOf(['bottom-right', 'bottom-left', 'top-right', 'top-left']),
marginBottom: PropTypes.number,
marginSide: PropTypes.number,
autoPopup: PropTypes.bool,
locale: PropTypes.string,
bubbleConfig: PropTypes.shape({
show: PropTypes.bool,
icon: PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
}),
theme: PropTypes.shape({
mode: PropTypes.oneOf(['light', 'dark', 'system']),
backgroundColor: PropTypes.string,
textColor: PropTypes.string,
}),
chatConfig: PropTypes.shape({
org: PropTypes.string.isRequired,
t: PropTypes.string.isRequired,
sid: PropTypes.string.isRequired,
}).isRequired,
}),
autoLoad: PropTypes.bool,
onLoad: PropTypes.func,
onError: PropTypes.func,
};
export default BytedeskWidget;

View File

@@ -0,0 +1,155 @@
/**
* Bytedesk客服配置文件
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
*
* 环境变量配置(.env文件:
* REACT_APP_BYTEDESK_ORG=df_org_uid
* REACT_APP_BYTEDESK_SID=df_wg_uid
*
* 架构说明:
* - iframe 使用完整域名https://valuefrontier.cn/bytedesk/chat/
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
* - 本地CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
* - baseUrl 保持官方 CDN用于加载 SDK 外部模块)
*
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
*/
// 从环境变量读取配置
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
/**
* Bytedesk客服基础配置
*/
export const bytedeskConfig = {
// API服务地址如果 SDK 需要调用 API
apiUrl: '/bytedesk/',
// 聊天页面地址(使用完整 HTTPS 域名,通过 /bytedesk/ 代理避免 React Router 冲突)
htmlUrl: 'https://valuefrontier.cn/chat/',
// SDK 资源基础路径(保持 Bytedesk 官方 CDN用于加载外部模块
baseUrl: 'https://www.weiyuai.cn',
// 客服图标位置
placement: 'bottom-right', // bottom-right | bottom-left | top-right | top-left
// 边距设置(像素)
marginBottom: 20,
marginSide: 20,
// 自动弹出(不推荐)
autoPopup: false,
// 语言设置
locale: 'zh-cn', // zh-cn | en | ja | ko
// 客服图标配置
bubbleConfig: {
show: true, // 是否显示客服图标
icon: '💬', // 图标emoji或图片URL
title: '在线客服', // 鼠标悬停标题
subtitle: '点击咨询', // 副标题
},
// 主题配置
theme: {
mode: 'system', // light | dark | system
backgroundColor: '#0066FF', // 主题色
textColor: '#ffffff', // 文字颜色
},
// 聊天配置(必需)
chatConfig: {
org: BYTEDESK_ORG, // 组织ID
t: '1', // 类型: 1=人工客服, 2=机器人
sid: BYTEDESK_SID, // 工作组ID
},
};
/**
* 获取Bytedesk配置根据环境自动切换
*
* @returns {Object} Bytedesk配置对象
*/
export const getBytedeskConfig = () => {
// 所有环境都使用公网地址(不使用代理)
return bytedeskConfig;
};
/**
* 获取带用户信息的配置
* 用于已登录用户,自动传递用户信息到客服端
*
* @param {Object} user - 用户对象
* @param {string} user.id - 用户ID
* @param {string} user.name - 用户名
* @param {string} user.email - 用户邮箱
* @param {string} user.mobile - 用户手机号
* @returns {Object} 带用户信息的Bytedesk配置
*/
export const getBytedeskConfigWithUser = (user) => {
const config = getBytedeskConfig();
if (user && user.id) {
return {
...config,
chatConfig: {
...config.chatConfig,
// 传递用户信息(可选)
customParams: {
userId: user.id,
userName: user.name || 'Guest',
userEmail: user.email || '',
userMobile: user.mobile || '',
source: 'web', // 来源标识
},
},
};
}
return config;
};
/**
* 根据页面路径判断是否显示客服
*
* @param {string} pathname - 当前页面路径
* @returns {boolean} 是否显示客服
*/
export const shouldShowCustomerService = (pathname) => {
// 在以下页面隐藏客服(黑名单)
const blockedPages = [
// '/home', // 登录页
];
// 检查是否在黑名单
if (blockedPages.some(page => pathname.startsWith(page))) {
return false;
}
// 默认所有页面都显示客服
return true;
/* ============================================
白名单模式(备用,需要时取消注释)
============================================
const allowedPages = [
'/', // 首页
'/home', // 主页
'/products', // 产品页
'/pricing', // 价格页
'/contact', // 联系我们
];
// 只在白名单页面显示客服
return allowedPages.some(page => pathname.startsWith(page));
============================================ */
};
export default {
bytedeskConfig,
getBytedeskConfig,
getBytedeskConfigWithUser,
shouldShowCustomerService,
};

View File

@@ -508,19 +508,19 @@ export default function WechatRegister() {
title="微信扫码登录"
width="300"
height="350"
scrolling="no" // ✅ 新增:禁止滚动
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
scrolling="no"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation"
allow="clipboard-write"
style={{
border: 'none',
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
transform: 'scale(0.77) translateY(-35px)',
transformOrigin: 'top left',
marginLeft: '-5px',
pointerEvents: 'auto', // 允许点击 │ │
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
pointerEvents: 'auto',
overflow: 'hidden',
}}
// 使用 onWheel 事件阻止滚动 │ │
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
onWheel={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
/>
) : (
/* 未获取:显示占位符 */

View File

@@ -95,7 +95,7 @@ export const ChatInterfaceV2 = () => {
});
};
// 发送消息Agent模式
// 发送消息Agent模式 - 流式
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
@@ -106,10 +106,16 @@ export const ChatInterfaceV2 = () => {
};
addMessage(userMessage);
const userInput = inputValue; // 保存输入值
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
// 用于存储步骤结果
let currentPlan = null;
let stepResults = [];
let executingMessageId = null;
try {
// 1. 显示思考状态
addMessage({
@@ -120,18 +126,40 @@ export const ChatInterfaceV2 = () => {
setCurrentProgress(10);
// Agent API
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat`, {
// 使EventSource 接收流式数据
const eventSource = new EventSource(
`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: userInput,
conversation_history: messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
}),
}
);
// 由于 EventSource 不支持 POST我们使用 fetch + ReadableStream
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: inputValue,
conversation_history: messages.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE).map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
message: userInput,
conversation_history: messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
}),
});
@@ -139,62 +167,139 @@ export const ChatInterfaceV2 = () => {
throw new Error('Agent请求失败');
}
const agentResponse = await response.json();
logger.info('Agent response', agentResponse);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
// 读取流式数据
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!agentResponse.success) {
throw new Error(agentResponse.message || '处理失败');
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop(); // 保留不完整的行
setCurrentProgress(30);
for (const line of lines) {
if (!line.trim()) continue;
// 2. 显示执行计划
if (agentResponse.plan) {
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: agentResponse.plan,
timestamp: new Date().toISOString(),
});
}
// 解析 SSE 消息
const eventMatch = line.match(/^event: (.+)$/m);
const dataMatch = line.match(/^data: (.+)$/m);
setCurrentProgress(40);
if (!eventMatch || !dataMatch) continue;
// 3. 显示执行过程
if (agentResponse.step_results && agentResponse.step_results.length > 0) {
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
timestamp: new Date().toISOString(),
});
const event = eventMatch[1];
const data = JSON.parse(dataMatch[1]);
// 模拟进度更新
for (let i = 0; i < agentResponse.step_results.length; i++) {
setCurrentProgress(40 + (i + 1) / agentResponse.step_results.length * 50);
await new Promise(resolve => setTimeout(resolve, 100));
logger.info(`SSE Event: ${event}`, data);
// 处理不同类型的事件
switch (event) {
case 'status':
if (data.stage === 'planning') {
// 移除思考消息,显示规划中
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
addMessage({
type: MessageTypes.AGENT_THINKING,
content: data.message,
timestamp: new Date().toISOString(),
});
setCurrentProgress(20);
} else if (data.stage === 'executing') {
setCurrentProgress(30);
} else if (data.stage === 'summarizing') {
setCurrentProgress(90);
}
break;
case 'plan':
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
// 显示执行计划
currentPlan = data;
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
break;
case 'step_start':
// 如果还没有执行中消息,创建一个
if (!executingMessageId) {
const executingMsg = {
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: currentPlan,
stepResults: [],
timestamp: new Date().toISOString(),
};
addMessage(executingMsg);
executingMessageId = Date.now();
}
break;
case 'step_complete':
// 添加步骤结果
stepResults.push({
step_index: data.step_index,
tool: data.tool,
status: data.status,
result: data.result,
error: data.error,
execution_time: data.execution_time,
arguments: data.arguments,
});
// 更新执行中消息
setMessages(prev =>
prev.map(msg =>
msg.type === MessageTypes.AGENT_EXECUTING
? { ...msg, stepResults: [...stepResults] }
: msg
)
);
// 更新进度
if (currentPlan) {
const progress = 30 + ((data.step_index + 1) / currentPlan.steps.length) * 60;
setCurrentProgress(progress);
}
break;
case 'summary':
// 移除执行中消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.content,
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
setCurrentProgress(100);
break;
case 'error':
throw new Error(data.message);
case 'done':
logger.info('Stream完成');
break;
default:
logger.warn('未知事件类型:', event);
}
}
}
setCurrentProgress(100);
// 移除执行中消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
// 4. 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: agentResponse.message || agentResponse.final_summary,
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
metadata: agentResponse.metadata,
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error('Agent chat error', error);

View File

@@ -0,0 +1,72 @@
// src/components/ChatBot/EChartsRenderer.js
// ECharts 图表渲染组件
import React, { useEffect, useRef } from 'react';
import { Box, useColorModeValue } from '@chakra-ui/react';
import * as echarts from 'echarts';
/**
* ECharts 图表渲染组件
* @param {Object} option - ECharts 配置对象
* @param {number} height - 图表高度(默认 400px
*/
export const EChartsRenderer = ({ option, height = 400 }) => {
const chartRef = useRef(null);
const chartInstance = useRef(null);
const bgColor = useColorModeValue('white', 'gray.800');
useEffect(() => {
if (!chartRef.current || !option) return;
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
// 设置默认主题配置
const defaultOption = {
backgroundColor: 'transparent',
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
...option,
};
// 设置图表配置
chartInstance.current.setOption(defaultOption, true);
// 响应式调整大小
const handleResize = () => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
// chartInstance.current?.dispose(); // 不要销毁,避免重新渲染时闪烁
};
}, [option]);
// 组件卸载时销毁图表
useEffect(() => {
return () => {
chartInstance.current?.dispose();
chartInstance.current = null;
};
}, []);
return (
<Box
ref={chartRef}
width="100%"
height={`${height}px`}
bg={bgColor}
borderRadius="md"
boxShadow="sm"
/>
);
};

View File

@@ -0,0 +1,189 @@
// src/components/ChatBot/MarkdownWithCharts.js
// 支持 ECharts 图表的 Markdown 渲染组件
import React from 'react';
import { Box, Alert, AlertIcon, Text, VStack, Code } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import { EChartsRenderer } from './EChartsRenderer';
import { logger } from '@utils/logger';
/**
* 解析 Markdown 内容,提取 ECharts 代码块
* @param {string} markdown - Markdown 文本
* @returns {Array} - 包含文本和图表的数组
*/
const parseMarkdownWithCharts = (markdown) => {
if (!markdown) return [];
const parts = [];
const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = echartsRegex.exec(markdown)) !== null) {
// 添加代码块前的文本
if (match.index > lastIndex) {
const textBefore = markdown.substring(lastIndex, match.index).trim();
if (textBefore) {
parts.push({ type: 'text', content: textBefore });
}
}
// 添加 ECharts 配置
const chartConfig = match[1].trim();
parts.push({ type: 'chart', content: chartConfig });
lastIndex = match.index + match[0].length;
}
// 添加剩余文本
if (lastIndex < markdown.length) {
const textAfter = markdown.substring(lastIndex).trim();
if (textAfter) {
parts.push({ type: 'text', content: textAfter });
}
}
// 如果没有找到图表,返回整个 markdown 作为文本
if (parts.length === 0) {
parts.push({ type: 'text', content: markdown });
}
return parts;
};
/**
* 支持 ECharts 图表的 Markdown 渲染组件
* @param {string} content - Markdown 文本
*/
export const MarkdownWithCharts = ({ content }) => {
const parts = parseMarkdownWithCharts(content);
return (
<VStack align="stretch" spacing={4}>
{parts.map((part, index) => {
if (part.type === 'text') {
// 渲染普通 Markdown
return (
<Box key={index}>
<ReactMarkdown
components={{
// 自定义渲染样式
p: ({ children }) => (
<Text mb={2} fontSize="sm">
{children}
</Text>
),
h1: ({ children }) => (
<Text fontSize="xl" fontWeight="bold" mb={3}>
{children}
</Text>
),
h2: ({ children }) => (
<Text fontSize="lg" fontWeight="bold" mb={2}>
{children}
</Text>
),
h3: ({ children }) => (
<Text fontSize="md" fontWeight="bold" mb={2}>
{children}
</Text>
),
ul: ({ children }) => (
<Box as="ul" pl={4} mb={2}>
{children}
</Box>
),
ol: ({ children }) => (
<Box as="ol" pl={4} mb={2}>
{children}
</Box>
),
li: ({ children }) => (
<Box as="li" fontSize="sm" mb={1}>
{children}
</Box>
),
code: ({ inline, children }) =>
inline ? (
<Code fontSize="sm" px={1}>
{children}
</Code>
) : (
<Code display="block" p={3} borderRadius="md" fontSize="sm" whiteSpace="pre-wrap">
{children}
</Code>
),
blockquote: ({ children }) => (
<Box
borderLeftWidth="4px"
borderLeftColor="blue.500"
pl={4}
py={2}
fontStyle="italic"
color="gray.600"
>
{children}
</Box>
),
}}
>
{part.content}
</ReactMarkdown>
</Box>
);
} else if (part.type === 'chart') {
// 渲染 ECharts 图表
try {
// 清理可能的 Markdown 残留符号
let cleanContent = part.content.trim();
// 移除可能的前后空白和不可见字符
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
// 尝试解析 JSON
const chartOption = JSON.parse(cleanContent);
// 验证是否是有效的 ECharts 配置
if (!chartOption || typeof chartOption !== 'object') {
throw new Error('Invalid chart configuration: not an object');
}
return (
<Box key={index}>
<EChartsRenderer option={chartOption} height={350} />
</Box>
);
} catch (error) {
// 记录详细的错误信息
logger.error('解析 ECharts 配置失败', {
error: error.message,
contentLength: part.content.length,
contentPreview: part.content.substring(0, 200),
errorStack: error.stack
});
return (
<Alert status="warning" key={index} borderRadius="md">
<AlertIcon />
<VStack align="flex-start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="bold">
图表配置解析失败
</Text>
<Text fontSize="xs" color="gray.600">
错误: {error.message}
</Text>
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
{part.content.substring(0, 300)}
{part.content.length > 300 ? '...' : ''}
</Code>
</VStack>
</Alert>
);
}
}
return null;
})}
</VStack>
);
};

View File

@@ -2,6 +2,7 @@
// 集中管理应用的全局组件
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useNotification } from '../contexts/NotificationContext';
import { logger } from '../utils/logger';
@@ -12,6 +13,10 @@ import NotificationTestTool from './NotificationTestTool';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
/**
* ConnectionStatusBar 包装组件
* 需要在 NotificationProvider 内部使用,所以在这里包装
@@ -67,8 +72,12 @@ function ConnectionStatusBarWrapper() {
* - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器
* - NotificationTestTool: 通知测试工具 (仅开发环境)
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
*/
export function GlobalComponents() {
const location = useLocation();
const showBytedesk = shouldShowCustomerService(location.pathname);
return (
<>
{/* Socket 连接状态条 */}
@@ -85,6 +94,14 @@ export function GlobalComponents() {
{/* 通知测试工具 (仅开发环境) */}
<NotificationTestTool />
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
{showBytedesk && (
<BytedeskWidget
config={getBytedeskConfig()}
autoLoad={true}
/>
)}
</>
);
}

View File

@@ -26,7 +26,6 @@ import {
} from '@chakra-ui/react';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import { SOCKET_TYPE } from '../../services/socket';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
const NotificationTestTool = () => {
@@ -295,7 +294,7 @@ const NotificationTestTool = () => {
{isConnected ? 'Connected' : 'Disconnected'}
</Badge>
<Badge colorScheme="purple">
{SOCKET_TYPE}
REAL
</Badge>
<Badge colorScheme={getPermissionColor()}>
浏览器: {getPermissionLabel()}

View File

@@ -58,7 +58,9 @@ export const AuthProvider = ({ children }) => {
// 创建超时控制器
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
const timeoutId = setTimeout(() => {
controller.abort(new Error('Session check timeout after 5 seconds'));
}, 5000); // 5秒超时
const response = await fetch(`/api/auth/session`, {
method: 'GET',
@@ -96,8 +98,18 @@ export const AuthProvider = ({ children }) => {
setIsAuthenticated((prev) => prev === false ? prev : false);
}
} catch (error) {
logger.error('AuthContext', 'checkSession', error);
// 网络错误或超时,设置为未登录状态
// ✅ 区分AbortError和真实错误
if (error.name === 'AbortError') {
logger.debug('AuthContext', 'Session check aborted', {
reason: error.message || 'Request cancelled',
isTimeout: error.message?.includes('timeout')
});
// AbortError不改变登录状态保持原状态
return;
}
// 只有真实错误才标记为未登录
logger.error('AuthContext', 'checkSession failed', error);
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
} finally {
@@ -108,7 +120,16 @@ export const AuthProvider = ({ children }) => {
// ⚡ 初始化时检查Session - 并行执行,不阻塞页面渲染
useEffect(() => {
const controller = new AbortController();
// 传递signal给checkSession需要修改checkSession签名
// 暂时使用原有方式但添加cleanup防止组件卸载时的内存泄漏
checkSession(); // 直接调用,与页面渲染并行
// ✅ Cleanup: 组件卸载时abort可能正在进行的请求
return () => {
controller.abort(new Error('AuthProvider unmounted'));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -2,20 +2,15 @@
/**
* 通知上下文 - 管理实时消息推送和通知显示
*
* 环境说明:
* - SOCKET_TYPE === 'REAL': 使用真实 Socket.IO 连接(生产环境),连接到 wss://valuefrontier.cn
* - SOCKET_TYPE === 'MOCK': 使用模拟 Socket 服务(开发环境),用于本地测试
*
* 环境切换:
* - 设置 REACT_APP_ENABLE_MOCK=true 或 REACT_APP_USE_MOCK_SOCKET=true 使用 MOCK 模式
* - 否则使用 REAL 模式连接生产环境
* 使用真实 Socket.IO 连接到后端服务器
* 连接地址配置在环境变量中 (REACT_APP_API_URL)
*/
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
import { BellIcon } from '@chakra-ui/icons';
import { logger } from '../utils/logger';
import socket, { SOCKET_TYPE } from '../services/socket';
import socket from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav';
import { browserNotificationService } from '../services/browserNotificationService';
import { notificationMetricsService } from '../services/notificationMetricsService';
@@ -62,6 +57,12 @@ export const NotificationProvider = ({ children }) => {
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
const processedEventIds = useRef(new Set()); // 用于Socket层去重记录已处理的事件ID
const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID避免内存泄漏
const notificationTimers = useRef(new Map()); // 跟踪所有通知的自动关闭定时器
// ⚡ 方案2: 使用 Ref 存储最新的回调函数引用(避免闭包陷阱)
const addNotificationRef = useRef(null);
const adaptEventToNotificationRef = useRef(null);
const isFirstConnect = useRef(true); // 标记是否首次连接
// ⚡ 使用权限引导管理 Hook
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
@@ -71,9 +72,20 @@ export const NotificationProvider = ({ children }) => {
try {
audioRef.current = new Audio(notificationSound);
audioRef.current.volume = 0.5;
logger.info('NotificationContext', 'Audio initialized');
} catch (error) {
logger.error('NotificationContext', 'Audio initialization failed', error);
}
// 清理函数:释放音频资源
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
logger.info('NotificationContext', 'Audio resources cleaned up');
}
};
}, []);
/**
@@ -104,6 +116,13 @@ export const NotificationProvider = ({ children }) => {
const removeNotification = useCallback((id, wasClicked = false) => {
logger.info('NotificationContext', 'Removing notification', { id, wasClicked });
// 清理对应的定时器
if (notificationTimers.current.has(id)) {
clearTimeout(notificationTimers.current.get(id));
notificationTimers.current.delete(id);
logger.info('NotificationContext', 'Cleared auto-close timer', { id });
}
// 监控埋点:追踪关闭(非点击的情况)
setNotifications(prev => {
const notification = prev.find(n => n.id === id);
@@ -119,6 +138,14 @@ export const NotificationProvider = ({ children }) => {
*/
const clearAllNotifications = useCallback(() => {
logger.info('NotificationContext', 'Clearing all notifications');
// 清理所有定时器
notificationTimers.current.forEach((timerId, id) => {
clearTimeout(timerId);
logger.info('NotificationContext', 'Cleared timer during clear all', { id });
});
notificationTimers.current.clear();
setNotifications([]);
}, []);
@@ -292,8 +319,13 @@ export const NotificationProvider = ({ children }) => {
* 发送浏览器通知
*/
const sendBrowserNotification = useCallback((notificationData) => {
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
console.log('[NotificationContext] 通知数据:', notificationData);
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted');
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
return;
}
@@ -305,6 +337,14 @@ export const NotificationProvider = ({ children }) => {
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
title,
body: content,
tag,
requireInteraction,
link
});
// 发送浏览器通知
const notification = browserNotificationService.sendNotification({
title: title || '新通知',
@@ -315,17 +355,24 @@ export const NotificationProvider = ({ children }) => {
autoClose: requireInteraction ? 0 : 8000,
});
// 设置点击处理(聚焦窗口并跳转)
if (notification && link) {
notification.onclick = () => {
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
if (notification) {
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
// 设置点击处理(聚焦窗口并跳转)
if (link) {
notification.onclick = () => {
console.log('[NotificationContext] 通知被点击,跳转到:', link);
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
} else {
console.error('[NotificationContext] ❌ 通知对象创建失败!');
}
}, [browserPermission]);
/**
@@ -426,9 +473,16 @@ export const NotificationProvider = ({ children }) => {
// 自动关闭
if (newNotification.autoClose && newNotification.autoClose > 0) {
setTimeout(() => {
const timerId = setTimeout(() => {
removeNotification(newNotification.id);
}, newNotification.autoClose);
// 将定时器ID保存到Map中
notificationTimers.current.set(newNotification.id, timerId);
logger.info('NotificationContext', 'Set auto-close timer', {
id: newNotification.id,
delay: newNotification.autoClose
});
}
}, [playNotificationSound, removeNotification]);
@@ -528,34 +582,11 @@ export const NotificationProvider = ({ children }) => {
const isPageHidden = document.hidden; // 页面是否在后台
// ========== 分发策略(按优先级区分)- 已废弃 ==========
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
// if (priority === PRIORITY_LEVELS.URGENT) {
// logger.info('NotificationContext', 'Urgent notification: sending browser + web');
// // 总是发送浏览器通知
// sendBrowserNotification(newNotification);
// // 如果在前台,也显示网页通知
// if (!isPageHidden) {
// addWebNotification(newNotification);
// }
// }
// 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页)
// else if (priority === PRIORITY_LEVELS.IMPORTANT) {
// if (isPageHidden) {
// logger.info('NotificationContext', 'Important notification (background): sending browser');
// sendBrowserNotification(newNotification);
// } else {
// logger.info('NotificationContext', 'Important notification (foreground): sending web');
// addWebNotification(newNotification);
// }
// }
// 策略 3: 普通通知 - 仅网页通知
// else {
// logger.info('NotificationContext', 'Normal notification: sending web only');
// addWebNotification(newNotification);
// }
// ========== 新分发策略(仅区分前后台) ==========
// ========== 通知分发策略(区分前后台) ==========
// 策略: 根据页面可见性智能分发通知
// - 页面在后台: 发送浏览器通知(系统级提醒)
// - 页面在前台: 发送网页通知(页面内 Toast
// 注: 不再区分优先级,统一使用前后台策略
if (isPageHidden) {
// 页面在后台:发送浏览器通知
logger.info('NotificationContext', 'Page hidden: sending browser notification');
@@ -569,26 +600,42 @@ export const NotificationProvider = ({ children }) => {
return newNotification.id;
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
// 连接到 Socket 服务
/**
* ✅ 方案2: 同步最新的回调函数到 Ref
* 确保 Socket 监听器始终使用最新的函数引用(避免闭包陷阱)
*/
useEffect(() => {
addNotificationRef.current = addNotification;
console.log('[NotificationContext] 📝 已更新 addNotificationRef');
}, [addNotification]);
useEffect(() => {
adaptEventToNotificationRef.current = adaptEventToNotification;
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef');
}, [adaptEventToNotification]);
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...');
console.log(`%c[NotificationContext] Initializing socket (type: ${SOCKET_TYPE})`, 'color: #673AB7; font-weight: bold;');
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接方案2只注册一次', 'color: #673AB7; font-weight: bold;');
// ✅ 第一步: 注册所有事件监听器
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
// 监听连接状态
// ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => {
const wasDisconnected = connectionStatus !== CONNECTION_STATUS.CONNECTED;
setIsConnected(true);
setReconnectAttempt(0);
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
if (wasDisconnected) {
// 判断是首次连接还是重连
if (isFirstConnect.current) {
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;');
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.());
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false;
logger.info('NotificationContext', 'Socket connected (first time)');
} else {
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
logger.info('NotificationContext', 'Reconnected, will auto-dismiss in 2s');
logger.info('NotificationContext', 'Socket reconnected');
// 清除之前的定时器
if (reconnectedTimerRef.current) {
@@ -600,116 +647,181 @@ export const NotificationProvider = ({ children }) => {
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
}, 2000);
} else {
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
}
// 如果使用 mock可以启动定期推送
if (SOCKET_TYPE === 'MOCK') {
// 启动模拟推送:使用配置的间隔和数量
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
socket.startMockPush(interval, maxBatch);
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;');
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType: 'all',
importance: 'all',
onSubscribed: (data) => {
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[NotificationContext] 订阅确认:', data);
logger.info('NotificationContext', 'Events subscribed', data);
},
});
} else {
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用');
}
});
// ========== 监听断开连接 ==========
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket disconnected', { reason });
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
});
// 监听连接错误
// ========== 监听连接错误 ==========
socket.on('connect_error', (error) => {
logger.error('NotificationContext', 'Socket connect_error', error);
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
// 获取重连次数Real 和 Mock 都支持)
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', 'Reconnection attempt', { attempts, socketType: SOCKET_TYPE });
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
});
// 监听重连失败
// ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', 'Socket reconnect_failed');
setConnectionStatus(CONNECTION_STATUS.FAILED);
console.error('[NotificationContext] ❌ 重连失败');
toast({
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
status: 'error',
duration: null, // 不自动关闭
duration: null,
isClosable: true,
});
});
// 监听新事件推送(统一事件名)
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
socket.on('new_event', (data) => {
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('[NotificationContext] 原始事件数据:', data);
console.log('[NotificationContext] 事件 ID:', data?.id);
console.log('[NotificationContext] 事件标题:', data?.title);
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type);
console.log('[NotificationContext] 事件重要性:', data?.importance);
logger.info('NotificationContext', 'Received new event', data);
// ⚠️ 防御性检查:确保 ref 已初始化
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;');
logger.error('NotificationContext', 'Refs not initialized', {
addNotificationRef: !!addNotificationRef.current,
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
});
return;
}
// ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type}_${data.publishTime}`;
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!data.id) {
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
eventId,
eventType: data.type,
title: data.title,
});
}
if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
return; // 重复事件,直接忽略
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
return;
}
// 记录已处理的事件ID
processedEventIds.current.add(eventId);
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
// 限制Set大小避免内存泄漏
// 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS
kept: MAX_PROCESSED_IDS,
});
}
// ========== Socket层去重检查结束 ==========
// 使用适配器转换事件格式
const notification = adaptEventToNotification(data);
addNotification(notification);
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
console.log('[NotificationContext] 正在转换事件格式...');
const notification = adaptEventToNotificationRef.current(data);
console.log('[NotificationContext] 转换后的通知对象:', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数
console.log('[NotificationContext] 准备添加通知到队列...');
addNotificationRef.current(notification);
console.log('[NotificationContext] ✅ 通知已添加到队列');
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
});
// 保留系统通知监听(兼容性)
// ========== 监听系统通知(兼容性) ==========
socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data);
addNotification(data);
console.log('[NotificationContext] 📢 收到系统通知:', data);
if (addNotificationRef.current) {
addNotificationRef.current(data);
} else {
console.error('[NotificationContext] ❌ addNotificationRef 未初始化');
}
});
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;');
// ✅ 第二步: 获取最大重连次数
// ========== 获取最大重连次数 ==========
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
setMaxReconnectAttempts(maxAttempts);
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// ✅ 第三步: 调用 socket.connect()
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
// ========== 启动连接 ==========
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;');
socket.connect();
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
// 清理函数
// ========== 清理函数(组件卸载时) ==========
return () => {
logger.info('NotificationContext', 'Cleaning up socket connection');
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
// 如果是 mock service停止推送
if (SOCKET_TYPE === 'MOCK') {
socket.stopMockPush();
// 清理 reconnected 状态定时器
if (reconnectedTimerRef.current) {
clearTimeout(reconnectedTimerRef.current);
reconnectedTimerRef.current = null;
}
// 清理所有通知的自动关闭定时器
notificationTimers.current.forEach((timerId, id) => {
clearTimeout(timerId);
logger.info('NotificationContext', 'Cleared timer during cleanup', { id });
});
notificationTimers.current.clear();
// 移除所有事件监听器
socket.off('connect');
socket.off('disconnect');
socket.off('connect_error');
socket.off('reconnect_failed');
socket.off('new_event');
socket.off('system_notification');
// 断开连接
socket.disconnect();
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;');
};
}, []); // 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
}, []); // ⚠️ 空依赖数组确保只执行一次
// ==================== 智能自动重试 ====================
@@ -720,11 +832,7 @@ export const NotificationProvider = ({ children }) => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && !isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
logger.info('NotificationContext', 'Tab refocused, attempting auto-reconnect');
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
socket.reconnect?.();
}
};
@@ -750,11 +858,7 @@ export const NotificationProvider = ({ children }) => {
isClosable: true,
});
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
socket.reconnect?.();
}
};
@@ -786,14 +890,137 @@ export const NotificationProvider = ({ children }) => {
const retryConnection = useCallback(() => {
logger.info('NotificationContext', 'Manual reconnection triggered');
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
socket.reconnect?.();
}, []);
/**
* 同步浏览器通知权限状态
* 场景:
* 1. 用户在其他标签页授权后返回
* 2. 用户在浏览器设置中修改权限
* 3. 页面长时间打开后权限状态变化
*/
useEffect(() => {
const checkPermission = () => {
const current = browserNotificationService.getPermissionStatus();
if (current !== browserPermission) {
logger.info('NotificationContext', 'Browser permission changed', {
old: browserPermission,
new: current
});
setBrowserPermission(current);
// 如果权限被授予,显示成功提示
if (current === 'granted' && browserPermission !== 'granted') {
toast({
title: '桌面通知已开启',
description: '您现在可以在后台接收重要通知',
status: 'success',
duration: 3000,
isClosable: true,
});
}
}
};
// 页面聚焦时检查
window.addEventListener('focus', checkPermission);
// 定期检查(可选,用于捕获浏览器设置中的变化)
const intervalId = setInterval(checkPermission, 30000); // 每30秒检查一次
return () => {
window.removeEventListener('focus', checkPermission);
clearInterval(intervalId);
};
}, [browserPermission, toast]);
// 🔧 开发环境调试:暴露方法到 window
useEffect(() => {
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_DEBUG === 'true') {
if (typeof window !== 'undefined') {
window.__TEST_NOTIFICATION__ = {
// 手动触发网页通知
testWebNotification: (type = 'event_alert', priority = 'normal') => {
console.log('%c[Debug] 手动触发网页通知', 'color: #FF9800; font-weight: bold;');
const testData = {
id: `test_${Date.now()}`,
type: type,
priority: priority,
title: '🧪 测试网页通知',
content: `这是一条测试${type === 'announcement' ? '公告' : type === 'stock_alert' ? '股票' : type === 'event_alert' ? '事件' : '分析'}通知 (优先级: ${priority})`,
timestamp: Date.now(),
clickable: true,
link: '/home',
};
console.log('测试数据:', testData);
addNotification(testData);
console.log('✅ 通知已添加到队列');
},
// 测试所有类型
testAllTypes: () => {
console.log('%c[Debug] 测试所有通知类型', 'color: #FF9800; font-weight: bold;');
const types = ['announcement', 'stock_alert', 'event_alert', 'analysis_report'];
types.forEach((type, i) => {
setTimeout(() => {
window.__TEST_NOTIFICATION__.testWebNotification(type, 'normal');
}, i * 2000); // 每 2 秒一个
});
},
// 测试所有优先级
testAllPriorities: () => {
console.log('%c[Debug] 测试所有优先级', 'color: #FF9800; font-weight: bold;');
const priorities = ['normal', 'important', 'urgent'];
priorities.forEach((priority, i) => {
setTimeout(() => {
window.__TEST_NOTIFICATION__.testWebNotification('event_alert', priority);
}, i * 2000);
});
},
// 帮助
help: () => {
console.log('\n%c=== 网页通知测试 API ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
console.log('\n%c基础用法:', 'color: #2196F3; font-weight: bold;');
console.log(' window.__TEST_NOTIFICATION__.testWebNotification(type, priority)');
console.log('\n%c参数说明:', 'color: #2196F3; font-weight: bold;');
console.log(' type (通知类型):');
console.log(' - "announcement" 公告通知(蓝色)');
console.log(' - "stock_alert" 股票动向(红色/绿色)');
console.log(' - "event_alert" 事件动向(橙色)');
console.log(' - "analysis_report" 分析报告(紫色)');
console.log('\n priority (优先级):');
console.log(' - "normal" 普通15秒自动关闭');
console.log(' - "important" 重要30秒自动关闭');
console.log(' - "urgent" 紧急(不自动关闭)');
console.log('\n%c示例:', 'color: #4CAF50; font-weight: bold;');
console.log(' // 测试紧急事件通知');
console.log(' window.__TEST_NOTIFICATION__.testWebNotification("event_alert", "urgent")');
console.log('\n // 测试所有类型');
console.log(' window.__TEST_NOTIFICATION__.testAllTypes()');
console.log('\n // 测试所有优先级');
console.log(' window.__TEST_NOTIFICATION__.testAllPriorities()');
console.log('\n');
}
};
console.log('[NotificationContext] 🔧 调试 API 已加载: window.__TEST_NOTIFICATION__');
console.log('[NotificationContext] 💡 使用 window.__TEST_NOTIFICATION__.help() 查看帮助');
}
}
// 清理函数
return () => {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
delete window.__TEST_NOTIFICATION__;
}
};
}, [addNotification]); // 依赖 addNotification 函数
const value = {
notifications,
isConnected,

253
src/devtools/apiDebugger.js Normal file
View File

@@ -0,0 +1,253 @@
// src/debug/apiDebugger.js
/**
* API 调试工具
* 生产环境临时调试使用,后期可整体删除 src/debug/ 目录
*/
import axios from 'axios';
import { getApiBase } from '@utils/apiConfig';
class ApiDebugger {
constructor() {
this.requestLog = [];
this.maxLogSize = 100;
this.isLogging = true;
}
/**
* 初始化 Axios 拦截器
*/
init() {
// 请求拦截器
axios.interceptors.request.use(
(config) => {
if (this.isLogging) {
const logEntry = {
type: 'request',
timestamp: new Date().toISOString(),
method: config.method.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
fullURL: this._getFullURL(config),
headers: config.headers,
data: config.data,
params: config.params,
};
this._addLog(logEntry);
console.log(
`%c[API Request] ${logEntry.method} ${logEntry.fullURL}`,
'color: #2196F3; font-weight: bold;',
{
headers: config.headers,
data: config.data,
params: config.params,
}
);
}
return config;
},
(error) => {
console.error('[API Request Error]', error);
return Promise.reject(error);
}
);
// 响应拦截器
axios.interceptors.response.use(
(response) => {
if (this.isLogging) {
const logEntry = {
type: 'response',
timestamp: new Date().toISOString(),
method: response.config.method.toUpperCase(),
url: response.config.url,
fullURL: this._getFullURL(response.config),
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
};
this._addLog(logEntry);
console.log(
`%c[API Response] ${logEntry.method} ${logEntry.fullURL} - ${logEntry.status}`,
'color: #4CAF50; font-weight: bold;',
{
status: response.status,
data: response.data,
headers: response.headers,
}
);
}
return response;
},
(error) => {
if (this.isLogging) {
const logEntry = {
type: 'error',
timestamp: new Date().toISOString(),
method: error.config?.method?.toUpperCase() || 'UNKNOWN',
url: error.config?.url || 'UNKNOWN',
fullURL: error.config ? this._getFullURL(error.config) : 'UNKNOWN',
status: error.response?.status,
statusText: error.response?.statusText,
message: error.message,
data: error.response?.data,
};
this._addLog(logEntry);
console.error(
`%c[API Error] ${logEntry.method} ${logEntry.fullURL}`,
'color: #F44336; font-weight: bold;',
{
status: error.response?.status,
message: error.message,
data: error.response?.data,
}
);
}
return Promise.reject(error);
}
);
console.log('%c[API Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
}
/**
* 获取完整 URL
*/
_getFullURL(config) {
const baseURL = config.baseURL || '';
const url = config.url || '';
const fullURL = baseURL + url;
// 添加查询参数
if (config.params) {
const params = new URLSearchParams(config.params).toString();
return params ? `${fullURL}?${params}` : fullURL;
}
return fullURL;
}
/**
* 添加日志
*/
_addLog(entry) {
this.requestLog.unshift(entry);
if (this.requestLog.length > this.maxLogSize) {
this.requestLog = this.requestLog.slice(0, this.maxLogSize);
}
}
/**
* 获取所有日志
*/
getLogs(type = 'all') {
if (type === 'all') {
return this.requestLog;
}
return this.requestLog.filter((log) => log.type === type);
}
/**
* 清空日志
*/
clearLogs() {
this.requestLog = [];
console.log('[API Debugger] Logs cleared');
}
/**
* 导出日志为 JSON
*/
exportLogs() {
const blob = new Blob([JSON.stringify(this.requestLog, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `api-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('[API Debugger] Logs exported');
}
/**
* 打印日志统计
*/
printStats() {
const stats = {
total: this.requestLog.length,
requests: this.requestLog.filter((log) => log.type === 'request').length,
responses: this.requestLog.filter((log) => log.type === 'response').length,
errors: this.requestLog.filter((log) => log.type === 'error').length,
};
console.table(stats);
return stats;
}
/**
* 手动发送 API 请求(测试用)
*/
async testRequest(method, endpoint, data = null, config = {}) {
const apiBase = getApiBase();
const url = `${apiBase}${endpoint}`;
console.log(`[API Debugger] Testing ${method.toUpperCase()} ${url}`);
try {
const response = await axios({
method,
url,
data,
...config,
});
console.log('[API Debugger] Test succeeded:', response.data);
return response.data;
} catch (error) {
console.error('[API Debugger] Test failed:', error);
throw error;
}
}
/**
* 开启/关闭日志记录
*/
toggleLogging(enabled) {
this.isLogging = enabled;
console.log(`[API Debugger] Logging ${enabled ? 'enabled' : 'disabled'}`);
}
/**
* 获取最近的错误
*/
getRecentErrors(count = 10) {
return this.requestLog.filter((log) => log.type === 'error').slice(0, count);
}
/**
* 按 URL 过滤日志
*/
getLogsByURL(urlPattern) {
return this.requestLog.filter((log) => log.url && log.url.includes(urlPattern));
}
/**
* 按状态码过滤日志
*/
getLogsByStatus(status) {
return this.requestLog.filter((log) => log.status === status);
}
}
// 导出单例
export const apiDebugger = new ApiDebugger();
export default apiDebugger;

271
src/devtools/index.js Normal file
View File

@@ -0,0 +1,271 @@
// src/debug/index.js
/**
* 调试工具统一入口
*
* 使用方法:
* 1. 开启调试: 在 .env.production 中设置 REACT_APP_ENABLE_DEBUG=true
* 2. 使用控制台命令: window.__DEBUG__.api.getLogs()
* 3. 后期移除: 删除整个 src/debug/ 目录 + 从 src/index.js 移除导入
*
* 全局 API:
* - window.__DEBUG__ - 调试 API 主对象
* - window.__DEBUG__.api - API 调试工具
* - window.__DEBUG__.notification - 通知调试工具
* - window.__DEBUG__.socket - Socket 调试工具
* - window.__DEBUG__.help() - 显示帮助信息
* - window.__DEBUG__.exportAll() - 导出所有日志
*/
import { apiDebugger } from './apiDebugger';
import { notificationDebugger } from './notificationDebugger';
import { socketDebugger } from './socketDebugger';
class DebugToolkit {
constructor() {
this.api = apiDebugger;
this.notification = notificationDebugger;
this.socket = socketDebugger;
}
/**
* 初始化所有调试工具
*/
init() {
console.log(
'%c╔════════════════════════════════════════════════════════════════╗',
'color: #FF9800; font-weight: bold;'
);
console.log(
'%c║ 🔧 调试模式已启用 (Debug Mode Enabled) ║',
'color: #FF9800; font-weight: bold;'
);
console.log(
'%c╚════════════════════════════════════════════════════════════════╝',
'color: #FF9800; font-weight: bold;'
);
console.log('');
// 初始化各个调试工具
this.api.init();
this.notification.init();
this.socket.init();
// 暴露到全局
window.__DEBUG__ = this;
// 打印帮助信息
this._printWelcome();
}
/**
* 打印欢迎信息
*/
_printWelcome() {
console.log('%c📚 调试工具使用指南:', 'color: #2196F3; font-weight: bold; font-size: 14px;');
console.log('');
console.log('%c1⃣ API 调试:', 'color: #2196F3; font-weight: bold;');
console.log(' __DEBUG__.api.getLogs() - 获取所有 API 日志');
console.log(' __DEBUG__.api.getRecentErrors() - 获取最近的错误');
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
console.log('');
console.log('%c2⃣ 通知调试:', 'color: #9C27B0; font-weight: bold;');
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
console.log(' __DEBUG__.notification.forceNotification() - 发送测试浏览器通知');
console.log(' __DEBUG__.notification.testWebNotification(type, priority) - 测试网页通知 🆕');
console.log(' __DEBUG__.notification.testAllNotificationTypes() - 测试所有类型 🆕');
console.log(' __DEBUG__.notification.testAllNotificationPriorities() - 测试所有优先级 🆕');
console.log(' __DEBUG__.notification.checkPermission() - 检查通知权限');
console.log(' __DEBUG__.notification.exportLogs() - 导出通知日志');
console.log('');
console.log('%c3⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
console.log(' __DEBUG__.socket.reconnect() - 手动重连');
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
console.log('');
console.log('%c4⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
console.log(' __DEBUG__.help() - 显示帮助信息');
console.log(' __DEBUG__.exportAll() - 导出所有日志');
console.log(' __DEBUG__.printStats() - 打印所有统计信息');
console.log(' __DEBUG__.clearAll() - 清空所有日志');
console.log('');
console.log(
'%c⚠ 警告: 调试模式会记录所有 API 请求和响应,请勿在生产环境长期开启!',
'color: #F44336; font-weight: bold;'
);
console.log('');
}
/**
* 显示帮助信息
*/
help() {
this._printWelcome();
}
/**
* 导出所有日志
*/
exportAll() {
console.log('[Debug Toolkit] Exporting all logs...');
const allLogs = {
timestamp: new Date().toISOString(),
api: this.api.getLogs(),
notification: this.notification.getLogs(),
socket: this.socket.getLogs(),
};
const blob = new Blob([JSON.stringify(allLogs, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `debug-all-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('[Debug Toolkit] ✅ All logs exported');
}
/**
* 打印所有统计信息
*/
printStats() {
console.log('\n%c=== 📊 调试统计信息 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
const apiStats = this.api.printStats();
console.log('\n%c[通知统计]', 'color: #9C27B0; font-weight: bold;');
const notificationStats = this.notification.printStats();
console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;');
const socketStats = this.socket.printStats();
return {
api: apiStats,
notification: notificationStats,
socket: socketStats,
};
}
/**
* 清空所有日志
*/
clearAll() {
console.log('[Debug Toolkit] Clearing all logs...');
this.api.clearLogs();
this.notification.clearLogs();
this.socket.clearLogs();
console.log('[Debug Toolkit] ✅ All logs cleared');
}
/**
* 快速诊断(检查所有系统状态)
*/
diagnose() {
console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
// 1. Socket 状态
console.log('\n%c[1/3] Socket 状态', 'color: #00BCD4; font-weight: bold;');
const socketStatus = this.socket.getStatus();
// 2. 通知权限
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;');
const notificationStatus = this.notification.checkPermission();
// 3. API 错误
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
const recentErrors = this.api.getRecentErrors(5);
if (recentErrors.length > 0) {
console.table(
recentErrors.map((err) => ({
时间: err.timestamp,
方法: err.method,
URL: err.url,
状态码: err.status,
错误信息: err.message,
}))
);
} else {
console.log('✅ 没有 API 错误');
}
// 4. 汇总报告
const report = {
timestamp: new Date().toISOString(),
socket: socketStatus,
notification: notificationStatus,
apiErrors: recentErrors.length,
};
console.log('\n%c=== 诊断报告 ===', 'color: #4CAF50; font-weight: bold;');
console.table(report);
return report;
}
/**
* 性能监控
*/
performance() {
console.log('\n%c=== ⚡ 性能监控 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
// 计算 API 平均响应时间
const apiLogs = this.api.getLogs();
const responseTimes = [];
for (let i = 0; i < apiLogs.length - 1; i++) {
const log = apiLogs[i];
const prevLog = apiLogs[i + 1];
if (
log.type === 'response' &&
prevLog.type === 'request' &&
log.url === prevLog.url
) {
const responseTime =
new Date(log.timestamp).getTime() - new Date(prevLog.timestamp).getTime();
responseTimes.push({
url: log.url,
method: log.method,
time: responseTime,
});
}
}
if (responseTimes.length > 0) {
const avgTime =
responseTimes.reduce((sum, item) => sum + item.time, 0) / responseTimes.length;
const maxTime = Math.max(...responseTimes.map((item) => item.time));
const minTime = Math.min(...responseTimes.map((item) => item.time));
console.log('API 响应时间统计:');
console.table({
平均响应时间: `${avgTime.toFixed(2)}ms`,
最快响应: `${minTime}ms`,
最慢响应: `${maxTime}ms`,
请求总数: responseTimes.length,
});
// 显示最慢的 5 个请求
console.log('\n最慢的 5 个请求:');
const slowest = responseTimes.sort((a, b) => b.time - a.time).slice(0, 5);
console.table(
slowest.map((item) => ({
方法: item.method,
URL: item.url,
响应时间: `${item.time}ms`,
}))
);
} else {
console.log('暂无性能数据');
}
}
}
// 导出单例
export const debugToolkit = new DebugToolkit();
export default debugToolkit;

View File

@@ -0,0 +1,204 @@
// src/debug/notificationDebugger.js
/**
* 通知系统调试工具
* 扩展现有的 window.__NOTIFY_DEBUG__添加更多生产环境调试能力
*/
import { browserNotificationService } from '@services/browserNotificationService';
class NotificationDebugger {
constructor() {
this.eventLog = [];
this.maxLogSize = 100;
}
/**
* 初始化调试工具
*/
init() {
console.log('%c[Notification Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
}
/**
* 记录通知事件
*/
logEvent(eventType, data) {
const logEntry = {
type: eventType,
timestamp: new Date().toISOString(),
data,
};
this.eventLog.unshift(logEntry);
if (this.eventLog.length > this.maxLogSize) {
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
}
console.log(
`%c[Notification Event] ${eventType}`,
'color: #9C27B0; font-weight: bold;',
data
);
}
/**
* 获取所有事件日志
*/
getLogs() {
return this.eventLog;
}
/**
* 清空日志
*/
clearLogs() {
this.eventLog = [];
console.log('[Notification Debugger] Logs cleared');
}
/**
* 导出日志
*/
exportLogs() {
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notification-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('[Notification Debugger] Logs exported');
}
/**
* 强制发送浏览器通知(测试用)
*/
forceNotification(options = {}) {
const defaultOptions = {
title: '🧪 测试通知',
body: `测试时间: ${new Date().toLocaleString()}`,
tag: `test_${Date.now()}`,
requireInteraction: false,
autoClose: 5000,
};
const finalOptions = { ...defaultOptions, ...options };
console.log('[Notification Debugger] Sending test notification:', finalOptions);
const notification = browserNotificationService.sendNotification(finalOptions);
if (notification) {
console.log('[Notification Debugger] ✅ Notification sent successfully');
} else {
console.error('[Notification Debugger] ❌ Failed to send notification');
}
return notification;
}
/**
* 检查通知权限状态
*/
checkPermission() {
const permission = browserNotificationService.getPermissionStatus();
const isSupported = browserNotificationService.isSupported();
const status = {
supported: isSupported,
permission,
canSend: isSupported && permission === 'granted',
};
console.table(status);
return status;
}
/**
* 请求通知权限
*/
async requestPermission() {
console.log('[Notification Debugger] Requesting notification permission...');
const result = await browserNotificationService.requestPermission();
console.log(`[Notification Debugger] Permission result: ${result}`);
return result;
}
/**
* 打印事件统计
*/
printStats() {
const stats = {
total: this.eventLog.length,
byType: {},
};
this.eventLog.forEach((log) => {
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
});
console.log('=== Notification Stats ===');
console.table(stats.byType);
console.log(`Total events: ${stats.total}`);
return stats;
}
/**
* 按类型过滤日志
*/
getLogsByType(eventType) {
return this.eventLog.filter((log) => log.type === eventType);
}
/**
* 获取最近的事件
*/
getRecentEvents(count = 10) {
return this.eventLog.slice(0, count);
}
/**
* 测试网页通知(需要 window.__TEST_NOTIFICATION__ 可用)
*/
testWebNotification(type = 'event_alert', priority = 'normal') {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
console.log('[Notification Debugger] 调用测试 API');
window.__TEST_NOTIFICATION__.testWebNotification(type, priority);
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
console.error('💡 请确保:');
console.error(' 1. REACT_APP_ENABLE_DEBUG=true');
console.error(' 2. NotificationContext 已加载');
console.error(' 3. 页面已刷新');
}
}
/**
* 测试所有通知类型
*/
testAllNotificationTypes() {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
window.__TEST_NOTIFICATION__.testAllTypes();
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
}
}
/**
* 测试所有优先级
*/
testAllNotificationPriorities() {
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
window.__TEST_NOTIFICATION__.testAllPriorities();
} else {
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
}
}
}
// 导出单例
export const notificationDebugger = new NotificationDebugger();
export default notificationDebugger;

View File

@@ -0,0 +1,194 @@
// src/debug/socketDebugger.js
/**
* Socket 调试工具
* 扩展现有的 window.__SOCKET_DEBUG__添加更多生产环境调试能力
*/
import { socket } from '@services/socket';
class SocketDebugger {
constructor() {
this.eventLog = [];
this.maxLogSize = 100;
}
/**
* 初始化调试工具
*/
init() {
// 监听所有 Socket 事件
this._attachEventListeners();
console.log('%c[Socket Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
}
/**
* 附加事件监听器
*/
_attachEventListeners() {
const events = [
'connect',
'disconnect',
'connect_error',
'reconnect',
'reconnect_failed',
'new_event',
'system_notification',
];
events.forEach((event) => {
socket.on(event, (data) => {
this.logEvent(event, data);
});
});
}
/**
* 记录 Socket 事件
*/
logEvent(eventType, data) {
const logEntry = {
type: eventType,
timestamp: new Date().toISOString(),
data,
};
this.eventLog.unshift(logEntry);
if (this.eventLog.length > this.maxLogSize) {
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
}
console.log(
`%c[Socket Event] ${eventType}`,
'color: #00BCD4; font-weight: bold;',
data
);
}
/**
* 获取所有事件日志
*/
getLogs() {
return this.eventLog;
}
/**
* 清空日志
*/
clearLogs() {
this.eventLog = [];
console.log('[Socket Debugger] Logs cleared');
}
/**
* 导出日志
*/
exportLogs() {
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `socket-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('[Socket Debugger] Logs exported');
}
/**
* 获取连接状态
*/
getStatus() {
const status = {
connected: socket.connected || false,
type: window.SOCKET_TYPE || 'UNKNOWN',
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
};
console.table(status);
return status;
}
/**
* 手动触发连接
*/
connect() {
console.log('[Socket Debugger] Manually connecting...');
socket.connect();
}
/**
* 手动断开连接
*/
disconnect() {
console.log('[Socket Debugger] Manually disconnecting...');
socket.disconnect();
}
/**
* 手动重连
*/
reconnect() {
console.log('[Socket Debugger] Manually reconnecting...');
socket.disconnect();
setTimeout(() => {
socket.connect();
}, 1000);
}
/**
* 发送测试事件
*/
emitTest(eventName, data = {}) {
console.log(`[Socket Debugger] Emitting test event: ${eventName}`, data);
socket.emit(eventName, data);
}
/**
* 打印事件统计
*/
printStats() {
const stats = {
total: this.eventLog.length,
byType: {},
};
this.eventLog.forEach((log) => {
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
});
console.log('=== Socket Stats ===');
console.table(stats.byType);
console.log(`Total events: ${stats.total}`);
return stats;
}
/**
* 按类型过滤日志
*/
getLogsByType(eventType) {
return this.eventLog.filter((log) => log.type === eventType);
}
/**
* 获取最近的事件
*/
getRecentEvents(count = 10) {
return this.eventLog.slice(0, count);
}
/**
* 获取错误事件
*/
getErrors() {
return this.eventLog.filter(
(log) => log.type === 'connect_error' || log.type === 'reconnect_failed'
);
}
}
// 导出单例
export const socketDebugger = new SocketDebugger();
export default socketDebugger;

View File

@@ -6,9 +6,29 @@ import { BrowserRouter as Router } from 'react-router-dom';
import './styles/brainwave.css';
import './styles/brainwave-colors.css';
// 导入 Bytedesk 客服系统 z-index 覆盖样式(必须在所有样式之后导入)
import './styles/bytedesk-override.css';
// Import the main App component
import App from './App';
// 导入通知服务并挂载到 window用于调试
import { browserNotificationService } from './services/browserNotificationService';
window.browserNotificationService = browserNotificationService;
// 🔧 条件导入调试工具(生产环境可选)
// 开启方式: 在 .env 文件中设置 REACT_APP_ENABLE_DEBUG=true
// 移除方式: 删除此段代码 + 删除 src/devtools/ 目录
if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
import('./devtools').then(({ debugToolkit }) => {
debugToolkit.init();
console.log(
'%c✅ 调试工具已加载!使用 window.__DEBUG__.help() 查看命令',
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
});
}
// 注册 Service Worker用于支持浏览器通知
function registerServiceWorker() {
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
@@ -27,24 +47,64 @@ function registerServiceWorker() {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => {
console.log('[App] Service Worker registered successfully:', registration.scope);
console.log('[App] Service Worker 注册成功');
console.log('[App] Scope:', registration.scope);
// 监听更新
// 检查当前激活状态
if (navigator.serviceWorker.controller) {
console.log('[App] ✅ Service Worker 已激活并控制页面');
} else {
console.log('[App] ⏳ Service Worker 已注册,等待激活...');
console.log('[App] 💡 刷新页面以激活 Service Worker');
// 监听 controller 变化Service Worker 激活后触发)
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[App] ✅ Service Worker 控制器已更新');
});
}
// 监听 Service Worker 更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('[App] Service Worker update found');
console.log('[App] 🔄 发现 Service Worker 更新');
if (newWorker) {
newWorker.addEventListener('statechange', () => {
console.log(`[App] Service Worker 状态: ${newWorker.state}`);
if (newWorker.state === 'activated') {
console.log('[App] Service Worker activated');
console.log('[App] Service Worker 已激活');
// 如果有旧的 Service Worker 在控制页面,提示用户刷新
if (navigator.serviceWorker.controller) {
console.log('[App] 💡 Service Worker 已更新,建议刷新页面');
}
}
});
}
});
})
.catch((error) => {
console.error('[App] Service Worker registration failed:', error);
console.error('[App] Service Worker 注册失败');
console.error('[App] 错误类型:', error.name);
console.error('[App] 错误信息:', error.message);
console.error('[App] 完整错误:', error);
// 额外检查:验证文件是否可访问
fetch('/service-worker.js', { method: 'HEAD' })
.then(response => {
if (response.ok) {
console.error('[App] Service Worker 文件存在但注册失败');
console.error('[App] 💡 可能的原因:');
console.error('[App] 1. Service Worker 文件有语法错误');
console.error('[App] 2. 浏览器不支持某些 Service Worker 特性');
console.error('[App] 3. HTTPS 证书问题Service Worker 需要 HTTPS');
} else {
console.error('[App] Service Worker 文件不存在HTTP', response.status, '');
}
})
.catch(fetchError => {
console.error('[App] 无法访问 Service Worker 文件:', fetchError.message);
});
});
});
} else {

View File

@@ -8,14 +8,14 @@ import RiskDisclaimer from '../components/RiskDisclaimer';
*/
const AppFooter = () => {
return (
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={2}>
<Container maxW="container.xl">
<VStack spacing={2}>
<VStack spacing={1}>
<RiskDisclaimer />
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<HStack spacing={1} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal

View File

@@ -217,7 +217,7 @@ export const REVENUE_EVENTS = {
};
// ============================================================================
// SPECIAL EVENTS (特殊事件) - Errors, performance, chatbot
// SPECIAL EVENTS (特殊事件) - Errors, performance
// ============================================================================
export const SPECIAL_EVENTS = {
// Errors
@@ -229,13 +229,6 @@ export const SPECIAL_EVENTS = {
PAGE_LOAD_TIME: 'Page Load Time',
API_RESPONSE_TIME: 'API Response Time',
// Chatbot (Dify)
CHATBOT_OPENED: 'Chatbot Opened',
CHATBOT_CLOSED: 'Chatbot Closed',
CHATBOT_MESSAGE_SENT: 'Chatbot Message Sent',
CHATBOT_MESSAGE_RECEIVED: 'Chatbot Message Received',
CHATBOT_FEEDBACK_PROVIDED: 'Chatbot Feedback Provided',
// Scroll depth
SCROLL_DEPTH_25: 'Scroll Depth 25%',
SCROLL_DEPTH_50: 'Scroll Depth 50%',

View File

@@ -1080,14 +1080,65 @@ export const eventHandlers = [
date.setDate(date.getDate() - daysAgo);
const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)];
const title = eventTitles[i % eventTitles.length];
// 带引用来源的研报数据
const researchReports = [
{
author: '中信证券',
report_title: `${title}深度研究报告`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 10) * 24 * 60 * 60 * 1000).toISOString()
},
{
author: '国泰君安',
report_title: `行业专题:${title}影响分析`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 15) * 24 * 60 * 60 * 1000).toISOString()
},
{
author: '华泰证券',
report_title: `${title}投资机会深度解析`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 20) * 24 * 60 * 60 * 1000).toISOString()
}
];
// 生成带引用标记的contentdata结构
const contentWithCitations = {
data: [
{
query_part: `${title}的详细描述。该事件对相关产业链产生重要影响【1】市场关注度高相关概念股表现活跃。`,
sentences: `根据券商研报分析,${title}将推动相关产业链快速发展【2】。预计未来${Math.floor(Math.random() * 2 + 1)}年内,相关企业营收增速有望达到${Math.floor(Math.random() * 30 + 20)}%以上【3】。该事件的影响范围广泛涉及多个细分领域投资机会显著。`,
match_score: importance >= 4 ? '好' : (importance >= 2 ? '中' : '一般'),
author: researchReports[0].author,
declare_date: researchReports[0].declare_date,
report_title: researchReports[0].report_title
},
{
query_part: `市场分析师认为该事件将带动产业链上下游企业协同发展【2】形成良性循环。`,
sentences: `从产业趋势来看,相关板块估值仍处于合理区间,具备较高的安全边际。机构投资者持续加仓相关标的,显示出对长期发展前景的看好。`,
match_score: importance >= 3 ? '好' : '中',
author: researchReports[1].author,
declare_date: researchReports[1].declare_date,
report_title: researchReports[1].report_title
},
{
query_part: `根据行业数据显示,受此事件影响,相关企业订单量同比增长${Math.floor(Math.random() * 40 + 30)}%【3】。`,
sentences: `行业景气度持续提升,龙头企业凭借技术优势和规模效应,市场份额有望进一步扩大。建议关注产业链核心环节的投资机会。`,
match_score: '好',
author: researchReports[2].author,
declare_date: researchReports[2].declare_date,
report_title: researchReports[2].report_title
}
]
};
events.push({
id: `hist_event_${i + 1}`,
title: eventTitles[i % eventTitles.length],
description: `${eventTitles[i % eventTitles.length]}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`,
title: title,
content: contentWithCitations, // 升级版本带引用来源的data结构
description: `${title}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, // 降级兼容
date: date.toISOString().split('T')[0],
importance: importance,
similarity: parseFloat((Math.random() * 0.3 + 0.7).toFixed(2)), // 0.7-1.0
similarity: Math.floor(Math.random() * 10) + 1, // 1-10
impact_sectors: [
['半导体', '芯片设计', 'EDA'],
['新能源汽车', '锂电池', '充电桩'],

View File

@@ -157,8 +157,8 @@ export const routeConfig = [
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: 'AI投资助手',
description: '基于MCP的智能投资顾问'
title: '价小前投研',
description: '北京价值前沿科技公司的AI投研聊天助手'
}
},
];

View File

@@ -30,6 +30,14 @@ class BrowserNotificationService {
return Notification.permission;
}
/**
* 检查是否有通知权限
* @returns {boolean}
*/
hasPermission() {
return this.isSupported() && Notification.permission === 'granted';
}
/**
* 请求通知权限
* @returns {Promise<string>} 权限状态
@@ -77,57 +85,99 @@ class BrowserNotificationService {
data = {},
autoClose = 0,
}) {
// 详细日志:检查支持状态
if (!this.isSupported()) {
logger.warn('browserNotificationService', 'Notifications not supported');
console.warn('[browserNotificationService] ❌ 浏览器不支持通知 API');
return null;
}
if (this.permission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted');
// 详细日志:检查权限状态
const currentPermission = Notification.permission;
console.log('[browserNotificationService] 当前权限状态:', currentPermission);
if (currentPermission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted', { permission: currentPermission });
console.warn(`[browserNotificationService] ❌ 权限未授予: ${currentPermission}`);
return null;
}
console.log('[browserNotificationService] ✅ 准备发送通知:', { title, body, tag, requireInteraction, autoClose });
try {
// 关闭相同 tag 的旧通知
if (tag && this.activeNotifications.has(tag)) {
const oldNotification = this.activeNotifications.get(tag);
oldNotification.close();
console.log('[browserNotificationService] 关闭旧通知:', tag);
}
// 创建通知
const finalTag = tag || `notification_${Date.now()}`;
console.log('[browserNotificationService] 创建通知对象...');
const notification = new Notification(title, {
body,
icon,
badge: '/badge.png',
tag: tag || `notification_${Date.now()}`,
tag: finalTag,
requireInteraction,
data,
silent: false, // 允许声音
});
console.log('[browserNotificationService] ✅ 通知对象已创建:', notification);
// 存储通知引用
if (tag) {
this.activeNotifications.set(tag, notification);
console.log('[browserNotificationService] 通知已存储到活跃列表');
}
// 自动关闭
if (autoClose > 0 && !requireInteraction) {
console.log(`[browserNotificationService] 设置自动关闭: ${autoClose}ms`);
setTimeout(() => {
notification.close();
console.log('[browserNotificationService] 通知已自动关闭');
}, autoClose);
}
// 通知关闭时清理引用
notification.onclose = () => {
console.log('[browserNotificationService] 通知被关闭:', finalTag);
if (tag) {
this.activeNotifications.delete(tag);
}
};
logger.info('browserNotificationService', 'Notification sent', { title, tag });
// 通知点击事件
notification.onclick = (event) => {
console.log('[browserNotificationService] 通知被点击:', finalTag, data);
};
// 通知显示事件
notification.onshow = () => {
console.log('[browserNotificationService] ✅ 通知已显示:', finalTag);
};
// 通知错误事件
notification.onerror = (error) => {
console.error('[browserNotificationService] ❌ 通知显示错误:', error);
};
logger.info('browserNotificationService', 'Notification sent', { title, tag: finalTag });
console.log('[browserNotificationService] ✅ 通知发送成功!');
return notification;
} catch (error) {
logger.error('browserNotificationService', 'sendNotification', error);
console.error('[browserNotificationService] ❌ 发送通知时发生错误:', error);
console.error('[browserNotificationService] 错误详情:', {
name: error.name,
message: error.message,
stack: error.stack
});
return null;
}
}

View File

@@ -1,916 +0,0 @@
// src/services/mockSocketService.js
/**
* Mock Socket 服务 - 用于开发环境模拟实时推送
* 模拟金融资讯、事件动向、分析报告等实时消息推送
*/
import { logger } from '../utils/logger';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../constants/notificationTypes';
// 模拟金融资讯数据
const mockFinancialNews = [
// ========== 公告通知 ==========
{
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '贵州茅台发布2024年度财报公告',
content: '2024年度营收同比增长15.2%净利润创历史新高董事会建议每10股派息180元',
publishTime: new Date('2024-03-28T15:30:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann001',
extra: {
announcementType: '财报',
companyCode: '600519',
companyName: '贵州茅台',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.URGENT,
title: '宁德时代发布重大资产重组公告',
content: '公司拟收购某新能源材料公司100%股权交易金额约120亿元预计增厚业绩20%',
publishTime: new Date('2024-03-28T09:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann002',
extra: {
announcementType: '重组',
companyCode: '300750',
companyName: '宁德时代',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
priority: PRIORITY_LEVELS.NORMAL,
title: '中国平安发布分红派息公告',
content: '2023年度利润分配方案每10股派发现金红利23.0元含税分红率达30.5%',
publishTime: new Date('2024-03-27T16:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann003',
extra: {
announcementType: '分红',
companyCode: '601318',
companyName: '中国平安',
},
autoClose: 10000,
},
// ========== 股票动向 ==========
{
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.URGENT,
title: '您关注的股票触发预警',
content: '宁德时代(300750) 当前价格 ¥245.50,盘中涨幅达 +5.2%,已触达您设置的目标价位',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/stock-overview?code=300750',
extra: {
stockCode: '300750',
stockName: '宁德时代',
priceChange: '+5.2%',
currentPrice: '245.50',
triggerType: '目标价',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '您关注的股票异常波动',
content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/stock-overview?code=002594',
extra: {
stockCode: '002594',
stockName: '比亚迪',
priceChange: '-3.8%',
currentPrice: '198.20',
triggerType: '异常波动',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '持仓股票表现',
content: '隆基绿能(601012) 今日表现优异,涨幅 +4.5%,您当前持仓浮盈 +¥8,200',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/trading-simulation',
extra: {
stockCode: '601012',
stockName: '隆基绿能',
priceChange: '+4.5%',
profit: '+8200',
},
autoClose: 8000,
},
// ========== 事件动向 ==========
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '央行宣布降准0.5个百分点',
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点释放长期资金约1万亿元利好股市',
publishTime: new Date('2024-03-28T09:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt001',
extra: {
eventId: 'evt001',
relatedStocks: 12,
impactLevel: '重大利好',
sectors: ['银行', '地产', '基建'],
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '新能源汽车补贴政策延期',
content: '财政部宣布新能源汽车购置补贴政策延长至2024年底涉及比亚迪、理想汽车等5家龙头企业',
publishTime: new Date('2024-03-28T10:30:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt002',
extra: {
eventId: 'evt002',
relatedStocks: 5,
impactLevel: '重大利好',
sectors: ['新能源汽车'],
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '芯片产业扶持政策出台',
content: '工信部发布《半导体产业发展指导意见》未来三年投入500亿专项资金支持芯片研发',
publishTime: new Date('2024-03-27T14:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt003',
extra: {
eventId: 'evt003',
relatedStocks: 8,
impactLevel: '中长期利好',
sectors: ['半导体', '芯片设计'],
},
autoClose: 10000,
},
// ========== 预测通知 ==========
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【预测】央行可能宣布降准政策',
content: '基于最新宏观数据分析预计央行将在本周宣布降准0.5个百分点,释放长期资金',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false, // ❌ 不可点击
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
relatedPredictionId: 'pred_001',
},
autoClose: 15000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【预测】新能源补贴政策或将延期',
content: '根据政策趋势分析财政部可能宣布新能源汽车购置补贴政策延长至2025年底',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false, // ❌ 不可点击
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
relatedPredictionId: 'pred_002',
},
autoClose: 15000,
},
// ========== 分析报告 ==========
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '医药行业深度报告:创新药迎来政策拐点',
content: 'CXO板块持续受益于全球创新药研发外包需求建议关注药明康德、凯莱英等龙头企业',
publishTime: new Date('2024-03-28T08:00:00').getTime(),
pushTime: Date.now(),
author: {
name: '李明',
organization: '中信证券',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=rpt001',
extra: {
reportType: '行业研报',
industry: '医药',
rating: '强烈推荐',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: 'AI产业链投资机会分析',
content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪',
publishTime: new Date('2024-03-28T07:30:00').getTime(),
pushTime: Date.now(),
author: {
name: '王芳',
organization: '招商证券',
},
isAIGenerated: true,
clickable: true,
link: '/forecast-report?id=rpt002',
extra: {
reportType: '策略报告',
industry: '人工智能',
rating: '推荐',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '比亚迪:新能源汽车龙头业绩持续超预期',
content: '2024年销量目标400万辆海外市场拓展顺利维持"买入"评级目标价280元',
publishTime: new Date('2024-03-27T09:00:00').getTime(),
pushTime: Date.now(),
author: {
name: '张伟',
organization: '国泰君安',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=rpt003',
extra: {
reportType: '公司研报',
industry: '新能源汽车',
rating: '买入',
targetPrice: '280',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '2024年A股市场展望结构性行情延续',
content: 'AI应用、高端制造、自主可控三大主线贯穿全年建议关注科技成长板块配置机会',
publishTime: new Date('2024-03-26T16:00:00').getTime(),
pushTime: Date.now(),
author: {
name: 'AI分析师',
organization: '价值前沿',
},
isAIGenerated: true,
clickable: true,
link: '/forecast-report?id=rpt004',
extra: {
reportType: '策略报告',
industry: '市场策略',
rating: '谨慎乐观',
},
autoClose: 10000,
},
];
class MockSocketService {
constructor() {
this.connected = false;
this.connecting = false; // 新增:正在连接标志,防止重复连接
this.listeners = new Map();
this.intervals = [];
this.messageQueue = [];
this.reconnectAttempts = 0;
this.customReconnectTimer = null;
this.failConnection = false; // 是否模拟连接失败
this.pushPaused = false; // 新增:暂停推送标志(保持连接)
}
/**
* 计算指数退避延迟Mock 模式使用更短的时间便于测试)
* 第1次: 10秒, 第2次: 20秒, 第3次: 40秒, 第4次及以后: 40秒
*/
getReconnectionDelay(attempt) {
const delays = [10000, 20000, 40000]; // 10s, 20s, 40s (缩短10倍便于测试)
const index = Math.min(attempt - 1, delays.length - 1);
return delays[index];
}
/**
* 连接到 mock socket
*/
connect() {
// ✅ 防止重复连接
if (this.connected) {
logger.warn('mockSocketService', 'Already connected');
console.log('%c[Mock Socket] Already connected, skipping', 'color: #FF9800; font-weight: bold;');
return;
}
if (this.connecting) {
logger.warn('mockSocketService', 'Connection in progress');
console.log('%c[Mock Socket] Connection already in progress, skipping', 'color: #FF9800; font-weight: bold;');
return;
}
this.connecting = true; // 标记为连接中
logger.info('mockSocketService', 'Connecting to mock socket service...');
console.log('%c[Mock Socket] 🔌 Connecting...', 'color: #2196F3; font-weight: bold;');
// 模拟连接延迟
setTimeout(() => {
// 检查是否应该模拟连接失败
if (this.failConnection) {
this.connecting = false; // 清除连接中标志
logger.warn('mockSocketService', 'Simulated connection failure');
console.log('%c[Mock Socket] ❌ Connection failed (simulated)', 'color: #F44336; font-weight: bold;');
// 触发连接错误事件
this.emit('connect_error', {
message: 'Mock connection error for testing',
timestamp: Date.now(),
});
// 安排下次重连(会继续失败,直到 failConnection 被清除)
this.scheduleReconnection();
return;
}
// 正常连接成功
this.connected = true;
this.connecting = false; // 清除连接中标志
this.reconnectAttempts = 0;
// 清除自定义重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
logger.info('mockSocketService', 'Mock socket connected successfully');
console.log('%c[Mock Socket] ✅ Connected successfully!', 'color: #4CAF50; font-weight: bold; font-size: 14px;');
console.log(`%c[Mock Socket] Status: connected=${this.connected}, connecting=${this.connecting}`, 'color: #4CAF50;');
// ✅ 使用 setTimeout(0) 确保监听器已注册后再触发事件
setTimeout(() => {
console.log('%c[Mock Socket] Emitting connect event...', 'color: #9C27B0;');
this.emit('connect', { timestamp: Date.now() });
console.log('%c[Mock Socket] Connect event emitted', 'color: #9C27B0;');
}, 0);
// 在连接后3秒发送欢迎消息
setTimeout(() => {
if (this.connected) {
this.emit('new_event', {
type: 'system_notification',
severity: 'info',
title: '连接成功',
message: '实时消息推送服务已启动 (Mock 模式)',
timestamp: Date.now(),
autoClose: 5000,
});
}
}, 3000);
}, 1000);
}
/**
* 断开连接
* @param {boolean} triggerReconnect - 是否触发自动重连(模拟意外断开)
*/
disconnect(triggerReconnect = false) {
if (!this.connected) {
return;
}
logger.info('mockSocketService', 'Disconnecting from mock socket service...');
// 清除所有定时器
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
this.pushPaused = false; // 重置暂停状态
const wasConnected = this.connected;
this.connected = false;
this.emit('disconnect', {
timestamp: Date.now(),
reason: triggerReconnect ? 'transport close' : 'io client disconnect'
});
// 如果需要触发重连(模拟意外断开)
if (triggerReconnect && wasConnected) {
this.scheduleReconnection();
} else {
// 清除重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
this.reconnectAttempts = 0;
}
}
/**
* 使用指数退避策略安排重连
*/
scheduleReconnection() {
// 清除之前的定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
}
this.reconnectAttempts++;
const delay = this.getReconnectionDelay(this.reconnectAttempts);
logger.info('mockSocketService', `Scheduling reconnection in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
// 触发 connect_error 事件通知UI
this.emit('connect_error', {
message: 'Mock connection error for testing',
timestamp: Date.now(),
});
this.customReconnectTimer = setTimeout(() => {
if (!this.connected) {
logger.info('mockSocketService', 'Attempting reconnection...', {
attempt: this.reconnectAttempts,
});
this.connect();
}
}, delay);
}
/**
* 手动重连
* @returns {boolean} 是否触发重连
*/
reconnect() {
if (this.connected) {
logger.info('mockSocketService', 'Already connected, no need to reconnect');
return false;
}
logger.info('mockSocketService', 'Manually triggering reconnection...');
// 清除自动重连定时器
if (this.customReconnectTimer) {
clearTimeout(this.customReconnectTimer);
this.customReconnectTimer = null;
}
// 重置重连计数
this.reconnectAttempts = 0;
// 立即触发重连
this.connect();
return true;
}
/**
* 模拟意外断线(测试用)
* @param {number} duration - 断线持续时间毫秒0表示需要手动重连
*/
simulateDisconnection(duration = 0) {
logger.info('mockSocketService', `Simulating disconnection${duration > 0 ? ` for ${duration}ms` : ' (manual reconnect required)'}...`);
if (duration > 0) {
// 短暂断线,自动重连
this.disconnect(true);
} else {
// 需要手动重连
this.disconnect(false);
}
}
/**
* 模拟持续连接失败(测试用)
* 连接会一直失败,直到调用 allowReconnection()
*/
simulateConnectionFailure() {
logger.info('mockSocketService', '🚫 Simulating persistent connection failure...');
logger.info('mockSocketService', 'Connection will keep failing until allowReconnection() is called');
// 设置失败标志
this.failConnection = true;
// 如果当前已连接,先断开并触发重连(会失败)
if (this.connected) {
this.disconnect(true);
} else {
// 如果未连接,直接触发一次连接尝试(会失败)
this.connect();
}
}
/**
* 允许重连成功(测试用)
* 清除连接失败标志,下次重连将会成功
*/
allowReconnection() {
logger.info('mockSocketService', '✅ Allowing reconnection to succeed...');
logger.info('mockSocketService', 'Next reconnection attempt will succeed');
// 清除失败标志
this.failConnection = false;
// 不立即重连,等待自动重连或手动重连
}
/**
* 监听事件
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
logger.info('mockSocketService', `Event listener added: ${event}`);
}
/**
* 移除事件监听
* @param {string} event - 事件名称
* @param {Function} callback - 回调函数
*/
off(event, callback) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
logger.info('mockSocketService', `Event listener removed: ${event}`);
}
// 如果没有监听器了,删除该事件
if (callbacks.length === 0) {
this.listeners.delete(event);
}
}
/**
* 触发事件
* @param {string} event - 事件名称
* @param {*} data - 事件数据
*/
emit(event, data) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('mockSocketService', 'emit', error, { event, data });
}
});
}
/**
* 启动模拟消息推送
* @param {number} interval - 推送间隔(毫秒)
* @param {number} burstCount - 每次推送的消息数量1-3条
*/
startMockPush(interval = 15000, burstCount = 1) {
if (!this.connected) {
logger.warn('mockSocketService', 'Cannot start mock push: not connected');
return;
}
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
const pushInterval = setInterval(() => {
// 检查是否暂停推送
if (this.pushPaused) {
logger.info('mockSocketService', '⏸️ Mock push is paused, skipping this cycle...');
return;
}
// 随机选择 1-burstCount 条消息
const count = Math.floor(Math.random() * burstCount) + 1;
for (let i = 0; i < count; i++) {
// 从模拟数据中随机选择一条
const randomIndex = Math.floor(Math.random() * mockFinancialNews.length);
const alert = {
...mockFinancialNews[randomIndex],
timestamp: Date.now(),
id: `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
};
// 延迟发送(模拟层叠效果)
setTimeout(() => {
this.emit('new_event', alert);
logger.info('mockSocketService', 'Mock notification sent', alert);
}, i * 500); // 每条消息间隔500ms
}
}, interval);
this.intervals.push(pushInterval);
}
/**
* 停止模拟推送
*/
stopMockPush() {
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
this.pushPaused = false; // 重置暂停状态
logger.info('mockSocketService', 'Mock push stopped');
}
/**
* 暂停自动推送(保持连接和定时器运行)
*/
pausePush() {
this.pushPaused = true;
logger.info('mockSocketService', '⏸️ Mock push paused (connection and intervals maintained)');
}
/**
* 恢复自动推送
*/
resumePush() {
this.pushPaused = false;
logger.info('mockSocketService', '▶️ Mock push resumed');
}
/**
* 查询推送暂停状态
* @returns {boolean} 是否已暂停
*/
isPushPaused() {
return this.pushPaused;
}
/**
* 手动触发一条测试消息
* @param {object} customData - 自定义消息数据(可选)
*/
sendTestNotification(customData = null) {
// 如果传入自定义数据,直接使用(向后兼容)
if (customData) {
this.emit('new_event', customData);
logger.info('mockSocketService', 'Custom test notification sent', customData);
return;
}
// 默认发送新格式的测试通知(符合当前通知系统规范)
const notification = {
type: 'announcement', // 公告通知类型
priority: 'important', // 重要优先级30秒自动关闭
title: '🧪 测试通知',
content: '这是一条手动触发的测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
id: `test_${Date.now()}`,
clickable: false,
};
this.emit('new_event', notification);
logger.info('mockSocketService', 'Test notification sent', notification);
}
/**
* 获取连接状态
*/
isConnected() {
return this.connected;
}
/**
* 获取当前重连尝试次数
*/
getReconnectAttempts() {
return this.reconnectAttempts;
}
/**
* 获取最大重连次数Mock 模式无限重试)
*/
getMaxReconnectAttempts() {
return Infinity;
}
/**
* 订阅事件推送Mock 实现)
* @param {object} options - 订阅选项
* @param {string} options.eventType - 事件类型 ('all' | 'policy' | 'market' | 'tech' | ...)
* @param {string} options.importance - 重要性 ('all' | 'S' | 'A' | 'B' | 'C')
* @param {Function} options.onNewEvent - 收到新事件时的回调函数
* @param {Function} options.onSubscribed - 订阅成功的回调函数(可选)
*/
subscribeToEvents(options = {}) {
const {
eventType = 'all',
importance = 'all',
onNewEvent,
onSubscribed,
} = options;
logger.info('mockSocketService', 'Subscribing to events', { eventType, importance });
// Mock: 立即触发订阅成功回调
if (onSubscribed) {
setTimeout(() => {
onSubscribed({
success: true,
event_type: eventType,
importance: importance,
message: 'Mock subscription confirmed'
});
}, 100);
}
// Mock: 如果提供了 onNewEvent 回调,监听 'new_event' 事件
if (onNewEvent) {
// 先移除之前的监听器(避免重复)
this.off('new_event', onNewEvent);
// 添加新的监听器
this.on('new_event', onNewEvent);
logger.info('mockSocketService', 'Event listener registered for new_event');
}
}
/**
* 取消订阅事件推送Mock 实现)
* @param {object} options - 取消订阅选项
* @param {string} options.eventType - 事件类型
* @param {Function} options.onUnsubscribed - 取消订阅成功的回调函数(可选)
*/
unsubscribeFromEvents(options = {}) {
const {
eventType = 'all',
onUnsubscribed,
} = options;
logger.info('mockSocketService', 'Unsubscribing from events', { eventType });
// Mock: 移除 new_event 监听器
this.off('new_event');
// Mock: 立即触发取消订阅成功回调
if (onUnsubscribed) {
setTimeout(() => {
onUnsubscribed({
success: true,
event_type: eventType,
message: 'Mock unsubscription confirmed'
});
}, 100);
}
}
/**
* 快捷方法订阅所有类型的事件Mock 实现)
* @param {Function} onNewEvent - 收到新事件时的回调函数
*/
subscribeToAllEvents(onNewEvent) {
this.subscribeToEvents({
eventType: 'all',
importance: 'all',
onNewEvent,
});
}
/**
* 快捷方法订阅指定重要性的事件Mock 实现)
* @param {string} importance - 重要性级别 ('S' | 'A' | 'B' | 'C')
* @param {Function} onNewEvent - 收到新事件时的回调函数
*/
subscribeToImportantEvents(importance, onNewEvent) {
this.subscribeToEvents({
eventType: 'all',
importance,
onNewEvent,
});
}
/**
* 快捷方法订阅指定类型的事件Mock 实现)
* @param {string} eventType - 事件类型
* @param {Function} onNewEvent - 收到新事件时的回调函数
*/
subscribeToEventType(eventType, onNewEvent) {
this.subscribeToEvents({
eventType,
importance: 'all',
onNewEvent,
});
}
}
// 导出单例
export const mockSocketService = new MockSocketService();
// 开发模式下添加全局测试函数
if (process.env.NODE_ENV === 'development') {
window.__mockSocket = {
// 模拟意外断线(自动重连成功)
simulateDisconnection: () => {
logger.info('mockSocketService', '🔌 Simulating disconnection (will auto-reconnect)...');
mockSocketService.simulateDisconnection(1); // 触发自动重连
},
// 模拟持续连接失败
simulateConnectionFailure: () => {
logger.info('mockSocketService', '🚫 Simulating connection failure (will keep retrying)...');
mockSocketService.simulateConnectionFailure();
},
// 允许重连成功
allowReconnection: () => {
logger.info('mockSocketService', '✅ Allowing next reconnection to succeed...');
mockSocketService.allowReconnection();
},
// 获取连接状态
isConnected: () => {
const connected = mockSocketService.isConnected();
logger.info('mockSocketService', `Connection status: ${connected ? '✅ Connected' : '❌ Disconnected'}`);
return connected;
},
// 手动重连
reconnect: () => {
logger.info('mockSocketService', '🔄 Manually triggering reconnection...');
return mockSocketService.reconnect();
},
// 获取重连尝试次数
getAttempts: () => {
const attempts = mockSocketService.getReconnectAttempts();
logger.info('mockSocketService', `Current reconnection attempts: ${attempts}`);
return attempts;
},
// 暂停自动推送(保持连接)
pausePush: () => {
mockSocketService.pausePush();
logger.info('mockSocketService', '⏸️ Auto push paused');
return true;
},
// 恢复自动推送
resumePush: () => {
mockSocketService.resumePush();
logger.info('mockSocketService', '▶️ Auto push resumed');
return true;
},
// 查看推送暂停状态
isPushPaused: () => {
const paused = mockSocketService.isPushPaused();
logger.info('mockSocketService', `Push status: ${paused ? '⏸️ Paused' : '▶️ Active'}`);
return paused;
},
};
logger.info('mockSocketService', '💡 Mock Socket test functions available:');
logger.info('mockSocketService', ' __mockSocket.simulateDisconnection() - 模拟断线(自动重连成功)');
logger.info('mockSocketService', ' __mockSocket.simulateConnectionFailure() - 模拟连接失败(持续失败)');
logger.info('mockSocketService', ' __mockSocket.allowReconnection() - 允许重连成功');
logger.info('mockSocketService', ' __mockSocket.isConnected() - 查看连接状态');
logger.info('mockSocketService', ' __mockSocket.reconnect() - 手动重连');
logger.info('mockSocketService', ' __mockSocket.getAttempts() - 查看重连次数');
logger.info('mockSocketService', ' __mockSocket.pausePush() - ⏸️ 暂停自动推送(保持连接)');
logger.info('mockSocketService', ' __mockSocket.resumePush() - ▶️ 恢复自动推送');
logger.info('mockSocketService', ' __mockSocket.isPushPaused() - 查看推送状态');
}
export default mockSocketService;

View File

@@ -1,364 +1,34 @@
// src/services/socket/index.js
/**
* Socket 服务统一导出
* 根据环境变量自动选择使用 Mock 或真实 Socket.IO 服务
* 使用真实 Socket.IO 服务连接后端
*/
import { mockSocketService } from '../mockSocketService';
import { socketService } from '../socketService';
// 判断是否使用 Mock
const useMock = process.env.REACT_APP_ENABLE_MOCK === 'true' || process.env.REACT_APP_USE_MOCK_SOCKET === 'true';
// 导出 socket 服务
export const socket = socketService;
export { socketService };
// 根据环境选择服务
export const socket = useMock ? mockSocketService : socketService;
// ⚡ 新增:暴露 Socket 实例到 window用于调试和验证
if (typeof window !== 'undefined') {
window.socket = socketService;
window.socketService = socketService;
// 同时导出两个服务,方便测试和调试
export { mockSocketService, socketService };
// 导出服务类型标识
export const SOCKET_TYPE = useMock ? 'MOCK' : 'REAL';
console.log(
'%c[Socket Service] ✅ Socket instance exposed to window',
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
console.log(' 📍 window.socket:', window.socket);
console.log(' 📍 window.socketService:', window.socketService);
console.log(' 📍 Socket.IO instance:', window.socket?.socket);
console.log(' 📍 Connection status:', window.socket?.connected ? '✅ Connected' : '❌ Disconnected');
}
// 打印当前使用的服务类型
console.log(
`%c[Socket Service] Using ${SOCKET_TYPE} Socket Service`,
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
'%c[Socket Service] Using REAL Socket Service',
'color: #4CAF50; font-weight: bold; font-size: 12px;'
);
// ========== 暴露调试 API 到全局 ==========
if (typeof window !== 'undefined') {
// 暴露 Socket 类型到全局
window.SOCKET_TYPE = SOCKET_TYPE;
// 暴露调试 API
window.__SOCKET_DEBUG__ = {
// 获取当前连接状态
getStatus: () => {
const isConnected = socket.connected || false;
return {
type: SOCKET_TYPE,
connected: isConnected,
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
service: useMock ? 'mockSocketService' : 'socketService',
};
},
// 手动重连
reconnect: () => {
console.log('[Socket Debug] Manual reconnect triggered');
if (socket.reconnect) {
socket.reconnect();
} else {
socket.disconnect();
socket.connect();
}
},
// 断开连接
disconnect: () => {
console.log('[Socket Debug] Manual disconnect triggered');
socket.disconnect();
},
// 连接
connect: () => {
console.log('[Socket Debug] Manual connect triggered');
socket.connect();
},
// 获取服务实例 (仅用于调试)
getService: () => socket,
// 导出诊断信息
exportDiagnostics: () => {
const status = window.__SOCKET_DEBUG__.getStatus();
const diagnostics = {
...status,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
env: {
NODE_ENV: process.env.NODE_ENV,
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
REACT_APP_ENV: process.env.REACT_APP_ENV,
},
};
console.log('[Socket Diagnostics]', diagnostics);
return diagnostics;
},
// 手动订阅事件
subscribe: (options = {}) => {
const { eventType = 'all', importance = 'all' } = options;
console.log(`[Socket Debug] Subscribing to events: type=${eventType}, importance=${importance}`);
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType,
importance,
onNewEvent: (event) => {
console.log('[Socket Debug] ✅ New event received:', event);
},
onSubscribed: (data) => {
console.log('[Socket Debug] ✅ Subscription confirmed:', data);
},
});
} else {
console.error('[Socket Debug] ❌ subscribeToEvents method not available');
}
},
// 测试连接质量
testConnection: () => {
console.log('[Socket Debug] Testing connection...');
const start = Date.now();
if (socket.emit) {
socket.emit('ping', { timestamp: start }, (response) => {
const latency = Date.now() - start;
console.log(`[Socket Debug] ✅ Connection OK - Latency: ${latency}ms`, response);
});
} else {
console.error('[Socket Debug] ❌ Cannot test connection - socket.emit not available');
}
},
// 检查配置是否正确
checkConfig: () => {
const config = {
socketType: SOCKET_TYPE,
useMock,
envVars: {
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
NODE_ENV: process.env.NODE_ENV,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
},
socketMethods: {
connect: typeof socket.connect,
disconnect: typeof socket.disconnect,
on: typeof socket.on,
emit: typeof socket.emit,
subscribeToEvents: typeof socket.subscribeToEvents,
},
};
console.log('[Socket Debug] Configuration Check:', config);
// 检查潜在问题
const issues = [];
if (SOCKET_TYPE === 'MOCK' && process.env.NODE_ENV === 'production') {
issues.push('⚠️ WARNING: Using MOCK socket in production!');
}
if (!socket.subscribeToEvents) {
issues.push('❌ ERROR: subscribeToEvents method missing');
}
if (issues.length > 0) {
console.warn('[Socket Debug] Issues found:', issues);
} else {
console.log('[Socket Debug] ✅ No issues found');
}
return { config, issues };
},
};
console.log(
'%c[Socket Debug] Debug API available at window.__SOCKET_DEBUG__',
'color: #2196F3; font-weight: bold;'
);
console.log(
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.checkConfig() - 检查配置',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.subscribe() - 手动订阅事件',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.testConnection() - 测试连接',
'color: #2196F3;'
);
// ========== 通知系统专用调试 API ==========
window.__NOTIFY_DEBUG__ = {
// 完整检查(配置+连接+订阅状态)
checkAll: () => {
console.log('\n==========【通知系统诊断】==========');
// 1. 检查 Socket 配置
const socketCheck = window.__SOCKET_DEBUG__.checkConfig();
console.log('\n✓ Socket 配置检查完成');
// 2. 检查连接状态
const status = window.__SOCKET_DEBUG__.getStatus();
console.log('\n✓ 连接状态:', status.connected ? '✅ 已连接' : '❌ 未连接');
// 3. 检查环境变量
console.log('\n✓ API Base:', process.env.REACT_APP_API_URL || '(使用相对路径)');
// 4. 检查浏览器通知权限
const browserPermission = Notification?.permission || 'unsupported';
console.log('\n✓ 浏览器通知权限:', browserPermission);
// 5. 汇总报告
const report = {
timestamp: new Date().toISOString(),
socket: {
type: SOCKET_TYPE,
connected: status.connected,
reconnectAttempts: status.reconnectAttempts,
},
env: socketCheck.config.envVars,
browserNotification: browserPermission,
issues: socketCheck.issues,
};
console.log('\n========== 诊断报告 ==========');
console.table(report);
if (report.issues.length > 0) {
console.warn('\n⚠ 发现问题:', report.issues);
} else {
console.log('\n✅ 系统正常,未发现问题');
}
// 提供修复建议
if (!status.connected) {
console.log('\n💡 修复建议:');
console.log(' 1. 检查网络连接');
console.log(' 2. 尝试手动重连: __SOCKET_DEBUG__.reconnect()');
console.log(' 3. 检查后端服务是否运行');
}
if (browserPermission === 'denied') {
console.log('\n💡 浏览器通知已被拒绝,请在浏览器设置中允许通知权限');
}
console.log('\n====================================\n');
return report;
},
// 手动订阅事件(简化版)
subscribe: (eventType = 'all', importance = 'all') => {
console.log(`\n[通知调试] 手动订阅事件: type=${eventType}, importance=${importance}`);
window.__SOCKET_DEBUG__.subscribe({ eventType, importance });
},
// 模拟接收通知用于测试UI
testNotify: (type = 'announcement') => {
console.log('\n[通知调试] 模拟通知:', type);
const mockNotifications = {
announcement: {
id: `test_${Date.now()}`,
type: 'announcement',
priority: 'important',
title: '🧪 测试公告通知',
content: '这是一条测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
},
stock_alert: {
id: `test_${Date.now()}`,
type: 'stock_alert',
priority: 'urgent',
title: '🧪 测试股票预警',
content: '贵州茅台触发价格预警: 1850.00元 (+5.2%)',
publishTime: Date.now(),
pushTime: Date.now(),
},
event_alert: {
id: `test_${Date.now()}`,
type: 'event_alert',
priority: 'important',
title: '🧪 测试事件动向',
content: 'AI大模型新政策发布影响科技板块',
publishTime: Date.now(),
pushTime: Date.now(),
},
analysis_report: {
id: `test_${Date.now()}`,
type: 'analysis_report',
priority: 'normal',
title: '🧪 测试分析报告',
content: '2024年Q1市场策略报告已发布',
publishTime: Date.now(),
pushTime: Date.now(),
},
};
const notification = mockNotifications[type] || mockNotifications.announcement;
// 触发 new_event 事件
if (socket.emit) {
// 对于真实 Socket模拟服务端推送实际上客户端无法这样做仅用于Mock模式
console.warn('⚠️ 真实 Socket 无法模拟服务端推送,请使用 Mock 模式或等待真实推送');
}
// 直接触发事件监听器(如果是 Mock 模式)
if (SOCKET_TYPE === 'MOCK' && socket.emit) {
socket.emit('new_event', notification);
console.log('✅ 已触发 Mock 通知事件');
}
console.log('通知数据:', notification);
return notification;
},
// 导出完整诊断报告
exportReport: () => {
const report = window.__NOTIFY_DEBUG__.checkAll();
// 生成可下载的 JSON
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notification-debug-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('✅ 诊断报告已导出');
return report;
},
// 快捷帮助
help: () => {
console.log('\n========== 通知系统调试 API ==========');
console.log('window.__NOTIFY_DEBUG__.checkAll() - 完整诊断检查');
console.log('window.__NOTIFY_DEBUG__.subscribe() - 手动订阅事件');
console.log('window.__NOTIFY_DEBUG__.testNotify(type) - 模拟通知 (announcement/stock_alert/event_alert/analysis_report)');
console.log('window.__NOTIFY_DEBUG__.exportReport() - 导出诊断报告');
console.log('\n========== Socket 调试 API ==========');
console.log('window.__SOCKET_DEBUG__.getStatus() - 获取连接状态');
console.log('window.__SOCKET_DEBUG__.checkConfig() - 检查配置');
console.log('window.__SOCKET_DEBUG__.reconnect() - 手动重连');
console.log('====================================\n');
},
};
console.log(
'%c[Notify Debug] Notification Debug API available at window.__NOTIFY_DEBUG__',
'color: #FF9800; font-weight: bold;'
);
console.log(
'%cTry: window.__NOTIFY_DEBUG__.checkAll() - 完整诊断',
'color: #FF9800;'
);
console.log(
'%c window.__NOTIFY_DEBUG__.help() - 查看所有命令',
'color: #FF9800;'
);
}
export default socket;

View File

@@ -16,6 +16,7 @@ class SocketService {
this.reconnectAttempts = 0;
this.maxReconnectAttempts = Infinity; // 无限重试
this.customReconnectTimer = null; // 自定义重连定时器
this.pendingListeners = []; // 暂存等待注册的事件监听器
}
/**
@@ -50,6 +51,23 @@ class SocketService {
...options,
});
// 注册所有暂存的事件监听器(保留 pendingListeners不清空
if (this.pendingListeners.length > 0) {
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
this.pendingListeners.forEach(({ event, callback }) => {
// 直接在 Socket.IO 实例上注册(避免递归调用 this.on()
const wrappedCallback = (...args) => {
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
console.log(`[socketService] 事件数据 (${event}):`, ...args);
callback(...args);
};
this.socket.on(event, wrappedCallback);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
});
// ⚠️ 重要:不清空 pendingListeners保留用于重连
}
// 监听连接成功
this.socket.on('connect', () => {
this.connected = true;
@@ -64,6 +82,12 @@ class SocketService {
logger.info('socketService', 'Socket.IO connected successfully', {
socketId: this.socket.id,
});
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] Socket ID:', this.socket.id);
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
// this.subscribeToAllEvents();
});
// 监听断开连接
@@ -141,12 +165,31 @@ class SocketService {
*/
on(event, callback) {
if (!this.socket) {
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
// Socket 未初始化,暂存监听器(检查是否已存在,避免重复)
const exists = this.pendingListeners.some(
(listener) => listener.event === event && listener.callback === callback
);
if (!exists) {
logger.info('socketService', 'Socket not ready, queuing listener', { event });
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
this.pendingListeners.push({ event, callback });
} else {
console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
}
return;
}
this.socket.on(event, callback);
// 包装回调函数,添加日志
const wrappedCallback = (...args) => {
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
console.log(`[socketService] 事件数据 (${event}):`, ...args);
callback(...args);
};
this.socket.on(event, wrappedCallback);
logger.info('socketService', `Event listener added: ${event}`);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
}
/**
@@ -337,13 +380,14 @@ class SocketService {
});
// 监听新事件推送
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext
// 多个监听器可以共存,都会被触发
if (onNewEvent) {
console.log('[SocketService DEBUG] 设置 new_event 监听器');
// 先移除之前的监听器(避免重复)
this.socket.off('new_event');
console.log('[SocketService DEBUG] ✓ 已移除旧的 new_event 监听器');
// 添加新的监听器
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
// 添加新的监听器(与其他监听器共存)
this.socket.on('new_event', (eventData) => {
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
console.log('[SocketService DEBUG] 事件数据:', eventData);
@@ -355,7 +399,7 @@ class SocketService {
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
});
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置');
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
}
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
@@ -403,14 +447,26 @@ class SocketService {
/**
* 快捷方法:订阅所有类型的事件
* @param {Function} onNewEvent - 收到新事件时的回调函数
* @param {Function} onNewEvent - 收到新事件时的回调函数(可选)
* @returns {Function} 取消订阅的函数
*/
subscribeToAllEvents(onNewEvent) {
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
// 如果没有提供回调,添加一个默认的日志回调
const defaultCallback = (event) => {
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 事件数据:', event);
};
this.subscribeToEvents({
eventType: 'all',
importance: 'all',
onNewEvent,
onNewEvent: onNewEvent || defaultCallback,
onSubscribed: (data) => {
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 订阅确认:', data);
},
});
// 返回取消订阅的清理函数

View File

@@ -0,0 +1,35 @@
/**
* Bytedesk 客服系统 z-index 覆盖样式
*
* 问题: Bytedesk 默认 z-index 为 10001项目中部分元素使用 z-index: 99999
* 导致客服 iframe 在首页被内容区覆盖,不可见
*
* 解决方案: 将所有 Bytedesk 相关元素的 z-index 提升到 999999
* 确保客服窗口始终显示在最上层
*/
/* Bytedesk 主容器 - 客服图标按钮 */
[class*="bytedesk"],
[id*="bytedesk"],
[class*="BytedeskWeb"] {
z-index: 999999 !important;
}
/* Bytedesk iframe - 聊天窗口 */
iframe[src*="bytedesk"],
iframe[src*="/chat/"],
iframe[src*="/visitor/"] {
z-index: 999999 !important;
}
/* Bytedesk 覆盖层(如果存在) */
.bytedesk-overlay,
[class*="bytedesk-overlay"] {
z-index: 999998 !important;
}
/* Bytedesk 通知气泡 */
.bytedesk-badge,
[class*="bytedesk-badge"] {
z-index: 1000000 !important;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
// src/views/AgentChat/index.js
// Agent聊天页面
import React from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import { ChatInterfaceV2 } from '../../components/ChatBot';
/**
* Agent聊天页面
* 提供基于MCP的AI助手对话功能
*/
const AgentChat = () => {
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
return (
<Box minH="calc(100vh - 200px)" bg={bgColor} py={8}>
<Container maxW="container.xl" h="100%">
<VStack spacing={6} align="stretch" h="100%">
{/* 页面标题 */}
<Box>
<Heading size="lg" mb={2}>AI投资助手</Heading>
<Text color="gray.600" fontSize="sm">
基于MCP协议的智能投资顾问支持股票查询新闻搜索概念分析等多种功能
</Text>
</Box>
{/* 聊天界面 */}
<Box
flex="1"
bg={cardBg}
borderRadius="xl"
boxShadow="xl"
overflow="hidden"
h="calc(100vh - 300px)"
minH="600px"
>
<ChatInterfaceV2 />
</Box>
</VStack>
</Container>
</Box>
);
};
export default AgentChat;

View File

@@ -0,0 +1,857 @@
// src/views/AgentChat/index_v3.js
// Agent聊天页面 V3 - 带左侧会话列表和用户信息集成
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Flex,
VStack,
HStack,
Text,
Input,
IconButton,
Button,
Avatar,
Heading,
Divider,
Spinner,
Badge,
useColorModeValue,
useToast,
Progress,
Fade,
Collapse,
useDisclosure,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuList,
MenuItem,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Tooltip,
} from '@chakra-ui/react';
import {
FiSend,
FiSearch,
FiPlus,
FiMessageSquare,
FiTrash2,
FiMoreVertical,
FiRefreshCw,
FiDownload,
FiCpu,
FiUser,
FiZap,
FiClock,
} from 'react-icons/fi';
import { useAuth } from '@contexts/AuthContext';
import { PlanCard } from '@components/ChatBot/PlanCard';
import { StepResultCard } from '@components/ChatBot/StepResultCard';
import { logger } from '@utils/logger';
import axios from 'axios';
/**
* Agent消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* Agent聊天页面 V3
*/
const AgentChatV3 = () => {
const { user } = useAuth(); // 获取当前用户信息
const toast = useToast();
// 会话相关状态
const [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
// 消息相关状态
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
// UI 状态
const [searchQuery, setSearchQuery] = useState('');
const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true });
// Refs
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// 颜色主题
const bgColor = useColorModeValue('gray.50', 'gray.900');
const sidebarBg = useColorModeValue('white', 'gray.800');
const chatBg = useColorModeValue('white', 'gray.800');
const inputBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const activeBg = useColorModeValue('blue.50', 'blue.900');
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
// ==================== 会话管理函数 ====================
// 加载会话列表
const loadSessions = async () => {
if (!user?.id) return;
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: user.id, limit: 50 },
});
if (response.data.success) {
setSessions(response.data.data);
logger.info('会话列表加载成功', response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
toast({
title: '加载失败',
description: '无法加载会话列表',
status: 'error',
duration: 3000,
});
} finally {
setIsLoadingSessions(false);
}
};
// 加载会话历史
const loadSessionHistory = async (sessionId) => {
if (!sessionId) return;
try {
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
params: { limit: 100 },
});
if (response.data.success) {
const history = response.data.data;
// 将历史记录转换为消息格式
const formattedMessages = history.map((msg, idx) => ({
id: `${sessionId}-${idx}`,
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
content: msg.message,
plan: msg.plan ? JSON.parse(msg.plan) : null,
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
timestamp: msg.timestamp,
}));
setMessages(formattedMessages);
logger.info('会话历史加载成功', formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
toast({
title: '加载失败',
description: '无法加载会话历史',
status: 'error',
duration: 3000,
});
}
};
// 创建新会话
const createNewSession = () => {
setCurrentSessionId(null);
setMessages([
{
id: Date.now(),
type: MessageTypes.AGENT_RESPONSE,
content: `你好${user?.nickname || ''}我是价小前北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`,
timestamp: new Date().toISOString(),
},
]);
};
// 切换会话
const switchSession = (sessionId) => {
setCurrentSessionId(sessionId);
loadSessionHistory(sessionId);
};
// 删除会话需要后端API支持
const deleteSession = async (sessionId) => {
// TODO: 实现删除会话的后端API
toast({
title: '删除会话',
description: '此功能尚未实现',
status: 'info',
duration: 2000,
});
};
// ==================== 消息处理函数 ====================
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 添加消息
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
// 发送消息
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
// 权限检查
if (user?.id !== 'max') {
toast({
title: '权限不足',
description: '「价小前投研」功能目前仅对特定用户开放。如需使用,请联系管理员。',
status: 'warning',
duration: 5000,
isClosable: true,
});
return;
}
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
const userInput = inputValue;
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
let currentPlan = null;
let stepResults = [];
try {
// 1. 显示思考状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
// 2. 调用后端API非流式
const response = await axios.post('/mcp/agent/chat', {
message: userInput,
conversation_history: messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((m) => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
user_id: user?.id || 'anonymous',
user_nickname: user?.nickname || '匿名用户',
user_avatar: user?.avatar || '',
session_id: currentSessionId,
});
// 移除思考消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
if (response.data.success) {
const data = response.data;
// 更新 session_id如果是新会话
if (data.session_id && !currentSessionId) {
setCurrentSessionId(data.session_id);
}
// 显示执行计划
if (data.plan) {
currentPlan = data.plan;
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data.plan,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
}
// 显示执行步骤
if (data.steps && data.steps.length > 0) {
stepResults = data.steps;
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: currentPlan,
stepResults: stepResults,
timestamp: new Date().toISOString(),
});
setCurrentProgress(70);
}
// 移除执行中消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.final_answer || data.message || '处理完成',
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
setCurrentProgress(100);
// 重新加载会话列表
loadSessions();
}
} catch (error) {
logger.error('Agent chat error', error);
// 移除思考/执行中消息
setMessages((prev) =>
prev.filter(
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
)
);
const errorMessage = error.response?.data?.error || error.message || '处理失败';
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${errorMessage}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
createNewSession();
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`)
.join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// ==================== 初始化 ====================
useEffect(() => {
if (user) {
loadSessions();
createNewSession();
}
}, [user]);
// ==================== 渲染 ====================
// 快捷问题
const quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
// 筛选会话
const filteredSessions = sessions.filter(
(session) =>
!searchQuery ||
session.last_message?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Flex h="calc(100vh - 80px)" bg={bgColor}>
{/* 左侧会话列表 */}
<Collapse in={isSidebarOpen} animateOpacity>
<Box
w="300px"
bg={sidebarBg}
borderRight="1px"
borderColor={borderColor}
h="100%"
display="flex"
flexDirection="column"
>
{/* 侧边栏头部 */}
<Box p={4} borderBottom="1px" borderColor={borderColor}>
<Button
leftIcon={<FiPlus />}
colorScheme="blue"
w="100%"
onClick={createNewSession}
size="sm"
>
新建对话
</Button>
{/* 搜索框 */}
<InputGroup mt={3} size="sm">
<InputLeftElement pointerEvents="none">
<FiSearch color="gray.300" />
</InputLeftElement>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
</Box>
{/* 会话列表 */}
<VStack
flex="1"
overflowY="auto"
spacing={0}
align="stretch"
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '3px',
},
}}
>
{isLoadingSessions ? (
<Flex justify="center" align="center" h="200px">
<Spinner />
</Flex>
) : filteredSessions.length === 0 ? (
<Flex justify="center" align="center" h="200px" direction="column">
<FiMessageSquare size={32} color="gray" />
<Text mt={2} fontSize="sm" color="gray.500">
{searchQuery ? '没有找到匹配的对话' : '暂无对话记录'}
</Text>
</Flex>
) : (
filteredSessions.map((session) => (
<Box
key={session.session_id}
p={3}
cursor="pointer"
bg={currentSessionId === session.session_id ? activeBg : 'transparent'}
_hover={{ bg: hoverBg }}
borderBottom="1px"
borderColor={borderColor}
onClick={() => switchSession(session.session_id)}
>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="medium" noOfLines={2}>
{session.last_message || '新对话'}
</Text>
<HStack spacing={2} fontSize="xs" color="gray.500">
<FiClock size={12} />
<Text>
{new Date(session.last_timestamp).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
})}
</Text>
<Badge colorScheme="blue" fontSize="xx-small">
{session.message_count}
</Badge>
</HStack>
</VStack>
<Menu>
<MenuButton
as={IconButton}
icon={<FiMoreVertical />}
size="xs"
variant="ghost"
onClick={(e) => e.stopPropagation()}
/>
<MenuList>
<MenuItem
icon={<FiTrash2 />}
color="red.500"
onClick={(e) => {
e.stopPropagation();
deleteSession(session.session_id);
}}
>
删除对话
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))
)}
</VStack>
{/* 用户信息 */}
<Box p={4} borderTop="1px" borderColor={borderColor}>
<HStack spacing={3}>
<Avatar size="sm" name={user?.nickname} src={user?.avatar} />
<VStack align="start" spacing={0} flex="1">
<Text fontSize="sm" fontWeight="medium">
{user?.nickname || '未登录'}
</Text>
<Text fontSize="xs" color="gray.500">
{user?.id || 'anonymous'}
</Text>
</VStack>
</HStack>
</Box>
</Box>
</Collapse>
{/* 主聊天区域 */}
<Flex flex="1" direction="column" h="100%">
{/* 聊天头部 */}
<Box bg={chatBg} borderBottom="1px" borderColor={borderColor} px={6} py={4}>
<HStack justify="space-between">
<HStack spacing={4}>
<IconButton
icon={<FiMessageSquare />}
size="sm"
variant="ghost"
aria-label="切换侧边栏"
onClick={toggleSidebar}
/>
<Avatar size="md" bg="blue.500" icon={<FiCpu fontSize="1.5rem" />} />
<VStack align="start" spacing={0}>
<Heading size="md">价小前投研</Heading>
<HStack>
<Badge colorScheme="green" fontSize="xs">
<HStack spacing={1}>
<FiZap size={10} />
<span>智能分析</span>
</HStack>
</Badge>
<Text fontSize="xs" color="gray.500">
多步骤深度研究
</Text>
</HStack>
</VStack>
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
</HStack>
</HStack>
{/* 进度条 */}
{isProcessing && (
<Progress
value={currentProgress}
size="xs"
colorScheme="blue"
mt={3}
borderRadius="full"
isAnimated
/>
)}
</Box>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={6}
py={4}
css={{
'&::-webkit-scrollbar': { width: '8px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={4} align="stretch">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} userAvatar={user?.avatar} />
</Fade>
))}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题 */}
{messages.length <= 2 && !isProcessing && (
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" mb={2}>
💡 试试这些问题
</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
colorScheme="blue"
fontSize="xs"
onClick={() => {
setInputValue(question);
inputRef.current?.focus();
}}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
{/* 输入框 */}
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的问题,我会进行深度分析..."
bg={inputBg}
border="1px"
borderColor={borderColor}
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
mr={2}
disabled={isProcessing}
size="lg"
/>
<IconButton
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isProcessing}
disabled={!inputValue.trim() || isProcessing}
size="lg"
/>
</Flex>
</Box>
</Flex>
</Flex>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message, userAvatar }) => {
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="flex-start" maxW="75%">
<Box
bg={userBubbleBg}
color="white"
px={4}
py={3}
borderRadius="lg"
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
</Box>
<Avatar size="sm" src={userAvatar} icon={<FiUser fontSize="1rem" />} />
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<HStack>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="purple.600">
{message.content}
</Text>
</HStack>
</Box>
</HStack>
</Flex>
);
case MessageTypes.AGENT_PLAN:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1">
<PlanCard plan={message.plan} stepResults={[]} />
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_EXECUTING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
<PlanCard plan={message.plan} stepResults={message.stepResults} />
{message.stepResults?.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
{/* 最终总结 */}
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
{/* 元数据 */}
{message.metadata && (
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
<Text>总步骤: {message.metadata.total_steps}</Text>
<Text> {message.metadata.successful_steps}</Text>
{message.metadata.failed_steps > 0 && (
<Text> {message.metadata.failed_steps}</Text>
)}
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
</HStack>
)}
</Box>
{/* 执行详情(可选) */}
{message.plan && message.stepResults && message.stepResults.length > 0 && (
<VStack align="stretch" spacing={2}>
<Divider />
<Text fontSize="xs" fontWeight="bold" color="gray.500">
📊 执行详情点击展开查看
</Text>
{message.stepResults.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
)}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg="red.50"
color="red.700"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="red.200"
>
<Text fontSize="sm">{message.content}</Text>
</Box>
</HStack>
</Flex>
);
default:
return null;
}
};
export default AgentChatV3;

View File

@@ -25,8 +25,12 @@ import {
useColorModeValue,
useToast,
useDisclosure,
Switch,
Tooltip,
Icon,
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
import { TimeIcon, BellIcon } from '@chakra-ui/icons';
import { useNotification } from '../../../contexts/NotificationContext';
import EventScrollList from './DynamicNewsCard/EventScrollList';
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
import PaginationControl from './DynamicNewsCard/PaginationControl';
@@ -73,6 +77,9 @@ const DynamicNewsCard = forwardRef(({
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// 通知权限相关
const { browserPermission, requestBrowserPermission } = useNotification();
// 固定模式状态
const [isFixedMode, setIsFixedMode] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
@@ -82,7 +89,7 @@ const DynamicNewsCard = forwardRef(({
// 导航栏和页脚固定高度
const NAVBAR_HEIGHT = 64; // 主导航高度
const SECONDARY_NAV_HEIGHT = 44; // 二级导航高度
const FOOTER_HEIGHT = 120; // 页脚高度(预留
const FOOTER_HEIGHT = 80; // 页脚高度(优化后
const TOTAL_NAV_HEIGHT = NAVBAR_HEIGHT + SECONDARY_NAV_HEIGHT; // 总导航高度 128px
// 从 Redux 读取关注状态
@@ -146,6 +153,23 @@ const [currentMode, setCurrentMode] = useState('vertical');
dispatch(toggleEventFollow(eventId));
}, [dispatch]);
// 通知开关处理
const handleNotificationToggle = useCallback(async () => {
if (browserPermission === 'granted') {
// 已授权,提示用户去浏览器设置中关闭
toast({
title: '已开启通知',
description: '要关闭通知,请在浏览器地址栏左侧点击锁图标,找到"通知"选项进行设置',
status: 'info',
duration: 5000,
isClosable: true,
});
} else {
// 未授权,请求权限
await requestBrowserPermission();
}
}, [browserPermission, requestBrowserPermission, toast]);
// 本地状态
const [selectedEvent, setSelectedEvent] = useState(null);
@@ -511,9 +535,66 @@ const [currentMode, setCurrentMode] = useState('vertical');
<Badge colorScheme="blue">快讯</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
<VStack align="end" spacing={2}>
{/* 通知开关 */}
<Tooltip
label={browserPermission === 'granted'
? '浏览器通知已开启,新事件将实时推送'
: '开启后可接收实时事件推送通知'}
placement="left"
hasArrow
>
<HStack
spacing={2}
px={3}
py={2}
borderRadius="md"
bg={browserPermission === 'granted'
? useColorModeValue('green.50', 'green.900')
: useColorModeValue('gray.50', 'gray.700')}
borderWidth="1px"
borderColor={browserPermission === 'granted'
? useColorModeValue('green.200', 'green.700')
: useColorModeValue('gray.200', 'gray.600')}
cursor="pointer"
_hover={{
borderColor: browserPermission === 'granted'
? useColorModeValue('green.300', 'green.600')
: useColorModeValue('blue.300', 'blue.600'),
}}
transition="all 0.2s"
onClick={handleNotificationToggle}
>
<Icon
as={BellIcon}
boxSize={4}
color={browserPermission === 'granted'
? useColorModeValue('green.600', 'green.300')
: useColorModeValue('gray.500', 'gray.400')}
/>
<Text
fontSize="sm"
fontWeight="medium"
color={browserPermission === 'granted'
? useColorModeValue('green.700', 'green.200')
: useColorModeValue('gray.600', 'gray.300')}
>
{browserPermission === 'granted' ? '通知已开启' : '开启通知'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="green"
/>
</HStack>
</Tooltip>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</VStack>
</Flex>
{/* 搜索和筛选组件 */}

View File

@@ -33,7 +33,7 @@ const VerticalModeLayout = ({
borderColor,
}) => {
// 布局模式状态:'detail' = 聚焦详情(默认),'list' = 聚焦列表
const [layoutMode, setLayoutMode] = useState('list');
const [layoutMode, setLayoutMode] = useState('detail');
// 详情面板重置 key切换到 list 模式时改变,强制重新渲染)
const [detailPanelKey, setDetailPanelKey] = useState(0);

View File

@@ -73,7 +73,7 @@ const VirtualizedFourRowGrid = ({
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
*
* 工作原理:
* 1. 向下滚动到 60% 位置时,触发 loadNextPage()
* 1. 向下滚动到 90% 位置时,触发 loadNextPage()
* - 调用 usePagination.loadNextPage()
* - 内部执行 handlePageChange(currentPage + 1)
* - dispatch(fetchDynamicNews({ page: nextPage }))
@@ -87,7 +87,7 @@ const VirtualizedFourRowGrid = ({
* - 与5分钟定时刷新协同工作
*
* 设计要点:
* - 60% 触发点:提前加载,避免滚动到底部时才出现加载状态
* - 90% 触发点:接近底部才加载,避免过早触发影响用户体验
* - 防抖机制isLoadingMore.current 防止重复触发
* - 两层缓存:
* - Redux 缓存HTTP层fourRowEvents 数组存储已加载数据,避免重复请求
@@ -107,9 +107,9 @@ const VirtualizedFourRowGrid = ({
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// 向下滚动:滚动到 60% 时开始加载下一页
if (loadNextPage && hasMore && scrollPercentage > 0.6) {
console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
// 向下滚动:滚动到 90% 时开始加载下一页(更接近底部,避免过早触发)
if (loadNextPage && hasMore && scrollPercentage > 0.9) {
console.log('%c📜 [无限滚动] 接近底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
isLoadingMore.current = true;
await loadNextPage();
isLoadingMore.current = false;

View File

@@ -162,7 +162,8 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
<div
style={{
width: '100%',
height: 30,
height: '100%',
minHeight: '35px',
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}

View File

@@ -125,13 +125,13 @@ const StockListItem = ({
transition="all 0.2s"
>
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
<HStack spacing={3} align="stretch">
{/* 左侧:股票名称 + 涨跌幅(垂直排列) - 收窄 */}
<HStack spacing={3} align="center" flexWrap="wrap">
{/* 左侧:股票代码 + 名称 + 涨跌幅(垂直排列) */}
<VStack
align="stretch"
spacing={1}
minW="100px"
maxW="120px"
minW="110px"
maxW="130px"
justify="center"
flexShrink={0}
>
@@ -143,17 +143,29 @@ const StockListItem = ({
color="white"
fontSize="xs"
>
<Text
fontSize="sm"
fontWeight="bold"
color={codeColor}
noOfLines={1}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_name}
</Text>
<VStack spacing={0} align="stretch">
<Text
fontSize="xs"
color={codeColor}
noOfLines={1}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code}
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={nameColor}
noOfLines={1}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_name}
</Text>
</VStack>
</Tooltip>
<HStack spacing={1} align="center">
<Text
@@ -177,89 +189,137 @@ const StockListItem = ({
</HStack>
</VStack>
{/* 分时图 - 固定宽度 */}
<Box
w="160px"
{/* 分时图 - 紧凑高度 */}
<VStack
minW="150px"
maxW="180px"
flex="1"
borderWidth="1px"
borderColor={useColorModeValue('blue.100', 'blue.700')}
borderRadius="md"
p={2}
px={2}
py={1}
bg={useColorModeValue('blue.50', 'blue.900')}
onClick={(e) => {
e.stopPropagation();
setIsModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
flexShrink={1}
align="stretch"
spacing={0}
h="fit-content"
_hover={{
borderColor: useColorModeValue('blue.300', 'blue.500'),
boxShadow: 'sm'
boxShadow: 'md',
transform: 'translateY(-1px)'
}}
transition="all 0.2s"
>
<Text
fontSize="xs"
color={useColorModeValue('blue.700', 'blue.200')}
mb={1}
fontWeight="semibold"
whiteSpace="nowrap"
mb={0.5}
>
📈 分时
</Text>
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
<Box h="35px">
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
</VStack>
{/* K线图 - 固定宽度 */}
<Box
w="160px"
{/* K线图 - 紧凑高度 */}
<VStack
minW="150px"
maxW="180px"
flex="1"
borderWidth="1px"
borderColor={useColorModeValue('purple.100', 'purple.700')}
borderRadius="md"
p={2}
px={2}
py={1}
bg={useColorModeValue('purple.50', 'purple.900')}
onClick={(e) => {
e.stopPropagation();
setIsModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
flexShrink={1}
align="stretch"
spacing={0}
h="fit-content"
_hover={{
borderColor: useColorModeValue('purple.300', 'purple.500'),
boxShadow: 'sm'
boxShadow: 'md',
transform: 'translateY(-1px)'
}}
transition="all 0.2s"
>
<Text
fontSize="xs"
color={useColorModeValue('purple.700', 'purple.200')}
mb={1}
fontWeight="semibold"
whiteSpace="nowrap"
mb={0.5}
>
📊 日线
</Text>
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
<Box h="35px">
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
</VStack>
{/* 关联描述 - 升级和降级处理 */}
{stock.relation_desc && (
<Box flex={1} minW={0}>
{stock.relation_desc?.data ? (
// 升级:带引用来源的版本
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
padding: '0',
}}
/>
// 升级:带引用来源的版本 - 添加折叠功能
<Tooltip
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
placement="top"
hasArrow
bg="gray.600"
color="white"
fontSize="xs"
>
<Box
onClick={(e) => {
e.stopPropagation();
setIsDescExpanded(!isDescExpanded);
}}
cursor="pointer"
px={3}
py={2}
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="md"
_hover={{
bg: useColorModeValue('gray.100', 'gray.600'),
}}
transition="background 0.2s"
position="relative"
>
<Collapse in={isDescExpanded} startingHeight={40}>
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: 'transparent',
borderRadius: '0',
padding: '0',
}}
/>
</Collapse>
</Box>
</Tooltip>
) : (
// 降级:纯文本版本(保留展开/收起功能)
<Tooltip

View File

@@ -166,7 +166,8 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
<div
style={{
width: '100%',
height: 30,
height: '100%',
minHeight: '35px',
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}

View File

@@ -1,5 +1,5 @@
// src/views/Community/index.js
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import {
@@ -10,6 +10,15 @@ import {
Box,
Container,
useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
HStack,
VStack,
Text,
} from '@chakra-ui/react';
// 导入组件
@@ -35,12 +44,17 @@ const Community = () => {
// Chakra UI hooks
const bgColor = useColorModeValue('gray.50', 'gray.900');
const alertBgColor = useColorModeValue('blue.50', 'blue.900');
const alertBorderColor = useColorModeValue('blue.200', 'blue.700');
// Ref用于首次滚动到内容区域
const containerRef = useRef(null);
// ⚡ 通知权限引导
const { showCommunityGuide } = useNotification();
const { browserPermission, requestBrowserPermission } = useNotification();
// 通知横幅显示状态
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
// 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate });
@@ -71,17 +85,38 @@ const Community = () => {
}
}, [events, loading, pagination, filters]);
// ⚡ 首次访问社区时,延迟显示权限引导
// ⚡ 检查通知权限状态,显示横幅提示
useEffect(() => {
if (showCommunityGuide) {
const timer = setTimeout(() => {
logger.info('Community', '显示社区权限引导');
showCommunityGuide();
}, 5000); // 延迟 5 秒,让用户先浏览页面
// 延迟3秒显示让用户先浏览页面
const timer = setTimeout(() => {
// 如果未授权或未请求过权限,显示横幅
if (browserPermission !== 'granted') {
const hasClosedBanner = localStorage.getItem('notification_banner_closed');
if (!hasClosedBanner) {
setShowNotificationBanner(true);
logger.info('Community', '显示通知权限横幅');
}
}
}, 3000);
return () => clearTimeout(timer);
return () => clearTimeout(timer);
}, [browserPermission]);
// 处理开启通知
const handleEnableNotifications = async () => {
const permission = await requestBrowserPermission();
if (permission === 'granted') {
setShowNotificationBanner(false);
logger.info('Community', '通知权限已授予');
}
}, [showCommunityGuide]); // 只在组件挂载时执行一次
};
// 处理关闭横幅
const handleCloseBanner = () => {
setShowNotificationBanner(false);
localStorage.setItem('notification_banner_closed', 'true');
logger.info('Community', '通知横幅已关闭');
};
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
useEffect(() => {
@@ -104,6 +139,42 @@ const Community = () => {
<Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */}
<Container ref={containerRef} maxW="1600px" pt={6} pb={8}>
{/* 通知权限提示横幅 */}
{showNotificationBanner && (
<Alert
status="info"
variant="subtle"
borderRadius="lg"
mb={4}
boxShadow="md"
bg={alertBgColor}
borderWidth="1px"
borderColor={alertBorderColor}
>
<AlertIcon />
<Box flex="1">
<AlertTitle fontSize="md" mb={1}>
开启桌面通知不错过重要事件
</AlertTitle>
<AlertDescription fontSize="sm">
即使浏览器最小化也能第一时间接收新事件推送通知
</AlertDescription>
</Box>
<HStack spacing={2} ml={4}>
<Button
size="sm"
colorScheme="blue"
onClick={handleEnableNotifications}
>
立即开启
</Button>
<CloseButton
onClick={handleCloseBanner}
position="relative"
/>
</HStack>
</Alert>
)}
{/* 热点事件区域 */}
<HotEventsSection
events={hotEvents}
@@ -112,7 +183,6 @@ const Community = () => {
{/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard
mt={6}
filters={filters}
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { logger } from '../../utils/logger';
import defaultEventImage from '../../assets/img/default-event.jpg';
import {
Box,
Container,
@@ -174,7 +175,7 @@ const ConceptCenter = () => {
const [stockMarketData, setStockMarketData] = useState({});
const [loadingStockData, setLoadingStockData] = useState(false);
// 默认图片路径
const defaultImage = '/assets/img/default-event.jpg';
const defaultImage = defaultEventImage;
// 获取最新交易日期
const fetchLatestTradeDate = useCallback(async () => {

View File

@@ -126,6 +126,14 @@ const HistoricalEvents = ({
return 'green';
};
// 获取相关度颜色1-10
const getSimilarityColor = (similarity) => {
if (similarity >= 8) return 'green';
if (similarity >= 6) return 'blue';
if (similarity >= 4) return 'orange';
return 'gray';
};
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '日期未知';
@@ -300,6 +308,11 @@ const HistoricalEvents = ({
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
{event.similarity !== undefined && event.similarity !== null && (
<Badge colorScheme={getSimilarityColor(event.similarity)} size="sm">
相关度: {event.similarity}
</Badge>
)}
</HStack>
</VStack>