重构: 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:
2026-06-22 02:19:23 +08:00
parent 280baeaee0
commit a5a4fc887a
19 changed files with 3 additions and 8 deletions

View 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,
}
}