/** * AI Chat composable(WS 版,miniapp 专用) * * 与桌面端 useAiSend/useAiEvents 的差异: * - 桌面用 Tauri invoke(发命令)/ listen(收事件),miniapp 用 WS 收发(relay 透传) * - 桌面有 useAiApproval/useAiStream/useAiConversations 等细粒度拆分, * miniapp MVP 合一(状态量小,后续可拆) * * 参考(桌面端逻辑结构,WS 替代 Tauri): * - src/composables/ai/useAiEvents.ts:699 handleEvent(事件分派逻辑) * - src/composables/ai/useAiSend.ts(sendMessage 流程) * * 状态: * - messages: 消息气泡列表(本地渲染,非真相源 —— 真相源在桌面端 session.messages) * - currentText: 流式累积文本(对齐桌面 state.currentText,AiTextDelta 增量拼) * - generating: 生成中(对齐桌面 state.generatingConvs,单 conv 简化为 bool) * - conversations: 会话列表(命令 device 同步,本地缓存) * - activeConversationId: 当前会话(对齐桌面 state.activeConversationId) * - wsStatus: WS 连接状态 * - tokenUsage: 最近回复 token 用量(可选展示) * * 方法: * - send(text): 发消息(send_message Command → relay → device invoke ai_chat_send) * - stop(): 停止生成(stop Command → device invoke ai_chat_stop) * - regenerate(): 重发最后一条 user 消息(重新 send_message) * - switchConversation(id): 切换会话(switch Command → device 切 active) * - 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 { wsClient } from '@/api/ws' import type { WsStatus } from '@/api/ws' import type { AiChatEvent, ChatMessage, Conversation, AiToolCallInfo, TokenUsage, } from '@/types/events' import type { BroadcastMessage, MiniCommand, ControlMessage } from '@/types/relay' /** 简易唯一 id 生成(miniapp 无 crypto.randomUUID 兼容性顾虑,时间戳+随机即可) */ function genId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}` } /** * 全局单例状态(miniapp 进程内一份,多页面共享) * * 与桌面端差异:桌面 state 在 src/stores/ai.ts(pinia-like 单例),miniapp 用模块级 * reactive(uni-app 无 pinia 依赖,模块单例即可)。 */ const messages = reactive([]) const currentText = ref('') const generating = ref(false) const agentRound = ref(0) const conversations = reactive([]) const activeConversationId = ref(null) const wsStatus = ref('disconnected') const wsStatusDetail = ref('') const tokenUsage = ref(null) const pendingApprovals = reactive([]) /** 已注册事件监听标志(防多次订阅 wsClient) */ let subscribed = false /** 从工具调用 id 在消息列表中查找(对齐桌面 findToolCall,useAiEvents.ts:408) */ function findToolCall(id: string): AiToolCallInfo | null { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i] if (m.toolCalls) { for (const tc of m.toolCalls) { if (tc.id === id) return tc } } } return null } /** 流式文本回填到最后一条 assistant 气泡(对齐桌面 flushCurrentText,useAiEvents.ts:186) */ function flushCurrentText(): void { if (!currentText.value) return for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i] if (m.role !== 'assistant') break if (!m.isError) { m.content = currentText.value break } } } /** * AiChatEvent 处理(对齐桌面 useAiEvents.ts handleEvent,按域简化合并) * * miniapp MVP 简化: * - 仅处理核心渲染相关事件(TextDelta/AgentRound/Completed/Error/Tool/Approval) * - 看门狗/超时/审批计时器等桌面机制未移植(后续批) * - 路由判断:无 conversation_id 或等于 active 时处理,其他忽略 */ export function handleEvent(event: AiChatEvent): void { const convId = event.conversation_id ?? null // 首次收到事件同步当前对话 id(对齐桌面 useAiEvents.ts:705) if (convId && !activeConversationId.value) { activeConversationId.value = convId } // 路由:非当前会话事件忽略(miniapp MVP 单会话视图,不追踪多 conv 生成态) const isCurrent = !convId || convId === activeConversationId.value if (!isCurrent) return switch (event.type) { case 'AiTextDelta': currentText.value += event.delta break case 'AiAgentRound': { // 新一轮:flush 当前文本到上一条 assistant,新建空 assistant 占位 flushCurrentText() currentText.value = '' messages.push({ id: genId('ai'), role: 'assistant', content: '', timestamp: Date.now(), }) if (event.round > 0) agentRound.value = event.round break } case 'AiHeartbeat': // 静默期报活,miniapp 仅维持连接(无 watchdog 需重置) break case 'AiStreamRetry': { // 重试提示:复用错误气泡或新建 const last = messages[messages.length - 1] const text = `重试中(${event.attempt}/${event.max_attempts})...` if (last && last.isError) { last.content = text } else { messages.push({ id: genId('retry'), role: 'assistant', content: text, isError: true, timestamp: Date.now(), }) } break } case 'AiMaxRoundsReached': // 达 max 暂停:miniapp 展示提示气泡(不实现继续/停止按钮,用户回桌面处理) messages.push({ id: genId('maxrounds'), role: 'system', content: '⚠ 已达最大轮次,请在桌面端处理(继续/停止)', timestamp: Date.now(), }) break case 'AiToolCallStarted': { const info: AiToolCallInfo = { id: event.id, name: event.name, args: event.args, status: 'running', } if (!findToolCall(event.id)) { const last = messages[messages.length - 1] if (last && last.role === 'assistant') { last.toolCalls = last.toolCalls || [] last.toolCalls.push(info) } } break } case 'AiToolCallCompleted': { const tc = findToolCall(event.id) if (tc) { tc.status = 'completed' tc.result = event.result } // 移除对应挂起审批(若有) const idx = pendingApprovals.findIndex((p) => p.id === event.id) if (idx >= 0) pendingApprovals.splice(idx, 1) break } case 'AiToolAutoApproved': // 会话级信任自动放行,miniapp 仅 console(不做 toast UI) console.log(`[auto-approved] ${event.tool} @ ${event.dir}`) break case 'AiApprovalRequired': { const info: AiToolCallInfo = { id: event.id, name: event.name, args: event.args, status: 'pending_approval', kind: 'risk', reason: event.reason, diff: event.diff ?? undefined, } pendingApprovals.push(info) const tc = findToolCall(event.id) if (tc) { tc.status = 'pending_approval' tc.reason = event.reason if (event.diff) tc.diff = event.diff } break } case 'AiApprovalResult': { if (!event.approved) { const tc = findToolCall(event.id) if (tc) tc.status = 'rejected' } const idx = pendingApprovals.findIndex((p) => p.id === event.id) if (idx >= 0) pendingApprovals.splice(idx, 1) break } case 'AiDirAuthRequired': // 路径授权:miniapp 仅提示(不实现授权 UI,用户回桌面处理) messages.push({ id: genId('dirauth'), role: 'system', content: `⚠ 路径授权请求:${event.tool} → ${event.dir}(请在桌面端处理)`, timestamp: Date.now(), }) break case 'AiCompleted': { flushCurrentText() currentText.value = '' generating.value = false agentRound.value = 0 // token 用量(可选展示) tokenUsage.value = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens, } // 不完整标记(网络中断保文) if (event.incomplete) { messages.push({ id: genId('incomplete'), role: 'assistant', content: '⚠ 响应因网络中断不完整', timestamp: Date.now(), }) } break } case 'AiError': { flushCurrentText() currentText.value = '' generating.value = false agentRound.value = 0 pendingApprovals.splice(0, pendingApprovals.length) messages.push({ id: genId('err'), role: 'assistant', content: friendlyError(event.error), isError: true, timestamp: Date.now(), }) break } case 'AiHelpRequired': // 断路器熔断求助:miniapp 展示求助卡(用户在桌面选 option,miniapp 不渲染选项按钮) flushCurrentText() currentText.value = '' generating.value = false messages.push({ id: genId('help'), role: 'system', content: `🆘 AI 求助:${event.reason}\n已试:${event.context}\n建议:${event.options.join(' / ')}(请在桌面端选择)`, timestamp: Date.now(), }) break case 'AiContextCleared': case 'AiCompressing': // 上下文分段/压缩中,miniapp 无需特殊处理(状态在桌面端) break case 'AiCompressed': // 压缩完成:在消息首位插入摘要提示 messages.unshift({ id: genId('compressed'), role: 'system', content: `📋 上下文已压缩(摘要:${event.summary.slice(0, 100)}...)`, timestamp: Date.now(), }) break case 'AiConvStateChanged': // 统一状态机:generating/stopping → generating=true;idle/error → false 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 } // compressed 是派生态,保持当前 generating(压缩完回原态) break } } /** 错误转用户友好提示(对齐桌面 friendlyError,useAiEvents.ts:177) */ export function friendlyError(raw: string): string { if (/404|not\s*found/i.test(raw)) return '未找到(可能对话已删除)' if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '鉴权失败,请检查 API Key 配置' if (/timeout|超时/i.test(raw)) return '请求超时' if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络错误,请检查连接' return raw } /** 注册 wsClient 订阅(幂等,App.vue onLaunch 调用一次) */ function subscribeWs(): void { if (subscribed) return subscribed = true wsClient.subscribe({ onEvent: (msg: BroadcastMessage) => { // payload 是 AiChatEvent JSON,透传给 handleEvent try { const event = msg.payload as AiChatEvent if (event && typeof event === 'object' && 'type' in event) { handleEvent(event) } } catch (e) { console.warn('[useAiChat] 事件 payload 解析失败', e) } }, onControl: (msg: ControlMessage) => { // 心跳响应等,miniapp 仅 console(不维持在线状态 UI) console.log('[useAiChat] control', msg) }, onStatus: (status: WsStatus, detail?: string) => { wsStatus.value = status wsStatusDetail.value = detail ?? '' }, }) } /** 发 send_message Command(relay → device invoke ai_chat_send) */ function send(text: string): void { const trimmed = text.trim() if (!trimmed) return if (generating.value) { console.warn('[useAiChat] 生成中,忽略新发送') return } // 本地先 push user 气泡(乐观渲染,对齐桌面 sendMessage) messages.push({ id: genId('user'), role: 'user', content: trimmed, timestamp: Date.now(), }) // 新建 assistant 占位气泡(等待流式增量填充) messages.push({ id: genId('ai'), role: 'assistant', content: '', timestamp: Date.now(), }) currentText.value = '' generating.value = true const cmd: MiniCommand = { cmd: 'send_message', args: { message: trimmed, conversation_id: activeConversationId.value, }, } if (!wsClient.send(cmd)) { // 发送失败:在 assistant 占位气泡显错误 flushCurrentText() messages.push({ id: genId('err'), role: 'assistant', content: '未连接桌面端,请检查网络/配置', isError: true, timestamp: Date.now(), }) generating.value = false } } /** 停止生成(relay → device invoke ai_chat_stop) */ function stop(): void { if (!generating.value) return const cmd: MiniCommand = { cmd: 'stop', args: { conversation_id: activeConversationId.value, }, } wsClient.send(cmd) } /** 重发最后一条 user 消息(重新 send_message) */ function regenerate(): void { // 找最后一条 user 消息 let lastUserText = '' for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'user') { lastUserText = messages[i].content break } } if (!lastUserText) return // 移除末尾 assistant 占位/错误气泡(若仍是空的) const last = messages[messages.length - 1] if (last && last.role === 'assistant' && !last.content) { messages.pop() } send(lastUserText) } /** * 审批工具调用(approve Command → relay → device invoke ai_approve) * * Phase3 §2.2 路由表:{cmd:"approve", args:{tool_call_id, approved}} * 对应事件:AiApprovalRequired(挂起审批列表 pendingApprovals)。 */ function approve(toolCallId: string, approved: boolean): void { if (!toolCallId) return const cmd: MiniCommand = { cmd: 'approve', args: { tool_call_id: toolCallId, approved, }, } wsClient.send(cmd) // 乐观从挂起列表移除(对齐 AiApprovalResult 事件处理,避免 UI 卡 pending) const idx = pendingApprovals.findIndex((p) => p.id === toolCallId) if (idx >= 0) pendingApprovals.splice(idx, 1) } /** * 路径授权(authorize_dir Command → relay → device invoke ai_authorize_dir) * * Phase3 §2.2 路由表:{cmd:"authorize_dir", args:{tool_call_id, decision}} * 对应事件:AiDirAuthRequired(决策:'once'=本次 / 'always'=永久 / 'deny'=拒绝)。 */ function authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): void { if (!toolCallId) return const cmd: MiniCommand = { cmd: 'authorize_dir', args: { tool_call_id: toolCallId, decision, }, } wsClient.send(cmd) } /** * 继续循环(continue_loop Command → relay → device invoke ai_continue_loop) * * Phase3 §2.2 路由表:{cmd:"continue_loop", args:{conversation_id}} * 对应场景:AiMaxRoundsReached 达最大轮次暂停后,用户选「继续」再跑一轮。 */ function continueLoop(conversationId?: string | null): void { const convId = conversationId ?? activeConversationId.value if (!convId) { console.warn('[useAiChat] continue_loop 缺 conversation_id') return } const cmd: MiniCommand = { cmd: 'continue_loop', args: { conversation_id: convId }, } wsClient.send(cmd) } /** * 停止循环(stop_loop Command → relay → device invoke ai_stop_loop) * * Phase3 §2.2 路由表:{cmd:"stop_loop", args:{conversation_id}} * 对应场景:AiMaxRoundsReached 达最大轮次暂停后,用户选「停止」彻底终止。 * 与 stop 区别:stop 是中断流式生成,stop_loop 是停止多轮 agent 循环。 */ function stopLoop(conversationId?: string | null): void { const convId = conversationId ?? activeConversationId.value if (!convId) { console.warn('[useAiChat] stop_loop 缺 conversation_id') return } const cmd: MiniCommand = { cmd: 'stop_loop', args: { conversation_id: convId }, } wsClient.send(cmd) } /** 切换会话(switch Command → device 切 active_conversation_id) */ function switchConversation(convId: string): void { activeConversationId.value = convId // 清空当前视图(新会话消息从 device 同步或留空) messages.splice(0, messages.length) currentText.value = '' generating.value = false const cmd: MiniCommand = { cmd: 'switch_conversation', args: { conversation_id: convId }, } wsClient.send(cmd) } /** 新建会话(本地清空 + 切到 null,下次 send 时 device 创建新 conv) */ function newConversation(): void { activeConversationId.value = null messages.splice(0, messages.length) currentText.value = '' generating.value = false pendingApprovals.splice(0, pendingApprovals.length) } /** * useAiChat 单例 hook * * 多次调用返回同一状态(模块级 reactive 单例)。 */ export function useAiChat() { subscribeWs() const isWsConnected = computed(() => wsStatus.value === 'connected') return { // 状态 messages, currentText, generating, agentRound, conversations, activeConversationId, wsStatus, wsStatusDetail, isWsConnected, tokenUsage, pendingApprovals, // 方法 connect: () => wsClient.connect(), disconnect: () => wsClient.disconnect(), resumeIfDisconnected: () => wsClient.resumeIfDisconnected(), send, stop, regenerate, switchConversation, newConversation, approve, authorizeDir, continueLoop, stopLoop, handleEvent, friendlyError, } }