重构: df-miniapp移入apps/(monorepo分层 crates=Rust库/apps=前端应用)
- df-miniapp/ -> apps/df-miniapp/(uni-app 工程归集前端应用层,与 crates/ Rust 库分层) - 删冗余 apps/df-miniapp/.gitignore(根已覆盖 node_modules/dist/.DS_Store 等) - 根 .gitignore 加 unpackage/(uni-app 编译产物,唯一根未覆盖项) - vue-tsc 零错(移动不改代码,类型不变)
This commit is contained in:
490
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
490
apps/df-miniapp/src/composables/useAiChat.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** 切换会话(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,
|
||||
handleEvent,
|
||||
friendlyError,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user