feature/zoujing #5

Merged
zoujing merged 14 commits from feature/zoujing into main 2026-01-19 18:44:15 +08:00
4 changed files with 1349 additions and 39 deletions
Showing only changes of commit ba7861f04b - Show all commits

View File

@@ -0,0 +1,307 @@
/**
* 简化的 WebSocket 管理器Web 版)
* 专门负责 WebSocket 连接和消息传输,不包含打字机逻辑
*/
import { IdUtils, CallbackUtils, MessageUtils, TimerUtils } from './index'
/* =======================
* 类型定义
* ======================= */
export interface WebSocketCallbacks {
onConnect?: (event?: Event) => void
onDisconnect?: (event?: CloseEvent) => void
onError?: (error: any) => void
onMessage?: (message: any) => void
getConversationId?: () => string
getAgentId?: () => string
}
export interface WebSocketManagerOptions extends WebSocketCallbacks {
wsUrl?: string
protocols?: string[]
reconnectInterval?: number
maxReconnectAttempts?: number
heartbeatInterval?: number
messageId?: string
baseDelay?: number
retries?: number
maxDelay?: number
tryReconnect?: boolean
}
export interface QueuedMessage {
[key: string]: any
retryCount?: number
timestamp?: number
}
export interface WebSocketStats {
messagesReceived: number
messagesSent: number
messagesDropped: number
reconnectCount: number
connectionStartTime: number | null
lastMessageTime: number | null
}
/* =======================
* WebSocketManager
* ======================= */
export class WebSocketManager {
private wsUrl = ''
private protocols: string[] = []
private reconnectInterval = 3000
private maxReconnectAttempts = 5
private heartbeatInterval = 30000
private ws: WebSocket | null = null
private reconnectAttempts = 0
private isConnecting = false
private connectionState = false
private heartbeatTimer: number | null = null
private reconnectTimer: number | null = null
private callbacks: Required<WebSocketCallbacks>
private messageQueue: QueuedMessage[] = []
private stats: WebSocketStats = {
messagesReceived: 0,
messagesSent: 0,
messagesDropped: 0,
reconnectCount: 0,
connectionStartTime: null,
lastMessageTime: null,
}
constructor(options: WebSocketManagerOptions = {}) {
this.wsUrl = options.wsUrl ?? ''
this.protocols = options.protocols ?? []
this.reconnectInterval = options.reconnectInterval ?? 3000
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5
this.heartbeatInterval = options.heartbeatInterval ?? 30000
this.callbacks = {
onConnect: options.onConnect ?? (() => { }),
onDisconnect: options.onDisconnect ?? (() => { }),
onError: options.onError ?? (() => { }),
onMessage: options.onMessage ?? (() => { }),
getConversationId: options.getConversationId ?? (() => ''),
getAgentId: options.getAgentId ?? (() => ''),
}
}
/* =======================
* 内部工具
* ======================= */
private safeCall(name: keyof WebSocketCallbacks, ...args: any[]): void {
CallbackUtils.safeCall(this.callbacks, name as string, ...args)
}
/* =======================
* 连接管理
* ======================= */
async init(wsUrl?: string): Promise<void> {
if (wsUrl) this.wsUrl = wsUrl
if (!this.wsUrl) throw new Error('WebSocket URL is required')
await this.connect()
}
async connect(): Promise<void> {
if (this.isConnecting || this.connectionState) return
this.isConnecting = true
try {
this.ws = new WebSocket(this.wsUrl, this.protocols)
this.ws.onopen = this.handleOpen
this.ws.onmessage = this.handleMessage
this.ws.onclose = this.handleClose
this.ws.onerror = this.handleError
} catch (error) {
this.isConnecting = false
this.safeCall('onError', error)
this.scheduleReconnect()
}
}
private handleOpen = (event: Event): void => {
this.isConnecting = false
this.connectionState = true
this.reconnectAttempts = 0
this.stats.connectionStartTime = Date.now()
this.startHeartbeat()
this.safeCall('onConnect', event)
this.processQueue()
}
private handleMessage = (event: MessageEvent): void => {
const raw = event.data
if (MessageUtils.isPongMessage(raw)) return
const data =
typeof raw === 'string'
? MessageUtils.safeParseJSON(raw)
: raw
if (!data) return
this.stats.messagesReceived++
this.stats.lastMessageTime = Date.now()
this.safeCall('onMessage', data)
}
private handleClose = (event: CloseEvent): void => {
this.connectionState = false
this.isConnecting = false
this.stopHeartbeat()
this.safeCall('onDisconnect', event)
if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
private handleError = (error: Event): void => {
this.connectionState = false
this.isConnecting = false
this.safeCall('onError', {
type: 'WEBSOCKET_ERROR',
error,
})
if (!this.reconnectTimer) {
this.scheduleReconnect()
}
}
/* =======================
* 消息发送
* ======================= */
sendMessage(message: QueuedMessage): boolean {
const data = {
...message,
timestamp: Date.now(),
retryCount: message.retryCount ?? 0,
}
if (!this.isConnected()) {
this.messageQueue.push(data)
this.connect()
return false
}
try {
this.ws!.send(JSON.stringify(data))
this.stats.messagesSent++
return true
} catch (error) {
this.messageQueue.push(data)
this.safeCall('onError', error)
return false
}
}
private processQueue(): void {
while (this.messageQueue.length) {
const msg = this.messageQueue.shift()!
if ((msg.retryCount ?? 0) >= 3) {
this.stats.messagesDropped++
continue
}
msg.retryCount = (msg.retryCount ?? 0) + 1
this.sendMessage(msg)
}
}
/* =======================
* 心跳 & 重连
* ======================= */
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = window.setInterval(() => {
if (!this.isConnected()) return
this.sendMessage({
messageType: '3',
messageContent: 'heartbeat',
messageId: IdUtils.generateMessageId(),
conversationId: this.callbacks.getConversationId(),
agentId: this.callbacks.getAgentId(),
})
}, this.heartbeatInterval)
}
private stopHeartbeat(): void {
this.heartbeatTimer = TimerUtils.clearTimer(this.heartbeatTimer, 'interval')
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return
this.reconnectAttempts++
this.stats.reconnectCount++
const delay = Math.min(
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
30000
)
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
/* =======================
* 对外 API
* ======================= */
isConnected(): boolean {
return (
this.connectionState &&
!!this.ws &&
this.ws.readyState === WebSocket.OPEN
)
}
getStats() {
return {
...this.stats,
queueLength: this.messageQueue.length,
isConnected: this.isConnected(),
}
}
close(): void {
this.stopHeartbeat()
TimerUtils.clearTimer(this.reconnectTimer)
this.reconnectTimer = null
this.ws?.close(1000)
this.ws = null
this.connectionState = false
this.isConnecting = false
this.messageQueue = []
}
destroy(): void {
this.close()
}
}
export default WebSocketManager

331
src/common/index.ts Normal file
View File

@@ -0,0 +1,331 @@
/**
* 工具函数集合
* 包含打字机效果、ID生成、回调安全调用等通用工具函数
*/
/* =======================
* ID 生成工具
* ======================= */
export class IdUtils {
/**
* 生成消息 ID
*/
static generateMessageId(): string {
const timestamp = Date.now()
const chars = 'abcdefghijklmnopqrstuvwxyz'
const randomStr = Array.from({ length: 4 }, () =>
chars.charAt(Math.floor(Math.random() * chars.length))
).join('')
return `mid${randomStr}${timestamp}`
}
}
/* =======================
* 回调安全调用工具
* ======================= */
export type CallbackMap = Record<string, (...args: any[]) => any>
export interface BatchCallbackConfig {
name: string
args?: any[]
}
export class CallbackUtils {
/**
* 安全调用回调函数
*/
static safeCall(
callbacks: CallbackMap | null | undefined,
callbackName: string,
...args: any[]
): void {
const cb = callbacks?.[callbackName]
if (typeof cb === 'function') {
try {
cb(...args)
} catch (error) {
console.error(`回调函数 ${callbackName} 执行出错:`, error)
}
} else {
console.warn(`回调函数 ${callbackName} 不可用`)
}
}
/**
* 批量安全调用回调函数
*/
static safeBatchCall(
callbacks: CallbackMap | null | undefined,
callbackConfigs: BatchCallbackConfig[]
): void {
callbackConfigs.forEach(({ name, args = [] }) => {
this.safeCall(callbacks, name, ...args)
})
}
}
/* =======================
* 消息处理工具
* ======================= */
export interface BaseMessage {
type: string
content?: any
timestamp: number
isComplete?: boolean
[key: string]: any
}
export class MessageUtils {
/**
* 验证消息格式
*/
static validateMessage(message: unknown): message is BaseMessage {
return (
typeof message === 'object' &&
message !== null &&
'type' in message
)
}
/**
* 格式化消息
*/
static formatMessage<T = any>(
type: string,
content: T,
options: Partial<BaseMessage> = {}
): BaseMessage {
return {
type,
content,
timestamp: Date.now(),
...options,
}
}
/**
* 是否为完整消息
*/
static isCompleteMessage(message: Partial<BaseMessage> | null | undefined): boolean {
return message?.isComplete === true
}
/**
* 是否为心跳 pong
*/
static isPongMessage(messageData: unknown): boolean {
if (typeof messageData === 'string') {
return messageData === 'pong' || messageData.toLowerCase().includes('pong')
}
if (typeof messageData === 'object' && messageData !== null) {
return (messageData as any).type === 'pong'
}
return false
}
/**
* 安全解析 JSON
*/
static safeParseJSON<T = any>(messageStr: string): T | null {
try {
return JSON.parse(messageStr) as T
} catch {
console.warn('JSON 解析失败:', messageStr)
return null
}
}
/**
* 创建打字机消息
*/
static createTypewriterMessage(
content: string,
isComplete = false,
type = 'typewriter'
): BaseMessage {
return {
type,
content,
isComplete,
timestamp: Date.now(),
}
}
/**
* 创建加载消息
*/
static createLoadingMessage(content = '加载中...'): BaseMessage {
return {
type: 'loading',
content,
timestamp: Date.now(),
}
}
/**
* 创建错误消息
*/
static createErrorMessage(error: unknown): BaseMessage {
return {
type: 'error',
content:
error instanceof Error ? error.message : String(error ?? '未知错误'),
timestamp: Date.now(),
}
}
}
/* =======================
* 定时器工具
* ======================= */
export type TimerType = 'timeout' | 'interval'
export interface CancelableTimer {
cancel(): void
isActive(): boolean
}
export class TimerUtils {
static safeClear(
timerId: number | null,
type: TimerType = 'timeout'
): null {
if (timerId !== null) {
type === 'interval' ? clearInterval(timerId) : clearTimeout(timerId)
}
return null
}
static clearTimer(
timerId: number | null,
type: TimerType = 'timeout'
): null {
return this.safeClear(timerId, type)
}
static createCancelableTimeout(
callback: () => void,
delay: number
): CancelableTimer {
let timerId: number | null = window.setTimeout(callback, delay)
return {
cancel() {
if (timerId !== null) {
clearTimeout(timerId)
timerId = null
}
},
isActive() {
return timerId !== null
},
}
}
static createCancelableInterval(
callback: () => void,
interval: number
): CancelableTimer {
let timerId: number | null = window.setInterval(callback, interval)
return {
cancel() {
if (timerId !== null) {
clearInterval(timerId)
timerId = null
}
},
isActive() {
return timerId !== null
},
}
}
}
/* =======================
* 防抖工具
* ======================= */
export class DebounceUtils {
static createDebounce<T extends (...args: any[]) => void>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timerId: number | null = null
return function (this: unknown, ...args: Parameters<T>) {
if (timerId !== null) {
clearTimeout(timerId)
}
timerId = window.setTimeout(() => func.apply(this, args), delay)
}
}
}
/* =======================
* 节流工具
* ======================= */
export class ThrottleUtils {
static createThrottle<T extends (...args: any[]) => void>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let prev = Date.now()
return function (this: unknown, ...args: Parameters<T>) {
const now = Date.now()
if (now - prev >= delay) {
func.apply(this, args)
prev = now
}
}
}
}
/* =======================
* 日期工具
* ======================= */
export class DateUtils {
static formatDate(
date: Date = new Date(),
format: string = 'yyyy-MM-dd'
): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return format
.replace('yyyy', String(year))
.replace('MM', month)
.replace('dd', day)
}
}
/* =======================
* 手机号校验
* ======================= */
export class PhoneUtils {
static validatePhone(phone: string): boolean {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
}
/* =======================
* 默认导出
* ======================= */
export default {
IdUtils,
CallbackUtils,
MessageUtils,
TimerUtils,
DateUtils,
DebounceUtils,
ThrottleUtils,
PhoneUtils,
}

View File

@@ -4,29 +4,33 @@
<!-- 消息列表唯一滚动区 -->
<div ref="listRef" class="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<div v-for="msg in messages" :key="msg.id" class="flex items-start gap-3"
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'">
<div v-for="msg in chatMsgList" :key="msg.msgId" class="flex items-start gap-3"
:class="msg.msgRole === MessageRole.ME ? 'justify-end' : 'justify-start'">
<!-- AI avatar -->
<img v-if="msg.role === 'ai'" class="w-9 h-9 rounded-full shrink-0" src="@assets/images/login/blue_logo.png" />
<img v-if="msg.msgRole === MessageRole.AI" class="w-9 h-9 rounded-full shrink-0"
src="@assets/images/login/blue_logo.png" />
<!-- 消息气泡 -->
<div class="max-w-[70%]">
<div class="flex items-start gap-2 pt-0.5 mb-2" :class="msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'">
<div class="flex items-start gap-2 pt-0.5 mb-2"
:class="msg.msgRole === MessageRole.ME ? 'flex-row-reverse' : 'flex-row'">
<span class="text-xs text-[#4E5969]"> ZHINIAN</span>
<span class="text-xs text-[#86909C]"> 20:30</span>
</div>
<div class="text-sm text-gray-700" :class="msg.role === 'user' ? 'bg-[#f7f9fc] rounded-md px-2 py-2' : ''">
{{ msg.content }}
<div class="text-sm text-gray-700"
:class="msg.msgRole === MessageRole.ME ? 'bg-[#f7f9fc] rounded-md px-2 py-2' : ''">
{{ msg.msg }}
</div>
<!-- AI 标识 -->
<div v-if="msg.role === 'ai'" class="mt-2 text-xs text-gray-400 ">
<div v-if="msg.msgRole === MessageRole.AI" class="mt-2 text-xs text-gray-400 ">
本回答由 AI 生成
</div>
<!-- AI 操作按钮 -->
<div v-if="msg.role === 'ai'" class="mt-4 text-gray-500 flex items-center justify-between gap-4 ">
<div v-if="msg.msgRole === MessageRole.AI"
class="mt-4 text-gray-500 flex items-center justify-between gap-4 ">
<RiFileCopyLine size="16px" @click="copyFileClick(msg)" />
<div class="flex items-center gap-4">
<RiShareForwardLine size="16px" @click="shareForwardClick(msg)" />
@@ -39,7 +43,8 @@
</div>
<!-- User avatar -->
<img v-if="msg.role === 'user'" class="w-9 h-9 rounded-full shrink-0" src="@assets/images/login/user_icon.png" />
<img v-if="msg.msgRole === MessageRole.ME" class="w-9 h-9 rounded-full shrink-0"
src="@assets/images/login/user_icon.png" />
</div>
</div>
@@ -54,13 +59,15 @@
<div class="h-[174px] bg-white rounded-lg border border-[#eef2f6]
shadow-[0_1px_0_rgba(0,0,0,0.03)]
p-[18px] mt-[8px] flex flex-col justify-between">
<textarea rows="2" placeholder="给我发布或者布置任务" class="flex-1 resize-none outline-none text-sm" />
<textarea rows="2" placeholder="给我发布或者布置任务" class="flex-1 resize-none outline-none text-sm"
v-model="inputMessage" />
<div class="flex justify-between items-end">
<button>
<RiLink />
</button>
<button class="w-[48px] h-[48px] bg-[#F5F7FA] px-2.5 py-1.5 rounded-md flex items-center justify-center">
<button class="w-[48px] h-[48px] bg-[#F5F7FA] px-2.5 py-1.5 rounded-md flex items-center justify-center"
@click="sendMessageAction()">
<RiSendPlaneFill />
</button>
</div>
@@ -73,44 +80,647 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RiLink, RiSendPlaneFill, RiFileCopyLine, RiShareForwardLine, RiDownload2Line, RiThumbUpLine, RiThumbDownLine } from '@remixicon/vue'
import { onMounted, nextTick, onUnmounted } from "vue";
import { WebSocketManager } from "@common/WebSocketManager";
import { MessageRole, MessageType, ChatMessage } from "./model/ChatModel";
import { ThrottleUtils, IdUtils } from "@common/index";
type Message = {
id: number
role: 'user' | 'ai'
content: string
}
/// 输入框组件引用
const inputAreaRef = ref(null);
const holdKeyboardTimer = ref(null);
/// focus时点击页面的时候不收起键盘
const holdKeyboard = ref(false);
///(控制滚动位置)
const scrollTop = ref(99999);
/// 会话列表
const chatMsgList = ref<ChatMessage[]>([]);
/// 输入口的输入消息
const inputMessage = ref("");
/// agentId 首页接口中获取
const agentId = ref("1");
/// 会话ID 历史数据接口中获取
const conversationId = ref("");
// 会话进行中标志
const isSessionActive = ref(false);
/// 指令
let commonType = "";
// WebSocket 相关
let webSocketManager: WebSocketManager | null = null;
/// 使用统一的连接状态判断函数,避免状态不同步
const isWsConnected = () => !!(webSocketManager && typeof webSocketManager.isConnected === "function" && webSocketManager.isConnected());
// pendingMap: messageId -> msgIndex
const pendingMap = new Map();
// pendingTimeouts: messageId -> timeoutId
const pendingTimeouts = new Map();
// 超时时间ms
const MESSAGE_TIMEOUT = 30000;
// 防止并发初始化 websocket
let isInitializing = false;
let pendingInitPromise: Promise<boolean> | null = null;
// sleep helper
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
// 当前会话的消息ID用于保持发送和终止的messageId一致
let currentSessionMessageId: string | null = null;
// 滚动到底部 - 优化版本,确保打字机效果始终可见
const scrollToBottom = () => {
nextTick(() => {
// 使用更大的值确保滚动到真正的底部
scrollTop.value = 99999;
// 强制触发滚动更新增加延迟确保DOM更新完成
setTimeout(() => {
scrollTop.value = scrollTop.value + Math.random();
}, 10);
});
};
// 延时滚动
const setTimeoutScrollToBottom = () => setTimeout(() => scrollToBottom(), 100);
// 发送普通消息
const handleReplyText = (text: string) => {
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(text);
setTimeoutScrollToBottom();
};
// 是发送指令消息
const handleReplyInstruct = async (message: string, type: string) => {
// await checkToken();
commonType = type;
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(message, true);
setTimeoutScrollToBottom();
};
const messages = ref<Message[]>(
Array.from({ length: 40 }).map((_, i) => ({
id: i,
role: i % 2 === 0 ? 'user' : 'ai',
content:
i % 2 === 0
? '这里是一段用户输入的消息,用于测试滚动效果。'.repeat(
(i % 3) + 1
)
: '这里是一段 AI 回复的内容,用于测试长文本下的滚动体验。'.repeat(
(i % 4) + 1
),
}))
)
/// actions 实现复制、分享、下载、点赞等功能
const copyFileClick = (msg: Message) => {
const copyFileClick = (msg: ChatMessage) => {
console.log('copy file', msg)
}
const shareForwardClick = (msg: Message) => {
const shareForwardClick = (msg: ChatMessage) => {
console.log('share forward', msg)
}
const downloadClick = (msg: Message) => {
const downloadClick = (msg: ChatMessage) => {
console.log('download', msg)
}
const thumbUpClick = (msg: Message) => {
const thumbUpClick = (msg: ChatMessage) => {
console.log('thumb up', msg)
}
const thumbDownClick = (msg: Message) => {
const thumbDownClick = (msg: ChatMessage) => {
console.log('thumb down', msg)
}
// 输入区的发送消息事件
const sendMessageAction = () => {
console.log("输入消息:", inputMessage.value);
if (!inputMessage.value.trim()) return;
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(inputMessage.value);
setTimeoutScrollToBottom();
};
// 页面加载时初始化
onMounted(() => {
try {
// 有token时加载最近会话、最近消息、初始化socket
initHandler();
} catch (error) {
console.error("初始化错误:", error);
}
});
// token存在初始化数据
const initHandler = () => {
console.log("initHandler");
const token = getAccessToken();
if (!token) return;
initWebSocket();
};
const getAccessToken = () => {
// 从本地存储获取 token
return localStorage.getItem("access_token") || "";
};
const checkToken = async () => {
const token = getAccessToken();
if (!token) {
throw new Error("未登录或登录已过期,请重新登录");
}
};
/// =============对话↓================
// 初始化WebSocket
const initWebSocket = async () => {
// 防止并发初始化
if (isInitializing) {
return pendingInitPromise;
}
isInitializing = true;
pendingInitPromise = (async () => {
// 清理旧实例
if (webSocketManager) {
try {
webSocketManager.destroy();
} catch (e) {
console.warn("destroy old webSocketManager failed:", e);
}
webSocketManager = null;
}
// 使用配置的WebSocket服务器地址
const token = getAccessToken();
const wsUrl = `?access_token=${token}`;
// 初始化WebSocket管理器
webSocketManager = new WebSocketManager({
wsUrl: wsUrl,
reconnectInterval: 3000, // 重连间隔
maxReconnectAttempts: 5, // 最大重连次数
heartbeatInterval: 30000, // 心跳间隔
// 连接成功回调
onConnect: (event) => {
console.log("WebSocket连接成功: ", event);
// 连接成功时重置会话状态,避免影响新消息发送
isSessionActive.value = false;
},
// 连接断开回调
onDisconnect: (event) => {
console.error("WebSocket连接断开: ", event);
// 停止当前会话
isSessionActive.value = false;
},
// 错误回调
onError: (error) => {
console.error("WebSocket错误:", error);
isSessionActive.value = false;
},
// 消息回调
onMessage: (data) => {
handleWebSocketMessage(data);
},
// 获取会话ID回调 (用于心跳检测)
getConversationId: () => conversationId.value,
// 获取代理ID回调 (用于心跳检测)
getAgentId: () => agentId.value,
});
try {
// 初始化连接
await webSocketManager.connect();
console.log("WebSocket连接初始化成功");
return true;
} catch (error) {
console.error("WebSocket连接失败:", error);
return false;
} finally {
isInitializing = false;
pendingInitPromise = null;
}
})();
return pendingInitPromise;
};
// 处理WebSocket消息
const handleWebSocketMessage = (data: any) => {
// 验证关键字段(若服务端传回 conversationId/agentId则校验是否属于当前会话
if (data.conversationId && data.conversationId !== conversationId.value) {
console.warn("收到不属于当前会话的消息,忽略", data.conversationId);
return;
}
if (data.agentId && data.agentId !== agentId.value) {
console.warn("收到不属于当前 agent 的消息,忽略", data.agentId);
return;
}
// 确保消息内容是字符串类型
if (data.content && typeof data.content !== "string") {
try {
data.content = JSON.stringify(data.content);
} catch (e) {
data.content = String(data.content);
}
}
// 优先使用 messageId 进行匹配
const msgId = data.messageId || data.id || data.msgId;
let aiMsgIndex = -1;
if (msgId && pendingMap.has(msgId)) {
aiMsgIndex = pendingMap.get(msgId);
} else if (!msgId && currentSessionMessageId && pendingMap.has(currentSessionMessageId)) {
// 服务端未返回 messageId 的场景:优先使用当前会话的 messageId 映射
aiMsgIndex = pendingMap.get(currentSessionMessageId);
} else {
// 向后搜索最近的 AI 消息
for (let i = chatMsgList.value.length - 1; i >= 0; i--) {
if (chatMsgList.value[i] && chatMsgList.value[i].msgRole === MessageRole.AI) {
aiMsgIndex = i;
break;
}
}
if (aiMsgIndex === -1) {
console.error("处理WebSocket消息时找不到对应的AI消息项");
return;
}
}
// 直接拼接内容到对应 AI 消息
if (data.content) {
if (chatMsgList.value[aiMsgIndex].isLoading) {
// 首次收到内容:替换“加载中”文案并取消 loading 状态(恢复原始渲染逻辑)
chatMsgList.value[aiMsgIndex].msg = data.content;
chatMsgList.value[aiMsgIndex].isLoading = false;
} else {
// 后续流式内容追加
chatMsgList.value[aiMsgIndex].msg += data.content;
}
nextTick(() => scrollToBottom());
}
// 处理完成状态
if (data.finish) {
const msg = chatMsgList.value[aiMsgIndex].msg;
if (!msg || chatMsgList.value[aiMsgIndex].isLoading) {
chatMsgList.value[aiMsgIndex].msg = "未获取到内容,请重试";
chatMsgList.value[aiMsgIndex].isLoading = false;
if (data.toolCall) {
chatMsgList.value[aiMsgIndex].msg = "";
}
}
// 处理toolCall
if (data.toolCall) {
chatMsgList.value[aiMsgIndex].toolCall = data.toolCall;
}
// 处理question
if (data.question && data.question.length > 0) {
chatMsgList.value[aiMsgIndex].question = data.question;
}
// 清理 pendingMap / timeout
const ownedMessageId = chatMsgList.value[aiMsgIndex].messageId || msgId;
if (ownedMessageId) {
if (pendingTimeouts.has(ownedMessageId)) {
clearTimeout(pendingTimeouts.get(ownedMessageId));
pendingTimeouts.delete(ownedMessageId);
}
pendingMap.delete(ownedMessageId);
}
// 重置会话状态
isSessionActive.value = false;
// 清理当前会话的 messageId避免保留陈旧 id
resetMessageState();
}
};
// 重置消息状态
const resetMessageState = () => {
// 重置当前会话消息ID
currentSessionMessageId = null;
};
// 发送消息的参数拼接
const sendMessage = async (message: string, isInstruct: boolean = false) => {
console.log("发送的消息:", message);
await checkToken();
// 检查WebSocket连接状态如果未连接尝试重新连接
if (!isWsConnected()) {
console.log("WebSocket未连接尝试重新连接...");
// 显示加载提示
// uni.showLoading({
// title: "正在连接服务器...",
// });
// 尝试重新初始化WebSocket连接
try {
await initWebSocket();
// 等待短暂时间确保连接建立
await new Promise(resolve => setTimeout(resolve, 1000));
// 检查连接是否成功建立
if (!isWsConnected()) {
// uni.hideLoading();
// uni.showToast({
// title: "连接服务器失败,请稍后重试",
// icon: "none",
// });
console.error("重新连接WebSocket后仍未连接成功");
return;
}
// uni.hideLoading();
} catch (error) {
console.error("重新连接WebSocket失败:", error);
// uni.hideLoading();
// uni.showToast({
// title: "连接服务器失败,请稍后重试",
// icon: "none",
// });
return;
}
}
if (isSessionActive.value) {
// uni.showToast({
// title: "请等待当前回复完成",
// icon: "none",
// });
console.warn("当前会话正在进行中,请等待回复完成");
return;
}
isSessionActive.value = true;
const newMsg: ChatMessage = {
msgId: `msg_${chatMsgList.value.length}`,
msgRole: MessageRole.ME,
msg: message,
msgContent: {
type: MessageType.TEXT,
text: message,
},
messageId: IdUtils.generateMessageId(),
};
chatMsgList.value.push(newMsg);
inputMessage.value = "";
// 发送消息后滚动到底部
setTimeoutScrollToBottom();
sendChat(message, isInstruct);
console.log("发送的新消息:", JSON.stringify(newMsg));
};
// 通用WebSocket消息发送函数 -> 返回 Promise<boolean>
const sendWebSocketMessage = async (messageType: number, messageContent: any, options: any = {}) => {
const args = {
conversationId: conversationId.value,
agentId: agentId.value,
messageType: String(messageType), // 消息类型 0-对话 1-指令 2-中断停止 3-心跳检测
messageContent: messageContent,
messageId: options.messageId || currentSessionMessageId,
};
const maxRetries = typeof options.messageId === 'number' ? options.retries : 3;
const baseDelay = typeof options.baseDelay === 'number' ? options.baseDelay : 300; // ms
const maxDelay = typeof options.maxDelay === 'number' ? options.maxDelay : 5000; // ms
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// 确保连接
if (!isWsConnected()) {
if (options.tryReconnect) {
try {
await initWebSocket();
} catch (e) {
console.error('reconnect failed in sendWebSocketMessage:', e);
}
}
}
if (!isWsConnected()) {
if (!options.silent) console.warn('WebSocket 未连接,无法发送消息', args);
// 如果还有重试机会,进行等待后重试
if (attempt < maxRetries) {
const delay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt));
await sleep(delay);
continue;
}
isSessionActive.value = false;
return false;
}
try {
const raw = webSocketManager!.sendMessage(args);
// 兼容可能返回同步布尔或 Promise 的实现
const result = await Promise.resolve(raw);
if (result) {
console.log(`WebSocket消息已发送 [类型:${messageType}]:`, args);
return true;
}
// 若返回 false消息可能已经被 manager 入队并触发连接流程。
// 在这种情况下避免立即当作失败处理,而是等待短暂时间以观察连接是否建立并由 manager 发送队列。
console.warn('webSocketManager.sendMessage 返回 false等待连接或队列发送...', { attempt, args });
const waitForConnectMs = typeof options.waitForConnectMs === 'number' ? options.waitForConnectMs : 5000;
if (webSocketManager && typeof webSocketManager.isConnected === 'function' && !webSocketManager.isConnected()) {
const startTs = Date.now();
while (Date.now() - startTs < waitForConnectMs) {
await sleep(200);
if (webSocketManager.isConnected()) {
// 给 manager 一点时间处理队列并发送
await sleep(150);
console.log('检测到 manager 已连接,假定队列消息已发送', args);
return true;
}
}
console.warn('等待 manager 建连超时,进入重试逻辑', { waitForConnectMs, args });
} else {
console.warn('sendMessage 返回 false 但 manager 看起来已连接或不可用,继续重试', { args });
}
} catch (error) {
console.error('发送WebSocket消息异常:', error, args);
}
// 失败且还有重试机会,等待指数退避
if (attempt < maxRetries) {
const delay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt));
await sleep(delay + Math.floor(Math.random() * 100));
continue;
}
// 最后一次失败
isSessionActive.value = false;
return false;
}
// 不可达,但为了类型安全
isSessionActive.value = false;
return false;
};
// 发送获取AI聊天消息
const sendChat = async (message: string, isInstruct = false) => {
// 检查WebSocket管理器是否存在如果不存在尝试重新初始化
if (!webSocketManager) {
console.error("WebSocket管理器不存在尝试重新初始化...");
initWebSocket();
// 短暂延迟后再次检查连接状态
setTimeout(() => {
const connected = webSocketManager && webSocketManager.isConnected();
isSessionActive.value = connected || false;
// 更新AI消息状态
const aiMsgIndex = chatMsgList.value.length - 1;
if (aiMsgIndex >= 0 && chatMsgList.value[aiMsgIndex].msgRole === MessageRole.AI) {
chatMsgList.value[aiMsgIndex].msg = connected ? "" : "发送消息失败,请重试";
chatMsgList.value[aiMsgIndex].isLoading = connected || false;
}
if (connected) {
// 连接成功后重新发送消息
sendChat(message, isInstruct);
} else {
console.error("WebSocket重新初始化失败");
}
}, 1000);
return;
}
const messageType = isInstruct ? 1 : 0;
const messageContent = isInstruct ? commonType : message;
// 生成 messageId 并保存到当前会话变量stopRequest 可能使用)
currentSessionMessageId = IdUtils.generateMessageId();
// 插入AI消息并在 pendingMap 中记录
const aiMsg: ChatMessage = {
msgId: `msg_${chatMsgList.value.length}`,
msgRole: MessageRole.AI,
msg: "加载中",
isLoading: true,
msgContent: {
type: MessageType.TEXT,
text: "",
},
messageId: currentSessionMessageId,
};
chatMsgList.value.push(aiMsg);
// 添加AI消息后滚动到底部
setTimeoutScrollToBottom();
const aiMsgIndex = chatMsgList.value.length - 1;
// 记录 pendingMap
pendingMap.set(currentSessionMessageId, aiMsgIndex);
// 启动超时回退
const timeoutId = setTimeout(() => {
const idx = pendingMap.get(currentSessionMessageId);
if (idx != null && chatMsgList.value[idx] && chatMsgList.value[idx].isLoading) {
chatMsgList.value[idx].msg = "请求超时,请重试";
chatMsgList.value[idx].isLoading = false;
pendingMap.delete(currentSessionMessageId);
pendingTimeouts.delete(currentSessionMessageId);
isSessionActive.value = false;
setTimeoutScrollToBottom();
}
}, MESSAGE_TIMEOUT);
pendingTimeouts.set(currentSessionMessageId, timeoutId);
// 发送消息,支持重连尝试
const success = await sendWebSocketMessage(messageType, messageContent, { messageId: currentSessionMessageId, tryReconnect: true });
if (!success) {
const idx = pendingMap.get(currentSessionMessageId);
if (idx != null && chatMsgList.value[idx]) {
chatMsgList.value[idx].msg = "发送消息失败,请重试";
chatMsgList.value[idx].isLoading = false;
}
// 清理 pending
if (pendingTimeouts.has(currentSessionMessageId)) {
clearTimeout(pendingTimeouts.get(currentSessionMessageId));
pendingTimeouts.delete(currentSessionMessageId);
}
pendingMap.delete(currentSessionMessageId);
isSessionActive.value = false;
}
};
// 停止请求函数
const stopRequest = async () => {
console.log("停止请求");
// 发送中断消息给服务器 (messageType=2),带上当前 messageId
try {
await sendWebSocketMessage(2, "stop_request", { messageId: currentSessionMessageId, silent: true });
} catch (e) {
console.warn("stopRequest send failed:", e);
}
// 直接将AI消息状态设为停止优先使用 pendingMap 映射)
let aiMsgIndex = -1;
if (currentSessionMessageId && pendingMap.has(currentSessionMessageId)) {
aiMsgIndex = pendingMap.get(currentSessionMessageId);
} else {
aiMsgIndex = chatMsgList.value.length - 1;
}
if (chatMsgList.value[aiMsgIndex] &&
chatMsgList.value[aiMsgIndex].msgRole === MessageRole.AI) {
chatMsgList.value[aiMsgIndex].isLoading = false;
if (chatMsgList.value[aiMsgIndex].msg &&
chatMsgList.value[aiMsgIndex].msg.trim() &&
!chatMsgList.value[aiMsgIndex].msg.startsWith("加载中")) {
// 保留已显示内容
} else {
chatMsgList.value[aiMsgIndex].msg = "请求已停止";
}
}
// 清理 pending
if (currentSessionMessageId) {
if (pendingTimeouts.has(currentSessionMessageId)) {
clearTimeout(pendingTimeouts.get(currentSessionMessageId));
pendingTimeouts.delete(currentSessionMessageId);
}
pendingMap.delete(currentSessionMessageId);
}
// 重置会话状态
isSessionActive.value = false;
setTimeoutScrollToBottom();
};
// 组件销毁时清理资源
onUnmounted(() => {
console.log("组件销毁");
resetConfig();
});
const resetConfig = () => {
// 清理WebSocket连接
if (webSocketManager) {
webSocketManager.destroy();
webSocketManager = null;
}
// 重置消息状态
resetMessageState();
isSessionActive.value = false;
// 清理 pendingMap / pendingTimeouts
try {
for (const t of pendingTimeouts.values()) {
clearTimeout(t);
}
} catch (e) {
// ignore
}
pendingTimeouts.clear();
pendingMap.clear();
// 清理定时器
if (holdKeyboardTimer.value) {
clearTimeout(holdKeyboardTimer.value);
holdKeyboardTimer.value = null;
}
};
</script>

View File

@@ -0,0 +1,62 @@
import { url } from "inspector";
export enum MessageRole {
// 智能体消息
AI = "AI",
// 我发送的消息
ME = "ME",
// 其他消息
OTHER = "OTHER",
};
export enum MessageType {
// 文本消息
TEXT = "TEXT",
// 图片消息
IMAGE = "IMAGE",
};
/// Chat消息模型
export interface ChatMessaageContent {
type: MessageType,
text: string
}
export class ChatMessage {
// 消息ID
msgId: string;
// 消息类型
msgRole: MessageRole;
// 消息内容
msg: string;
// 是否加载中
isLoading?: boolean;
// 消息内容详情
msgContent: ChatMessaageContent;
// 消息唯一标识
messageId: string;
// 工具调用信息
toolCall?: any;
// 问题信息
question?: any;
constructor(
msgId: string,
msgRole: MessageRole,
msg: string,
isLoading: boolean = false,
msgContent: ChatMessaageContent,
messageId: string,
toolCall?: any,
question?: any
) {
this.msgId = msgId;
this.msgRole = msgRole;
this.msg = msg;
this.isLoading = isLoading;
this.msgContent = msgContent;
this.messageId = messageId;
this.toolCall = toolCall;
this.question = question;
}
}