mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
refactor: switch webchat from sse to websocket (#1808)
* refactor: switch webchat from sse to websocket * perf: image preview dialog * chore: remove console.log
This commit is contained in:
committed by
GitHub
parent
348620ac0a
commit
d09b823c49
412
MIGRATION_SUMMARY.md
Normal file
412
MIGRATION_SUMMARY.md
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
# WebChat 到 WebSocket 迁移总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
已完全移除旧的基于SSE的WebChat系统,并替换为基于WebSocket的双向实时通信系统。这是一个内置在LangBot中的完整IM系统,支持流式输出。
|
||||||
|
|
||||||
|
## 已删除的文件
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- ❌ `src/langbot/pkg/api/http/controller/groups/pipelines/webchat.py` - 旧的SSE路由
|
||||||
|
- ❌ `src/langbot/pkg/platform/sources/webchat.py` - 旧的WebChat适配器
|
||||||
|
- ❌ `src/langbot/pkg/platform/sources/webchat.yaml` - 旧的配置文件
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- ❌ BackendClient中所有SSE相关代码已完全移除
|
||||||
|
- ❌ DebugDialog中所有SSE相关逻辑已完全替换
|
||||||
|
|
||||||
|
## 新增的文件
|
||||||
|
|
||||||
|
### 后端核心文件
|
||||||
|
|
||||||
|
**1. WebSocket连接管理器**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/platform/sources/websocket_manager.py
|
||||||
|
```
|
||||||
|
- 管理所有并发WebSocket连接
|
||||||
|
- 线程安全的连接池
|
||||||
|
- 按流水线、会话类型分组
|
||||||
|
- 广播和单播消息功能
|
||||||
|
- 连接统计和监控
|
||||||
|
|
||||||
|
**2. WebSocket适配器**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/platform/sources/websocket_adapter.py
|
||||||
|
```
|
||||||
|
- 实现平台适配器接口
|
||||||
|
- **完整流式支持** (`reply_message_chunk` 方法)
|
||||||
|
- 双向消息流处理
|
||||||
|
- 消息历史管理
|
||||||
|
- 会话管理
|
||||||
|
|
||||||
|
**3. WebSocket路由控制器**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py
|
||||||
|
```
|
||||||
|
- WebSocket端点处理
|
||||||
|
- REST API接口
|
||||||
|
- 心跳机制
|
||||||
|
- 连接生命周期管理
|
||||||
|
|
||||||
|
**4. 配置文件**
|
||||||
|
```
|
||||||
|
src/langbot/pkg/platform/sources/websocket.yaml
|
||||||
|
```
|
||||||
|
- WebSocket适配器元数据
|
||||||
|
|
||||||
|
### 前端核心文件
|
||||||
|
|
||||||
|
**1. WebSocket客户端**
|
||||||
|
```
|
||||||
|
web/src/app/infra/websocket/WebSocketClient.ts
|
||||||
|
```
|
||||||
|
- WebSocket连接管理
|
||||||
|
- 自动重连(最多5次)
|
||||||
|
- 心跳机制(30秒)
|
||||||
|
- 事件回调系统
|
||||||
|
|
||||||
|
**2. 更新的组件**
|
||||||
|
```
|
||||||
|
web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx
|
||||||
|
```
|
||||||
|
- 完全重写,使用WebSocket
|
||||||
|
- 实时连接状态显示
|
||||||
|
- 流式消息支持
|
||||||
|
- 自动重连
|
||||||
|
|
||||||
|
**3. HTTP客户端更新**
|
||||||
|
```
|
||||||
|
web/src/app/infra/http/BackendClient.ts
|
||||||
|
```
|
||||||
|
- 移除所有旧的WebChat API
|
||||||
|
- 仅保留WebSocket API
|
||||||
|
|
||||||
|
### 测试工具
|
||||||
|
|
||||||
|
**Python测试客户端**
|
||||||
|
```
|
||||||
|
test_websocket_client.py
|
||||||
|
```
|
||||||
|
- 单连接交互测试
|
||||||
|
- 多连接并发测试
|
||||||
|
- 命令行工具
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
**使用文档**
|
||||||
|
```
|
||||||
|
WEBSOCKET_README.md
|
||||||
|
```
|
||||||
|
- 完整的API文档
|
||||||
|
- 架构说明
|
||||||
|
- 使用示例
|
||||||
|
- 故障排查
|
||||||
|
|
||||||
|
## 核心变更
|
||||||
|
|
||||||
|
### 后端变更
|
||||||
|
|
||||||
|
**1. botmgr.py**
|
||||||
|
- ❌ 移除 `webchat_proxy_bot`
|
||||||
|
- ✅ 仅保留 `websocket_proxy_bot`
|
||||||
|
- ✅ 更新适配器过滤逻辑(排除`websocket`而非`webchat`)
|
||||||
|
|
||||||
|
**2. 适配器注册**
|
||||||
|
```python
|
||||||
|
# 旧代码(已删除)
|
||||||
|
webchat_adapter_class = self.adapter_dict['webchat']
|
||||||
|
self.webchat_proxy_bot = RuntimeBot(...)
|
||||||
|
|
||||||
|
# 新代码
|
||||||
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
|
self.websocket_proxy_bot = RuntimeBot(
|
||||||
|
uuid='websocket-proxy-bot',
|
||||||
|
name='WebSocket',
|
||||||
|
adapter='websocket',
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端变更
|
||||||
|
|
||||||
|
**1. API调用完全更换**
|
||||||
|
|
||||||
|
旧代码(已删除):
|
||||||
|
```typescript
|
||||||
|
// SSE流式请求
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ is_stream: true })
|
||||||
|
})
|
||||||
|
// 手动解析 text/event-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
新代码:
|
||||||
|
```typescript
|
||||||
|
// WebSocket实时通信
|
||||||
|
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||||
|
await wsClient.connect();
|
||||||
|
|
||||||
|
wsClient.onMessage((message) => {
|
||||||
|
// 流式消息自动处理
|
||||||
|
setMessages(prev => [...prev, message]);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.sendMessage(messageChain);
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 连接状态管理**
|
||||||
|
|
||||||
|
新增功能:
|
||||||
|
- ✅ 实时连接状态指示器(绿色/红色圆点)
|
||||||
|
- ✅ 连接/断开toast提示
|
||||||
|
- ✅ 自动重连逻辑
|
||||||
|
- ✅ 心跳保活
|
||||||
|
|
||||||
|
**3. 流式支持**
|
||||||
|
|
||||||
|
完整的流式消息处理:
|
||||||
|
```typescript
|
||||||
|
wsClient.onMessage((message) => {
|
||||||
|
if (message.is_final) {
|
||||||
|
// 最终消息
|
||||||
|
finalizeBotMessage(message);
|
||||||
|
} else {
|
||||||
|
// 中间消息块,实时更新UI
|
||||||
|
updateBotMessage(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API对比
|
||||||
|
|
||||||
|
### WebSocket端点
|
||||||
|
|
||||||
|
**连接**
|
||||||
|
```
|
||||||
|
ws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**消息格式**
|
||||||
|
|
||||||
|
客户端发送:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"message": [
|
||||||
|
{"type": "Plain", "text": "你好"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器响应(流式):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "response",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "你好,我是...",
|
||||||
|
"is_final": false,
|
||||||
|
"timestamp": "2025-01-28T..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/messages/<type>` | GET | 获取消息历史 |
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/reset/<type>` | POST | 重置会话 |
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/connections` | GET | 获取连接统计 |
|
||||||
|
| `/api/v1/pipelines/<uuid>/ws/broadcast` | POST | 广播消息 |
|
||||||
|
|
||||||
|
## 流式支持详解
|
||||||
|
|
||||||
|
### 后端流式实现
|
||||||
|
|
||||||
|
**WebSocket Adapter**
|
||||||
|
```python
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""回复消息块 - 流式"""
|
||||||
|
message_data = WebSocketMessage(
|
||||||
|
id=-1,
|
||||||
|
role='assistant',
|
||||||
|
content=str(message),
|
||||||
|
message_chain=[component.__dict__ for component in message],
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送到队列,由WebSocket连接处理发送
|
||||||
|
await session.resp_queues[message_id].put(message_data)
|
||||||
|
return message_data.model_dump()
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
"""WebSocket始终支持流式输出"""
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端流式处理
|
||||||
|
|
||||||
|
**DebugDialog组件**
|
||||||
|
```typescript
|
||||||
|
wsClient.onMessage((message) => {
|
||||||
|
setMessages((prevMessages) => {
|
||||||
|
const existingIndex = prevMessages.findIndex(
|
||||||
|
(msg) => msg.role === 'assistant' && msg.content === 'Generating...'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// 更新正在生成的消息
|
||||||
|
const updatedMessages = [...prevMessages];
|
||||||
|
updatedMessages[existingIndex] = message;
|
||||||
|
return updatedMessages;
|
||||||
|
} else {
|
||||||
|
// 添加新消息
|
||||||
|
return [...prevMessages, message];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 兼容性说明
|
||||||
|
|
||||||
|
### ⚠️ 不兼容旧版本
|
||||||
|
|
||||||
|
此次迁移**完全不兼容**旧的WebChat系统:
|
||||||
|
|
||||||
|
1. **API端点变更**
|
||||||
|
- 旧: `/api/v1/pipelines/<uuid>/chat/send`
|
||||||
|
- 新: `ws://.../<uuid>/ws/connect`
|
||||||
|
|
||||||
|
2. **通信协议变更**
|
||||||
|
- 旧: HTTP + SSE (Server-Sent Events)
|
||||||
|
- 新: WebSocket (双向)
|
||||||
|
|
||||||
|
3. **流式实现变更**
|
||||||
|
- 旧: `text/event-stream` 格式
|
||||||
|
- 新: WebSocket JSON消息
|
||||||
|
|
||||||
|
### 迁移要求
|
||||||
|
|
||||||
|
使用新系统需要:
|
||||||
|
1. ✅ 前端必须支持WebSocket
|
||||||
|
2. ✅ 后端必须运行新的WebSocket适配器
|
||||||
|
3. ✅ 清除旧的WebChat相关配置
|
||||||
|
|
||||||
|
## 优势对比
|
||||||
|
|
||||||
|
| 特性 | 旧WebChat (SSE) | 新WebSocket |
|
||||||
|
|------|----------------|-------------|
|
||||||
|
| 双向通信 | ❌ 单向(服务器→客户端) | ✅ 双向 |
|
||||||
|
| 主动推送 | ❌ 不支持 | ✅ 支持 |
|
||||||
|
| 连接管理 | ❌ 无状态 | ✅ 有状态,完整生命周期 |
|
||||||
|
| 流式输出 | ✅ 支持 | ✅ 支持(更优) |
|
||||||
|
| 心跳机制 | ❌ 无 | ✅ 30秒心跳 |
|
||||||
|
| 自动重连 | ❌ 无 | ✅ 最多5次 |
|
||||||
|
| 多连接 | ⚠️ 难以管理 | ✅ 完整支持 |
|
||||||
|
| 连接状态 | ❌ 不可见 | ✅ 实时显示 |
|
||||||
|
| 广播功能 | ❌ 不支持 | ✅ 支持 |
|
||||||
|
|
||||||
|
## 测试方式
|
||||||
|
|
||||||
|
### 1. Python测试客户端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 单连接测试
|
||||||
|
python test_websocket_client.py <pipeline_uuid>
|
||||||
|
|
||||||
|
# 指定会话类型
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --session-type group
|
||||||
|
|
||||||
|
# 多连接并发测试(5个连接)
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --multi 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 前端测试
|
||||||
|
|
||||||
|
1. 启动LangBot服务器
|
||||||
|
2. 访问前端界面
|
||||||
|
3. 打开流水线调试对话框
|
||||||
|
4. 观察连接状态指示器(左下角圆点)
|
||||||
|
5. 发送消息测试流式响应
|
||||||
|
|
||||||
|
### 3. 浏览器控制台测试
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/<uuid>/ws/connect?session_type=person');
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('已连接');
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
message: [{type: 'Plain', text: '你好'}]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
console.log('收到:', JSON.parse(event.data));
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么完全删除旧代码而不保留兼容性?
|
||||||
|
A: 根据需求,不需要考虑任何对老版本的兼容性,彻底迁移可以避免代码冗余和维护负担。
|
||||||
|
|
||||||
|
### Q: 流式输出如何工作?
|
||||||
|
A:
|
||||||
|
1. 后端通过`reply_message_chunk`发送消息块
|
||||||
|
2. 消息块放入队列
|
||||||
|
3. WebSocket连接从队列取出并发送
|
||||||
|
4. 前端实时更新UI
|
||||||
|
5. `is_final=true`表示最后一块
|
||||||
|
|
||||||
|
### Q: 如何确保连接不断开?
|
||||||
|
A:
|
||||||
|
1. 客户端每30秒发送心跳(ping)
|
||||||
|
2. 服务器响应pong
|
||||||
|
3. 连接断开时自动重连(最多5次)
|
||||||
|
|
||||||
|
### Q: 如何实现后端主动推送?
|
||||||
|
A:
|
||||||
|
1. 调用 `/api/v1/pipelines/<uuid>/ws/broadcast` API
|
||||||
|
2. 消息会被推送到该流水线的所有连接
|
||||||
|
3. 前端通过`onBroadcast`回调接收
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
✅ **完成的工作**
|
||||||
|
- 完全移除旧的WebChat/SSE系统
|
||||||
|
- 实现完整的WebSocket双向通信系统
|
||||||
|
- 支持流式输出
|
||||||
|
- 支持多连接并发
|
||||||
|
- 实现自动重连和心跳机制
|
||||||
|
- 提供完整的测试工具和文档
|
||||||
|
|
||||||
|
✅ **核心特性**
|
||||||
|
- 双向实时通信
|
||||||
|
- 流式消息支持
|
||||||
|
- 多连接管理
|
||||||
|
- 自动重连
|
||||||
|
- 心跳保活
|
||||||
|
- 连接状态可视化
|
||||||
|
- 广播消息
|
||||||
|
|
||||||
|
✅ **技术亮点**
|
||||||
|
- 异步架构(asyncio)
|
||||||
|
- 线程安全的连接管理
|
||||||
|
- 独立的消息队列
|
||||||
|
- 完整的错误处理
|
||||||
|
- 模块化设计
|
||||||
|
|
||||||
|
🎉 系统已完全迁移到WebSocket,无任何旧代码遗留!
|
||||||
394
WEBSOCKET_README.md
Normal file
394
WEBSOCKET_README.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# LangBot WebSocket 双向通信系统
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
这是一个内置在 LangBot 中的完整 IM (即时通讯) 系统,支持:
|
||||||
|
|
||||||
|
- ✅ WebSocket 双向实时通信
|
||||||
|
- ✅ 多个客户端并发连接
|
||||||
|
- ✅ 前端到后端的消息发送
|
||||||
|
- ✅ 后端到前端的主动推送
|
||||||
|
- ✅ 流式响应支持
|
||||||
|
- ✅ 连接管理和会话隔离
|
||||||
|
- ✅ 心跳机制
|
||||||
|
- ✅ 广播消息功能
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
### 核心组件
|
||||||
|
|
||||||
|
1. **WebSocketConnectionManager** (`websocket_manager.py`)
|
||||||
|
- 管理所有活跃的 WebSocket 连接
|
||||||
|
- 支持按流水线、会话类型查询连接
|
||||||
|
- 提供广播和单播功能
|
||||||
|
- 线程安全的并发访问控制
|
||||||
|
|
||||||
|
2. **WebSocketAdapter** (`websocket_adapter.py`)
|
||||||
|
- 实现平台适配器接口
|
||||||
|
- 处理消息的接收和发送
|
||||||
|
- 支持流式输出
|
||||||
|
- 管理消息历史
|
||||||
|
|
||||||
|
3. **WebSocketChatRouterGroup** (`websocket_chat.py`)
|
||||||
|
- WebSocket 路由控制器
|
||||||
|
- 处理连接建立、消息收发
|
||||||
|
- 实现心跳机制
|
||||||
|
- 提供 REST API 接口
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### WebSocket 连接
|
||||||
|
|
||||||
|
#### 建立连接
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://localhost:8000/api/v1/pipelines/<pipeline_uuid>/ws/connect?session_type=<person|group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `pipeline_uuid`: 流水线 UUID (必需)
|
||||||
|
- `session_type`: 会话类型,可选 `person` 或 `group` (默认: `person`)
|
||||||
|
|
||||||
|
**连接成功响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "connected",
|
||||||
|
"connection_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"pipeline_uuid": "your-pipeline-uuid",
|
||||||
|
"session_type": "person",
|
||||||
|
"timestamp": "2025-01-28T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
|
||||||
|
#### 客户端发送消息
|
||||||
|
|
||||||
|
**发送聊天消息:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "Plain",
|
||||||
|
"text": "你好,这是一条测试消息"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**发送心跳:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ping"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**主动断开连接:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "disconnect"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 服务器响应消息
|
||||||
|
|
||||||
|
**聊天响应 (流式):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "response",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "这是机器人的回复",
|
||||||
|
"message_chain": [...],
|
||||||
|
"timestamp": "2025-01-28T12:00:00",
|
||||||
|
"is_final": false,
|
||||||
|
"connection_id": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**心跳响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pong",
|
||||||
|
"timestamp": "2025-01-28T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**广播消息:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "broadcast",
|
||||||
|
"message": "这是一条广播消息",
|
||||||
|
"timestamp": "2025-01-28T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误消息:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"message": "错误描述"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST API 接口
|
||||||
|
|
||||||
|
#### 1. 获取消息历史
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/pipelines/<pipeline_uuid>/ws/messages/<session_type>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"messages": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 重置会话
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/pipelines/<pipeline_uuid>/ws/reset/<session_type>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"message": "Session reset successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 获取连接统计
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/pipelines/<pipeline_uuid>/ws/connections
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"stats": {
|
||||||
|
"total_connections": 5,
|
||||||
|
"pipelines": 2,
|
||||||
|
"connections_by_pipeline": {
|
||||||
|
"pipeline-1": 3,
|
||||||
|
"pipeline-2": 2
|
||||||
|
},
|
||||||
|
"connections_by_session_type": {
|
||||||
|
"person": 4,
|
||||||
|
"group": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"connection_id": "...",
|
||||||
|
"session_type": "person",
|
||||||
|
"created_at": "2025-01-28T12:00:00",
|
||||||
|
"last_active": "2025-01-28T12:05:00",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 广播消息 (后端主动推送)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/pipelines/<pipeline_uuid>/ws/broadcast
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "这是一条广播消息"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"message": "Broadcast sent successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### Python 客户端示例
|
||||||
|
|
||||||
|
使用提供的测试客户端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
pip install websockets
|
||||||
|
|
||||||
|
# 单个连接测试
|
||||||
|
python test_websocket_client.py <pipeline_uuid>
|
||||||
|
|
||||||
|
# 指定会话类型
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --session-type group
|
||||||
|
|
||||||
|
# 多连接并发测试
|
||||||
|
python test_websocket_client.py <pipeline_uuid> --multi 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript 客户端示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 建立 WebSocket 连接
|
||||||
|
const ws = new WebSocket('ws://localhost:8000/api/v1/pipelines/your-pipeline-uuid/ws/connect?session_type=person');
|
||||||
|
|
||||||
|
// 连接建立
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket 连接已建立');
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
message: [
|
||||||
|
{
|
||||||
|
type: 'Plain',
|
||||||
|
text: '你好'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接收消息
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'connected') {
|
||||||
|
console.log('连接成功:', data.connection_id);
|
||||||
|
} else if (data.type === 'response') {
|
||||||
|
console.log('机器人回复:', data.data.content);
|
||||||
|
if (data.data.is_final) {
|
||||||
|
console.log('响应完成');
|
||||||
|
}
|
||||||
|
} else if (data.type === 'broadcast') {
|
||||||
|
console.log('收到广播:', data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接关闭
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket 连接已关闭');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket 错误:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送心跳
|
||||||
|
setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
}
|
||||||
|
}, 30000); // 每 30 秒发送一次心跳
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特性说明
|
||||||
|
|
||||||
|
### 1. 多连接支持
|
||||||
|
|
||||||
|
系统支持同时建立多个 WebSocket 连接,每个连接都有唯一的 `connection_id`。连接按照流水线和会话类型进行分组管理。
|
||||||
|
|
||||||
|
### 2. 双向通信
|
||||||
|
|
||||||
|
- **前端 → 后端**: 客户端可以主动发送消息给服务器
|
||||||
|
- **后端 → 前端**: 服务器可以通过广播 API 主动推送消息给客户端
|
||||||
|
|
||||||
|
### 3. 流式响应
|
||||||
|
|
||||||
|
支持流式输出,机器人的响应会分块发送,客户端可以实时显示部分响应内容。
|
||||||
|
|
||||||
|
### 4. 会话隔离
|
||||||
|
|
||||||
|
支持 `person` 和 `group` 两种会话类型,不同类型的会话消息历史互不影响。
|
||||||
|
|
||||||
|
### 5. 连接管理
|
||||||
|
|
||||||
|
- 自动追踪连接状态
|
||||||
|
- 记录最后活跃时间
|
||||||
|
- 支持连接统计查询
|
||||||
|
- 连接断开时自动清理资源
|
||||||
|
|
||||||
|
### 6. 心跳机制
|
||||||
|
|
||||||
|
客户端可以定期发送 `ping` 消息,服务器会响应 `pong`,用于保持连接活跃和检测连接状态。
|
||||||
|
|
||||||
|
## 架构优势
|
||||||
|
|
||||||
|
1. **高并发**: 使用 asyncio 异步架构,支持大量并发连接
|
||||||
|
2. **可扩展**: 模块化设计,易于扩展新功能
|
||||||
|
3. **线程安全**: 连接管理器使用锁机制保证并发安全
|
||||||
|
4. **消息队列**: 每个连接独立的发送队列,避免消息混乱
|
||||||
|
5. **灵活路由**: 支持按流水线、会话类型灵活路由消息
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **认证**: 当前 WebSocket 连接不需要认证,生产环境建议添加认证机制
|
||||||
|
2. **心跳**: 建议客户端实现心跳机制,避免连接超时
|
||||||
|
3. **重连**: 客户端应实现断线重连逻辑
|
||||||
|
4. **消息大小**: 注意控制单条消息大小,避免内存溢出
|
||||||
|
5. **连接数限制**: 生产环境建议设置最大连接数限制
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 连接失败
|
||||||
|
|
||||||
|
1. 检查流水线 UUID 是否正确
|
||||||
|
2. 检查服务器是否正常运行
|
||||||
|
3. 检查防火墙设置
|
||||||
|
|
||||||
|
### 消息发送失败
|
||||||
|
|
||||||
|
1. 检查消息格式是否正确
|
||||||
|
2. 检查连接是否仍然活跃
|
||||||
|
3. 查看服务器日志获取详细错误信息
|
||||||
|
|
||||||
|
### 性能问题
|
||||||
|
|
||||||
|
1. 检查并发连接数是否过多
|
||||||
|
2. 检查消息处理速度
|
||||||
|
3. 考虑使用连接池或负载均衡
|
||||||
|
|
||||||
|
## 开发调试
|
||||||
|
|
||||||
|
启用详细日志:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
logging.getLogger('langbot.pkg.platform.sources.websocket_adapter').setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger('langbot.pkg.platform.sources.websocket_manager').setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger('langbot.pkg.api.http.controller.groups.pipelines.websocket_chat').setLevel(logging.DEBUG)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后续改进建议
|
||||||
|
|
||||||
|
1. 添加用户认证和授权机制
|
||||||
|
2. 实现消息持久化
|
||||||
|
3. 添加消息加密
|
||||||
|
4. 实现更丰富的消息类型 (图片、文件等)
|
||||||
|
5. 添加消息已读/未读状态
|
||||||
|
6. 实现群组聊天功能
|
||||||
|
7. 添加在线状态显示
|
||||||
|
8. 实现消息撤回功能
|
||||||
@@ -28,8 +28,56 @@ class FilesRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return quart.Response(image_bytes, mimetype=mime_type)
|
return quart.Response(image_bytes, mimetype=mime_type)
|
||||||
|
|
||||||
|
@self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def upload_image() -> quart.Response:
|
||||||
|
request = quart.request
|
||||||
|
|
||||||
|
# Check file size limit before reading the file
|
||||||
|
content_length = request.content_length
|
||||||
|
if content_length and content_length > group.MAX_FILE_SIZE:
|
||||||
|
return self.fail(400, 'Image size exceeds 10MB limit.')
|
||||||
|
|
||||||
|
# get file bytes from 'file'
|
||||||
|
files = await request.files
|
||||||
|
if 'file' not in files:
|
||||||
|
return self.fail(400, 'No image file provided')
|
||||||
|
|
||||||
|
file = files['file']
|
||||||
|
assert isinstance(file, quart.datastructures.FileStorage)
|
||||||
|
|
||||||
|
file_bytes = await asyncio.to_thread(file.stream.read)
|
||||||
|
|
||||||
|
# Double-check actual file size after reading
|
||||||
|
if len(file_bytes) > group.MAX_FILE_SIZE:
|
||||||
|
return self.fail(400, 'Image size exceeds 10MB limit.')
|
||||||
|
|
||||||
|
# Validate image file extension
|
||||||
|
allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'}
|
||||||
|
if '.' in file.filename:
|
||||||
|
file_name, extension = file.filename.rsplit('.', 1)
|
||||||
|
extension = extension.lower()
|
||||||
|
else:
|
||||||
|
return self.fail(400, 'Invalid image file: no file extension')
|
||||||
|
|
||||||
|
if extension not in allowed_extensions:
|
||||||
|
return self.fail(400, f'Invalid image format. Allowed formats: {", ".join(allowed_extensions)}')
|
||||||
|
|
||||||
|
# check if file name contains '/' or '\'
|
||||||
|
if '/' in file_name or '\\' in file_name:
|
||||||
|
return self.fail(400, 'File name contains invalid characters')
|
||||||
|
|
||||||
|
file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension
|
||||||
|
|
||||||
|
# save file to storage
|
||||||
|
await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'file_key': file_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> quart.Response:
|
async def upload_document() -> quart.Response:
|
||||||
request = quart.request
|
request = quart.request
|
||||||
|
|
||||||
# Check file size limit before reading the file
|
# Check file size limit before reading the file
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import quart
|
|
||||||
|
|
||||||
from ... import group
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('webchat', '/api/v1/pipelines/<pipeline_uuid>/chat')
|
|
||||||
class WebChatDebugRouterGroup(group.RouterGroup):
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('/send', methods=['POST'])
|
|
||||||
async def send_message(pipeline_uuid: str) -> str:
|
|
||||||
"""Send a message to the pipeline for debugging"""
|
|
||||||
|
|
||||||
async def stream_generator(generator):
|
|
||||||
yield 'data: {"type": "start"}\n\n'
|
|
||||||
async for message in generator:
|
|
||||||
yield f'data: {json.dumps({"message": message})}\n\n'
|
|
||||||
yield 'data: {"type": "end"}\n\n'
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await quart.request.get_json()
|
|
||||||
session_type = data.get('session_type', 'person')
|
|
||||||
message_chain_obj = data.get('message', [])
|
|
||||||
is_stream = data.get('is_stream', False)
|
|
||||||
|
|
||||||
if not message_chain_obj:
|
|
||||||
return self.http_status(400, -1, 'message is required')
|
|
||||||
|
|
||||||
if session_type not in ['person', 'group']:
|
|
||||||
return self.http_status(400, -1, 'session_type must be person or group')
|
|
||||||
|
|
||||||
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
|
|
||||||
|
|
||||||
if not webchat_adapter:
|
|
||||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
|
||||||
|
|
||||||
if is_stream:
|
|
||||||
generator = webchat_adapter.send_webchat_message(
|
|
||||||
pipeline_uuid, session_type, message_chain_obj, is_stream
|
|
||||||
)
|
|
||||||
# 设置正确的响应头
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Transfer-Encoding': 'chunked',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
}
|
|
||||||
return quart.Response(stream_generator(generator), mimetype='text/event-stream', headers=headers)
|
|
||||||
|
|
||||||
else: # non-stream
|
|
||||||
result = None
|
|
||||||
async for message in webchat_adapter.send_webchat_message(
|
|
||||||
pipeline_uuid, session_type, message_chain_obj
|
|
||||||
):
|
|
||||||
result = message
|
|
||||||
if result is not None:
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'message': result,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return self.http_status(400, -1, 'message is required')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
|
||||||
|
|
||||||
@self.route('/messages/<session_type>', methods=['GET'])
|
|
||||||
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
|
|
||||||
"""Get the message history of the pipeline for debugging"""
|
|
||||||
try:
|
|
||||||
if session_type not in ['person', 'group']:
|
|
||||||
return self.http_status(400, -1, 'session_type must be person or group')
|
|
||||||
|
|
||||||
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
|
|
||||||
|
|
||||||
if not webchat_adapter:
|
|
||||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
|
||||||
|
|
||||||
messages = webchat_adapter.get_webchat_messages(pipeline_uuid, session_type)
|
|
||||||
|
|
||||||
return self.success(data={'messages': messages})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
|
||||||
|
|
||||||
@self.route('/reset/<session_type>', methods=['POST'])
|
|
||||||
async def reset_session(session_type: str) -> str:
|
|
||||||
"""Reset the debug session"""
|
|
||||||
try:
|
|
||||||
if session_type not in ['person', 'group']:
|
|
||||||
return self.http_status(400, -1, 'session_type must be person or group')
|
|
||||||
|
|
||||||
webchat_adapter = None
|
|
||||||
for bot in self.ap.platform_mgr.bots:
|
|
||||||
if hasattr(bot.adapter, '__class__') and bot.adapter.__class__.__name__ == 'WebChatAdapter':
|
|
||||||
webchat_adapter = bot.adapter
|
|
||||||
break
|
|
||||||
|
|
||||||
if not webchat_adapter:
|
|
||||||
return self.http_status(404, -1, 'WebChat adapter not found')
|
|
||||||
|
|
||||||
webchat_adapter.reset_debug_session(session_type)
|
|
||||||
|
|
||||||
return self.success(data={'message': 'Session reset successfully'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""WebSocket聊天路由 - 支持双向实时通信"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('websocket_chat', '/api/v1/pipelines/<pipeline_uuid>/ws')
|
||||||
|
class WebSocketChatRouterGroup(group.RouterGroup):
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
# 直接使用 quart_app 注册 WebSocket 路由
|
||||||
|
@self.quart_app.websocket(self.path + '/connect')
|
||||||
|
async def websocket_connect(pipeline_uuid: str):
|
||||||
|
"""
|
||||||
|
建立WebSocket连接
|
||||||
|
|
||||||
|
URL参数:
|
||||||
|
- pipeline_uuid: 流水线UUID
|
||||||
|
- session_type: 会话类型 (person/group)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取参数 - 在WebSocket上下文中使用 quart.websocket.args
|
||||||
|
session_type = quart.websocket.args.get('session_type', 'person')
|
||||||
|
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取WebSocket适配器
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
|
||||||
|
if not websocket_adapter:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 注册连接
|
||||||
|
connection = await ws_connection_manager.add_connection(
|
||||||
|
websocket=quart.websocket._get_current_object(),
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
session_type=session_type,
|
||||||
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送连接成功消息
|
||||||
|
await quart.websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
'type': 'connected',
|
||||||
|
'connection_id': connection.connection_id,
|
||||||
|
'pipeline_uuid': pipeline_uuid,
|
||||||
|
'session_type': session_type,
|
||||||
|
'timestamp': connection.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f'WebSocket connection established: {connection.connection_id} '
|
||||||
|
f'(pipeline={pipeline_uuid}, session_type={session_type})'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建接收和发送任务
|
||||||
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
||||||
|
send_task = asyncio.create_task(self._handle_send(connection))
|
||||||
|
|
||||||
|
# 等待任务完成
|
||||||
|
try:
|
||||||
|
await asyncio.gather(receive_task, send_task)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'WebSocket task execution error: {e}')
|
||||||
|
finally:
|
||||||
|
# 清理连接
|
||||||
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||||
|
logger.debug(f'WebSocket connection cleaned: {connection.connection_id}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'WebSocket connection error: {e}', exc_info=True)
|
||||||
|
try:
|
||||||
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@self.route('/messages/<session_type>', methods=['GET'])
|
||||||
|
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
|
||||||
|
"""获取消息历史"""
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||||
|
|
||||||
|
return self.success(data={'messages': messages})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/reset/<session_type>', methods=['POST'])
|
||||||
|
async def reset_session(pipeline_uuid: str, session_type: str) -> str:
|
||||||
|
"""重置会话"""
|
||||||
|
try:
|
||||||
|
if session_type not in ['person', 'group']:
|
||||||
|
return self.http_status(400, -1, 'session_type must be person or group')
|
||||||
|
|
||||||
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||||
|
|
||||||
|
if not websocket_adapter:
|
||||||
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||||
|
|
||||||
|
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||||
|
|
||||||
|
return self.success(data={'message': 'Session reset successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/connections', methods=['GET'])
|
||||||
|
async def get_connections(pipeline_uuid: str) -> str:
|
||||||
|
"""获取当前连接统计"""
|
||||||
|
try:
|
||||||
|
stats = ws_connection_manager.get_stats()
|
||||||
|
connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid)
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'stats': stats,
|
||||||
|
'connections': [
|
||||||
|
{
|
||||||
|
'connection_id': conn.connection_id,
|
||||||
|
'session_type': conn.session_type,
|
||||||
|
'created_at': conn.created_at.isoformat(),
|
||||||
|
'last_active': conn.last_active.isoformat(),
|
||||||
|
'is_active': conn.is_active,
|
||||||
|
}
|
||||||
|
for conn in connections
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
@self.route('/broadcast', methods=['POST'])
|
||||||
|
async def broadcast_message(pipeline_uuid: str) -> str:
|
||||||
|
"""向所有连接广播消息(后端主动推送)"""
|
||||||
|
try:
|
||||||
|
data = await quart.request.get_json()
|
||||||
|
message = data.get('message')
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
return self.http_status(400, -1, 'message is required')
|
||||||
|
|
||||||
|
# 广播消息
|
||||||
|
broadcast_data = {
|
||||||
|
'type': 'broadcast',
|
||||||
|
'message': message,
|
||||||
|
'timestamp': datetime.datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data)
|
||||||
|
|
||||||
|
return self.success(data={'message': 'Broadcast sent successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||||
|
|
||||||
|
async def _handle_receive(self, connection, websocket_adapter):
|
||||||
|
"""处理接收消息的任务"""
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
# 接收消息
|
||||||
|
message = await quart.websocket.receive()
|
||||||
|
|
||||||
|
# 更新活跃时间
|
||||||
|
await ws_connection_manager.update_activity(connection.connection_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
message_type = data.get('type', 'message')
|
||||||
|
|
||||||
|
if message_type == 'ping':
|
||||||
|
# 心跳响应
|
||||||
|
await connection.send_queue.put(
|
||||||
|
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif message_type == 'message':
|
||||||
|
# 处理用户消息
|
||||||
|
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||||
|
|
||||||
|
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||||
|
await websocket_adapter.handle_websocket_message(connection, data)
|
||||||
|
|
||||||
|
elif message_type == 'disconnect':
|
||||||
|
# 客户端主动断开
|
||||||
|
logger.debug(f'Client disconnected: {connection.connection_id}')
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f'Unknown message type: {message_type}')
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f'Invalid JSON message: {message}')
|
||||||
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Receive message error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
|
|
||||||
|
async def _handle_send(self, connection):
|
||||||
|
"""处理发送消息的任务"""
|
||||||
|
try:
|
||||||
|
while connection.is_active:
|
||||||
|
# 从队列获取消息
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||||
|
|
||||||
|
# 发送消息
|
||||||
|
await quart.websocket.send(json.dumps(message))
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# 超时继续循环
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Send message error: {e}', exc_info=True)
|
||||||
|
finally:
|
||||||
|
connection.is_active = False
|
||||||
@@ -156,7 +156,7 @@ class PlatformManager:
|
|||||||
|
|
||||||
bots: list[RuntimeBot]
|
bots: list[RuntimeBot]
|
||||||
|
|
||||||
webchat_proxy_bot: RuntimeBot
|
websocket_proxy_bot: RuntimeBot
|
||||||
|
|
||||||
adapter_components: list[engine.Component]
|
adapter_components: list[engine.Component]
|
||||||
|
|
||||||
@@ -178,31 +178,29 @@ class PlatformManager:
|
|||||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||||
self.adapter_dict = adapter_dict
|
self.adapter_dict = adapter_dict
|
||||||
|
|
||||||
webchat_adapter_class = self.adapter_dict['webchat']
|
# initialize websocket adapter
|
||||||
|
websocket_adapter_class = self.adapter_dict['websocket']
|
||||||
# initialize webchat adapter
|
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||||
webchat_logger = EventLogger(name='webchat-adapter', ap=self.ap)
|
websocket_adapter_inst = websocket_adapter_class(
|
||||||
webchat_adapter_inst = webchat_adapter_class(
|
|
||||||
{},
|
{},
|
||||||
webchat_logger,
|
websocket_logger,
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
is_stream=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.webchat_proxy_bot = RuntimeBot(
|
self.websocket_proxy_bot = RuntimeBot(
|
||||||
ap=self.ap,
|
ap=self.ap,
|
||||||
bot_entity=persistence_bot.Bot(
|
bot_entity=persistence_bot.Bot(
|
||||||
uuid='webchat-proxy-bot',
|
uuid='websocket-proxy-bot',
|
||||||
name='WebChat',
|
name='WebSocket',
|
||||||
description='',
|
description='',
|
||||||
adapter='webchat',
|
adapter='websocket',
|
||||||
adapter_config={},
|
adapter_config={},
|
||||||
enable=True,
|
enable=True,
|
||||||
),
|
),
|
||||||
adapter=webchat_adapter_inst,
|
adapter=websocket_adapter_inst,
|
||||||
logger=webchat_logger,
|
logger=websocket_logger,
|
||||||
)
|
)
|
||||||
await self.webchat_proxy_bot.initialize()
|
await self.websocket_proxy_bot.initialize()
|
||||||
|
|
||||||
await self.load_bots_from_db()
|
await self.load_bots_from_db()
|
||||||
|
|
||||||
@@ -271,7 +269,7 @@ class PlatformManager:
|
|||||||
|
|
||||||
def get_available_adapters_info(self) -> list[dict]:
|
def get_available_adapters_info(self) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'webchat'
|
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket'
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
||||||
@@ -288,7 +286,7 @@ class PlatformManager:
|
|||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
# This method will only be called when the application launching
|
# This method will only be called when the application launching
|
||||||
await self.webchat_proxy_bot.run()
|
await self.websocket_proxy_bot.run()
|
||||||
|
|
||||||
for bot in self.bots:
|
for bot in self.bots:
|
||||||
if bot.enable:
|
if bot.enable:
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import typing
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pydantic
|
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
|
||||||
from ...core import app
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WebChatMessage(pydantic.BaseModel):
|
|
||||||
id: int
|
|
||||||
role: str
|
|
||||||
content: str
|
|
||||||
message_chain: list[dict]
|
|
||||||
timestamp: str
|
|
||||||
is_final: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class WebChatSession:
|
|
||||||
id: str
|
|
||||||
message_lists: dict[str, list[WebChatMessage]] = {}
|
|
||||||
resp_waiters: dict[int, asyncio.Future[WebChatMessage]]
|
|
||||||
resp_queues: dict[int, asyncio.Queue[WebChatMessage]]
|
|
||||||
|
|
||||||
def __init__(self, id: str):
|
|
||||||
self.id = id
|
|
||||||
self.message_lists = {}
|
|
||||||
self.resp_waiters = {}
|
|
||||||
self.resp_queues = {}
|
|
||||||
|
|
||||||
def get_message_list(self, pipeline_uuid: str) -> list[WebChatMessage]:
|
|
||||||
if pipeline_uuid not in self.message_lists:
|
|
||||||
self.message_lists[pipeline_uuid] = []
|
|
||||||
|
|
||||||
return self.message_lists[pipeline_uuid]
|
|
||||||
|
|
||||||
|
|
||||||
class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|
||||||
"""WebChat调试适配器,用于流水线调试"""
|
|
||||||
|
|
||||||
webchat_person_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
|
||||||
webchat_group_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
|
||||||
|
|
||||||
listeners: dict[
|
|
||||||
typing.Type[platform_events.Event],
|
|
||||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
|
||||||
] = pydantic.Field(default_factory=dict, exclude=True)
|
|
||||||
|
|
||||||
is_stream: bool = pydantic.Field(exclude=True)
|
|
||||||
debug_messages: dict[str, list[dict]] = pydantic.Field(default_factory=dict, exclude=True)
|
|
||||||
|
|
||||||
ap: app.Application = pydantic.Field(exclude=True)
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
config=config,
|
|
||||||
logger=logger,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.webchat_person_session = WebChatSession(id='webchatperson')
|
|
||||||
self.webchat_group_session = WebChatSession(id='webchatgroup')
|
|
||||||
|
|
||||||
self.bot_account_id = 'webchatbot'
|
|
||||||
|
|
||||||
self.debug_messages = {}
|
|
||||||
|
|
||||||
async def send_message(
|
|
||||||
self,
|
|
||||||
target_type: str,
|
|
||||||
target_id: str,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
) -> dict:
|
|
||||||
"""发送消息到调试会话"""
|
|
||||||
session_key = target_id
|
|
||||||
|
|
||||||
if session_key not in self.debug_messages:
|
|
||||||
self.debug_messages[session_key] = []
|
|
||||||
|
|
||||||
message_data = {
|
|
||||||
'id': len(self.debug_messages[session_key]) + 1,
|
|
||||||
'type': 'bot',
|
|
||||||
'content': str(message),
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'message_chain': [component.__dict__ for component in message],
|
|
||||||
}
|
|
||||||
|
|
||||||
self.debug_messages[session_key].append(message_data)
|
|
||||||
|
|
||||||
await self.logger.info(f'Send message to {session_key}: {message}')
|
|
||||||
|
|
||||||
return message_data
|
|
||||||
|
|
||||||
async def reply_message(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
"""回复消息"""
|
|
||||||
message_data = WebChatMessage(
|
|
||||||
id=-1,
|
|
||||||
role='assistant',
|
|
||||||
content=str(message),
|
|
||||||
message_chain=[component.__dict__ for component in message],
|
|
||||||
timestamp=datetime.now().isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# notify waiter
|
|
||||||
if isinstance(message_source, platform_events.FriendMessage):
|
|
||||||
await self.webchat_person_session.resp_queues[message_source.message_chain.message_id].put(message_data)
|
|
||||||
elif isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
await self.webchat_group_session.resp_queues[message_source.message_chain.message_id].put(message_data)
|
|
||||||
|
|
||||||
return message_data.model_dump()
|
|
||||||
|
|
||||||
async def reply_message_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
bot_message,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
is_final: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
"""回复消息"""
|
|
||||||
message_data = WebChatMessage(
|
|
||||||
id=-1,
|
|
||||||
role='assistant',
|
|
||||||
content=str(message),
|
|
||||||
message_chain=[component.__dict__ for component in message],
|
|
||||||
timestamp=datetime.now().isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# notify waiter
|
|
||||||
session = (
|
|
||||||
self.webchat_group_session
|
|
||||||
if isinstance(message_source, platform_events.GroupMessage)
|
|
||||||
else self.webchat_person_session
|
|
||||||
)
|
|
||||||
if message_source.message_chain.message_id not in session.resp_waiters:
|
|
||||||
# session.resp_waiters[message_source.message_chain.message_id] = asyncio.Queue()
|
|
||||||
queue = session.resp_queues[message_source.message_chain.message_id]
|
|
||||||
|
|
||||||
# if isinstance(message_source, platform_events.FriendMessage):
|
|
||||||
# queue = self.webchat_person_session.resp_queues[message_source.message_chain.message_id]
|
|
||||||
# elif isinstance(message_source, platform_events.GroupMessage):
|
|
||||||
# queue = self.webchat_group_session.resp_queues[message_source.message_chain.message_id]
|
|
||||||
if is_final and bot_message.tool_calls is None:
|
|
||||||
message_data.is_final = True
|
|
||||||
# print(message_data)
|
|
||||||
await queue.put(message_data)
|
|
||||||
|
|
||||||
return message_data.model_dump()
|
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
|
||||||
return self.is_stream
|
|
||||||
|
|
||||||
def register_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
func: typing.Callable[
|
|
||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
|
||||||
],
|
|
||||||
):
|
|
||||||
"""注册事件监听器"""
|
|
||||||
self.listeners[event_type] = func
|
|
||||||
|
|
||||||
def unregister_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
func: typing.Callable[
|
|
||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
|
||||||
],
|
|
||||||
):
|
|
||||||
"""取消注册事件监听器"""
|
|
||||||
del self.listeners[event_type]
|
|
||||||
|
|
||||||
async def is_muted(self, group_id: int) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def run_async(self):
|
|
||||||
"""运行适配器"""
|
|
||||||
await self.logger.info('WebChat调试适配器已启动')
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
await self.logger.info('WebChat调试适配器已停止')
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def kill(self):
|
|
||||||
"""停止适配器"""
|
|
||||||
await self.logger.info('WebChat调试适配器正在停止')
|
|
||||||
|
|
||||||
async def send_webchat_message(
|
|
||||||
self,
|
|
||||||
pipeline_uuid: str,
|
|
||||||
session_type: str,
|
|
||||||
message_chain_obj: typing.List[dict],
|
|
||||||
is_stream: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
self.is_stream = is_stream
|
|
||||||
"""发送调试消息到流水线"""
|
|
||||||
if session_type == 'person':
|
|
||||||
use_session = self.webchat_person_session
|
|
||||||
else:
|
|
||||||
use_session = self.webchat_group_session
|
|
||||||
|
|
||||||
message_chain = platform_message.MessageChain.parse_obj(message_chain_obj)
|
|
||||||
|
|
||||||
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
|
||||||
|
|
||||||
use_session.resp_queues[message_id] = asyncio.Queue()
|
|
||||||
logger.debug(f'Initialized queue for message_id: {message_id}')
|
|
||||||
|
|
||||||
use_session.get_message_list(pipeline_uuid).append(
|
|
||||||
WebChatMessage(
|
|
||||||
id=message_id,
|
|
||||||
role='user',
|
|
||||||
content=str(message_chain),
|
|
||||||
message_chain=message_chain_obj,
|
|
||||||
timestamp=datetime.now().isoformat(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
|
||||||
|
|
||||||
if session_type == 'person':
|
|
||||||
sender = platform_entities.Friend(id='webchatperson', nickname='User', remark='User')
|
|
||||||
event = platform_events.FriendMessage(
|
|
||||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
group = platform_entities.Group(
|
|
||||||
id='webchatgroup', name='Group', permission=platform_entities.Permission.Member
|
|
||||||
)
|
|
||||||
sender = platform_entities.GroupMember(
|
|
||||||
id='webchatperson',
|
|
||||||
member_name='User',
|
|
||||||
group=group,
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
)
|
|
||||||
event = platform_events.GroupMessage(
|
|
||||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
|
||||||
)
|
|
||||||
|
|
||||||
self.ap.platform_mgr.webchat_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
|
||||||
|
|
||||||
# trigger pipeline
|
|
||||||
if event.__class__ in self.listeners:
|
|
||||||
await self.listeners[event.__class__](event, self)
|
|
||||||
|
|
||||||
if is_stream:
|
|
||||||
queue = use_session.resp_queues[message_id]
|
|
||||||
msg_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
|
||||||
while True:
|
|
||||||
resp_message = await queue.get()
|
|
||||||
resp_message.id = msg_id
|
|
||||||
if resp_message.is_final:
|
|
||||||
resp_message.id = msg_id
|
|
||||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
|
||||||
yield resp_message.model_dump()
|
|
||||||
break
|
|
||||||
yield resp_message.model_dump()
|
|
||||||
use_session.resp_queues.pop(message_id)
|
|
||||||
|
|
||||||
else: # non-stream
|
|
||||||
# set waiter
|
|
||||||
# waiter = asyncio.Future[WebChatMessage]()
|
|
||||||
# use_session.resp_waiters[message_id] = waiter
|
|
||||||
# # waiter.add_done_callback(lambda future: use_session.resp_waiters.pop(message_id))
|
|
||||||
#
|
|
||||||
# resp_message = await waiter
|
|
||||||
#
|
|
||||||
# resp_message.id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
|
||||||
#
|
|
||||||
# use_session.get_message_list(pipeline_uuid).append(resp_message)
|
|
||||||
#
|
|
||||||
# yield resp_message.model_dump()
|
|
||||||
msg_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
|
||||||
|
|
||||||
queue = use_session.resp_queues[message_id]
|
|
||||||
resp_message = await queue.get()
|
|
||||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
|
||||||
resp_message.id = msg_id
|
|
||||||
resp_message.is_final = True
|
|
||||||
|
|
||||||
yield resp_message.model_dump()
|
|
||||||
|
|
||||||
def get_webchat_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
|
||||||
"""获取调试消息历史"""
|
|
||||||
if session_type == 'person':
|
|
||||||
return [message.model_dump() for message in self.webchat_person_session.get_message_list(pipeline_uuid)]
|
|
||||||
else:
|
|
||||||
return [message.model_dump() for message in self.webchat_group_session.get_message_list(pipeline_uuid)]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: MessagePlatformAdapter
|
|
||||||
metadata:
|
|
||||||
name: webchat
|
|
||||||
label:
|
|
||||||
en_US: "WebChat Debug"
|
|
||||||
zh_Hans: "网页聊天调试"
|
|
||||||
description:
|
|
||||||
en_US: "WebChat adapter for pipeline debugging"
|
|
||||||
zh_Hans: "用于流水线调试的网页聊天适配器"
|
|
||||||
icon: ""
|
|
||||||
spec:
|
|
||||||
config: []
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: "webchat.py"
|
|
||||||
attr: "WebChatAdapter"
|
|
||||||
17
src/langbot/pkg/platform/sources/websocket.yaml
Normal file
17
src/langbot/pkg/platform/sources/websocket.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: websocket
|
||||||
|
label:
|
||||||
|
en_US: "WebSocket Chat"
|
||||||
|
zh_Hans: "WebSocket 聊天"
|
||||||
|
description:
|
||||||
|
en_US: "WebSocket adapter for bidirectional real-time communication"
|
||||||
|
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
||||||
|
icon: ""
|
||||||
|
spec:
|
||||||
|
config: []
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: "websocket_adapter.py"
|
||||||
|
attr: "WebSocketAdapter"
|
||||||
402
src/langbot/pkg/platform/sources/websocket_adapter.py
Normal file
402
src/langbot/pkg/platform/sources/websocket_adapter.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""WebSocket适配器 - 支持双向通信的IM系统"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import typing
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pydantic
|
||||||
|
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
from ...core import app
|
||||||
|
from .websocket_manager import ws_connection_manager, WebSocketConnection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketMessage(pydantic.BaseModel):
|
||||||
|
"""WebSocket消息格式"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
role: str # 'user' or 'assistant'
|
||||||
|
content: str
|
||||||
|
message_chain: list[dict]
|
||||||
|
timestamp: str
|
||||||
|
is_final: bool = False
|
||||||
|
connection_id: str = ''
|
||||||
|
"""发送者连接ID"""
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketSession:
|
||||||
|
"""WebSocket会话 - 管理单个会话的消息历史"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
message_lists: dict[str, list[WebSocketMessage]] = {}
|
||||||
|
"""消息列表 {pipeline_uuid: [messages]}"""
|
||||||
|
|
||||||
|
def __init__(self, id: str):
|
||||||
|
self.id = id
|
||||||
|
self.message_lists = {}
|
||||||
|
|
||||||
|
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
||||||
|
if pipeline_uuid not in self.message_lists:
|
||||||
|
self.message_lists[pipeline_uuid] = []
|
||||||
|
return self.message_lists[pipeline_uuid]
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
|
"""WebSocket适配器 - 支持双向实时通信"""
|
||||||
|
|
||||||
|
websocket_person_session: WebSocketSession = pydantic.Field(exclude=True, default_factory=WebSocketSession)
|
||||||
|
websocket_group_session: WebSocketSession = pydantic.Field(exclude=True, default_factory=WebSocketSession)
|
||||||
|
|
||||||
|
listeners: dict[
|
||||||
|
typing.Type[platform_events.Event],
|
||||||
|
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||||
|
] = pydantic.Field(default_factory=dict, exclude=True)
|
||||||
|
|
||||||
|
ap: app.Application = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
|
# 主动推送消息的队列
|
||||||
|
outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
|
||||||
|
"""后端主动推送消息的队列"""
|
||||||
|
|
||||||
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
config=config,
|
||||||
|
logger=logger,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.websocket_person_session = WebSocketSession(id='websocketperson')
|
||||||
|
self.websocket_group_session = WebSocketSession(id='websocketgroup')
|
||||||
|
|
||||||
|
self.bot_account_id = 'websocketbot'
|
||||||
|
self.outbound_message_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
) -> dict:
|
||||||
|
"""发送消息 - 这里用于主动推送消息到前端"""
|
||||||
|
message_data = {
|
||||||
|
'type': 'bot_message',
|
||||||
|
'target_type': target_type,
|
||||||
|
'target_id': target_id,
|
||||||
|
'content': str(message),
|
||||||
|
'message_chain': [component.__dict__ for component in message],
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 推送到所有相关连接
|
||||||
|
await self.outbound_message_queue.put(message_data)
|
||||||
|
|
||||||
|
await self.logger.info(f'Send message to {target_id}: {message}')
|
||||||
|
|
||||||
|
return message_data
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""回复消息 - 非流式"""
|
||||||
|
# 获取会话和pipeline信息
|
||||||
|
session = (
|
||||||
|
self.websocket_group_session
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage)
|
||||||
|
else self.websocket_person_session
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从message_source获取pipeline_uuid和connection_id
|
||||||
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
|
# session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||||
|
|
||||||
|
# 生成新的消息ID
|
||||||
|
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||||
|
|
||||||
|
message_data = WebSocketMessage(
|
||||||
|
id=msg_id,
|
||||||
|
role='assistant',
|
||||||
|
content=str(message),
|
||||||
|
message_chain=[component.__dict__ for component in message],
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存到历史记录
|
||||||
|
session.get_message_list(pipeline_uuid).append(message_data)
|
||||||
|
|
||||||
|
# 直接广播到所有该pipeline的连接
|
||||||
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
|
pipeline_uuid,
|
||||||
|
{
|
||||||
|
'type': 'response',
|
||||||
|
'data': message_data.model_dump(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return message_data.model_dump()
|
||||||
|
|
||||||
|
async def reply_message_chunk(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
bot_message,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
is_final: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""回复消息块 - 流式"""
|
||||||
|
# 获取会话和pipeline信息
|
||||||
|
session = (
|
||||||
|
self.websocket_group_session
|
||||||
|
if isinstance(message_source, platform_events.GroupMessage)
|
||||||
|
else self.websocket_person_session
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
|
message_list = session.get_message_list(pipeline_uuid)
|
||||||
|
|
||||||
|
# 检查是否是新的流式消息(通过bot_message对象判断)
|
||||||
|
# 如果列表为空,或者最后一条消息已经is_final=True,则创建新消息
|
||||||
|
if not message_list or message_list[-1].is_final:
|
||||||
|
# 创建新消息
|
||||||
|
msg_id = len(message_list) + 1
|
||||||
|
message_data = WebSocketMessage(
|
||||||
|
id=msg_id,
|
||||||
|
role='assistant',
|
||||||
|
content=str(message),
|
||||||
|
message_chain=[component.__dict__ for component in message],
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 只有在is_final时才保存到历史记录
|
||||||
|
if is_final and bot_message.tool_calls is None:
|
||||||
|
message_list.append(message_data)
|
||||||
|
else:
|
||||||
|
# 更新最后一条消息
|
||||||
|
msg_id = message_list[-1].id
|
||||||
|
message_data = WebSocketMessage(
|
||||||
|
id=msg_id,
|
||||||
|
role='assistant',
|
||||||
|
content=str(message),
|
||||||
|
message_chain=[component.__dict__ for component in message],
|
||||||
|
timestamp=message_list[-1].timestamp, # 保持原始时间戳
|
||||||
|
is_final=is_final and bot_message.tool_calls is None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果是final,更新历史记录中的最后一条
|
||||||
|
if is_final and bot_message.tool_calls is None:
|
||||||
|
message_list[-1] = message_data
|
||||||
|
|
||||||
|
# 直接广播到所有该pipeline的连接
|
||||||
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
|
pipeline_uuid,
|
||||||
|
{
|
||||||
|
'type': 'response',
|
||||||
|
'data': message_data.model_dump(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return message_data.model_dump()
|
||||||
|
|
||||||
|
async def is_stream_output_supported(self) -> bool:
|
||||||
|
"""WebSocket始终支持流式输出"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def register_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
func: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||||
|
],
|
||||||
|
):
|
||||||
|
"""注册事件监听器"""
|
||||||
|
self.listeners[event_type] = func
|
||||||
|
|
||||||
|
def unregister_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
func: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||||
|
],
|
||||||
|
):
|
||||||
|
"""取消注册事件监听器"""
|
||||||
|
del self.listeners[event_type]
|
||||||
|
|
||||||
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def run_async(self):
|
||||||
|
"""运行适配器"""
|
||||||
|
await self.logger.info('WebSocket适配器已启动')
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# 处理主动推送消息
|
||||||
|
if not self.outbound_message_queue.empty():
|
||||||
|
try:
|
||||||
|
message = await asyncio.wait_for(self.outbound_message_queue.get(), timeout=0.1)
|
||||||
|
# 广播到所有相关连接
|
||||||
|
target_id = message.get('target_id', '')
|
||||||
|
await ws_connection_manager.broadcast_to_pipeline(target_id, message)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await self.logger.info('WebSocket适配器已停止')
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def kill(self):
|
||||||
|
"""停止适配器"""
|
||||||
|
await self.logger.info('WebSocket适配器正在停止')
|
||||||
|
|
||||||
|
async def _process_image_components(self, message_chain_obj: list):
|
||||||
|
"""
|
||||||
|
处理消息链中的图片组件,将path转换为base64
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_chain_obj: 消息链对象列表
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
storage_mgr = self.ap.storage_mgr
|
||||||
|
|
||||||
|
for component in message_chain_obj:
|
||||||
|
if component.get('type') == 'Image' and component.get('path'):
|
||||||
|
try:
|
||||||
|
# 从storage读取文件
|
||||||
|
file_content = await storage_mgr.storage_provider.load(component['path'])
|
||||||
|
|
||||||
|
# 转换为base64
|
||||||
|
base64_str = base64.b64encode(file_content).decode('utf-8')
|
||||||
|
|
||||||
|
# 添加data URI前缀(根据文件扩展名判断MIME类型)
|
||||||
|
file_key = component['path']
|
||||||
|
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
||||||
|
mime_type = 'image/jpeg'
|
||||||
|
elif file_key.lower().endswith('.png'):
|
||||||
|
mime_type = 'image/png'
|
||||||
|
elif file_key.lower().endswith('.gif'):
|
||||||
|
mime_type = 'image/gif'
|
||||||
|
elif file_key.lower().endswith('.webp'):
|
||||||
|
mime_type = 'image/webp'
|
||||||
|
else:
|
||||||
|
mime_type = 'image/png' # 默认
|
||||||
|
|
||||||
|
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||||
|
await storage_mgr.storage_provider.delete(component['path'])
|
||||||
|
component['path'] = ''
|
||||||
|
# 保留path字段用于后端处理,前端使用base64显示
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}')
|
||||||
|
|
||||||
|
async def handle_websocket_message(
|
||||||
|
self,
|
||||||
|
connection: WebSocketConnection,
|
||||||
|
message_data: dict,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
处理从WebSocket接收的消息
|
||||||
|
|
||||||
|
这个方法只负责接收消息、保存到历史记录、并触发事件处理
|
||||||
|
不等待任何响应,响应消息会通过reply_message/reply_message_chunk直接发送
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connection: WebSocket连接对象
|
||||||
|
message_data: 消息数据
|
||||||
|
"""
|
||||||
|
pipeline_uuid = connection.pipeline_uuid
|
||||||
|
session_type = connection.session_type
|
||||||
|
|
||||||
|
# 选择会话
|
||||||
|
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||||
|
|
||||||
|
# 解析消息链
|
||||||
|
message_chain_obj = message_data.get('message', [])
|
||||||
|
|
||||||
|
# 处理图片组件:将path转换为base64
|
||||||
|
await self._process_image_components(message_chain_obj)
|
||||||
|
|
||||||
|
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
|
||||||
|
|
||||||
|
# 生成消息ID
|
||||||
|
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||||
|
|
||||||
|
# 保存用户消息
|
||||||
|
user_message = WebSocketMessage(
|
||||||
|
id=message_id,
|
||||||
|
role='user',
|
||||||
|
content=str(message_chain),
|
||||||
|
message_chain=message_chain_obj,
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
connection_id=connection.connection_id,
|
||||||
|
is_final=True, # 用户消息始终是完整的,非流式
|
||||||
|
)
|
||||||
|
use_session.get_message_list(pipeline_uuid).append(user_message)
|
||||||
|
|
||||||
|
# 广播用户消息到所有连接(包括发送者)
|
||||||
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
|
pipeline_uuid,
|
||||||
|
{
|
||||||
|
'type': 'user_message',
|
||||||
|
'data': user_message.model_dump(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加消息源
|
||||||
|
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||||
|
|
||||||
|
# 创建事件
|
||||||
|
if session_type == 'person':
|
||||||
|
sender = platform_entities.Friend(
|
||||||
|
id=f'websocket_{connection.connection_id}', nickname='User', remark='User'
|
||||||
|
)
|
||||||
|
event = platform_events.FriendMessage(
|
||||||
|
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
group = platform_entities.Group(
|
||||||
|
id='websocketgroup', name='Group', permission=platform_entities.Permission.Member
|
||||||
|
)
|
||||||
|
sender = platform_entities.GroupMember(
|
||||||
|
id=f'websocket_{connection.connection_id}',
|
||||||
|
member_name='User',
|
||||||
|
group=group,
|
||||||
|
permission=platform_entities.Permission.Member,
|
||||||
|
)
|
||||||
|
event = platform_events.GroupMessage(
|
||||||
|
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置流水线UUID
|
||||||
|
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||||
|
|
||||||
|
# 异步触发事件处理(不等待结果)
|
||||||
|
if event.__class__ in self.listeners:
|
||||||
|
asyncio.create_task(self.listeners[event.__class__](event, self))
|
||||||
|
|
||||||
|
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||||
|
"""获取消息历史"""
|
||||||
|
if session_type == 'person':
|
||||||
|
return [message.model_dump() for message in self.websocket_person_session.get_message_list(pipeline_uuid)]
|
||||||
|
else:
|
||||||
|
return [message.model_dump() for message in self.websocket_group_session.get_message_list(pipeline_uuid)]
|
||||||
|
|
||||||
|
def reset_session(self, pipeline_uuid: str, session_type: str):
|
||||||
|
"""重置会话"""
|
||||||
|
if session_type == 'person':
|
||||||
|
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||||
|
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
||||||
|
else:
|
||||||
|
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||||
|
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
||||||
177
src/langbot/pkg/platform/sources/websocket_manager.py
Normal file
177
src/langbot/pkg/platform/sources/websocket_manager.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""WebSocket连接管理器 - 管理多个并发WebSocket连接"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import typing
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pydantic
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketConnection(pydantic.BaseModel):
|
||||||
|
"""单个WebSocket连接"""
|
||||||
|
|
||||||
|
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
connection_id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
"""连接唯一ID"""
|
||||||
|
|
||||||
|
pipeline_uuid: str
|
||||||
|
"""关联的流水线UUID"""
|
||||||
|
|
||||||
|
session_type: str # 'person' or 'group'
|
||||||
|
"""会话类型"""
|
||||||
|
|
||||||
|
websocket: typing.Any = pydantic.Field(exclude=True)
|
||||||
|
"""WebSocket连接对象 (quart.websocket)"""
|
||||||
|
|
||||||
|
created_at: datetime = pydantic.Field(default_factory=datetime.now)
|
||||||
|
"""连接创建时间"""
|
||||||
|
|
||||||
|
last_active: datetime = pydantic.Field(default_factory=datetime.now)
|
||||||
|
"""最后活跃时间"""
|
||||||
|
|
||||||
|
send_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
|
||||||
|
"""发送消息队列"""
|
||||||
|
|
||||||
|
is_active: bool = True
|
||||||
|
"""连接是否活跃"""
|
||||||
|
|
||||||
|
metadata: dict = pydantic.Field(default_factory=dict)
|
||||||
|
"""连接元数据(可存储额外信息)"""
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketConnectionManager:
|
||||||
|
"""WebSocket连接管理器 - 支持多连接并发"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connections: dict[str, WebSocketConnection] = {}
|
||||||
|
"""所有活跃连接 {connection_id: connection}"""
|
||||||
|
|
||||||
|
self.pipeline_connections: dict[str, set[str]] = {}
|
||||||
|
"""流水线到连接的映射 {pipeline_uuid: {connection_id, ...}}"""
|
||||||
|
|
||||||
|
self.session_connections: dict[str, set[str]] = {}
|
||||||
|
"""会话类型到连接的映射 {session_type: {connection_id, ...}}"""
|
||||||
|
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
"""线程锁,保护并发访问"""
|
||||||
|
|
||||||
|
async def add_connection(
|
||||||
|
self,
|
||||||
|
websocket: typing.Any,
|
||||||
|
pipeline_uuid: str,
|
||||||
|
session_type: str,
|
||||||
|
metadata: dict = None,
|
||||||
|
) -> WebSocketConnection:
|
||||||
|
"""添加新的WebSocket连接"""
|
||||||
|
async with self._lock:
|
||||||
|
connection = WebSocketConnection(
|
||||||
|
pipeline_uuid=pipeline_uuid,
|
||||||
|
session_type=session_type,
|
||||||
|
websocket=websocket,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.connections[connection.connection_id] = connection
|
||||||
|
|
||||||
|
# 更新流水线映射
|
||||||
|
if pipeline_uuid not in self.pipeline_connections:
|
||||||
|
self.pipeline_connections[pipeline_uuid] = set()
|
||||||
|
self.pipeline_connections[pipeline_uuid].add(connection.connection_id)
|
||||||
|
|
||||||
|
# 更新会话类型映射
|
||||||
|
if session_type not in self.session_connections:
|
||||||
|
self.session_connections[session_type] = set()
|
||||||
|
self.session_connections[session_type].add(connection.connection_id)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f'WebSocket connection established: {connection.connection_id} '
|
||||||
|
f'(pipeline={pipeline_uuid}, session_type={session_type})'
|
||||||
|
)
|
||||||
|
|
||||||
|
return connection
|
||||||
|
|
||||||
|
async def remove_connection(self, connection_id: str):
|
||||||
|
"""移除WebSocket连接"""
|
||||||
|
async with self._lock:
|
||||||
|
if connection_id not in self.connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
connection = self.connections[connection_id]
|
||||||
|
connection.is_active = False
|
||||||
|
|
||||||
|
# 从流水线映射中移除
|
||||||
|
if connection.pipeline_uuid in self.pipeline_connections:
|
||||||
|
self.pipeline_connections[connection.pipeline_uuid].discard(connection_id)
|
||||||
|
if not self.pipeline_connections[connection.pipeline_uuid]:
|
||||||
|
del self.pipeline_connections[connection.pipeline_uuid]
|
||||||
|
|
||||||
|
# 从会话类型映射中移除
|
||||||
|
if connection.session_type in self.session_connections:
|
||||||
|
self.session_connections[connection.session_type].discard(connection_id)
|
||||||
|
if not self.session_connections[connection.session_type]:
|
||||||
|
del self.session_connections[connection.session_type]
|
||||||
|
|
||||||
|
del self.connections[connection_id]
|
||||||
|
|
||||||
|
logger.debug(f'WebSocket connection disconnected: {connection_id}')
|
||||||
|
|
||||||
|
async def get_connection(self, connection_id: str) -> typing.Optional[WebSocketConnection]:
|
||||||
|
"""获取指定连接"""
|
||||||
|
return self.connections.get(connection_id)
|
||||||
|
|
||||||
|
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
|
||||||
|
"""获取指定流水线的所有连接"""
|
||||||
|
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
|
||||||
|
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||||
|
|
||||||
|
async def get_connections_by_session_type(self, session_type: str) -> list[WebSocketConnection]:
|
||||||
|
"""获取指定会话类型的所有连接"""
|
||||||
|
connection_ids = self.session_connections.get(session_type, set())
|
||||||
|
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||||
|
|
||||||
|
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict):
|
||||||
|
"""向指定流水线的所有连接广播消息"""
|
||||||
|
connections = await self.get_connections_by_pipeline(pipeline_uuid)
|
||||||
|
tasks = []
|
||||||
|
for conn in connections:
|
||||||
|
tasks.append(self.send_to_connection(conn.connection_id, message))
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
async def send_to_connection(self, connection_id: str, message: dict):
|
||||||
|
"""向指定连接发送消息"""
|
||||||
|
connection = await self.get_connection(connection_id)
|
||||||
|
if not connection or not connection.is_active:
|
||||||
|
logger.warning(f'Attempt to send message to invalid connection: {connection_id}')
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await connection.send_queue.put(message)
|
||||||
|
connection.last_active = datetime.now()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send message to connection {connection_id}: {e}')
|
||||||
|
await self.remove_connection(connection_id)
|
||||||
|
|
||||||
|
async def update_activity(self, connection_id: str):
|
||||||
|
"""更新连接活跃时间"""
|
||||||
|
connection = await self.get_connection(connection_id)
|
||||||
|
if connection:
|
||||||
|
connection.last_active = datetime.now()
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""获取连接统计信息"""
|
||||||
|
return {
|
||||||
|
'total_connections': len(self.connections),
|
||||||
|
'pipelines': len(self.pipeline_connections),
|
||||||
|
'connections_by_pipeline': {k: len(v) for k, v in self.pipeline_connections.items()},
|
||||||
|
'connections_by_session_type': {k: len(v) for k, v in self.session_connections.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 全局连接管理器实例
|
||||||
|
ws_connection_manager = WebSocketConnectionManager()
|
||||||
@@ -129,7 +129,6 @@ export default function BotForm({
|
|||||||
form.setValue('adapter_config', val.adapter_config);
|
form.setValue('adapter_config', val.adapter_config);
|
||||||
form.setValue('enable', val.enable);
|
form.setValue('enable', val.enable);
|
||||||
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
|
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
|
||||||
console.log('form', form.getValues());
|
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
// dynamicForm.setFieldsValue(val.adapter_config);
|
// dynamicForm.setFieldsValue(val.adapter_config);
|
||||||
})
|
})
|
||||||
@@ -145,7 +144,6 @@ export default function BotForm({
|
|||||||
async function initBotFormComponent() {
|
async function initBotFormComponent() {
|
||||||
// 初始化流水线列表
|
// 初始化流水线列表
|
||||||
const pipelinesRes = await httpClient.getPipelines();
|
const pipelinesRes = await httpClient.getPipelines();
|
||||||
console.log('rawPipelineList', pipelinesRes);
|
|
||||||
setPipelineNameList(
|
setPipelineNameList(
|
||||||
pipelinesRes.pipelines.map((item) => {
|
pipelinesRes.pipelines.map((item) => {
|
||||||
return {
|
return {
|
||||||
@@ -157,7 +155,6 @@ export default function BotForm({
|
|||||||
|
|
||||||
// 拉取adapter
|
// 拉取adapter
|
||||||
const adaptersRes = await httpClient.getAdapters();
|
const adaptersRes = await httpClient.getAdapters();
|
||||||
console.log('rawAdapterList', adaptersRes);
|
|
||||||
setAdapterNameList(
|
setAdapterNameList(
|
||||||
adaptersRes.adapters.map((item) => {
|
adaptersRes.adapters.map((item) => {
|
||||||
return {
|
return {
|
||||||
@@ -253,12 +250,10 @@ export default function BotForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
|
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
|
||||||
function onDynamicFormSubmit(value: object) {
|
function onDynamicFormSubmit() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
console.log('set loading', true);
|
|
||||||
if (initBotId) {
|
if (initBotId) {
|
||||||
// 编辑提交
|
// 编辑提交
|
||||||
// console.log('submit edit', form.getFieldsValue(), value);
|
|
||||||
const updateBot: Bot = {
|
const updateBot: Bot = {
|
||||||
uuid: initBotId,
|
uuid: initBotId,
|
||||||
name: form.getValues().name,
|
name: form.getValues().name,
|
||||||
@@ -270,8 +265,7 @@ export default function BotForm({
|
|||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.updateBot(initBotId, updateBot)
|
.updateBot(initBotId, updateBot)
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
console.log('update bot success', res);
|
|
||||||
onFormSubmit(form.getValues());
|
onFormSubmit(form.getValues());
|
||||||
toast.success(t('bots.saveSuccess'));
|
toast.success(t('bots.saveSuccess'));
|
||||||
})
|
})
|
||||||
@@ -285,7 +279,6 @@ export default function BotForm({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 创建提交
|
// 创建提交
|
||||||
console.log('submit create', form.getValues(), value);
|
|
||||||
const newBot: Bot = {
|
const newBot: Bot = {
|
||||||
name: form.getValues().name,
|
name: form.getValues().name,
|
||||||
description: form.getValues().description,
|
description: form.getValues().description,
|
||||||
@@ -295,7 +288,6 @@ export default function BotForm({
|
|||||||
httpClient
|
httpClient
|
||||||
.createBot(newBot)
|
.createBot(newBot)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
console.log('create bot success', res);
|
|
||||||
toast.success(t('bots.createSuccess'));
|
toast.success(t('bots.createSuccess'));
|
||||||
initBotId = res.uuid;
|
initBotId = res.uuid;
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export default function BotConfigPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNewBotCreated(botId: string) {
|
function handleNewBotCreated(botId: string) {
|
||||||
console.log('new bot created', botId);
|
|
||||||
getBotList();
|
getBotList();
|
||||||
setSelectedBotId(botId);
|
setSelectedBotId(botId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ export default function DynamicFormComponent({
|
|||||||
// 当 initialValues 变化时更新表单值
|
// 当 initialValues 变化时更新表单值
|
||||||
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('initialValues', initialValues);
|
|
||||||
|
|
||||||
// 首次挂载时,使用 initialValues 初始化表单
|
// 首次挂载时,使用 initialValues 初始化表单
|
||||||
if (isInitialMount.current) {
|
if (isInitialMount.current) {
|
||||||
isInitialMount.current = false;
|
isInitialMount.current = false;
|
||||||
@@ -148,7 +146,6 @@ export default function DynamicFormComponent({
|
|||||||
const subscription = form.watch(() => {
|
const subscription = form.watch(() => {
|
||||||
// 获取完整的表单值,确保包含所有默认值
|
// 获取完整的表单值,确保包含所有默认值
|
||||||
const formValues = form.getValues();
|
const formValues = form.getValues();
|
||||||
console.log('formValues', formValues);
|
|
||||||
const finalValues = itemConfigList.reduce(
|
const finalValues = itemConfigList.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc[item.name] = formValues[item.name] ?? item.default;
|
acc[item.name] = formValues[item.name] ?? item.default;
|
||||||
@@ -156,7 +153,6 @@ export default function DynamicFormComponent({
|
|||||||
},
|
},
|
||||||
{} as Record<string, object>,
|
{} as Record<string, object>,
|
||||||
);
|
);
|
||||||
console.log('finalValues', finalValues);
|
|
||||||
onSubmit?.(finalValues);
|
onSubmit?.(finalValues);
|
||||||
});
|
});
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export default function HomeSidebar({
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to fetch GitHub star count:', error);
|
console.error('Failed to fetch GitHub star count:', error);
|
||||||
});
|
});
|
||||||
return () => console.log('sidebar.unmounted');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleChildClick(child: SidebarChildVO) {
|
function handleChildClick(child: SidebarChildVO) {
|
||||||
@@ -90,7 +89,6 @@ export default function HomeSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRoute(child: SidebarChildVO) {
|
function handleRoute(child: SidebarChildVO) {
|
||||||
console.log(child);
|
|
||||||
router.push(`${child.route}`);
|
router.push(`${child.route}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +100,6 @@ export default function HomeSidebar({
|
|||||||
routeList[1] === 'home' &&
|
routeList[1] === 'home' &&
|
||||||
sidebarConfigList.find((childConfig) => childConfig.route === pathname)
|
sidebarConfigList.find((childConfig) => childConfig.route === pathname)
|
||||||
) {
|
) {
|
||||||
console.log('find success');
|
|
||||||
const routeSelectChild = sidebarConfigList.find(
|
const routeSelectChild = sidebarConfigList.find(
|
||||||
(childConfig) => childConfig.route === pathname,
|
(childConfig) => childConfig.route === pathname,
|
||||||
);
|
);
|
||||||
@@ -144,7 +141,6 @@ export default function HomeSidebar({
|
|||||||
<div
|
<div
|
||||||
key={config.id}
|
key={config.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('click:', config.id);
|
|
||||||
handleChildClick(config);
|
handleChildClick(config);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -103,8 +103,6 @@ export default function KBForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||||
console.log('data', data);
|
|
||||||
|
|
||||||
if (initKbId) {
|
if (initKbId) {
|
||||||
// update knowledge base
|
// update knowledge base
|
||||||
const updateKb: KnowledgeBase = {
|
const updateKb: KnowledgeBase = {
|
||||||
@@ -116,7 +114,6 @@ export default function KBForm({
|
|||||||
httpClient
|
httpClient
|
||||||
.updateKnowledgeBase(initKbId, updateKb)
|
.updateKnowledgeBase(initKbId, updateKb)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
console.log('update knowledge base success', res);
|
|
||||||
onKbUpdated(res.uuid);
|
onKbUpdated(res.uuid);
|
||||||
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||||
})
|
})
|
||||||
@@ -135,7 +132,6 @@ export default function KBForm({
|
|||||||
httpClient
|
httpClient
|
||||||
.createKnowledgeBase(newKb)
|
.createKnowledgeBase(newKb)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
console.log('create knowledge base success', res);
|
|
||||||
onNewKbCreated(res.uuid);
|
onNewKbCreated(res.uuid);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -200,7 +196,6 @@ export default function KBForm({
|
|||||||
disabled={!!initKbId}
|
disabled={!!initKbId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
console.log('value', value);
|
|
||||||
}}
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -326,8 +326,7 @@ export default function EmbeddingForm({
|
|||||||
api_keys: apiKey ? [apiKey] : [],
|
api_keys: apiKey ? [apiKey] : [],
|
||||||
extra_args: extraArgsObj,
|
extra_args: extraArgsObj,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
console.log(res);
|
|
||||||
toast.success(t('models.testSuccess'));
|
toast.success(t('models.testSuccess'));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -341,8 +341,7 @@ export default function LLMForm({
|
|||||||
abilities: form.getValues('abilities'),
|
abilities: form.getValues('abilities'),
|
||||||
extra_args: extraArgsObj,
|
extra_args: extraArgsObj,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
console.log(res);
|
|
||||||
toast.success(t('models.testSuccess'));
|
toast.success(t('models.testSuccess'));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export default function LLMConfigPage() {
|
|||||||
.getProviderLLMModels()
|
.getProviderLLMModels()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
const llmModelList: LLMCardVO[] = resp.models.map((model: LLMModel) => {
|
const llmModelList: LLMCardVO[] = resp.models.map((model: LLMModel) => {
|
||||||
console.log('model', model);
|
|
||||||
return new LLMCardVO({
|
return new LLMCardVO({
|
||||||
id: model.uuid,
|
id: model.uuid,
|
||||||
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
|
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
|
||||||
@@ -66,7 +65,6 @@ export default function LLMConfigPage() {
|
|||||||
abilities: model.abilities || [],
|
abilities: model.abilities || [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
console.log('get llmModelList', llmModelList);
|
|
||||||
setCardList(llmModelList);
|
setCardList(llmModelList);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -78,7 +76,6 @@ export default function LLMConfigPage() {
|
|||||||
function selectLLM(cardVO: LLMCardVO) {
|
function selectLLM(cardVO: LLMCardVO) {
|
||||||
setIsEditForm(true);
|
setIsEditForm(true);
|
||||||
setNowSelectedLLM(cardVO);
|
setNowSelectedLLM(cardVO);
|
||||||
console.log('set now vo', cardVO);
|
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
function handleCreateModelClick() {
|
function handleCreateModelClick() {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function PipelineDialog({
|
|||||||
propPipelineId,
|
propPipelineId,
|
||||||
);
|
);
|
||||||
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
|
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
|
||||||
|
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPipelineId(propPipelineId);
|
setPipelineId(propPipelineId);
|
||||||
@@ -184,10 +185,29 @@ export default function PipelineDialog({
|
|||||||
</Sidebar>
|
</Sidebar>
|
||||||
<main className="flex flex-1 flex-col h-full min-h-0">
|
<main className="flex flex-1 flex-col h-full min-h-0">
|
||||||
<DialogHeader
|
<DialogHeader
|
||||||
className="px-6 pt-6 pb-4 shrink-0"
|
className="px-6 pt-6 pb-4 shrink-0 flex flex-row items-center justify-start"
|
||||||
style={{ height: '4rem' }}
|
style={{ height: '4rem' }}
|
||||||
>
|
>
|
||||||
<DialogTitle>{getDialogTitle()}</DialogTitle>
|
<DialogTitle>{getDialogTitle()}</DialogTitle>
|
||||||
|
{currentMode === 'debug' && (
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
<div
|
||||||
|
className={`w-2.5 h-2.5 rounded-full ${
|
||||||
|
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isWebSocketConnected
|
||||||
|
? t('pipelines.debugDialog.connected')
|
||||||
|
: t('pipelines.debugDialog.disconnected')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{isWebSocketConnected
|
||||||
|
? t('pipelines.debugDialog.connected')
|
||||||
|
: t('pipelines.debugDialog.disconnected')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto px-6 pb-4 w-full"
|
className="flex-1 overflow-y-auto px-6 pb-4 w-full"
|
||||||
@@ -217,6 +237,7 @@ export default function PipelineDialog({
|
|||||||
open={true}
|
open={true}
|
||||||
pipelineId={pipelineId}
|
pipelineId={pipelineId}
|
||||||
isEmbedded={true}
|
isEmbedded={true}
|
||||||
|
onConnectionStatusChange={setIsWebSocketConnected}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,30 +4,32 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { DialogContent } from '@/components/ui/dialog';
|
import { DialogContent } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Message } from '@/app/infra/entities/message';
|
import {
|
||||||
|
Message,
|
||||||
|
MessageChainComponent,
|
||||||
|
Image,
|
||||||
|
Plain,
|
||||||
|
At,
|
||||||
|
} from '@/app/infra/entities/message';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import AtBadge from './AtBadge';
|
import AtBadge from './AtBadge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { WebSocketClient } from '@/app/infra/websocket/WebSocketClient';
|
||||||
|
import ImagePreviewDialog from './ImagePreviewDialog';
|
||||||
interface MessageComponent {
|
|
||||||
type: 'At' | 'Plain';
|
|
||||||
target?: string;
|
|
||||||
text?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DebugDialogProps {
|
interface DebugDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
isEmbedded?: boolean;
|
isEmbedded?: boolean;
|
||||||
|
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DebugDialog({
|
export default function DebugDialog({
|
||||||
open,
|
open,
|
||||||
pipelineId,
|
pipelineId,
|
||||||
isEmbedded = false,
|
isEmbedded = false,
|
||||||
|
onConnectionStatusChange,
|
||||||
}: DebugDialogProps) {
|
}: DebugDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||||
@@ -37,10 +39,19 @@ export default function DebugDialog({
|
|||||||
const [showAtPopover, setShowAtPopover] = useState(false);
|
const [showAtPopover, setShowAtPopover] = useState(false);
|
||||||
const [hasAt, setHasAt] = useState(false);
|
const [hasAt, setHasAt] = useState(false);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
const [isStreaming, setIsStreaming] = useState(true);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [selectedImages, setSelectedImages] = useState<
|
||||||
|
Array<{ file: File; preview: string; fileKey?: string }>
|
||||||
|
>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
|
||||||
|
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wsClientRef = useRef<WebSocketClient | null>(null);
|
||||||
|
const isInitializingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
// 使用setTimeout确保在DOM更新后执行滚动
|
// 使用setTimeout确保在DOM更新后执行滚动
|
||||||
@@ -60,7 +71,7 @@ export default function DebugDialog({
|
|||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (pipelineId: string) => {
|
async (pipelineId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.getWebChatHistoryMessages(
|
const response = await httpClient.getWebSocketHistoryMessages(
|
||||||
pipelineId,
|
pipelineId,
|
||||||
sessionType,
|
sessionType,
|
||||||
);
|
);
|
||||||
@@ -71,23 +82,123 @@ export default function DebugDialog({
|
|||||||
},
|
},
|
||||||
[sessionType],
|
[sessionType],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 初始化WebSocket连接
|
||||||
|
const initWebSocket = useCallback(
|
||||||
|
async (pipelineId: string) => {
|
||||||
|
// 防止重复初始化
|
||||||
|
if (isInitializingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isInitializingRef.current = true;
|
||||||
|
|
||||||
|
// 断开旧连接
|
||||||
|
if (wsClientRef.current) {
|
||||||
|
wsClientRef.current.disconnect();
|
||||||
|
wsClientRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新连接
|
||||||
|
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||||
|
|
||||||
|
wsClient
|
||||||
|
.onConnected(() => {
|
||||||
|
setIsConnected(true);
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
})
|
||||||
|
.onMessage((wsMessage) => {
|
||||||
|
// 将 WebSocketMessage 转换为 Message 类型
|
||||||
|
const message: Message = {
|
||||||
|
...wsMessage,
|
||||||
|
message_chain: wsMessage.message_chain as MessageChainComponent[],
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prevMessages) => {
|
||||||
|
// 查找是否已存在相同ID的消息
|
||||||
|
const existingIndex = prevMessages.findIndex(
|
||||||
|
(m) => m.id === message.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 更新已存在的消息(流式输出)
|
||||||
|
const newMessages = [...prevMessages];
|
||||||
|
newMessages[existingIndex] = message;
|
||||||
|
return newMessages;
|
||||||
|
} else {
|
||||||
|
// 添加新消息
|
||||||
|
return [...prevMessages, message];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onError((error) => {
|
||||||
|
console.error('WebSocket错误:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
toast.error(t('pipelines.debugDialog.connectionError'));
|
||||||
|
})
|
||||||
|
.onClose(() => {
|
||||||
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
})
|
||||||
|
.onBroadcast((message) => {
|
||||||
|
toast.info(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
await wsClient.connect();
|
||||||
|
wsClientRef.current = wsClient;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebSocket连接失败:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionType, t],
|
||||||
|
);
|
||||||
|
|
||||||
// 在useEffect中监听messages变化时滚动
|
// 在useEffect中监听messages变化时滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, scrollToBottom]);
|
}, [messages, scrollToBottom]);
|
||||||
|
|
||||||
|
// 监听 open 和 pipelineId 变化,进入时连接,离开时断开
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSelectedPipelineId(pipelineId);
|
setSelectedPipelineId(pipelineId);
|
||||||
loadMessages(pipelineId);
|
} else {
|
||||||
|
// 关闭对话框时立即断开WebSocket
|
||||||
|
if (wsClientRef.current) {
|
||||||
|
wsClientRef.current.disconnect();
|
||||||
|
wsClientRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
isInitializingRef.current = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 组件卸载时断开WebSocket
|
||||||
|
if (wsClientRef.current) {
|
||||||
|
wsClientRef.current.disconnect();
|
||||||
|
wsClientRef.current = null;
|
||||||
|
isInitializingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [open, pipelineId]);
|
}, [open, pipelineId]);
|
||||||
|
|
||||||
|
// 监听 sessionType 和 selectedPipelineId 变化,重新加载消息和连接
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
loadMessages(selectedPipelineId);
|
loadMessages(selectedPipelineId);
|
||||||
|
initWebSocket(selectedPipelineId);
|
||||||
}
|
}
|
||||||
}, [sessionType, selectedPipelineId, open, loadMessages]);
|
}, [sessionType, selectedPipelineId, open, loadMessages, initWebSocket]);
|
||||||
|
|
||||||
|
// 通知父组件连接状态变化
|
||||||
|
useEffect(() => {
|
||||||
|
onConnectionStatusChange?.(isConnected);
|
||||||
|
}, [isConnected, onConnectionStatusChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -147,10 +258,42 @@ export default function DebugDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const newImages: Array<{ file: File; preview: string }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const preview = URL.createObjectURL(file);
|
||||||
|
newImages.push({ file, preview });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImages((prev) => [...prev, ...newImages]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveImage = (index: number) => {
|
||||||
|
setSelectedImages((prev) => {
|
||||||
|
const newImages = [...prev];
|
||||||
|
URL.revokeObjectURL(newImages[index].preview);
|
||||||
|
newImages.splice(index, 1);
|
||||||
|
return newImages;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!inputValue.trim() && !hasAt) return;
|
if (!inputValue.trim() && !hasAt && selectedImages.length === 0) return;
|
||||||
|
if (!isConnected || !wsClientRef.current) {
|
||||||
|
toast.error(t('pipelines.debugDialog.notConnected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
const messageChain = [];
|
const messageChain = [];
|
||||||
|
|
||||||
let text_content = inputValue.trim();
|
let text_content = inputValue.trim();
|
||||||
@@ -161,142 +304,133 @@ export default function DebugDialog({
|
|||||||
if (hasAt) {
|
if (hasAt) {
|
||||||
messageChain.push({
|
messageChain.push({
|
||||||
type: 'At',
|
type: 'At',
|
||||||
target: 'webchatbot',
|
target: 'websocketbot',
|
||||||
|
display: 'websocketbot',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加文本
|
||||||
|
if (text_content) {
|
||||||
messageChain.push({
|
messageChain.push({
|
||||||
type: 'Plain',
|
type: 'Plain',
|
||||||
text: text_content,
|
text: text_content,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasAt) {
|
|
||||||
// for showing
|
|
||||||
text_content = '@webchatbot' + text_content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessage: Message = {
|
// 上传图片并添加到消息链
|
||||||
id: -1,
|
for (const image of selectedImages) {
|
||||||
role: 'user',
|
|
||||||
content: text_content,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
message_chain: messageChain,
|
|
||||||
};
|
|
||||||
// 根据isStreaming状态决定使用哪种传输方式
|
|
||||||
if (isStreaming) {
|
|
||||||
// streaming
|
|
||||||
// 创建初始bot消息
|
|
||||||
const placeholderRandomId = Math.floor(Math.random() * 1000000);
|
|
||||||
const botMessagePlaceholder: Message = {
|
|
||||||
id: placeholderRandomId,
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Generating...',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
message_chain: [{ type: 'Plain', text: 'Generating...' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加用户消息和初始bot消息到状态
|
|
||||||
|
|
||||||
setMessages((prevMessages) => [
|
|
||||||
...prevMessages,
|
|
||||||
userMessage,
|
|
||||||
botMessagePlaceholder,
|
|
||||||
]);
|
|
||||||
setInputValue('');
|
|
||||||
setHasAt(false);
|
|
||||||
try {
|
try {
|
||||||
await httpClient.sendStreamingWebChatMessage(
|
const result = await httpClient.uploadWebSocketImage(
|
||||||
sessionType,
|
|
||||||
messageChain,
|
|
||||||
selectedPipelineId,
|
selectedPipelineId,
|
||||||
(data) => {
|
image.file,
|
||||||
// 处理流式响应数据
|
|
||||||
console.log('data', data);
|
|
||||||
if (data.message) {
|
|
||||||
// 更新完整内容
|
|
||||||
|
|
||||||
setMessages((prevMessages) => {
|
|
||||||
const updatedMessages = [...prevMessages];
|
|
||||||
const botMessageIndex = updatedMessages.findIndex(
|
|
||||||
(message) => message.id === placeholderRandomId,
|
|
||||||
);
|
);
|
||||||
if (botMessageIndex !== -1) {
|
messageChain.push({
|
||||||
updatedMessages[botMessageIndex] = {
|
type: 'Image',
|
||||||
...updatedMessages[botMessageIndex],
|
path: result.file_key,
|
||||||
content: data.message.content,
|
|
||||||
message_chain: [
|
|
||||||
{ type: 'Plain', text: data.message.content },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return updatedMessages;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
(error) => {
|
|
||||||
// 处理错误
|
|
||||||
console.error('Streaming error:', error);
|
|
||||||
if (sessionType === 'person') {
|
|
||||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send streaming message:', error);
|
console.error('图片上传失败:', error);
|
||||||
if (sessionType === 'person') {
|
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
|
||||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// non-streaming
|
// 清空输入框和图片
|
||||||
setMessages((prevMessages) => [...prevMessages, userMessage]);
|
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
setHasAt(false);
|
setHasAt(false);
|
||||||
|
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
|
||||||
|
setSelectedImages([]);
|
||||||
|
|
||||||
const response = await httpClient.sendWebChatMessage(
|
// 通过WebSocket发送消息
|
||||||
sessionType,
|
// 不在本地添加消息,等待后端广播回来(带有正确的ID)
|
||||||
messageChain,
|
wsClientRef.current.sendMessage(messageChain);
|
||||||
selectedPipelineId,
|
} catch (error) {
|
||||||
180000,
|
console.error('Failed to send message:', error);
|
||||||
|
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessageComponent = (
|
||||||
|
component: MessageChainComponent,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
switch (component.type) {
|
||||||
|
case 'Plain':
|
||||||
|
return <span key={index}>{(component as Plain).text}</span>;
|
||||||
|
|
||||||
|
case 'At': {
|
||||||
|
const atComponent = component as At;
|
||||||
|
// 优先使用 display,如果没有则使用 target
|
||||||
|
const displayName =
|
||||||
|
atComponent.display || atComponent.target?.toString() || '';
|
||||||
|
return (
|
||||||
|
<span key={index} className="inline-flex align-middle mx-1">
|
||||||
|
<AtBadge targetName={displayName} readonly={true} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'AtAll':
|
||||||
|
return (
|
||||||
|
<span key={index} className="inline-flex align-middle mx-1">
|
||||||
|
<AtBadge targetName="全体成员" readonly={true} />
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
setMessages((prevMessages) => [...prevMessages, response.message]);
|
case 'Image': {
|
||||||
}
|
const img = component as Image;
|
||||||
} catch (
|
const imageUrl = img.url || (img.base64 ? img.base64 : '');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
error: any
|
|
||||||
) {
|
|
||||||
console.log(error, 'type of error', typeof error);
|
|
||||||
console.error('Failed to send message:', error);
|
|
||||||
|
|
||||||
if (!error.message.includes('timeout') && sessionType === 'person') {
|
if (!imageUrl) return null;
|
||||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="my-2">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Image"
|
||||||
|
className="max-w-full max-h-96 rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewImageUrl(imageUrl);
|
||||||
|
setShowImagePreview(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
inputRef.current?.focus();
|
case 'File': {
|
||||||
|
const file = component as MessageChainComponent & { name?: string };
|
||||||
|
return (
|
||||||
|
<div key={index} className="my-2 flex items-center gap-2 text-sm">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
|
||||||
|
</svg>
|
||||||
|
<span>[文件] {file.name || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Voice':
|
||||||
|
return <span key={index}>[语音]</span>;
|
||||||
|
|
||||||
|
case 'Source':
|
||||||
|
// Source 不显示
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <span key={index}>[{component.type}]</span>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMessageContent = (message: Message) => {
|
const renderMessageContent = (message: Message) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
<div className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
||||||
{(message.message_chain as MessageComponent[]).map(
|
{message.message_chain.map((component, index) =>
|
||||||
(component, index) => {
|
renderMessageComponent(component, index),
|
||||||
if (component.type === 'At') {
|
|
||||||
return (
|
|
||||||
<AtBadge
|
|
||||||
key={index}
|
|
||||||
targetName={component.target || ''}
|
|
||||||
readonly={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (component.type === 'Plain') {
|
|
||||||
return <span key={index}>{component.text}</span>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,11 +475,10 @@ export default function DebugDialog({
|
|||||||
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
|
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black">
|
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black scroll-area">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||||
@@ -389,16 +522,65 @@ export default function DebugDialog({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 图片预览区域 */}
|
||||||
|
{selectedImages.length > 0 && (
|
||||||
|
<div className="px-4 pb-2 bg-white dark:bg-black">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{selectedImages.map((image, index) => (
|
||||||
|
<div key={index} className="relative group">
|
||||||
|
<img
|
||||||
|
src={image.preview}
|
||||||
|
alt={`preview-${index}`}
|
||||||
|
className="w-20 h-20 object-cover rounded-lg border border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveImage(index)}
|
||||||
|
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
|
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="text-sm text-gray-600">
|
<input
|
||||||
{t('pipelines.debugDialog.streaming')}
|
ref={fileInputRef}
|
||||||
</span>
|
type="file"
|
||||||
<Switch checked={isStreaming} onCheckedChange={setIsStreaming} />
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={!isConnected || isUploading}
|
||||||
|
className="w-10 h-10 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="上传图片"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center gap-2">
|
<div className="flex-1 flex items-center gap-2">
|
||||||
{hasAt && (
|
{hasAt && (
|
||||||
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
|
<AtBadge targetName="websocketbot" onRemove={handleAtRemove} />
|
||||||
)}
|
)}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -412,7 +594,8 @@ export default function DebugDialog({
|
|||||||
? t('pipelines.debugDialog.privateChat')
|
? t('pipelines.debugDialog.privateChat')
|
||||||
: t('pipelines.debugDialog.groupChat'),
|
: t('pipelines.debugDialog.groupChat'),
|
||||||
})}
|
})}
|
||||||
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base"
|
disabled={!isConnected || isUploading}
|
||||||
|
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 focus:border-[#2288ee] transition-none text-base disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{showAtPopover && (
|
{showAtPopover && (
|
||||||
<div
|
<div
|
||||||
@@ -431,7 +614,7 @@ export default function DebugDialog({
|
|||||||
onMouseLeave={() => setIsHovering(false)}
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
>
|
>
|
||||||
<span className="text-gray-800 dark:text-gray-200">
|
<span className="text-gray-800 dark:text-gray-200">
|
||||||
@webchatbot - {t('pipelines.debugDialog.atTips')}
|
@websocketbot - {t('pipelines.debugDialog.atTips')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,10 +623,14 @@ export default function DebugDialog({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={sendMessage}
|
onClick={sendMessage}
|
||||||
disabled={!inputValue.trim() && !hasAt}
|
disabled={
|
||||||
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none"
|
(!inputValue.trim() && !hasAt && selectedImages.length === 0) ||
|
||||||
|
!isConnected ||
|
||||||
|
isUploading
|
||||||
|
}
|
||||||
|
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<>{t('pipelines.debugDialog.send')}</>
|
{isUploading ? '上传中...' : t('pipelines.debugDialog.send')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,16 +640,30 @@ export default function DebugDialog({
|
|||||||
// 如果是嵌入模式,直接返回内容
|
// 如果是嵌入模式,直接返回内容
|
||||||
if (isEmbedded) {
|
if (isEmbedded) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
|
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ImagePreviewDialog
|
||||||
|
open={showImagePreview}
|
||||||
|
imageUrl={previewImageUrl}
|
||||||
|
onClose={() => setShowImagePreview(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原有的Dialog包装
|
// 原有的Dialog包装
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
|
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<ImagePreviewDialog
|
||||||
|
open={showImagePreview}
|
||||||
|
imageUrl={previewImageUrl}
|
||||||
|
onClose={() => setShowImagePreview(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ImagePreviewDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
imageUrl: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImagePreviewDialog({
|
||||||
|
open,
|
||||||
|
imageUrl,
|
||||||
|
onClose,
|
||||||
|
}: ImagePreviewDialogProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center p-8 animate-in fade-in duration-200"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div className="absolute inset-0 bg-black/20 " />
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center gap-2">
|
||||||
|
{/* 关闭按钮 - 在图片上方 */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 图片 */}
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-w-[50vw] max-h-[50vh] object-contain rounded-lg shadow-2xl bg-white"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -159,7 +159,6 @@ export default function PipelineFormComponent({
|
|||||||
}, [form, isEditMode]);
|
}, [form, isEditMode]);
|
||||||
|
|
||||||
function handleFormSubmit(values: FormValues) {
|
function handleFormSubmit(values: FormValues) {
|
||||||
console.log('handleFormSubmit', values);
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
handleModify(values);
|
handleModify(values);
|
||||||
} else {
|
} else {
|
||||||
@@ -168,7 +167,6 @@ export default function PipelineFormComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCreate(values: FormValues) {
|
function handleCreate(values: FormValues) {
|
||||||
console.log('handleCreate', values);
|
|
||||||
const pipeline: Pipeline = {
|
const pipeline: Pipeline = {
|
||||||
config: {},
|
config: {},
|
||||||
description: values.basic.description,
|
description: values.basic.description,
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export default function PluginConfigPage() {
|
|||||||
setPipelineList(pipelineList);
|
setPipelineList(pipelineList);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
|
||||||
toast.error(t('pipelines.getPipelineListError') + error.message);
|
toast.error(t('pipelines.getPipelineListError') + error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,7 +329,6 @@ function MarketPageContent({
|
|||||||
|
|
||||||
// 安装插件
|
// 安装插件
|
||||||
// const handleInstallPlugin = (plugin: PluginV4) => {
|
// const handleInstallPlugin = (plugin: PluginV4) => {
|
||||||
// console.log('install plugin', plugin);
|
|
||||||
// };
|
// };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -123,7 +123,6 @@
|
|||||||
|
|
||||||
// function handleDragEnd(event: DragEndEvent) {
|
// function handleDragEnd(event: DragEndEvent) {
|
||||||
// const { active, over } = event;
|
// const { active, over } = event;
|
||||||
// console.log('Drag end event:', { active, over });
|
|
||||||
|
|
||||||
// if (over && active.id !== over.id) {
|
// if (over && active.id !== over.id) {
|
||||||
// setSortedPlugins((items) => {
|
// setSortedPlugins((items) => {
|
||||||
|
|||||||
@@ -266,7 +266,6 @@ export default function PluginConfigPage() {
|
|||||||
watchTask(taskId);
|
watchTask(taskId);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log('error when install plugin:', err);
|
|
||||||
setInstallError(err.message);
|
setInstallError(err.message);
|
||||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||||
});
|
});
|
||||||
@@ -278,7 +277,6 @@ export default function PluginConfigPage() {
|
|||||||
watchTask(taskId);
|
watchTask(taskId);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log('error when install plugin:', err);
|
|
||||||
setInstallError(err.message);
|
setInstallError(err.message);
|
||||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||||
});
|
});
|
||||||
@@ -431,7 +429,9 @@ export default function PluginConfigPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.pageContainer} h-full flex flex-col ${isDragOver ? 'bg-blue-50' : ''}`}
|
className={`${styles.pageContainer} h-full flex flex-col ${
|
||||||
|
isDragOver ? 'bg-blue-50' : ''
|
||||||
|
}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
|||||||
@@ -1,7 +1,148 @@
|
|||||||
|
// Message component base interface
|
||||||
|
export interface MessageComponent {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source component
|
||||||
|
export interface Source extends MessageComponent {
|
||||||
|
type: 'Source';
|
||||||
|
id: number | string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text component
|
||||||
|
export interface Plain extends MessageComponent {
|
||||||
|
type: 'Plain';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quote component
|
||||||
|
export interface Quote extends MessageComponent {
|
||||||
|
type: 'Quote';
|
||||||
|
id?: number;
|
||||||
|
group_id?: number | string;
|
||||||
|
sender_id?: number | string;
|
||||||
|
target_id?: number | string;
|
||||||
|
origin: MessageComponent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// At component
|
||||||
|
export interface At extends MessageComponent {
|
||||||
|
type: 'At';
|
||||||
|
target: number | string;
|
||||||
|
display?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AtAll component
|
||||||
|
export interface AtAll extends MessageComponent {
|
||||||
|
type: 'AtAll';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image component
|
||||||
|
export interface Image extends MessageComponent {
|
||||||
|
type: 'Image';
|
||||||
|
image_id?: string;
|
||||||
|
url?: string;
|
||||||
|
path?: string;
|
||||||
|
base64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice component
|
||||||
|
export interface Voice extends MessageComponent {
|
||||||
|
type: 'Voice';
|
||||||
|
voice_id?: string;
|
||||||
|
url?: string;
|
||||||
|
path?: string;
|
||||||
|
base64?: string;
|
||||||
|
length?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File component
|
||||||
|
export interface File extends MessageComponent {
|
||||||
|
type: 'File';
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
size?: number;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown component
|
||||||
|
export interface Unknown extends MessageComponent {
|
||||||
|
type: 'Unknown';
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward message node
|
||||||
|
export interface ForwardMessageNode {
|
||||||
|
sender_id?: number | string;
|
||||||
|
sender_name?: string;
|
||||||
|
message_chain?: MessageComponent[];
|
||||||
|
message_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward message display
|
||||||
|
export interface ForwardMessageDisplay {
|
||||||
|
title?: string;
|
||||||
|
brief?: string;
|
||||||
|
source?: string;
|
||||||
|
preview?: string[];
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward component
|
||||||
|
export interface Forward extends MessageComponent {
|
||||||
|
type: 'Forward';
|
||||||
|
display?: ForwardMessageDisplay;
|
||||||
|
node_list?: ForwardMessageNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeChat specific components
|
||||||
|
export interface WeChatMiniPrograms extends MessageComponent {
|
||||||
|
type: 'WeChatMiniPrograms';
|
||||||
|
mini_app_id: string;
|
||||||
|
user_name: string;
|
||||||
|
display_name?: string;
|
||||||
|
page_path?: string;
|
||||||
|
title?: string;
|
||||||
|
image_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeChatEmoji extends MessageComponent {
|
||||||
|
type: 'WeChatEmoji';
|
||||||
|
emoji_md5: string;
|
||||||
|
emoji_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeChatLink extends MessageComponent {
|
||||||
|
type: 'WeChatLink';
|
||||||
|
link_title?: string;
|
||||||
|
link_desc?: string;
|
||||||
|
link_url?: string;
|
||||||
|
link_thumb_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for all message components
|
||||||
|
export type MessageChainComponent =
|
||||||
|
| Source
|
||||||
|
| Plain
|
||||||
|
| Quote
|
||||||
|
| At
|
||||||
|
| AtAll
|
||||||
|
| Image
|
||||||
|
| Voice
|
||||||
|
| File
|
||||||
|
| Unknown
|
||||||
|
| Forward
|
||||||
|
| WeChatMiniPrograms
|
||||||
|
| WeChatEmoji
|
||||||
|
| WeChatLink;
|
||||||
|
|
||||||
|
// Message interface
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
message_chain: object[];
|
message_chain: MessageChainComponent[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
is_final?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
GetPipelineResponseData,
|
GetPipelineResponseData,
|
||||||
GetPipelineMetadataResponseData,
|
GetPipelineMetadataResponseData,
|
||||||
AsyncTask,
|
AsyncTask,
|
||||||
ApiRespWebChatMessage,
|
|
||||||
ApiRespWebChatMessages,
|
ApiRespWebChatMessages,
|
||||||
ApiRespKnowledgeBases,
|
ApiRespKnowledgeBases,
|
||||||
ApiRespKnowledgeBase,
|
ApiRespKnowledgeBase,
|
||||||
@@ -199,136 +198,58 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Debug WebChat API ============
|
// ============ WebSocket Chat API ============
|
||||||
|
public getWebSocketHistoryMessages(
|
||||||
// ============ Debug WebChat API ============
|
|
||||||
public sendWebChatMessage(
|
|
||||||
sessionType: string,
|
|
||||||
messageChain: object[],
|
|
||||||
pipelineId: string,
|
|
||||||
timeout: number = 15000,
|
|
||||||
): Promise<ApiRespWebChatMessage> {
|
|
||||||
return this.post(
|
|
||||||
`/api/v1/pipelines/${pipelineId}/chat/send`,
|
|
||||||
{
|
|
||||||
session_type: sessionType,
|
|
||||||
message: messageChain,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timeout,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sendStreamingWebChatMessage(
|
|
||||||
sessionType: string,
|
|
||||||
messageChain: object[],
|
|
||||||
pipelineId: string,
|
|
||||||
onMessage: (data: ApiRespWebChatMessage) => void,
|
|
||||||
onComplete: () => void,
|
|
||||||
onError: (error: Error) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// 构造完整的URL,处理相对路径的情况
|
|
||||||
let url = `${this.baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
|
|
||||||
if (this.baseURL === '/') {
|
|
||||||
// 获取用户访问的完整URL
|
|
||||||
const baseURL = window.location.origin;
|
|
||||||
url = `${baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用fetch发送流式请求,因为axios在浏览器环境中不直接支持流式响应
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.getSessionSync()}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_type: sessionType,
|
|
||||||
message: messageChain,
|
|
||||||
is_stream: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error('ReadableStream not supported');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
// 读取流式响应
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
|
|
||||||
if (done) {
|
|
||||||
onComplete();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解码数据
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
|
|
||||||
// 处理完整的JSON对象
|
|
||||||
const lines = buffer.split('\n\n');
|
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(line.slice(5));
|
|
||||||
|
|
||||||
if (data.type === 'end') {
|
|
||||||
// 流传输结束
|
|
||||||
reader.cancel();
|
|
||||||
onComplete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.type === 'start') {
|
|
||||||
console.log(data.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.message) {
|
|
||||||
// 处理消息数据
|
|
||||||
onMessage(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing streaming data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
onError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getWebChatHistoryMessages(
|
|
||||||
pipelineId: string,
|
pipelineId: string,
|
||||||
sessionType: string,
|
sessionType: string,
|
||||||
): Promise<ApiRespWebChatMessages> {
|
): Promise<ApiRespWebChatMessages> {
|
||||||
return this.get(
|
return this.get(
|
||||||
`/api/v1/pipelines/${pipelineId}/chat/messages/${sessionType}`,
|
`/api/v1/pipelines/${pipelineId}/ws/messages/${sessionType}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetWebChatSession(
|
public async uploadWebSocketImage(
|
||||||
|
pipelineId: string,
|
||||||
|
imageFile: File,
|
||||||
|
): Promise<{ file_key: string }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', imageFile);
|
||||||
|
|
||||||
|
return this.postFile(`/api/v1/files/images`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetWebSocketSession(
|
||||||
pipelineId: string,
|
pipelineId: string,
|
||||||
sessionType: string,
|
sessionType: string,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
return this.post(
|
return this.post(`/api/v1/pipelines/${pipelineId}/ws/reset/${sessionType}`);
|
||||||
`/api/v1/pipelines/${pipelineId}/chat/reset/${sessionType}`,
|
}
|
||||||
);
|
|
||||||
|
public getWebSocketConnections(pipelineId: string): Promise<{
|
||||||
|
stats: {
|
||||||
|
total_connections: number;
|
||||||
|
pipelines: number;
|
||||||
|
connections_by_pipeline: Record<string, number>;
|
||||||
|
connections_by_session_type: Record<string, number>;
|
||||||
|
};
|
||||||
|
connections: Array<{
|
||||||
|
connection_id: string;
|
||||||
|
session_type: string;
|
||||||
|
created_at: string;
|
||||||
|
last_active: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
return this.get(`/api/v1/pipelines/${pipelineId}/ws/connections`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public broadcastWebSocketMessage(
|
||||||
|
pipelineId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
return this.post(`/api/v1/pipelines/${pipelineId}/ws/broadcast`, {
|
||||||
|
message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Platform API ============
|
// ============ Platform API ============
|
||||||
|
|||||||
@@ -97,8 +97,6 @@ export abstract class BaseHttpClient {
|
|||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401:
|
case 401:
|
||||||
console.log('401 error: ', errMessage, error.request);
|
|
||||||
console.log('responseURL', error.request.responseURL);
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
if (!error.request.responseURL.includes('/check-token')) {
|
if (!error.request.responseURL.includes('/check-token')) {
|
||||||
|
|||||||
295
web/src/app/infra/websocket/WebSocketClient.ts
Normal file
295
web/src/app/infra/websocket/WebSocketClient.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket客户端类
|
||||||
|
* 用于管理WebSocket连接和消息处理
|
||||||
|
*/
|
||||||
|
export interface WebSocketMessage {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
message_chain: Array<{ type: string; text?: string; target?: string }>;
|
||||||
|
timestamp: string;
|
||||||
|
is_final?: boolean;
|
||||||
|
connection_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketResponse {
|
||||||
|
type:
|
||||||
|
| 'connected'
|
||||||
|
| 'response'
|
||||||
|
| 'user_message'
|
||||||
|
| 'pong'
|
||||||
|
| 'broadcast'
|
||||||
|
| 'error';
|
||||||
|
connection_id?: string;
|
||||||
|
pipeline_uuid?: string;
|
||||||
|
session_type?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
data?: WebSocketMessage;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebSocketClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private connectionId: string | null = null;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectAttempts = 5;
|
||||||
|
private reconnectDelay = 3000; // 3秒重连间隔
|
||||||
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
|
private heartbeatIntervalMs = 30000; // 30秒
|
||||||
|
private isConnecting = false; // 防止重复连接
|
||||||
|
|
||||||
|
// 事件回调
|
||||||
|
private onConnectedCallback?: (data: WebSocketResponse) => void;
|
||||||
|
private onMessageCallback?: (data: WebSocketMessage) => void;
|
||||||
|
private onErrorCallback?: (error: Error) => void;
|
||||||
|
private onCloseCallback?: () => void;
|
||||||
|
private onBroadcastCallback?: (message: string) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private pipelineId: string,
|
||||||
|
private sessionType: 'person' | 'group' = 'person',
|
||||||
|
private token?: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接到WebSocket服务器
|
||||||
|
*/
|
||||||
|
public connect(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// 防止重复连接
|
||||||
|
if (
|
||||||
|
this.isConnecting ||
|
||||||
|
(this.ws && this.ws.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
console.warn('WebSocket正在连接中,忽略重复连接请求');
|
||||||
|
reject(new Error('Connection already in progress'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经连接,直接返回
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
console.warn('WebSocket已连接,忽略重复连接请求');
|
||||||
|
resolve(this.connectionId || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting = true;
|
||||||
|
|
||||||
|
// 构建WebSocket URL
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
// extract host from process.env.NEXT_PUBLIC_API_BASE_URL
|
||||||
|
const host =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] || '';
|
||||||
|
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
|
||||||
|
// 连接打开
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.startHeartbeat();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接收消息
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data: WebSocketResponse = JSON.parse(event.data);
|
||||||
|
this.handleMessage(data);
|
||||||
|
|
||||||
|
// 第一次连接成功
|
||||||
|
if (data.type === 'connected' && data.connection_id) {
|
||||||
|
this.connectionId = data.connection_id;
|
||||||
|
resolve(data.connection_id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析WebSocket消息失败:', error);
|
||||||
|
this.onErrorCallback?.(error as Error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接关闭
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.onCloseCallback?.();
|
||||||
|
|
||||||
|
// 自动重连
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect().catch(console.error);
|
||||||
|
}, this.reconnectDelay * this.reconnectAttempts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接错误
|
||||||
|
this.ws.onerror = (event) => {
|
||||||
|
console.error('WebSocket错误:', event);
|
||||||
|
this.isConnecting = false;
|
||||||
|
const error = new Error('WebSocket连接失败');
|
||||||
|
this.onErrorCallback?.(error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.isConnecting = false;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理接收到的消息
|
||||||
|
*/
|
||||||
|
private handleMessage(data: WebSocketResponse) {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'connected':
|
||||||
|
this.onConnectedCallback?.(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'response':
|
||||||
|
if (data.data) {
|
||||||
|
this.onMessageCallback?.(data.data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user_message':
|
||||||
|
// 用户消息广播(包括自己发送的消息)
|
||||||
|
if (data.data) {
|
||||||
|
this.onMessageCallback?.(data.data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
// 心跳响应
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'broadcast':
|
||||||
|
if (data.message) {
|
||||||
|
this.onBroadcastCallback?.(data.message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
const error = new Error(data.message || '未知错误');
|
||||||
|
this.onErrorCallback?.(error);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('未知消息类型:', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息
|
||||||
|
*/
|
||||||
|
public sendMessage(
|
||||||
|
messageChain: Array<{ type: string; text?: string; target?: string }>,
|
||||||
|
) {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error('WebSocket未连接');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'message',
|
||||||
|
message: messageChain,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送心跳
|
||||||
|
*/
|
||||||
|
private sendHeartbeat() {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动心跳
|
||||||
|
*/
|
||||||
|
private startHeartbeat() {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
this.sendHeartbeat();
|
||||||
|
}, this.heartbeatIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳
|
||||||
|
*/
|
||||||
|
private stopHeartbeat() {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
public disconnect() {
|
||||||
|
if (this.ws) {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
|
||||||
|
// 停止自动重连
|
||||||
|
this.reconnectAttempts = this.maxReconnectAttempts;
|
||||||
|
|
||||||
|
// 发送断开消息
|
||||||
|
if (this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify({ type: 'disconnect' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
this.connectionId = null;
|
||||||
|
this.isConnecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接ID
|
||||||
|
*/
|
||||||
|
public getConnectionId(): string | null {
|
||||||
|
return this.connectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接状态
|
||||||
|
*/
|
||||||
|
public isConnected(): boolean {
|
||||||
|
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 事件回调设置 =====
|
||||||
|
|
||||||
|
public onConnected(callback: (data: WebSocketResponse) => void) {
|
||||||
|
this.onConnectedCallback = callback;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onMessage(callback: (data: WebSocketMessage) => void) {
|
||||||
|
this.onMessageCallback = callback;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onError(callback: (error: Error) => void) {
|
||||||
|
this.onErrorCallback = callback;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClose(callback: () => void) {
|
||||||
|
this.onCloseCallback = callback;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onBroadcast(callback: (message: string) => void) {
|
||||||
|
this.onBroadcastCallback = callback;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,9 +61,7 @@ export default function Login() {
|
|||||||
router.push('/register');
|
router.push('/register');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(() => {});
|
||||||
console.log('error at getIsInitialized: ', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkIfAlreadyLoggedIn() {
|
function checkIfAlreadyLoggedIn() {
|
||||||
@@ -75,9 +73,7 @@ export default function Login() {
|
|||||||
router.push('/home');
|
router.push('/home');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(() => {});
|
||||||
console.log('error at checkIfAlreadyLoggedIn: ', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||||
handleLogin(values.email, values.password);
|
handleLogin(values.email, values.password);
|
||||||
@@ -89,13 +85,10 @@ export default function Login() {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
localStorage.setItem('token', res.token);
|
localStorage.setItem('token', res.token);
|
||||||
localStorage.setItem('userEmail', username);
|
localStorage.setItem('userEmail', username);
|
||||||
console.log('login success: ', res);
|
|
||||||
router.push('/home');
|
router.push('/home');
|
||||||
toast.success(t('common.loginSuccess'));
|
toast.success(t('common.loginSuccess'));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(() => {
|
||||||
console.log('login error: ', err);
|
|
||||||
|
|
||||||
toast.error(t('common.loginFailed'));
|
toast.error(t('common.loginFailed'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,7 @@ export default function Register() {
|
|||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(() => {});
|
||||||
console.log('error at getIsInitialized: ', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||||
@@ -71,13 +69,11 @@ export default function Register() {
|
|||||||
function handleRegister(username: string, password: string) {
|
function handleRegister(username: string, password: string) {
|
||||||
httpClient
|
httpClient
|
||||||
.initUser(username, password)
|
.initUser(username, password)
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
console.log('init user success: ', res);
|
|
||||||
toast.success(t('register.initSuccess'));
|
toast.success(t('register.initSuccess'));
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err: Error) => {
|
||||||
console.log('init user error: ', err);
|
|
||||||
toast.error(t('register.initFailed') + err.message);
|
toast.error(t('register.initFailed') + err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,11 @@ export default function ResetPassword() {
|
|||||||
setIsResetting(true);
|
setIsResetting(true);
|
||||||
httpClient
|
httpClient
|
||||||
.resetPassword(email, recoveryKey, newPassword)
|
.resetPassword(email, recoveryKey, newPassword)
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
console.log('reset password success: ', res);
|
|
||||||
toast.success(t('resetPassword.resetSuccess'));
|
toast.success(t('resetPassword.resetSuccess'));
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(() => {
|
||||||
console.log('reset password error: ', err);
|
|
||||||
toast.error(t('resetPassword.resetFailed'));
|
toast.error(t('resetPassword.resetFailed'));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ export default function I18nProvider({ children }: I18nProviderProps) {
|
|||||||
export const extractI18nObject = (i18nObject: I18nObject): string => {
|
export const extractI18nObject = (i18nObject: I18nObject): string => {
|
||||||
// 根据当前语言返回对应的值, fallback优先级:en_US、zh_Hans、zh_Hant、ja_JP
|
// 根据当前语言返回对应的值, fallback优先级:en_US、zh_Hans、zh_Hant、ja_JP
|
||||||
const language = i18n.language.replace('-', '_');
|
const language = i18n.language.replace('-', '_');
|
||||||
console.log('language:', language);
|
|
||||||
console.log('i18nObject:', i18nObject);
|
|
||||||
if (language === 'en_US' && i18nObject.en_US) return i18nObject.en_US;
|
if (language === 'en_US' && i18nObject.en_US) return i18nObject.en_US;
|
||||||
if (language === 'zh_Hans' && i18nObject.zh_Hans) return i18nObject.zh_Hans;
|
if (language === 'zh_Hans' && i18nObject.zh_Hans) return i18nObject.zh_Hans;
|
||||||
if (language === 'zh_Hant' && i18nObject.zh_Hant) return i18nObject.zh_Hant;
|
if (language === 'zh_Hant' && i18nObject.zh_Hant) return i18nObject.zh_Hant;
|
||||||
|
|||||||
@@ -518,6 +518,12 @@ const enUS = {
|
|||||||
loadPipelinesFailed: 'Failed to load pipelines',
|
loadPipelinesFailed: 'Failed to load pipelines',
|
||||||
atTips: 'Mention the bot',
|
atTips: 'Mention the bot',
|
||||||
streaming: 'Streaming',
|
streaming: 'Streaming',
|
||||||
|
connected: 'WebSocket connected',
|
||||||
|
disconnected: 'WebSocket disconnected',
|
||||||
|
connectionError: 'WebSocket connection error',
|
||||||
|
connectionFailed: 'WebSocket connection failed',
|
||||||
|
notConnected: 'WebSocket not connected, please try again later',
|
||||||
|
imageUploadFailed: 'Image upload failed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
@@ -521,6 +521,13 @@ const jaJP = {
|
|||||||
loadPipelinesFailed: 'パイプラインの読み込みに失敗しました',
|
loadPipelinesFailed: 'パイプラインの読み込みに失敗しました',
|
||||||
atTips: 'ボットをメンション',
|
atTips: 'ボットをメンション',
|
||||||
streaming: 'ストリーミング',
|
streaming: 'ストリーミング',
|
||||||
|
connected: 'WebSocket接続済み',
|
||||||
|
disconnected: 'WebSocket未接続',
|
||||||
|
connectionError: 'WebSocket接続エラー',
|
||||||
|
connectionFailed: 'WebSocket接続に失敗しました',
|
||||||
|
notConnected:
|
||||||
|
'WebSocketに接続されていません。しばらくしてからやり直してください',
|
||||||
|
imageUploadFailed: '画像のアップロードに失敗しました',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
@@ -500,6 +500,12 @@ const zhHans = {
|
|||||||
loadPipelinesFailed: '加载流水线失败',
|
loadPipelinesFailed: '加载流水线失败',
|
||||||
atTips: '提及机器人',
|
atTips: '提及机器人',
|
||||||
streaming: '流式传输',
|
streaming: '流式传输',
|
||||||
|
connected: 'WebSocket已连接',
|
||||||
|
disconnected: 'WebSocket未连接',
|
||||||
|
connectionError: 'WebSocket连接错误',
|
||||||
|
connectionFailed: 'WebSocket连接失败',
|
||||||
|
notConnected: 'WebSocket未连接,请稍后重试',
|
||||||
|
imageUploadFailed: '图片上传失败',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
@@ -497,6 +497,13 @@ const zhHant = {
|
|||||||
loadMessagesFailed: '載入訊息失敗',
|
loadMessagesFailed: '載入訊息失敗',
|
||||||
loadPipelinesFailed: '載入流程線失敗',
|
loadPipelinesFailed: '載入流程線失敗',
|
||||||
atTips: '提及機器人',
|
atTips: '提及機器人',
|
||||||
|
streaming: '串流傳輸',
|
||||||
|
connected: 'WebSocket已連接',
|
||||||
|
disconnected: 'WebSocket未連接',
|
||||||
|
connectionError: 'WebSocket連接錯誤',
|
||||||
|
connectionFailed: 'WebSocket連接失敗',
|
||||||
|
notConnected: 'WebSocket未連接,請稍後重試',
|
||||||
|
imageUploadFailed: '圖片上傳失敗',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
Reference in New Issue
Block a user