新增: 小程序端跨端AI对话

This commit is contained in:
2026-06-24 00:19:54 +08:00
parent 0c7c5e6068
commit 698d981962
20 changed files with 5647 additions and 1242 deletions

View File

@@ -11,11 +11,23 @@
* - H5:uni.connectSocket 封装浏览器 WebSocket
* - 统一经 uni API 封装,业务层不直接 new WebSocket
*
* 机制:
* - 重连:指数退避(baseDelay * 2^n,封顶 maxDelay),握手失败/网络断/服务关闭触发
* - 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
* - JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
*
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
* 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
* JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
*/
import { getConfig } from '@/config'
@@ -34,6 +46,25 @@ export interface WsHandlers {
onStatus: (status: WsStatus, detail?: string) => void
}
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
interface QueuedCmd {
cmd: MiniCommand
ts: number
}
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
const HANDSHAKE_TIMEOUT_MS = 12000
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
const CONNECT_TIMEOUT_MS = 8000
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
const PENDING_MAX = 50
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
const PENDING_TTL_MS = 60000
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
const HEARTBEAT_DEAD_FACTOR = 2
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
const MAX_RECONNECT_ATTEMPTS = 20
/**
* WS 客户端单例(miniapp 全局一个连接)
*
@@ -48,9 +79,22 @@ class WsClient {
private handshaked = false
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
private connectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false
/**
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
*/
private pendingQueue: QueuedCmd[] = []
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
private lastInboundAt = 0
/** 订阅(单订阅,composable 注册一次) */
subscribe(h: WsHandlers): void {
@@ -68,20 +112,58 @@ class WsClient {
return
}
this.manualClose = false
this.openSocket()
// 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
this.reconnectAttempts = 0
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
this.reconnectNow()
}
/** 主动断开(用户手动,不重连) */
disconnect(): void {
this.manualClose = true
this.reconnectAttempts = 0
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
this.cleanup()
this.setStatus('disconnected', 'manual close')
}
/** 若已断开则触发重连(onShow 时调用,小程序后台回前台) */
/**
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
*
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
* - disconnected:onClose 已触发,直接重连;
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
*
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
*/
resumeIfDisconnected(): void {
if (this.status === 'disconnected' && !this.manualClose) {
this.scheduleReconnect()
if (this.manualClose) return
if (this.status === 'disconnected' || this.status === 'reconnecting') {
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
this.reconnectAttempts = 0
this.reconnectNow()
}
}
/**
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
*
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
*/
pauseBackground(): void {
this.stopHeartbeat()
}
/**
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
*/
resumeBackground(): void {
if (this.status === 'connected' && this.handshaked) {
this.startHeartbeat()
} else {
this.resumeIfDisconnected()
}
}
@@ -92,8 +174,16 @@ class WsClient {
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/
send(cmd: MiniCommand): boolean {
console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
if (!this.socket || !this.handshaked) {
console.warn('[WsClient] 未连接或握手未完成,丢弃命令', cmd.cmd)
// 握手未完成丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
if (this.manualClose) {
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
return false
}
this.enqueuePending(cmd)
return false
}
try {
@@ -106,15 +196,77 @@ class WsClient {
}
}
// ─── 内部实现 ──────────────────────────────────────────
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
private enqueuePending(cmd: MiniCommand): void {
if (this.pendingQueue.length >= PENDING_MAX) {
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
this.pendingQueue.shift()
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
}
this.pendingQueue.push({ cmd, ts: Date.now() })
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
}
/**
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
*
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
*/
private flushPendingQueue(): void {
if (this.pendingQueue.length === 0) return
const now = Date.now()
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
while (this.pendingQueue.length > 0) {
const item = this.pendingQueue.shift()!
if (now - item.ts > PENDING_TTL_MS) {
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
continue
}
try {
if (this.socket) {
this.socket.send({ data: JSON.stringify(item.cmd) })
}
} catch (e) {
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
}
}
}
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
/**
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
*
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
* 统一经此,保证:
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
*/
private reconnectNow(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.cleanup()
this.openSocket()
}
private openSocket(): void {
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
if (this.socket) {
this.destroySocket(this.socket)
this.socket = null
}
const cfg = getConfig()
this.setStatus('connecting')
console.log('[WsClient] 连接', cfg.relayHost)
let task: UniApp.SocketTask
try {
this.socket = uni.connectSocket({
task = uni.connectSocket({
url: cfg.relayHost,
complete: () => {
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
@@ -125,15 +277,32 @@ class WsClient {
this.scheduleReconnect()
return
}
this.socket = task
this.socket.onOpen(() => this.handleOpen())
this.socket.onMessage((res) => this.handleMessage(res))
this.socket.onClose((res) => this.handleClose(res))
this.socket.onError((res) => this.handleError(res))
task.onOpen(() => this.handleOpen())
task.onMessage((res) => this.handleMessage(res))
task.onClose((res) => this.handleClose(res))
task.onError((res) => this.handleError(res))
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (this.status === 'connecting') {
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, CONNECT_TIMEOUT_MS)
}
/** WS 已建立连接 → 立即发 Hello 握手帧 */
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
private handleOpen(): void {
// onOpen 到达,取消连接超时
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
this.setStatus('handshaking')
const cfg = getConfig()
const hello: Hello = {
@@ -145,14 +314,35 @@ class WsClient {
this.socket?.send({ data: JSON.stringify(hello) })
} catch (e) {
console.error('[WsClient] 发送 Hello 失败', e)
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
this.cleanup()
this.scheduleReconnect()
return
}
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (!this.handshaked) {
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
this.cleanup()
this.scheduleReconnect()
}
}, HANDSHAKE_TIMEOUT_MS)
}
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
private handleMessage(res: { data: string | ArrayBuffer }): void {
const text = typeof res.data === 'string' ? res.data : ''
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
if (typeof res.data !== 'string') {
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
return
}
const text = res.data
if (!text) return
// 活性:任意入站帧更新(relay 无 pong,用心跳期间入站消息替代判活)
this.lastInboundAt = Date.now()
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
if (!this.handshaked) {
@@ -178,23 +368,72 @@ class WsClient {
// command 是 device→miniapp 反向(不应在此端收到,忽略)
}
/** 处理握手响应(relay 握手通过后无显式 ack,首条业务消息即视为握手成功;
* 失败时 relay 发 {"kind":"control","error":"..."} + 关连接) */
/** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
private handleHandshakeResponse(text: string): void {
// relay 握手失败帧(relay.rs:200-238):{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"}
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
let parsedOk = false
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
try {
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
parsedOk = true
} catch (e) {
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
}
if (parsedOk && parsed) {
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
if (parsed.error !== undefined) {
console.error('[WsClient] 握手失败', parsed.error, text)
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
this.clearHandshakeTimer()
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
this.cleanup()
return
}
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
// device 离线则无响应,文案保持「已连接中继」诚实表述。
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
this.flushPendingQueue()
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
return
}
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
return
}
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
if (text.includes('"error"')) {
console.error('[WsClient] 握手失败', text)
console.error('[WsClient] 握手失败(includes 兜底)', text)
this.setStatus('disconnected', `handshake failed: ${text}`)
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
this.clearHandshakeTimer()
this.cleanup()
return
}
// 首条非错误消息视为握手通过(实际握手后 relay 会路由 device 的 Event 过来)
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
this.clearHandshakeTimer()
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
// 该首消息也是业务消息,转 handleMessage 处理(此时 handshaked=true,走 BroadcastMessage 解析路径)
this.send({ cmd: 'list_conversations', args: {} })
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
this.flushPendingQueue()
this.handleMessage({ data: text })
}
@@ -210,14 +449,31 @@ class WsClient {
private handleError(res: { errMsg: string }): void {
console.error('[WsClient] socket 错误', res.errMsg)
// 不直接 scheduleReconnect:onClose 会跟着触发,在 onClose 统一处理重连
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
if (this.status === 'connecting' || this.status === 'handshaking') {
this.cleanup()
this.scheduleReconnect()
}
}
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'}) */
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
private startHeartbeat(): void {
this.stopHeartbeat()
const cfg = getConfig()
// 连接成功初始化活性时间(首次心跳检查不立即判死)
this.lastInboundAt = Date.now()
this.heartbeatTimer = setInterval(() => {
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
const now = Date.now()
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
this.cleanup()
this.scheduleReconnect()
return
}
const ping: ControlMessage = { control_kind: 'ping' }
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
try {
@@ -237,36 +493,87 @@ class WsClient {
}
}
/** 指数退避重连(baseDelay * 2^attempts,封顶 maxDelay) */
/**
* 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
*
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
*
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) return
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
this.cleanup()
return
}
const cfg = getConfig()
const attempt = this.reconnectAttempts++
const delay = Math.min(cfg.reconnectBaseDelay * Math.pow(2, attempt), cfg.reconnectMaxDelay)
const exp = Math.min(attempt, 6)
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
const jitter = 0.5 + Math.random() * 0.5
const delay = Math.round(base * jitter)
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.openSocket()
this.reconnectNow()
}, delay)
}
/** 清理 socket + 定时器(连接关闭时调) */
/** 清握手超时定时器(握手成功/失败时调) */
private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
/**
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
*
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
*/
private destroySocket(task: UniApp.SocketTask): void {
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
try {
task.close({})
} catch {
// 已关闭的 socket close 抛错忽略
}
}
/**
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
*
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
*/
private cleanup(): void {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.clearHandshakeTimer()
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
if (this.socket) {
try {
this.socket.close({})
} catch {
// 已关闭的 socket close 抛错忽略
}
this.destroySocket(this.socket)
this.socket = null
}
this.handshaked = false
this.pendingQueue = []
}
private setStatus(status: WsStatus, detail?: string): void {