新增: 小程序端跨端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

1
Cargo.lock generated
View File

@@ -975,7 +975,6 @@ dependencies = [
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.23.1",
"tracing",
"tracing-subscriber",
]

File diff suppressed because it is too large Load Diff

View File

@@ -11,24 +11,25 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "vue3",
"@dcloudio/uni-components": "vue3",
"@dcloudio/uni-mp-weixin": "vue3",
"@dcloudio/uni-h5": "vue3",
"@dcloudio/uni-app": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"marked": "^18.0.5",
"vue": "^3.4.0",
"vue-i18n": "^9.0.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "vue3",
"@dcloudio/uni-cli-shared": "vue3",
"@dcloudio/vite-plugin-uni": "vue3",
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0",
"sass": "^1.70.0",
"typescript": "^5.3.0",
"vite": "^7.3.0",
"vite": "^5.2.8",
"vue-tsc": "^2.0.0"
}
}

View File

@@ -11,19 +11,18 @@ onLaunch(() => {
})
onShow(() => {
// 切回前台:若 WS 已断,触发重连
aiChat.resumeIfDisconnected()
// 切回前台:重启心跳/重连 + 恢复流式看门狗(P1-9/P1-10)
aiChat.resumeBackground()
})
onHide(() => {
// 切后台:停心跳 + 流式看门狗,防后台定时器节流触发写状态(P1-9/P1-10)
console.log('[DevFlow Mini] 应用进入后台')
aiChat.pauseBackground()
})
</script>
<!-- uni-app 规范:App.vue 不渲染具体内容,页面由 pages.json 路由自动注入
全局样式放 App.vue style( scoped),各页面继承 -->
<style lang="scss">
/* 全局基础样式(暗色主题,各页面 page 标签继承) */
page {
background-color: $uni-bg-color;
color: $uni-text-color;

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 {

View File

@@ -0,0 +1,36 @@
<template>
<!-- rich-text 小程序原生组件(无子组件依赖,)marked mdhtml rich-text :nodes 渲染
alpha 根因已查实(uni-app 2026 alpha tap 失效),rich-text 非元凶,降级后正常用 -->
<rich-text :nodes="html" :selectable="true" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import { styleMarkdown } from '@/utils/mdRenderer'
defineOptions({ name: 'MdView' })
/**
* Markdown 渲染气泡(miniapp,rich-text 原生方案)。
*
* 链路:marked.parse(md) → styleMarkdown 注入 inline style → rich-text :nodes(原生渲染 html 节点)。
* - breaks:true 聊天换行(单换行转 <br>)
* - P1-G:styleMarkdown 给代码块/链接/表格加 inline style(rich-text 不认 class)
* - parse 失败回退原文
* - 空内容返空(rich-text 不渲染)
*
* 流式:currentText 变化 → html computed 重算 → rich-text 重渲染。
*/
const props = defineProps<{ content: string }>()
const html = computed(() => {
const src = props.content || ''
if (!src) return ''
try {
return styleMarkdown(marked.parse(src, { breaks: true, async: false }) as string)
} catch {
return src
}
})
</script>

View File

@@ -0,0 +1,133 @@
<template>
<!-- @/ 联想弹层(MVP)
固定占位列表:不接真实技能路由/实体检索,仅作 chip 插入
/file /web /task /memory + @file @web @task 四类技能占位
渲染对齐 chat/index.vue mp-weixin 稳定风格(原生 view/text + 绝对定位浮层) -->
<view v-if="visible" class="mention-mask" @tap="onClose">
<view class="mention-pop" @tap.stop>
<view class="mention-head">
<text class="mention-title">{{ trigger === '/' ? '技能' : '提及' }}</text>
<text class="mention-hint">MVP 占位 · 点击插入</text>
</view>
<scroll-view class="mention-list" scroll-y>
<view
v-for="(item, idx) in items"
:key="idx"
class="mention-item"
@tap="onSelect(item)"
>
<text class="mention-item-label">{{ item.label }}</text>
<text class="mention-item-insert">{{ item.insert.trim() }}</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* MentionInput 联想弹层组件(MVP)。
*
* 设计:
* - 纯展示:接收 visible/trigger/items,选中后 emit select(insert) + close
* - 不查后端/不调路由:items 由父组件固定注入
* - 触发字符 `/`(技能)或 `@`(提及)由父组件依据光标位置判定
* - 选中即在 inputText 末尾插入对应字面量(如 `/file `),发送时整体纯文本走 ai.send
*
* props:
* - visible:是否显示
* - trigger:触发类型(`/` 技能 / `@` 提及)
* - items:占位项列表 {label, insert}
* emits:
* - select(insert):插入文本
* - close():关闭弹层
*/
defineOptions({ name: 'MentionInput' })
interface MentionItem {
label: string
insert: string
}
defineProps<{
visible: boolean
trigger: '/' | '@'
items: MentionItem[]
}>()
const emit = defineEmits<{
(e: 'select', insert: string): void
(e: 'close'): void
}>()
function onSelect(item: MentionItem): void {
emit('select', item.insert)
}
function onClose(): void {
emit('close')
}
</script>
<style scoped>
.mention-mask {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.mention-pop {
position: absolute;
left: 12px;
right: 12px;
bottom: 56px;
max-height: 260px;
background-color: #1f1f1f;
border: 1px solid #333333;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mention-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #262626;
border-bottom: 1px solid #2e2e2e;
}
.mention-title {
color: #e0e0e0;
font-size: 13px;
font-weight: bold;
}
.mention-hint {
color: #777777;
font-size: 11px;
}
.mention-list {
max-height: 220px;
}
.mention-item {
display: flex;
flex-direction: column;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
}
.mention-item:last-child {
border-bottom: none;
}
.mention-item-label {
color: #e0e0e0;
font-size: 14px;
}
.mention-item-insert {
color: #4a9eff;
font-size: 12px;
margin-top: 2px;
}
</style>

View File

@@ -20,17 +20,20 @@
* - tokenUsage: 最近回复 token 用量(可选展示)
*
* 方法:
* - send(text): 发消息(send_message Command → relay → device invoke ai_chat_send)
* - send(text, skill?, spans?): 发消息(send_message Command → relay → device invoke ai_chat_send)
* F-#95 联想扩展:skill(`/<skill>` 选中)+ spans(`@项目/@任务/@灵感` mention_spans)可选透传
* - stop(): 停止生成(stop Command → device invoke ai_chat_stop)
* - regenerate(): 重发最后一条 user 消息(重新 send_message)
* - switchConversation(id): 切换会话(switch Command → device 切 active)
* - listSkills(): 拉技能列表(list_skills Command → AiSkillList 事件回流 skills)
* - listEntities(): 拉实体列表(list_entities Command → AiEntityList 事件回流 entities)
* - approve(toolCallId, approved): 审批工具调用(approve Command → device invoke ai_approve)
* - authorizeDir(toolCallId, decision): 路径授权(authorize_dir Command → device invoke ai_authorize_dir)
* - continueLoop(convId): 继续循环(continue_loop Command → device invoke ai_continue_loop)
* - stopLoop(convId): 停止循环(stop_loop Command → device invoke ai_stop_loop)
*/
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { wsClient } from '@/api/ws'
import type { WsStatus } from '@/api/ws'
import type {
@@ -39,8 +42,17 @@ import type {
Conversation,
AiToolCallInfo,
TokenUsage,
ProjectRecord,
TaskRecord,
IdeaRecord,
} from '@/types/events'
import type { BroadcastMessage, MiniCommand, ControlMessage } from '@/types/relay'
import type {
BroadcastMessage,
MiniCommand,
ControlMessage,
SkillInfo,
MentionSpan,
} from '@/types/relay'
/** 简易唯一 id 生成(miniapp 无 crypto.randomUUID 兼容性顾虑,时间戳+随机即可) */
function genId(prefix: string): string {
@@ -55,14 +67,159 @@ function genId(prefix: string): string {
*/
const messages = reactive<ChatMessage[]>([])
const currentText = ref<string>('')
/**
* 当前流式累积目标 assistant 气泡 id(F3:精确回填,取代启发式「末尾非 error assistant」)。
* send/AiAgentRound push 新占位时记录;flushCurrentText 按 id 精确写入,治 retry/error/
* compressed 气泡穿插致启发式定位错位(新一轮内容写入旧气泡)。找不到则回退启发式兜底。
*/
const currentAssistantMsgId = ref<string | null>(null)
const generating = ref<boolean>(false)
const agentRound = ref<number>(0)
/**
* 达最大轮次挂起标志(P1-E):true 时 chat 渲染「继续/停止循环」按钮(对齐桌面 MaxRounds)。
* AiMaxRoundsReached 置 true;continueLoop/stopLoop/send/switch/new 清 false。
*/
const maxRoundsActive = ref<boolean>(false)
const conversations = reactive<Conversation[]>([])
const activeConversationId = ref<string | null>(null)
/** 消息内存上限(P3-A:防长会话 OOM;渲染另有 visibleMessages slice 200 兜底,此为存储层) */
const MAX_STORE_MESSAGES = 500
watch(
() => messages.length,
(len) => {
if (len > MAX_STORE_MESSAGES) messages.splice(0, len - MAX_STORE_MESSAGES)
},
)
/** 冷启动恢复上次激活会话(P1-8)的 storage key */
const ACTIVE_CONV_STORAGE_KEY = 'df_active_conv_id'
/** 读取持久化的激活会话 id(冷启动恢复,模块加载时调一次) */
function loadPersistedActiveConv(): string | null {
try {
const v = uni.getStorageSync(ACTIVE_CONV_STORAGE_KEY)
return v || null
} catch {
return null
}
}
/** 持久化激活会话 id(switch/newConversation 时调,冷启动据此恢复) */
function persistActiveConv(convId: string | null): void {
try {
if (convId) uni.setStorageSync(ACTIVE_CONV_STORAGE_KEY, convId)
else uni.removeStorageSync(ACTIVE_CONV_STORAGE_KEY)
} catch (e) {
console.warn('[useAiChat] 持久化 active conv 失败', e)
}
}
const activeConversationId = ref<string | null>(loadPersistedActiveConv())
/**
* 最近发起 load_messages 的目标会话(MINIAPP-02 历史丢失竞态修复)。
*
* 问题:switchConversation 同步置 activeConversationId=convId 后发 load_messages,
* 但 AiMessageHistory 回流判定 `event.conversation_id === activeConversationId.value`。
* 用户快速切 A→B→A,或切后立即 newConversation() 置 null,致 activeConversationId
* 在历史回流前已变 → is_active=false → 49 条历史静默丢弃 → 点进会话空白。
*
* 修复:用此变量记录最近一次 load 目标,回流时除 active 外还比对它,命中则写入
* (避免用户停留目标会话时被竞态丢弃);不命中则 console.warn 明确丢弃原因(非静默)。
*/
const lastLoadedConvId = ref<string | null>(null)
const wsStatus = ref<WsStatus>('disconnected')
const wsStatusDetail = ref<string>('')
const tokenUsage = ref<TokenUsage | null>(null)
const pendingApprovals = reactive<AiToolCallInfo[]>([])
/**
* 最近批准(approved=true)的工具调用(P2-E 审批失败回填)。
* approve 成功发出后记录;device 返 AiError(执行失败/已被桌面端处理)时回填 pendingApprovals 让用户重试,
* 避免 UI 误导「已批准」实际未生效。AiApprovalResult/AiToolCallCompleted 确认 → 清;切会话/新建/断连 → 清。
*/
const lastApprovedTc = ref<AiToolCallInfo | null>(null)
/**
* `/` 联想技能列表(F-#95 扩展,list_skills 命令 → device route_list_skills → AiSkillList 回流)。
* 初始空数组,chat/index.vue inputText 以 / 开头时 listSkills() 拉取后填充浮层。
*/
const skills = reactive<SkillInfo[]>([])
/**
* `@` 联想实体列表(F-#95 扩展,list_entities 命令 → device route_list_entities → AiEntityList 回流)。
* 含 projects/tasks/ideas 三类,chat/index.vue 末尾 @ 触发 listEntities() 后填充浮层。
* 单 reactive 对象聚合三类(对齐 AiEntityList 事件载荷结构,UI 用 entities.projects/tasks/ideas)。
*/
const entities = reactive<{ projects: ProjectRecord[]; tasks: TaskRecord[]; ideas: IdeaRecord[] }>({
projects: [],
tasks: [],
ideas: [],
})
/**
* device(桌面端)在线探测(F-#95)。
*
* 问题:wsStatus=connected 仅表 relay 握手成功(中继链路通),relay 纯透传不广播 device presence,
* 故「device 是否在线」无法从 ws 状态直接得 —— device 离线时 miniapp 点发无响应,用户体验差。
*
* 探测口径:收到 `AiConversationList` 响应 = device 在线(route_list_conversations 由 device 桥接层发出)。
* - list_conversations 命令经 ws→relay→device,device 响应 AiConversationList 透传回 miniapp。
* - 收到即置 true;device 离线/命令未达则永不置 true,文案保持「已连接中继」诚实表述。
*
* 注:MVP 不做超时复位(device 离线后不回退 deviceOnline=false),因 device 离线时连 send 超时兜底
* 已覆盖(15s 后 reset generating + 错误提示),deviceOnline 仅影响状态文案,容许滞后。
*/
const deviceOnline = ref<boolean>(false)
/**
* 流式看门狗(miniapp 状态机健壮性,对齐桌面 STREAM_TIMEOUT_MS — memory devflow-generating-statemachine)
*
* 治「发送不了」根因:旧 15s send 超时首事件即 clearSendTimeout(:189),首事件到达后中途断
* (后端 hang/网络断不发 AiCompleted/AiError)无兜底 → generating 永卡 true → 后续 handleSend 被
* `generating` 守卫拦截 → 点发无响应(消息停留输入框)。
*
* 现机制(130s 持续 watchdog,非首事件清除):
* - send 后 resetWatchdog 启动
* - 活跃事件(AiTextDelta/AiAgentRound/AiToolCallStarted/Heartbeat/Retry)resetWatchdog 重置 130s
* - 终态(AiCompleted/AiError)/挂起(Approval/DirAuth/MaxRounds/Help)/断连 clearWatchdog 清除
* - 超时(130s 无活跃事件)= 中途断 → flush + 错误气泡 + reset generating,用户可重发
*/
let streamTimer: ReturnType<typeof setTimeout> | null = null
// 130s(对齐桌面 STREAM_TIMEOUT_MS,≥后端 120s idle timeout + 余量)。
const STREAM_TIMEOUT_MS = 130000
/**
* 停止标志(F9:stop 终态兜底)。治「点了停止还在生成」:stop 命令在途期间 device 可能续推
* AiTextDelta(持续 reset 看门狗致 generating 卡 true)。stop() 成功后乐观复位 generating + 置
* stopping,handleEvent 对 AiTextDelta/AiAgentRound 提前 return 忽略迟到增量;终态事件清 stopping。
* 无超时(布尔无泄漏,至下次 send/终态自然清)。
*/
let stopping = false
/** 看门狗超时回调:中途断兜底(flush + 错误气泡 + reset generating) */
function onStreamTimeout(): void {
streamTimer = null
if (generating.value) {
flushCurrentText()
currentText.value = ''
generating.value = false
messages.push({
id: genId('timeout'),
role: 'assistant',
content: '⚠ 回复中断(桌面端长时间无响应),请检查连接后重试',
isError: true,
timestamp: Date.now(),
})
}
}
/** 重置看门狗(send 后启动 / 活跃事件重置,确保 watchdog 运行计 STREAM_TIMEOUT_MS) */
function resetWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer)
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
}
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer)
streamTimer = null
}
/** 已注册事件监听标志(防多次订阅 wsClient) */
let subscribed = false
@@ -80,9 +237,19 @@ function findToolCall(id: string): AiToolCallInfo | null {
return null
}
/** 流式文本回填到最后一条 assistant 气泡(对齐桌面 flushCurrentText,useAiEvents.ts:186) */
/** 流式文本回填到当前轮 assistant 气泡(F3:按 id 精确,回退启发式) */
function flushCurrentText(): void {
if (!currentText.value) return
// F3:优先按记录的 id 精确写入(治 retry/error/compressed 气泡穿插致启发式定位错位)
if (currentAssistantMsgId.value) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].id === currentAssistantMsgId.value) {
messages[i].content = currentText.value
return
}
}
}
// 回退启发式:取末尾第一个非 error assistant(id 失配时兼容,对齐桌面 useAiEvents.ts:186)
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (m.role !== 'assistant') break
@@ -102,6 +269,105 @@ function flushCurrentText(): void {
* - 路由判断:无 conversation_id 或等于 active 时处理,其他忽略
*/
export function handleEvent(event: AiChatEvent): void {
// 诊断:事件类型 + 频率(排查 device 持续推流式致 view 层过载 / tap 失效)
console.log('[dbg:event] type=', event.type)
// F-#95:会话列表同步是全局事件(无 conversation_id),不受单会话路由守卫约束,
// 提前处理,避免下方 event.conversation_id narrowing 报错(该变体无此字段)。
if (event.type === 'AiConversationList') {
// splice 替换(conversations 是 reactive 数组,直接赋值丢响应性)
conversations.splice(0, conversations.length, ...event.conversations)
// 收到响应即 device 在线(探测成功),chat 页状态文案据此切「已连接桌面端」。
deviceOnline.value = true
return
}
// F-#95 联想扩展:`/` 技能列表同步(全局事件,提前处理避免 conversation_id narrowing —— 该变体无此字段)。
// route_list_skills(device 桥接层)调 ai_list_skills → publish_event(AiSkillList) 跨端透传回 miniapp。
// 收到即 splice 替换 skills(chat/index.vue `/` 联想浮层消费),并置 deviceOnline=true(响应=在线)。
if (event.type === 'AiSkillList') {
skills.splice(0, skills.length, ...event.skills)
deviceOnline.value = true
return
}
// F-#95 联想扩展:`@` 实体列表同步(全局事件,提前处理避免 conversation_id narrowing —— 该变体无此字段)。
// route_list_entities(device 桥接层)调 list_projects/list_tasks/list_ideas → publish_event(AiEntityList)。
// 收到即 splice 替换三类实体(chat/index.vue `@` 联想浮层消费),并置 deviceOnline=true。
if (event.type === 'AiEntityList') {
entities.projects.splice(0, entities.projects.length, ...event.projects)
entities.tasks.splice(0, entities.tasks.length, ...event.tasks)
entities.ideas.splice(0, entities.ideas.length, ...event.ideas)
deviceOnline.value = true
return
}
// F-260622-02 跨端用户消息同步:device route_send_message 放行后 publish_event 广播,
// miniapp 自身也订阅 ai_event_bus,故此事件会回灌 miniapp 自己。去重防双气泡:
// 最近一条 user 气泡已是同 content(send 时本地乐观 push 过)则跳过,否则补 user 气泡。
// 提前处理(在 convId narrowing 之前),因该事件必带 conversation_id 但语义独立于 watchdog/
// generating 态,不应触发活跃事件 reset。
//
// MINIAPP-01(P1)去重失效修复:旧判定仅看 messages 末条 role==='user',但 send() 乐观 push 后
// 紧跟 assistant 占位气泡(:499-504),AiUserMessage 回灌时末条是 assistant 占位,role!=='user'
// 成立 → 误判「未重复」→ push 第二个重复 user 气泡(夹在乐观 user 与 assistant 占位之间错位)。
// 改:从末尾向前扫,跳过非 user 气泡(占位/系统),取最近一条 user 比 content。
if (event.type === 'AiUserMessage') {
// P1-4/P1-5:会话守卫。AiUserMessage 由 device route_send_message publish_event 广播,
// miniapp 自身也订阅 ai_event_bus → 自回灌;多设备同 relay 时他机消息也广播到本机。
// 切会话/新建会话后旧会话的 AiUserMessage 异步回流会污染新会话视图(串话)。
// 守卫:事件带 conversation_id 且非当前会话也非最近 load 目标 → 丢弃(乐观 push 已在发送方本地渲染)。
// 保留 null 放行(新会话首条,active 也可能 null,避免误杀自回灌)。
const uc = event.conversation_id ?? null
if (uc && uc !== activeConversationId.value && uc !== lastLoadedConvId.value) {
console.warn(
`[useAiChat] AiUserMessage conv=${uc} 非当前会话(active=${activeConversationId.value})丢弃,防串话`,
)
return
}
let lastUser: ChatMessage | undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUser = messages[i]
break
}
}
if (!(lastUser && lastUser.content === event.message)) {
messages.push({
id: genId('user'),
role: 'user',
content: event.message,
timestamp: Date.now(),
})
}
return
}
// F-#95 扩展:历史消息同步(全局事件,提前处理避免 conversation_id narrowing)。
// 仅当消息属当前活跃会话才替换(防串会话:device 可能推非当前 conv 历史)。
if (event.type === 'AiMessageHistory') {
const histConv = event.conversation_id
const isActive = histConv === activeConversationId.value
// MINIAPP-02:竞态兜底。用户快速 A→B→A 或切后 newConversation() 置 null,
// 会使 activeConversationId 在历史回流前已变 → is_active=false → 49 条历史静默丢弃。
// 优先按 active 写入;其次若与最近发起的 load 目标相符,仍写入(用户停留该会话时
// 不应丢);都不符(确实已切走)才丢,并 console.warn 明确原因(非静默)。
const isLastLoaded = histConv !== null && histConv === lastLoadedConvId.value
if (isActive || isLastLoaded) {
if (!isActive) {
console.warn(
`[useAiChat] 历史回流 conv=${histConv} active=${activeConversationId.value} 已切走,但与最近 load 目标相符,仍写入(竞态兜底)`,
)
}
// splice 替换(messages 是 reactive 数组,保留响应性)
messages.splice(0, messages.length, ...event.messages)
} else {
console.warn(
`[useAiChat] 历史 conv=${histConv} 因会话已切换被丢弃(active=${activeConversationId.value} lastLoaded=${lastLoadedConvId.value}) count=${event.messages.length}`,
)
}
return
}
const convId = event.conversation_id ?? null
// 首次收到事件同步当前对话 id(对齐桌面 useAiEvents.ts:705)
@@ -113,17 +379,28 @@ export function handleEvent(event: AiChatEvent): void {
const isCurrent = !convId || convId === activeConversationId.value
if (!isCurrent) return
// 收到当前会话活跃事件 = device 在响应,重置看门狗(重新计 130s)。
// 终态/挂起事件在各自 case 内 clearWatchdog 覆盖此 reset。
// F9:stopping 期间不重置(stop 已 clearWatchdog,迟到 delta 不应续命看门狗)
if (!stopping) resetWatchdog()
switch (event.type) {
case 'AiTextDelta':
// F9:stopping 期间忽略迟到增量(用户已点停止,device 续推 delta 丢弃,防文本回填)
if (stopping) break
currentText.value += event.delta
break
case 'AiAgentRound': {
// F9:stopping 期间忽略新一轮(用户已停,device 未及时停的续推不新建气泡)
if (stopping) break
// 新一轮:flush 当前文本到上一条 assistant,新建空 assistant 占位
flushCurrentText()
currentText.value = ''
const roundPlaceholderId = genId('ai')
currentAssistantMsgId.value = roundPlaceholderId // F3:记录新一轮目标气泡
messages.push({
id: genId('ai'),
id: roundPlaceholderId,
role: 'assistant',
content: '',
timestamp: Date.now(),
@@ -155,11 +432,12 @@ export function handleEvent(event: AiChatEvent): void {
}
case 'AiMaxRoundsReached':
// 达 max 暂停:miniapp 展示提示气泡(不实现继续/停止按钮,用户回桌面处理)
clearWatchdog() // 达 max 挂起,停看门狗
maxRoundsActive.value = true // P1-E:chat 渲染「继续/停止循环」按钮(替代旧「回桌面处理」)
messages.push({
id: genId('maxrounds'),
role: 'system',
content: '⚠ 已达最大轮次,请在桌面端处理(继续/停止)',
content: '⚠ 已达最大轮次,可选择继续停止',
timestamp: Date.now(),
})
break
@@ -190,6 +468,10 @@ export function handleEvent(event: AiChatEvent): void {
// 移除对应挂起审批(若有)
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1)
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null
}
break
}
@@ -208,7 +490,11 @@ export function handleEvent(event: AiChatEvent): void {
reason: event.reason,
diff: event.diff ?? undefined,
}
pendingApprovals.push(info)
// req3 去重:sync_pending 重发 + 实时事件竞态可能同 id 重复,面板只保留一条
if (!pendingApprovals.some((p) => p.id === info.id)) {
pendingApprovals.push(info)
}
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
@@ -225,25 +511,49 @@ export function handleEvent(event: AiChatEvent): void {
}
const idx = pendingApprovals.findIndex((p) => p.id === event.id)
if (idx >= 0) pendingApprovals.splice(idx, 1)
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
lastApprovedTc.value = null
}
break
}
case 'AiDirAuthRequired':
// 路径授权:miniapp 仅提示(不实现授权 UI,用户回桌面处理)
messages.push({
id: genId('dirauth'),
role: 'system',
content: `⚠ 路径授权请求:${event.tool}${event.dir}(请在桌面端处理)`,
timestamp: Date.now(),
})
case 'AiDirAuthRequired': {
clearWatchdog() // 挂起等授权,停看门狗
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
const authInfo: AiToolCallInfo = {
id: event.id,
name: event.tool,
args: {},
status: 'pending_approval',
kind: 'path',
dir: event.dir,
path: event.path,
}
// req3 去重:同 AiApprovalRequired(sync_pending 重发 + 实时事件竞态防重复)
if (!pendingApprovals.some((p) => p.id === authInfo.id)) {
pendingApprovals.push(authInfo)
}
// 对齐 AiApprovalRequired:同步更新消息内工具卡片为 pending_approval + path 授权信息,
// 使 chat 页消息内联渲染 once/always/deny 按钮(单一渲染源,避免消息 tc 卡 running 态)。
const authTc = findToolCall(event.id)
if (authTc) {
authTc.status = 'pending_approval'
authTc.kind = 'path'
authTc.dir = event.dir
authTc.path = event.path
}
break
}
case 'AiCompleted': {
stopping = false // F9:终态清停止标志
flushCurrentText()
currentText.value = ''
generating.value = false
agentRound.value = 0
// token 用量(可选展示)
clearWatchdog() // 终态:停看门狗
tokenUsage.value = {
prompt: event.prompt_tokens,
completion: event.completion_tokens,
@@ -262,11 +572,20 @@ export function handleEvent(event: AiChatEvent): void {
}
case 'AiError': {
stopping = false // F9:终态清停止标志
flushCurrentText()
currentText.value = ''
generating.value = false
agentRound.value = 0
clearWatchdog() // 终态:停看门狗
pendingApprovals.splice(0, pendingApprovals.length)
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
if (lastApprovedTc.value) {
pendingApprovals.push(lastApprovedTc.value)
uni.showToast({ title: '审批操作未生效,请重试', icon: 'none' })
lastApprovedTc.value = null
}
messages.push({
id: genId('err'),
role: 'assistant',
@@ -278,6 +597,7 @@ export function handleEvent(event: AiChatEvent): void {
}
case 'AiHelpRequired':
clearWatchdog() // 求助挂起,停看门狗
// 断路器熔断求助:miniapp 展示求助卡(用户在桌面选 option,miniapp 不渲染选项按钮)
flushCurrentText()
currentText.value = ''
@@ -306,14 +626,22 @@ export function handleEvent(event: AiChatEvent): void {
break
case 'AiConvStateChanged':
// 统一状态机:generating/stopping → generating=true;idle/error → false
// 统一状态机:generating/stopping → generating=true(看门狗已在 :340 reset 续计 130s);
// idle/error → false + clearWatchdog(终态停看门狗,兑现 :156 注释承诺,防 timer 泄漏假超时)。
if (event.conv_state === 'generating' || event.conv_state === 'stopping') {
generating.value = true
} else if (event.conv_state === 'idle' || event.conv_state === 'error') {
generating.value = false
clearWatchdog()
stopping = false // F9:终态清停止标志
}
// compressed 是派生态,保持当前 generating(压缩完回原态)
break
default:
// 生产可观测兜底:后端新增事件变体时前端可见(:229 dbg:event 是开发日志,不够)
console.warn('[useAiChat] 未处理事件类型', (event as { type?: string }).type)
break
}
}
@@ -349,18 +677,40 @@ function subscribeWs(): void {
onStatus: (status: WsStatus, detail?: string) => {
wsStatus.value = status
wsStatusDetail.value = detail ?? ''
if (status === 'connected') {
// req2/req3:重连/初始连接从远端恢复完整状态(拉完整消息 + 同步挂起审批)
syncOnConnect()
} else if (status === 'disconnected' || status === 'reconnecting') {
// 断开/重连中:清看门狗 + reset generating + device 离线复位
// (连接断肯定停,防 generating 卡死拦截后续 send;deviceOnline 置位后需回退,
// 否则文案恒显「已连接桌面端」误导用户 —— 旧实现:76-77 注释自认「离线不复位」置位永不回退)
clearWatchdog()
generating.value = false
stopping = false // F9:断连清停止标志
lastApprovedTc.value = null // P2-E:断连清审批回填标志
deviceOnline.value = false
}
},
})
}
/** 发 send_message Command(relay → device invoke ai_chat_send) */
function send(text: string): void {
/**
* 发 send_message Command(relay → device invoke ai_chat_send)
*
* F-#95 联想扩展:skill(`/<skill>` 选中后透传,后端注入对应 SKILL.md 全文到 prompt)+
* spans(`@项目/@任务/@灵感` 选中后透传 mention_spans,后端 resolve 投影成 Augmentation)。
* 二者均可选,空/undefined=普通对话零回归。
*/
function send(text: string, skill?: string | null, spans?: MentionSpan[] | null): void {
const trimmed = text.trim()
console.log('[dbg:send] enter trimmed=', JSON.stringify(trimmed), 'gen=', generating.value)
if (!trimmed) return
if (generating.value) {
console.warn('[useAiChat] 生成中,忽略新发送')
return
}
stopping = false // F9:新 generation 清停止标志(上次 stop 后重启)
maxRoundsActive.value = false // P1-E:新 generation 清 MaxRounds 挂起
// 本地先 push user 气泡(乐观渲染,对齐桌面 sendMessage)
messages.push({
@@ -370,9 +720,11 @@ function send(text: string): void {
timestamp: Date.now(),
})
// 新建 assistant 占位气泡(等待流式增量填充)
// 新建 assistant 占位气泡(等待流式增量填充);记录 id 供 flushCurrentText 精确回填(F3)
const aiPlaceholderId = genId('ai')
currentAssistantMsgId.value = aiPlaceholderId
messages.push({
id: genId('ai'),
id: aiPlaceholderId,
role: 'assistant',
content: '',
timestamp: Date.now(),
@@ -385,9 +737,14 @@ function send(text: string): void {
args: {
message: trimmed,
conversation_id: activeConversationId.value,
// skill/spans 仅在有值时透传(对齐 ai_chat_send Option 参数,None 走默认路径)
...(skill ? { skill } : {}),
...(spans && spans.length > 0 ? { mention_spans: spans } : {}),
},
}
if (!wsClient.send(cmd)) {
const _sent = wsClient.send(cmd)
console.log('[dbg:send] wsClient.send 返回=', _sent)
if (!_sent) {
// 发送失败:在 assistant 占位气泡显错误
flushCurrentText()
messages.push({
@@ -398,6 +755,9 @@ function send(text: string): void {
timestamp: Date.now(),
})
generating.value = false
} else {
// 发送成功:启动流式看门狗(活跃事件 reset,终态/挂起/断开 clear,超时 reset generating 防卡死)
resetWatchdog()
}
}
@@ -410,11 +770,35 @@ function stop(): void {
conversation_id: activeConversationId.value,
},
}
wsClient.send(cmd)
if (!wsClient.send(cmd)) {
// P1-6:发送失败(ws.send 返 false,如 relay 中断但 wsStatus 尚未翻转)必须复位 generating,
// 否则后端永不推 AiCompleted(命令未达)→ generating 永卡 → 后续 send 被守卫拦截。
// 对齐 send() 发送失败分支:clearWatchdog + flush + 错误提示 + reset generating。
uni.showToast({ title: '未连接,操作未发出', icon: 'none' })
clearWatchdog()
flushCurrentText()
currentText.value = ''
generating.value = false
return
}
// F9:发送成功乐观停止反馈。不等 device 推 AiConvStateChanged idle(在途 delta 续 reset 看门狗
// 致「点了停止还在生成」)。立即 flush 当前文本(用户可见已生成部分)+ 复位 generating +
// clearWatchdog + 置 stopping(忽略迟到 delta/新轮)。终态事件/下次 send/切会话清 stopping。
flushCurrentText()
currentText.value = ''
generating.value = false
clearWatchdog()
stopping = true
}
/** 重发最后一条 user 消息(重新 send_message) */
function regenerate(): void {
// 生成中守卫:send() 内虽有 generating 守卫但静默 return 无反馈,这里显式 toast 提示。
// (避免用户点重发后末尾气泡被 pop 但无新消息生成,以为卡死)
if (generating.value) {
uni.showToast({ title: '生成中,请先停止', icon: 'none' })
return
}
// 找最后一条 user 消息
let lastUserText = ''
for (let i = messages.length - 1; i >= 0; i--) {
@@ -447,10 +831,19 @@ function approve(toolCallId: string, approved: boolean): void {
approved,
},
}
wsClient.send(cmd)
// 乐观从挂起列表移除(对齐 AiApprovalResult 事件处理,避免 UI 卡 pending)
if (!wsClient.send(cmd)) {
// 发送失败:wsClient.send 返 false(未连接),提示用户操作未发出,挂起审批保留(下次可重批)
uni.showToast({ title: '未连接,操作未发出', icon: 'none' })
return
}
// 发送成功才乐观移除挂起审批(对齐 AiApprovalResult 事件处理,避免 UI 卡 pending)。
// 旧实现无条件乐观移除:send 失败时用户已批但命令未发,挂起条目消失误导用户以为已批。
const idx = pendingApprovals.findIndex((p) => p.id === toolCallId)
if (idx >= 0) pendingApprovals.splice(idx, 1)
if (idx >= 0) {
// P2-E:批准(approved=true)记录,device 返 AiError 时回填让用户重试(执行失败/已被桌面端处理)
if (approved) lastApprovedTc.value = pendingApprovals[idx]
pendingApprovals.splice(idx, 1)
}
}
/**
@@ -468,7 +861,10 @@ function authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'):
decision,
},
}
wsClient.send(cmd)
if (!wsClient.send(cmd)) {
// 发送失败:wsClient.send 返 false(未连接),提示用户操作未发出
uni.showToast({ title: '未连接,操作未发出', icon: 'none' })
}
}
/**
@@ -488,6 +884,7 @@ function continueLoop(conversationId?: string | null): void {
args: { conversation_id: convId },
}
wsClient.send(cmd)
maxRoundsActive.value = false // P1-E:用户已选继续,收起按钮
}
/**
@@ -508,32 +905,157 @@ function stopLoop(conversationId?: string | null): void {
args: { conversation_id: convId },
}
wsClient.send(cmd)
maxRoundsActive.value = false // P1-E:用户已选停止,收起按钮
}
/** 切换会话(switch Command → device 切 active_conversation_id) */
function switchConversation(convId: string): void {
activeConversationId.value = convId
// 清空当前视图(新会话消息从 device 同步或留空)
// P1-8:持久化激活会话,冷启动据此恢复。
persistActiveConv(convId)
// MINIAPP-02:记录本次 load 目标,AiMessageHistory 回流时除 active 外还比对它,
// 防止用户快速切会话致 active 在回流前已变 → 历史静默丢弃。置位须早于 load_messages 发送。
lastLoadedConvId.value = convId
// 清空当前视图,等 AiMessageHistory 事件回流替换(对齐列表链路异步语义)。
messages.splice(0, messages.length)
currentText.value = ''
currentAssistantMsgId.value = null // F3:复位精确回填目标
stopping = false // F9:切会话清停止标志
maxRoundsActive.value = false // P1-E:切会话清 MaxRounds 挂起
lastApprovedTc.value = null // P2-E:切会话清审批回填标志(防跨会话误回填)
generating.value = false
// MINIAPP-03:清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。
pendingApprovals.splice(0, pendingApprovals.length)
clearWatchdog()
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
// 不发 switch_conversation(后端 §2.2 决策 a 忽略,无 Tauri command 对应)。
const cmd: MiniCommand = {
cmd: 'switch_conversation',
cmd: 'load_messages',
args: { conversation_id: convId },
}
wsClient.send(cmd)
}
/**
* 拉取会话列表(F-#95)。
*
* 发 `list_conversations` Command → device route_list_conversations 读库 →
* 推回 `AiConversationList` 事件(handleEvent 替换 conversations + 置 deviceOnline=true)。
*
* 调用时机:
* - ws 握手成功后(ws.ts handleHandshakeResponse,device 在线探测 + 首屏列表)
* - 会话列表页下拉刷新(conversations/index.vue onPullDownRefresh)
*
* 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流
* (非 request-reply 同步返回,对齐 relay 纯透传单向语义)。
*/
function refreshConversations(): boolean {
const cmd: MiniCommand = {
cmd: 'list_conversations',
args: {},
}
return wsClient.send(cmd)
}
/**
* 拉取技能列表(F-#95 `/` 联想扩展)。
*
* 发 `list_skills` Command → device route_list_skills 调 ai_list_skills →
* 推回 `AiSkillList` 事件(handleEvent 替换 skills reactive 数组)。
*
* 调用时机:chat 页输入框文本以 `/` 开头时(技能联想浮层渲染前)。
*
* 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流
* (非 request-reply 同步返回,对齐 relay 纯透传单向语义)。
*/
function listSkills(): boolean {
const cmd: MiniCommand = {
cmd: 'list_skills',
args: {},
}
return wsClient.send(cmd)
}
/**
* 拉取实体列表(F-#95 `@` 联想扩展,项目/任务/灵感三类聚合)。
*
* 发 `list_entities` Command → device route_list_entities 并行调
* list_projects/list_tasks/list_ideas → 推回 `AiEntityList` 事件
* (handleEvent 替换 entities.projects/tasks/ideas 三个数组)。
*
* 调用时机:chat 页输入框末尾触发 `@` 时(实体联想浮层渲染前)。
*
* 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流
* (非 request-reply 同步返回,对齐 relay 纯透传单向语义)。
*/
function listEntities(): boolean {
const cmd: MiniCommand = {
cmd: 'list_entities',
args: {},
}
return wsClient.send(cmd)
}
/** 新建会话(本地清空 + 切到 null,下次 send 时 device 创建新 conv) */
function newConversation(): void {
activeConversationId.value = null
// P1-8:清持久化(新建会话,冷启动不再恢复到旧会话)。
persistActiveConv(null)
// MINIAPP-02:作废最近 load 目标,防切换前已发的 load_messages 历史回流时
// 命中宽松兜底(isLastLoaded)污染本应空白的新会话视图。
lastLoadedConvId.value = null
messages.splice(0, messages.length)
currentText.value = ''
currentAssistantMsgId.value = null // F3:复位精确回填目标
stopping = false // F9:新会话清停止标志
maxRoundsActive.value = false // P1-E:新会话清 MaxRounds 挂起
lastApprovedTc.value = null // P2-E:新会话清审批回填标志
generating.value = false
pendingApprovals.splice(0, pendingApprovals.length)
}
/**
* 重连/初始连接同步(req2 断网不丢消息 + req3 审批状态恢复)。
*
* 连接成功后从远端恢复完整权威状态:
* - req2:发 load_messages 拉当前会话完整历史(断网期间 missed 消息恢复;兼修冷启动空白 P1-C)。
* - req3:清本地 pendingApprovals + 发 sync_pending,device 重推挂起审批事件重建卡片
* (断网期间可能错过审批事件,或桌面端已处理;清+重推拿权威状态,避本地陈旧)。
*
* 审批卡从 pendingApprovals 独立面板渲染(与 messages 解耦),故 load_messages 替换 messages
* 不影响审批卡(避重连时两异步响应乱序竞态:load_messages 替换 vs sync_pending 重建)。
*
* 无 activeConversationId(新建会话/未选会话)则跳过(无目标 conv 可同步)。
*/
function syncOnConnect(): void {
const convId = activeConversationId.value
if (!convId) return
// req2:拉完整历史
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
// req3:清本地 pending + 发 sync_pending 重推权威审批状态
pendingApprovals.splice(0, pendingApprovals.length)
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
}
/**
* 重命名会话(rename_conversation Command → relay → device invoke ai_conversation_rename)。
*
* 成功后 device 推 AiConversationList 刷新(列表 reactive 见新标题)。本地乐观更新标题即时反馈。
*/
function renameConversation(convId: string, title: string): boolean {
const trimmed = title.trim()
if (!trimmed) return false
// 乐观本地更新(即时反馈,列表事件回流确认)
const conv = conversations.find((c) => c.id === convId)
if (conv) conv.title = trimmed
const cmd: MiniCommand = {
cmd: 'rename_conversation',
args: { conversation_id: convId, title: trimmed },
}
return wsClient.send(cmd)
}
/**
* useAiChat 单例 hook
*
@@ -550,6 +1072,7 @@ export function useAiChat() {
currentText,
generating,
agentRound,
maxRoundsActive,
conversations,
activeConversationId,
wsStatus,
@@ -557,6 +1080,10 @@ export function useAiChat() {
isWsConnected,
tokenUsage,
pendingApprovals,
deviceOnline,
// F-#95 联想扩展:技能/实体列表(`/<skill>` 与 `@项目/@任务/@灵感`)
skills,
entities,
// 方法
connect: () => wsClient.connect(),
disconnect: () => wsClient.disconnect(),
@@ -565,6 +1092,10 @@ export function useAiChat() {
stop,
regenerate,
switchConversation,
renameConversation,
refreshConversations,
listSkills,
listEntities,
newConversation,
approve,
authorizeDir,
@@ -572,5 +1103,16 @@ export function useAiChat() {
stopLoop,
handleEvent,
friendlyError,
// P1-9/P1-10:后台定时器清理(App.vue onHide/onShow 调)
pauseBackground: () => {
// 停后台定时器(心跳 + 流式看门狗),保留连接
clearWatchdog()
wsClient.pauseBackground()
},
resumeBackground: () => {
// 前台恢复:重启心跳/重连 + 若仍在生成重启看门狗
wsClient.resumeBackground()
if (generating.value && !streamTimer) resetWatchdog()
},
}
}

View File

@@ -30,8 +30,12 @@ export interface MiniappConfig {
* 这符合预期,提示用户配置)。
*/
export const defaultConfig: MiniappConfig = {
relayHost: 'ws://localhost:8080/ws/miniapp',
deviceId: 'PLACEHOLDER_DEVICE_ID',
// 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws)
// 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名)
relayHost: 'wss://u-work.1216.top/ws/miniapp',
// 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端)
// 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程)
deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0',
token: 'devflow-relay-default-token',
heartbeatInterval: 30000,
reconnectBaseDelay: 1000,

View File

@@ -36,8 +36,7 @@
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": [],
"lazyCodeLoading": "requiredComponents"
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useAiChat } from '@/composables/useAiChat'
import type { Conversation } from '@/types/events'
const ai = useAiChat()
@@ -17,13 +18,30 @@ function handleNew(): void {
uni.switchTab({ url: '/pages/chat/index' })
}
/** 下拉刷新(后续接 device 同步会话列表的 Command) */
/** 长按会话项重命名(对齐桌面端改会话名) */
function handleRename(conv: Conversation): void {
uni.showModal({
title: '重命名会话',
editable: true,
placeholderText: '输入新名称',
content: conv.title || '',
success: (r) => {
if (r.confirm && r.content) {
const newTitle = r.content.trim()
if (newTitle && newTitle !== conv.title) {
ai.renameConversation(conv.id, newTitle)
}
}
},
})
}
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
onPullDownRefresh(() => {
// MVP:会话列表来自本地缓存,刷新空动作占位
// 后续批:发 list_conversations Command → device 返回列表 → 更新 ai.conversations
setTimeout(() => {
uni.stopPullDownRefresh()
}, 500)
ai.refreshConversations()
uni.stopPullDownRefresh()
})
/** 格式化时间戳为可读字符串 */
@@ -59,6 +77,7 @@ function formatTime(ts?: number): string {
class="conv-item"
:class="{ active: conv.id === ai.activeConversationId.value }"
@click="handleSelect(conv.id)"
@longpress="handleRename(conv)"
>
<view class="conv-main">
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
@@ -66,6 +85,9 @@ function formatTime(ts?: number): string {
</view>
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
</view>
<view v-if="ai.conversations.length > 0" class="list-hint">
<text>长按会话可重命名</text>
</view>
</scroll-view>
</view>
</template>
@@ -156,4 +178,12 @@ function formatTime(ts?: number): string {
color: $uni-text-color-disable;
margin-left: 16rpx;
}
.list-hint {
padding: 24rpx;
text-align: center;
}
.list-hint text {
color: $uni-text-color-disable;
font-size: $uni-font-size-sm;
}
</style>

View File

@@ -3,7 +3,7 @@
*
* 类型证据:
* - Rust 定义:src-tauri/src/commands/ai/mod.rs:105-216 `pub enum AiChatEvent`
* (18 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
* (19 个变体,#[serde(tag = "type")] → TS 用 type discriminator 镜像)
* - Rust 字段 snake_case:Rust 默认 snake_case 字段,TS 直接对齐(不加 camelCase 转换)
* - 桌面 TS:src/api/types.ts(未在本批核验,但变体名/字段从 Rust 源码逐字对齐)
*
@@ -11,6 +11,9 @@
* 变体名 PascalCase(Rust enum 变体名原样,serde 默认 PascalCase 序列化)。
*/
// SkillInfo 复用 relay.ts 同协议层定义(`/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill)
import type { SkillInfo } from './relay'
/** 错误源分类(对齐 Rust ErrorType,mod.rs:89-102,snake_case 序列化) */
export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'unknown'
@@ -18,10 +21,13 @@ export type ErrorType = 'auth' | 'network' | 'timeout' | 'provider_config' | 'un
export type ConvState = 'idle' | 'generating' | 'stopping' | 'error' | 'compressed'
/**
* AI 聊天事件联合类型(18 个变体,严格对齐 Rust mod.rs:107-216)
* AI 聊天事件联合类型(19 个变体,严格对齐 Rust mod.rs:107-216)
*
* 字段全部对齐 Rust serde(rename_all 默认 snake_case 字段名,tag = "type")。
* 变体名 PascalCase 来自 Rust enum 变体名(serde 默认 PascalCase 序列化 enum variant)。
*
* 注:AiConversationList 的 conversations 字段对齐 Rust ConvSummary(#[serde(rename_all="camelCase")]),
* 元素结构同本文件 Conversation interface(id/title/lastMessage?/updatedAt?/createdAt?)。
*/
export type AiChatEvent =
| { type: 'AiTextDelta'; delta: string; conversation_id?: string | null }
@@ -47,6 +53,7 @@ export type AiChatEvent =
conversation_id?: string | null
}
| { type: 'AiError'; error: string; error_type?: ErrorType | null; conversation_id?: string | null }
| { type: 'AiUserMessage'; message: string; conversation_id?: string | null }
| { type: 'AiAgentRound'; round: number; conversation_id?: string | null }
| { type: 'AiHeartbeat'; conversation_id?: string | null }
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
@@ -70,6 +77,15 @@ export type AiChatEvent =
conversation_id?: string | null
}
| { type: 'AiConvStateChanged'; conv_state: ConvState; conversation_id?: string | null }
| { type: 'AiConversationList'; conversations: Conversation[] }
| { type: 'AiMessageHistory'; conversation_id: string; messages: ChatMessage[] }
| { type: 'AiSkillList'; skills: SkillInfo[] }
| {
type: 'AiEntityList'
projects: ProjectRecord[]
tasks: TaskRecord[]
ideas: IdeaRecord[]
}
/** AiChatEvent 的 type 字面量集合(用于 narrowing / 分派) */
export type AiChatEventType = AiChatEvent['type']
@@ -123,3 +139,55 @@ export interface TokenUsage {
completion: number
total: number
}
// ============================================================
// 实体列表类型(F-#95 联想扩展,AiEntityList 事件载荷)
// ============================================================
//
// 精简字段对齐桌面端 src/api/types.ts(后端 Record 全量字段经 Tauri command Serialize 直传,
// miniapp 仅声明联想浮层展示所需字段;多余字段 JSON 解析保留不报错)。
// SkillInfo 从 ./relay 复用(同协议层定义,避免重复)。
/** 项目记录(对齐桌面端 src/api/types.ts:80 ProjectRecord,miniapp 精简版) */
export interface ProjectRecord {
id: string
name: string
description: string
status: string
idea_id: string | null
/** 绑定的本地代码目录绝对路径(null=未绑定) */
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
created_at: string
updated_at: string
}
/** 任务记录(对齐桌面端 src/api/types.ts:135 TaskRecord,miniapp 精简版) */
export interface TaskRecord {
id: string
project_id: string
title: string
description: string
status: string
/** 优先级:0=critical, 1=high, 2=medium, 3=low */
priority: number
branch_name: string | null
assignee: string | null
created_at: string
updated_at: string
}
/** 灵感记录(对齐桌面端 src/api/types.ts:11 IdeaRecord,miniapp 精简版) */
export interface IdeaRecord {
id: string
title: string
description: string
status: string
priority: number
score: number | null
tags: string | null
source: string | null
created_at: string
updated_at: string
}

View File

@@ -83,9 +83,12 @@ export type ControlMessage =
* - cmd:命令名(对齐 src-tauri command 函数名)
* - args:命令参数(对齐各 command 的参数)
*
* Phase3 §2.2 路由表全覆盖(8 条):
* 路由表全覆盖(14 条):
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
*/
export interface MiniCommand {
/** Tauri command 名 */
@@ -99,6 +102,60 @@ export interface SendMessageArgs {
message: string
conversation_id?: string | null
model_override?: string | null
/**
* 选中技能名(`/<skill>` 联想选中后透传,对齐 ai_chat_send 的 skill 参数)。
* null/undefined=无技能(普通对话);非空=后端注入对应 SKILL.md 全文到 prompt。
*/
skill?: string | null
/**
* @ mention 区间元数据(用户选中 @项目/@任务/@灵感 后透传,
* 对齐 ai_chat_send 的 mention_spans 参数,后端 resolve 投影成 Augmentation 注入)。
* null/undefined/空=无 mention(纯文本消息)。
*/
mention_spans?: MentionSpan[] | null
}
/**
* 用户消息内 mention 区间的元数据
* (对齐后端 MentionSpanDto,crates/df-types/src/augmentation.rs:235)。
*
* 字段名对齐后端 serde:refId(camelCase,后端 serde rename)。
* 前端按 {start, length} 在原文中切出区间替换为 chip。
*/
export interface MentionSpan {
/** 区间起点(字符偏移,前端约定,后端仅透传不解释) */
start: number
/** 区间长度 */
length: number
/** kind 标签:project / task / idea / skill(对齐后端 MentionKind serde snake_case) */
kind: 'project' | 'task' | 'idea' | 'skill'
/** 引用稳定标识(Project/Task/Idea 为 id,Skill 为 name),前端用于反查 */
refId: string
/** chip 展示文本(项目名/任务标题/技能名) */
label: string
}
/**
* 技能信息(对齐桌面端 src/api/types.ts:614 SkillInfo + 后端 SkillInfo,
* src-tauri/src/commands/ai/skills.rs:19-34)。
*
* 供 `/` 联想浮层渲染 + 选中后 name 透传 SendMessageArgs.skill。
* duplicates 可选:后端 #[serde(skip_serializing_if=Option::is_none)] 无冲突不序列化,
* miniapp 加 `?` 兜底防 prod 反序列化宽松报错。
*/
export interface SkillInfo {
/** 技能名(联想浮层展示 + 选中后透传 skill 参数) */
name: string
/** 技能描述(浮层副标题) */
description: string
/** 参数提示(可选,如 "<url>") */
argument_hint?: string
/** skill | command | plugin(来源分类) */
source: string
/** SKILL.md 绝对路径(后端注入时读全文) */
path: string
/** 同名冲突的其他来源路径(可选,无冲突不序列化) */
duplicates?: string[]
}
/** switch_conversation 命令参数 */

View File

@@ -0,0 +1,62 @@
/**
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。
*
* 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
* - 纯字符串后处理规避 marked renderer 版本签名差异(v5 前 (text,info) / v5+ Token 对象),
* 版本无关,build 不依赖 renderer TS 签名。
*
* mp-weixin rich-text 限制(已核官方文档,非臆测):
* - <a> 节点事件屏蔽,无法 bindtap 跳转 → 链接仅展示 + 长按复制(rich-text selectable=true);
* 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
*/
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
return html.replace(re, (m, attrs: string) => {
if (attrs && /\sstyle\s*=/.test(attrs)) {
// 已有 style:追加(在现有 style 值末尾加分号 + 新 style)
return m.replace(/style\s*=\s*"([^"]*)"/, (_full, prev: string) => {
const merged = prev.endsWith(';') ? prev : `${prev};`
return `style="${merged}${style}"`
})
}
return `<${tag}${attrs || ''} style="${style}">`
})
}
/**
* 对 marked.parse 产出的 HTML 注入 inline style。
*
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读)。
*/
export function styleMarkdown(html: string): string {
let out = html
// 块代码 <pre>:深色背景 + 等宽 + 圆角 + pre-wrap 换行防撑爆
out = styleTag(
out,
'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
)
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块)
out = styleTag(
out,
'code',
'font-family:monospace;color:#e0a070;background-color:transparent;font-size:12px',
)
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制)
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline')
// 表格:border + 块级 + 横向溢出处理(rich-text 非滚动,靠 word-break 兜底;border 提升可读)
out = styleTag(
out,
'table',
'display:block;border-collapse:collapse;width:100%;font-size:12px;word-break:break-all',
)
out = styleTag(out, 'th', 'border:1px solid #333;padding:4px 6px;background-color:#1a1a1a')
out = styleTag(out, 'td', 'border:1px solid #333;padding:4px 6px')
return out
}

View File

@@ -15,7 +15,7 @@ tracing = { workspace = true }
# binary 入口(main.rs)日志初始化
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# HTTP + WS 服务端
# HTTP + WS 服务端(axum ws 内部用 tungstenite 协议层;df-relay 不直接依赖 tokio-tungstenite。
# TLS 由 nginx wss 终止,:9180 明文 ws → 去 native-tls 避 openssl 依赖,利 linux 交叉/容器编译)
axum = { version = "0.7", features = ["ws"] }
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
futures-util = "0.3"

View File

@@ -247,6 +247,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
"连接握手通过,进入收发循环"
);
// 握手通过:立即发 ack 控制帧给客户端。
// 客户端据此判定握手成功(首条非 error 消息即 handshaked),不依赖等待对端首条业务消息 ——
// 否则单端连入时(device 离线)relay 静默,客户端永卡 handshaking,send 被 handshaked 守卫拦截。
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","payload":{"control_kind":"hello_ack"}}"#,
)
.await;
// 建立广播投递 mpsc(连接读取任务消费 → 写回 socket)
let (bc_tx, bc_rx) = mpsc::unbounded_channel::<BroadcastMessage>();
let handle = ConnHandle::new(conn_id, kind, device_id.clone(), bc_tx);

View File

@@ -55,6 +55,8 @@ use serde::{Deserialize, Serialize};
// RiskLevel 不在此 import:PendingApproval.risk_level 删后 mod.rs 不再用(audit 流程在 audit.rs 自 import)
use df_ai::context::ContextManager;
use df_ai::context::ContextConfig;
// F-#95 扩展:AiMessageHistory 事件载荷 Vec<ChatMessage>(历史消息同步)。
use df_ai::provider::ChatMessage;
// ============================================================
// 重导出 — 保路径不变(state.rs / lib.rs / knowledge.rs 在用)
@@ -145,6 +147,24 @@ pub enum AiChatEvent {
error_type: Option<ErrorType>,
conversation_id: Option<String>,
},
/// F-260622-02 跨端用户消息同步(miniapp→device→桌面 user 气泡渲染):
///
/// 触发:remote_bridge `route_send_message` 放行后(调 ai_chat_send 前)`publish_event`,
/// 把 miniapp 发来的用户消息广播到 ai_event_bus。语义:远程入口(miniapp)发来的用户消息,
/// 专供桌面 useAiEvents 收到并补 user 气泡(桌面本地 sendMessage 是乐观渲染不发此事件)。
///
/// 根因(为何需要此事件):ai_chat_send 内部只 push session.messages,全程不 emit 用户消息,
/// 桌面用户气泡是纯本地乐观渲染(useAiSend.sendMessage 本地 push,不依赖事件)。
/// miniapp 发消息时桌面用户没在自己输入框敲字 → 桌面 store 没有这条 user 气泡 →
/// 桌面只看到 assistant 响应气泡(经 AiAgentRound/AiTextDelta 流式建),前面缺对应 user prompt
/// → **孤立响应气泡**。此事件填补该缺口。
///
/// 去重(防双气泡):前端 handleEvent 收到此事件时,若末条已是同 content 的 user(本地乐观
/// push 的),跳过。miniapp 自身乐观 push 也会收到此事件回灌,同样去重处理。
AiUserMessage {
message: String,
conversation_id: Option<String>,
},
/// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」)
@@ -200,6 +220,36 @@ pub enum AiChatEvent {
options: Vec<String>,
conversation_id: Option<String>,
},
/// F-#95 跨端会话列表同步(Phase3,2026-06-22):device 响应 miniapp `list_conversations`
/// 命令,把本地 AiConversationRepo 列表摘要(不含消息体,省带宽)推回 miniapp。
///
/// 触发:remote_bridge `list_conversations` 路由 → `list_all()` → 映射 ConvSummary →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 双职责:
/// 1. miniapp 会话列表渲染(conversations/index.vue 替换本地空表)
/// 2. device 在线探测(miniapp 收到此响应 = device 在线,chat/index.vue 状态文案切「已连接桌面端」)
///
/// 兼容:relay 纯透传不广播 device presence,故「device 是否在线」靠此响应隐式判定
/// (device 离线则 miniapp 发 list_conversations 无响应,文案保持「已连接中继」诚实表述)。
AiConversationList {
/// 会话摘要列表(已按 updated_at 倒序,不含 messages 全文)
conversations: Vec<ConvSummary>,
},
/// F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表)。
///
/// 触发:remote_bridge `load_messages` 路由 → `ai_messages.list_by_conversation` →
/// 映射 `ChatMessage`(record_to_message,对齐 ai_conversation_switch 读路径) →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 点进对话时加载历史消息(替代 switch_conversation 后端忽略致空白)。
/// miniapp 据 conversation_id 匹配 activeConversationId 才替换,防串会话。
AiMessageHistory {
/// 对话 ID
conversation_id: String,
/// 历史消息数组(ORDER BY seq ASC,含 role/content/tool_calls 等完整字段)
messages: Vec<ChatMessage>,
},
/// L2 统一状态机(批2 1b,2026-06-22):对话生命周期状态变更通知。
///
/// 后端 `ConvState`(Idle/Generating/Stopping/Error/Compressed)经写收敛
@@ -214,6 +264,61 @@ pub enum AiChatEvent {
conv_state: ConvState,
conversation_id: Option<String>,
},
/// F-#95 跨端技能列表同步(Phase3,2026-06-23):device 响应 miniapp `list_skills` 命令,
/// 把本机 Claude 技能(skills/commands/plugins 三类,`ai_list_skills` 进程内缓存)推回 miniapp。
///
/// 触发:remote_bridge `list_skills` 路由 → `ai_list_skills().await` →
/// `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。
///
/// 职责:miniapp 技能联想(输入框 `/` 触发浮层),对齐桌面端 `/` 联想体验。
/// 内嵌 `SkillInfo` 自身序列化字段(name/description/argument_hint?/source/path/duplicates?),
/// 变体整体无额外 rename_all 需求(照 AiConversationList 变体模式)。
AiSkillList {
/// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重)
skills: Vec<crate::commands::ai::skills::SkillInfo>,
},
/// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令,
/// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。
///
/// 触发:remote_bridge `list_entities` 路由 → 并行调三 Tauri command(query/project_id/status
/// 均传 None 走 list_active/list_all 全量) → 合并打包 → `publish_event` 跨端透传。
///
/// 职责:miniapp `@` 实体联想(输入框 `@` 触发浮层选项目/任务/灵感),对齐桌面端 @ 体验。
/// 全量字段(对齐桌面端 src/api/types.ts,带宽敏感裁剪留后续批;Record 类型是
/// #[tauri::command] 返回值,必然 impl Serialize)。
AiEntityList {
/// 项目全量列表(list_active,deleted_at IS NULL)
projects: Vec<df_storage::models::ProjectRecord>,
/// 任务全量列表(list_active 等价,未删任务)
tasks: Vec<df_storage::models::TaskRecord>,
/// 灵感全量列表(list_by_query 空 query,等价 list_all)
ideas: Vec<df_storage::models::IdeaRecord>,
},
}
// ============================================================
// F-#95 跨端会话列表同步:会话摘要结构
// ============================================================
/// 会话摘要(miniapp 会话列表渲染用,对齐 apps/df-miniapp/src/types/events.ts:78 Conversation)。
///
/// 与 `AiConversationRecord` 区别:仅含列表展示必需字段,**不含 messages 全文**(省带宽,
/// miniapp MVP 不在列表展示历史消息,进入会话时按需同步)。`updated_at` 用 i64(ms 数值)
/// 便于前端直接比较排序;record 里是 ms 字符串,routing 层 parse 转。
///
/// `#[serde(rename_all = "camelCase")]` 对齐 miniapp Conversation 字段名
/// (id/title/lastMessage/updatedAt/createdAt),TS 侧无需 snake→camel 转换。
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConvSummary {
pub id: String,
pub title: String,
/// 最近一条消息摘要(MVP 暂不取,留 None;后续批可解析 messages 末条)
pub last_message: Option<String>,
/// 最近更新时间(ms 数值)
pub updated_at: Option<i64>,
/// 创建时间(ms 数值,可选展示)
pub created_at: Option<i64>,
}
// ============================================================

View File

@@ -22,6 +22,10 @@
//! | `continue_loop` | `ai_continue_loop` | conversation_id |
//! | `stop_loop` | `ai_stop_loop` | conversation_id |
//! | `switch_conversation` | (无,MVP 不处理) | — |
//! | `list_conversations` | (无,直接读库+事件推送) | — |
//! | `load_messages` | (无,直接读库+事件推送) | conversation_id |
//! | `rename_conversation` | `ai_conversation_rename` | conversation_id, title |
//! | `sync_pending` | (无,读 session 重发审批事件) | conversation_id |
//! | (未知 cmd) | (兜底臂:log+忽略) | — |
//!
//! ## R1 兜底(硬性,不可省,§3.3 + D6)
@@ -38,8 +42,9 @@
//! 回调)留待联调轮做,不在本批范围。
//!
//! dead_code:本模块入口 `handle_remote_command` 及其内部链(route_send_message /
//! check_generating_reject / MiniCommand::from_payload / args_get_*)零调用方 ——
//! 联调轮 setup 注册为 tunnel on_command 回调即消除(预留保留,非死代码)。
//! check_generating_reject / MiniCommand::from_payload / args_get_*)已接 tunnel
//! on_command 回调(lib.rs:150 setup 注册),非死代码。保留 `#![allow(dead_code)]`
//! 仅为防 route_* 等部分内部函数未被全用时的告警(预留保留)。
#![allow(dead_code)]
@@ -57,9 +62,19 @@ use crate::state::AppState;
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
// 故 import 不含 Manager(避免 unused)。
use crate::commands::ai::{
ai_approve, ai_authorize_dir, ai_chat_send, ai_chat_stop, ai_continue_loop,
ai_regenerate, ai_stop_loop, AiChatEvent,
ai_approve, ai_authorize_dir, ai_chat_send, ai_chat_stop, ai_conversation_rename,
ai_continue_loop, ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message,
AiChatEvent, ApprovalKind, ConvSummary, PendingApproval,
};
// F-#95 跨端实体联想:list_projects/list_tasks/list_ideas 是项目/任务/灵感的 #[tauri::command],
// 经 commands::{project,task,idea} 模块路径引用(commands/mod.rs pub mod 声明)。
use crate::commands::{idea::list_ideas, project::list_projects, task::list_tasks};
// F-#95 跨端技能/mention:SkillInfo(commands/ai/skills.rs) + MentionSpanDto(df-types augmentation)。
use crate::commands::ai::skills::SkillInfo;
// MentionSpanDto 来自 df_types::augmentation(df-types 已是 src-tauri 依赖,chat.rs:20 同源)。
use df_types::augmentation::MentionSpanDto;
// F-#95 扩展:历史消息同步 route_load_messages 用(record_to_message 映射 AiMessageRecord→ChatMessage)。
use df_ai::provider::ChatMessage;
// CONV_STATE_ENABLED 与 ConvState::is_active 双轨口径(对齐 ai_is_generating 实现)。
use crate::commands::ai::agentic::conv_state::CONV_STATE_ENABLED;
@@ -82,10 +97,17 @@ pub struct MiniCommand {
/// Tauri command 名(如 "send_message" / "stop" / "approve" ...)
pub cmd: String,
/// 命令参数(原始 JSON 对象,缺失时回退空 Object 便于各臂 from_value 兜底)
#[serde(default)]
/// BUG-260623-05:`#[serde(default)]` 对 Value 给 Null(非 Object),须 custom default 返空 Object
#[serde(default = "default_empty_object")]
pub args: Value,
}
/// MiniCommand.args 缺省值:空 Object。
/// `#[serde(default)]` 对 serde_json::Value 给 Null(非 Object),不符各臂 from_value 兜底语义。
fn default_empty_object() -> Value {
serde_json::json!({})
}
impl MiniCommand {
/// 从原始 payload 反序列化为 MiniCommand。
///
@@ -242,6 +264,82 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
);
}
// ── list_conversations:F-#95 会话列表同步(device→miniapp 推列表) ──
// 无 Tauri command 对应 —— 直接读 state.ai_conversations.list_all() 映射 ConvSummary,
// 经 ai_event_bus.publish_event(AiChatEvent::AiConversationList) 跨端推回 miniapp。
// 双职责:① miniapp 会话列表渲染 ② device 在线探测(miniapp 收响应即 device 活)。
"list_conversations" => {
route_list_conversations(&state).await;
}
// ── load_messages:F-#95 扩展:miniapp 历史消息同步(device→miniapp 推消息列表) ──
// 无 Tauri command 对应(桌面 switch 是纯前端 + 本地 store 加载) —— 直接读
// ai_messages.list_by_conversation 映射 ChatMessage(对齐 ai_conversation_switch 读路径),
// 经 publish_event(AiMessageHistory) 跨端推回 miniapp。替代 switch_conversation(后端忽略)。
"load_messages" => {
match args_get_string(&command.args, "conversation_id") {
Some(id) => route_load_messages(&state, id).await,
None => tracing::warn!(
"[remote_bridge] load_messages 缺 conversation_id 参数,忽略"
),
}
}
// ── rename_conversation:ai_conversation_rename(conversation_id, title) + 推刷新列表 ──
// miniapp 对齐桌面端改会话名。成功后 route_list_conversations 推 AiConversationList,
// miniapp(及桌面端)列表 reactive 刷新见新标题。返回值忽略,结果经列表事件回流。
"rename_conversation" => {
match (
args_get_string(&command.args, "conversation_id"),
args_get_string(&command.args, "title"),
) {
(Some(id), Some(title)) => {
match ai_conversation_rename(state.clone(), id, title).await {
Ok(()) => {
// 推刷新列表(miniapp 据此更新会话标题)
route_list_conversations(&state).await;
}
Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] rename_conversation 失败");
}
}
}
_ => {
tracing::warn!(
"[remote_bridge] rename_conversation 缺 conversation_id 或 title 参数,忽略"
);
}
}
}
// ── sync_pending:重连/初始连接恢复 conv 挂起审批(device 重推审批事件) ──
// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired,
// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,
// device 读 AiSession.pending_approvals(按 conv 过滤)逐条重发审批事件,
// miniapp handleEvent 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦)。
"sync_pending" => {
match args_get_string(&command.args, "conversation_id") {
Some(id) => route_sync_pending(&state, id).await,
None => tracing::warn!(
"[remote_bridge] sync_pending 缺 conversation_id 参数,忽略"
),
}
}
// ── list_skills:F-#95 跨端技能联想(device→miniapp 推本机 Claude 技能列表) ──
// 调 ai_list_skills(零参,直接读进程内缓存) → publish_event(AiSkillList) 跨端透传。
// 对齐 route_list_conversations 模式(非 app.emit,publish 才跨端透传)。
"list_skills" => {
route_list_skills(&state).await;
}
// ── list_entities:F-#95 跨端实体联想(device→miniapp 推项目/任务/灵感列表) ──
// 调 list_projects/list_tasks/list_ideas(query/project_id/status 均传 None 走全量等价路径)
// → 合并打包 publish_event(AiEntityList) 跨端透传,供 miniapp @ 联想浮层渲染。
"list_entities" => {
route_list_entities(&state).await;
}
// ── 兜底臂:未知 cmd,log+忽略(不崩溃) ──
// 对齐 §1.2 风险缓解「桥接层 match 末尾加兜底臂,非法命令不崩溃只记录」。
unknown => {
@@ -253,6 +351,282 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
}
}
// ============================================================
// F-#95:会话列表同步路由(device→miniapp 推列表)
// ============================================================
/// `list_conversations` 路由 —— 读本地会话库摘要,跨端推回 miniapp。
///
/// 流程:
/// 1. `state.ai_conversations.list_all()` → `Vec<AiConversationRecord>`
/// 2. 映射 `ConvSummary`(id/title/title 兜底空串,last_message 暂 None,
/// updated_at/created_at ms 字符串 parse 为 i64)
/// 3. 按 updated_at 倒序(最新在前,对齐桌面端列表展示习惯)
/// 4. `state.ai_event_bus.publish_event(AiConversationList)` 跨端透传
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
///
/// 双职责:① miniapp 会话列表渲染 ② device 在线探测(miniapp 收到此事件 = device 活)。
///
/// 错误兜底:list_all / parse 失败 log warn 不崩溃(空库/脏数据不影响 miniapp 连接态)。
async fn route_list_conversations(state: &State<'_, AppState>) {
// 读全量会话记录(list_all 内部 ORDER BY created_at DESC,但展示口径按 updated_at 重排)。
let records = match state.ai_conversations.list_all().await {
Ok(rs) => rs,
Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] list_conversations 读会话库失败,推送空列表");
// 失败也推一次空列表(miniapp 可据此判 device 在线 + 清空本地过期缓存)。
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiConversationList {
conversations: Vec::new(),
});
return;
}
};
// 映射摘要 + parse 时间戳。updated_at 缺失/脏 → None(排序兜底 0)。
let mut summaries: Vec<ConvSummary> = records
.into_iter()
.map(|r| ConvSummary {
id: r.id,
title: r.title.unwrap_or_default(),
// MVP 暂不取末条消息(需解析 messages JSON,留后续批);miniapp lastMessage 渲染留空。
last_message: None,
updated_at: r.updated_at.parse::<i64>().ok(),
created_at: r.created_at.parse::<i64>().ok(),
})
.collect();
// 按 updated_at 倒序(None 兜底 0 排末)。stable 排序保 None 项相对顺序。
summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at.unwrap_or(0)));
tracing::info!(
count = summaries.len(),
"[remote_bridge] list_conversations 推送会话摘要列表(跨端)"
);
// 跨端发布(非 app.emit)。publish_event 内部 to_value → publish 透传 tunnel subscriber。
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiConversationList {
conversations: summaries,
});
}
// ============================================================
// F-#95 扩展:历史消息同步路由(device→miniapp 推消息列表)
// ============================================================
/// `load_messages` 路由 —— 按 conversation_id 加载历史消息,跨端推回 miniapp。
///
/// 对齐 `ai_conversation_switch`(conversation.rs:259-277)读路径:
/// 1. `state.ai_messages.list_by_conversation(id)` → `Vec<AiMessageRecord>`
/// 2. 映射 `Vec<ChatMessage>`(record_to_message);表空 fallback 旧 messages JSON 列(老库兼容)
/// 3. `state.ai_event_bus.publish_event(AiMessageHistory)` 跨端透传
///
/// 兜底:读/解析失败 log warn 不崩溃(推送空消息列表,miniapp 可据此判 device 在线)。
async fn route_load_messages(state: &State<'_, AppState>, conversation_id: String) {
let records = match state.ai_messages.list_by_conversation(&conversation_id).await {
Ok(rs) => rs,
Err(e) => {
tracing::warn!(
error = %e,
conv_id = %conversation_id,
"[remote_bridge] load_messages 读消息失败,推送空列表"
);
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiMessageHistory {
conversation_id,
messages: Vec::new(),
});
return;
}
};
// 表空 → fallback 旧 messages JSON 列(对齐 ai_conversation_switch:263-277 老库兼容)。
let messages: Vec<ChatMessage> = if !records.is_empty() {
records.iter().map(record_to_message).collect()
} else {
match state.ai_conversations.get_by_id(&conversation_id).await {
Ok(Some(rec)) if rec.messages != "[]" && !rec.messages.is_empty() => {
tracing::warn!(
conv_id = %conversation_id,
"[remote_bridge] load_messages ai_messages 表空,回退旧 messages JSON 列(老库未迁移)"
);
serde_json::from_str(&rec.messages).unwrap_or_default()
}
_ => Vec::new(),
}
};
tracing::info!(
conv_id = %conversation_id,
count = messages.len(),
"[remote_bridge] load_messages 推送历史消息(跨端)"
);
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiMessageHistory {
conversation_id,
messages,
});
}
// ============================================================
// 重连审批恢复:sync_pending 路由(device→miniapp 重推 conv 挂起审批)
// ============================================================
/// `sync_pending` 路由 —— 重连/初始连接时 miniapp 请求当前 conv 的挂起审批快照。
///
/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired 事件,
/// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,device 读
/// AiSession.pending_approvals(按 conversation_id 过滤)逐条重发审批事件,miniapp handleEvent
/// 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦避重连竞态)。
///
/// reason 泛化为「等待审批(重连恢复)」:原 reason 由 build_approval_reason 实时构造(需 risk_level
/// + db 查询),PendingApproval 不存 risk_level/reason;重连恢复场景 tool_name 已在卡上,泛化可接受。
///
/// 错误兜底:读 session 失败 log warn 不崩溃(推 0 条,miniapp pending 维持清空态)。
async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String) {
let approvals: Vec<PendingApproval> = {
let session = state.ai_session.lock().await;
session
.pending_approvals
.values()
.filter(|p| p.conversation_id.as_deref() == Some(conversation_id.as_str()))
.cloned()
.collect()
};
tracing::info!(
conv_id = %conversation_id,
count = approvals.len(),
"[remote_bridge] sync_pending 重推挂起审批(跨端)"
);
for p in approvals {
let conv_id = Some(conversation_id.clone());
let ev = match &p.kind {
ApprovalKind::Risk { diff } => AiChatEvent::AiApprovalRequired {
id: p.tool_call_id,
name: p.tool_name,
args: p.arguments,
reason: "等待审批(重连恢复)".to_string(),
diff: diff.clone(),
conversation_id: conv_id,
},
ApprovalKind::Path(req) => AiChatEvent::AiDirAuthRequired {
id: p.tool_call_id,
tool: p.tool_name,
path: req.raw_paths.first().cloned().unwrap_or_default(),
dir: req
.dirs
.first()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default(),
conversation_id: conv_id,
},
};
let _ = state.ai_event_bus.publish_event(ev);
}
}
// ============================================================
// F-#95:技能列表同步路由(device→miniapp 推本机 Claude 技能)
// ============================================================
/// `list_skills` 路由 —— 读本机 Claude 技能进程内缓存,跨端推回 miniapp。
///
/// 流程:
/// 1. `ai_list_skills().await`(零参,直接读 `skills_cached()` 进程内缓存,不重复扫盘)
/// → `Vec<SkillInfo>`(已按 skills>commands>plugins 优先级去重)
/// 2. `state.ai_event_bus.publish_event(AiSkillList)` 跨端透传
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
///
/// 职责:miniapp 输入框 `/` 触发技能联想浮层,对齐桌面端 `/` 联想体验。
///
/// 错误兜底:ai_list_skills 返 Err(skills_cached 内部理论上不失败,但签名返 Result 兜底)
/// → publish 空 Vec(对齐 route_list_conversations 失败推空列表语义)。
///
/// 注:ai_list_skills 是 `#[tauri::command]` 零参函数(不需要 app/state),此处直接 async 调,
/// 不经 IPC invoke(对齐 route_send_message 调 ai_chat_send 的直接调用模式)。
async fn route_list_skills(state: &State<'_, AppState>) {
let skills: Vec<SkillInfo> = match ai_list_skills().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] list_skills 读技能缓存失败,推送空列表");
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiSkillList {
skills: Vec::new(),
});
return;
}
};
tracing::info!(
count = skills.len(),
"[remote_bridge] list_skills 推送本机技能列表(跨端)"
);
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiSkillList { skills });
}
// ============================================================
// F-#95:实体列表同步路由(device→miniapp 推项目/任务/灵感)
// ============================================================
/// `list_entities` 路由 —— 读本地项目/任务/灵感全量列表,跨端推回 miniapp。
///
/// 流程:
/// 1. 并行顺序调三 Tauri command(query/project_id/status 均传 None 走 list_active/list_all
/// 全量等价路径):list_projects(state, None) / list_tasks(state, None, None) /
/// list_ideas(state, None, None)
/// 2. 合并打包成 `AiEntityList { projects, tasks, ideas }` 经 publish_event 跨端透传
/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端)
///
/// 职责:miniapp 输入框 `@` 触发实体联想浮层(选项目/任务/灵感),对齐桌面端 @ 体验。
/// 全量字段(对齐桌面端 src/api/types.ts);带宽敏感裁剪留后续批,本批不动。
///
/// 错误兜底:任一 command 失败 → 该实体降级为空 Vec(不阻断其余实体),最终 publish_event
/// 推含成功的实体(可能部分空)。对齐 route_list_conversations 失败推空列表语义(部分失败
/// 不致整个路由崩溃,miniapp 拿到部分列表仍可用)。
///
/// 注:三 Tauri command 第二参是 `State<'_, AppState>`,本路由收 `&State`,调时传
/// `state.clone()`(tauri::State Clone,对齐 route_send_message:468-478 ai_chat_send 调用模式)。
/// 不传 query/project_id/status 即走 list_active/list_by_query 空 query 全量等价路径。
async fn route_list_entities(state: &State<'_, AppState>) {
// list_projects(None query) → list_active 全量(deleted_at IS NULL + created_at DESC)。
let projects = match list_projects(state.clone(), None).await {
Ok(ps) => ps,
Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] list_entities list_projects 失败,降级空 Vec");
Vec::new()
}
};
// list_tasks(None project_id, None query) → 全量未删任务(等价改造前 list_active)。
let tasks = match list_tasks(state.clone(), None, None).await {
Ok(ts) => ts,
Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] list_entities list_tasks 失败,降级空 Vec");
Vec::new()
}
};
// list_ideas(None status, None query) → list_by_query 空 query(等价 list_all,created_at DESC)。
let ideas = match list_ideas(state.clone(), None, None).await {
Ok(is) => is,
Err(e) => {
tracing::warn!(error = %e, "[remote_bridge] list_entities list_ideas 失败,降级空 Vec");
Vec::new()
}
};
tracing::info!(
projects = projects.len(),
tasks = tasks.len(),
ideas = ideas.len(),
"[remote_bridge] list_entities 推送项目/任务/灵感列表(跨端)"
);
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiEntityList {
projects,
tasks,
ideas,
});
}
// ============================================================
// R1 兜底:send_message 路由前置 generating 检查
// ============================================================
@@ -282,6 +656,11 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
};
let conversation_id = args_get_string(&args, "conversation_id");
let model_override = args_get_string(&args, "model_override");
// F-#95 跨端技能/mention 透传:miniapp 选技能(/ 联想) + @ mention 区间透传 ai_chat_send,
// 让后端注入技能正文 + 解析 mention(对齐桌面端 ai_chat_send chat.rs:300-320 调用)。
// skill:技能名(Option<String>,空字符串规整为 None);mention_spans:@ 区间(失败兜底 None)。
let skill = args_get_string(&args, "skill").filter(|s| !s.is_empty());
let mention_spans = args_get_mention_spans(&args, "mention_spans");
// ── R1 前置 generating 检查(双轨口径对齐 ai_is_generating) ──
// 目标 conv:入参 conversation_id 优先 → active conv 兜底(与 ai_is_generating 同口径)。
@@ -305,24 +684,35 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
return;
}
// ── 放行:调 ai_chat_send(其余可选参数 language/skill/parts/mention_spans 不经远程,
// miniapp MVP 仅透传 message/conversation_id/model_override,其余走默认 None) ──
// ── 放行:调 ai_chat_send(其余可选参数 language/parts 不经远程;skill + mention_spans
// 经远程透传以支持 miniapp 的 / 技能联想 + @ mention 体验,对齐桌面端 ai_chat_send) ──
//
// F 核验(2026-06-22):ai_chat_send 内部已有 generating guard(chat.rs:354-358)。
// R1 前置检查是双保险(任务原文要求"无论 ai_chat_send 内部是否有 guard"都加)。
// 这里额外把 ai_chat_send 返回的 Err<String> 转 AiError emit 回 miniapp ——
// 无论 R1 命中(理论上不会到这)/ 内部 guard 命中 / Provider 配置错 / 参数错,
// 用户都能在 miniapp 看到 AiError 反馈(否则命令返 Err 静默,miniapp 不知失败)。
// F-260622-02 跨端用户消息同步:调 ai_chat_send 前广播 AiUserMessage 到 ai_event_bus,
// 让桌面 useAiEvents 收到并补 user 气泡(否则桌面只看到孤立的 assistant 响应气泡)。
// 用 publish_event(跨端透传,与 route_list_conversations:335 同通路),非 app.emit
// (emit 仅桌面前端,publish 经 EventBus→tunnel subscriber,桌面端自身也是订阅者能收到)。
// 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。
// 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。
let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage {
message: message.clone(),
conversation_id: conversation_id.clone(),
});
let result = ai_chat_send(
app.clone(),
state.clone(),
message,
None, // language:远程默认不传,ai_chat_send 内部 fallback "zh-CN"
None, // skill:远程不传技能名(技能调用经本地命令栏)
skill, // skill:miniapp / 联想选中技能名透传(技能正文注入)
model_override, // model_override:远程透传用户选择
conversation_id.clone(),// conversation_id:远程透传目标 conv
None, // parts:多模态片段,远程 MVP 不传
None, // mention_spans:@ mention 区间,远程 MVP 不传
mention_spans, // mention_spans:@ mention 区间透传(对齐桌面端 ai_chat_send)
)
.await;
if let Err(err_msg) = result {
@@ -404,6 +794,37 @@ fn args_get_bool(args: &Value, key: &str) -> Option<bool> {
args.get(key).and_then(|v| v.as_bool())
}
/// 从 args 取 mention_spans 字段(数组)反序列化为 `Vec<MentionSpanDto>`。
///
/// 用于 route_send_message 透传 miniapp @ mention 区间到 ai_chat_send。对齐
/// ai_chat_send 第 9 参 `Option<Vec<MentionSpanDto>>`(chat.rs:319)。
///
/// 缺失 / 非数组 / 元素反序列化失败 → None(对齐 miniapp MVP 无 mention 走 None 的旧路径,
/// 不因 mention_spans 脏数据阻断 send_message 路由)。失败仅 log warn,不 panic。
fn args_get_mention_spans(args: &Value, key: &str) -> Option<Vec<MentionSpanDto>> {
match args.get(key) {
Some(Value::Array(_)) => match serde_json::from_value::<Vec<MentionSpanDto>>(args[key].clone())
{
Ok(spans) => {
if spans.is_empty() {
None
} else {
Some(spans)
}
}
Err(e) => {
tracing::warn!(
error = %e,
"[remote_bridge] mention_spans 反序列化失败,降级 None(不阻断 send)"
);
None
}
},
// 缺失或非数组(null/string 等) → None(向后兼容无 mention 的远程调用)。
_ => None,
}
}
// ============================================================
// 单元测试
// ============================================================
@@ -552,8 +973,12 @@ mod tests {
/// R1 判定:目标 conv 在跑 → 拒绝(generating=true)。
#[test]
fn test_r1_target_generating_reject() {
use crate::commands::ai::agentic::conv_state::ConvState;
let mut session = AiSession::new();
let conv = session.conv("conv-busy");
// 双轨一致:CONV_STATE_ENABLED on 时 R1 读 conv_state.is_active()(非 generating bool),
// 须同步设 conv_state=Generating(对齐 test_r1_conv_state_active_reject 设态范式)
conv.conv_state = ConvState::Generating;
conv.generating = true;
let is_gen = session
.conv_read("conv-busy")

View File

@@ -105,7 +105,10 @@ pub fn run() {
id
}
};
// relay_url:KV 取,无则默认本地(df-miniapp config.ts:33 同源 localhost:8080)。
// relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。
// settings value 契约模糊(JSON 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON):
// 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底,
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
let relay_url = state
.settings
.get("relay_url")
@@ -113,6 +116,10 @@ pub fn run() {
.ok()
.flatten()
.filter(|s| !s.is_empty())
.map(|raw| {
serde_json::from_str::<String>(&raw)
.unwrap_or_else(|_| raw.trim_matches('"').to_string())
})
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
let token = "devflow-relay-default-token".to_string();
@@ -157,10 +164,11 @@ pub fn run() {
},
);
// connect(失败非阻断,tunnel 可后续手动重连)。收发循环在 client 内部 spawn。
// connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。
// 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command)
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => tracing::info!(
@@ -168,7 +176,44 @@ pub fn run() {
device_id,
relay_url
),
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断): {}", e),
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
}
// ── supervisor:断线自动重连(根治 device 离线) ──
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
let mut backoff = std::time::Duration::from_secs(2);
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if !state.tunnel.is_connected() {
tracing::info!(
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
relay_url,
device_id
);
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => {
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
backoff = std::time::Duration::from_secs(2);
}
Err(e) => {
tracing::warn!(
error = %e,
backoff_secs = backoff.as_secs(),
"[tunnel-supervisor] 重连失败,退避后重试"
);
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
}
}
}
}
});