577 lines
18 KiB
TypeScript
577 lines
18 KiB
TypeScript
/**
|
|
* 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<ChatMessage[]>([])
|
|
const currentText = ref<string>('')
|
|
const generating = ref<boolean>(false)
|
|
const agentRound = ref<number>(0)
|
|
const conversations = reactive<Conversation[]>([])
|
|
const activeConversationId = ref<string | null>(null)
|
|
const wsStatus = ref<WsStatus>('disconnected')
|
|
const wsStatusDetail = ref<string>('')
|
|
const tokenUsage = ref<TokenUsage | null>(null)
|
|
const pendingApprovals = reactive<AiToolCallInfo[]>([])
|
|
|
|
/** 已注册事件监听标志(防多次订阅 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,
|
|
}
|
|
}
|