From 71fdaac1b4531197d8d3962cdb417f5a6cb74634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 5 Aug 2026 22:14:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20df-miniapp=20=E8=B7=A8?= =?UTF-8?q?=E7=AB=AF=20AI=20Chat=20=E6=9B=B4=E6=96=B0(=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=89=8D=E5=9F=BA=E7=BA=BF=E6=94=B6=E5=B0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ws.ts 心跳 pong 闭环 + useAiChat 看门狗治半连接挂死 - pages 新增 settings/ + chat/conversations 更新 + types events/relay + mdRenderer + tsconfig --- apps/df-miniapp/src/api/ws.ts | 22 +- apps/df-miniapp/src/composables/useAiChat.ts | 508 ++++++++++--- apps/df-miniapp/src/pages.json | 10 + apps/df-miniapp/src/pages/chat/index.vue | 687 ++++++++++++++++-- .../src/pages/conversations/index.vue | 49 +- apps/df-miniapp/src/pages/settings/index.vue | 200 +++++ apps/df-miniapp/src/types/events.ts | 19 + apps/df-miniapp/src/types/relay.ts | 28 + apps/df-miniapp/src/utils/mdRenderer.ts | 28 +- apps/df-miniapp/tsconfig.json | 2 +- 10 files changed, 1361 insertions(+), 192 deletions(-) create mode 100644 apps/df-miniapp/src/pages/settings/index.vue diff --git a/apps/df-miniapp/src/api/ws.ts b/apps/df-miniapp/src/api/ws.ts index a918615..ff61e21 100644 --- a/apps/df-miniapp/src/api/ws.ts +++ b/apps/df-miniapp/src/api/ws.ts @@ -33,6 +33,9 @@ import { getConfig } from '@/config' import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay' +/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */ +const DEBUG = import.meta.env.DEV + /** WS 连接状态 */ export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting' @@ -86,6 +89,8 @@ class WsClient { private reconnectAttempts = 0 /** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */ private manualClose = false + /** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */ + private recoverable = false /** * 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。 * 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪, @@ -112,7 +117,8 @@ class WsClient { return } this.manualClose = false - // 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试) + this.recoverable = false + // 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试) this.reconnectAttempts = 0 // 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连 this.reconnectNow() @@ -138,7 +144,9 @@ class WsClient { */ resumeIfDisconnected(): void { if (this.manualClose) return - if (this.status === 'disconnected' || this.status === 'reconnecting') { + // 已达上限 disconnected(recoverable=true)也允许重置计数并重连 + if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) { + this.recoverable = false // 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复 this.reconnectAttempts = 0 this.reconnectNow() @@ -174,7 +182,7 @@ 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 (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked) if (!this.socket || !this.handshaked) { // 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。 // 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队, @@ -508,8 +516,14 @@ class WsClient { // 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。 if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`) + // 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer + // 让 connect()/resumeIfDisconnected() 可重置计数后恢复 this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连') - this.cleanup() + this.recoverable = true + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } return } const cfg = getConfig() diff --git a/apps/df-miniapp/src/composables/useAiChat.ts b/apps/df-miniapp/src/composables/useAiChat.ts index 6b34263..f0b4797 100644 --- a/apps/df-miniapp/src/composables/useAiChat.ts +++ b/apps/df-miniapp/src/composables/useAiChat.ts @@ -18,6 +18,7 @@ * - activeConversationId: 当前会话(对齐桌面 state.activeConversationId) * - wsStatus: WS 连接状态 * - tokenUsage: 最近回复 token 用量(可选展示) + * - loadingMessages / loadingConversations / loading: 加载态 * * 方法: * - send(text, skill?, spans?): 发消息(send_message Command → relay → device invoke ai_chat_send) @@ -33,7 +34,7 @@ * - stopLoop(convId): 停止循环(stop_loop Command → device invoke ai_stop_loop) */ -import { ref, reactive, computed, watch } from 'vue' +import { ref, computed, watch, triggerRef } from 'vue' import { wsClient } from '@/api/ws' import type { WsStatus } from '@/api/ws' import type { @@ -45,6 +46,7 @@ import type { ProjectRecord, TaskRecord, IdeaRecord, + ModelInfo, } from '@/types/events' import type { BroadcastMessage, @@ -52,6 +54,7 @@ import type { ControlMessage, SkillInfo, MentionSpan, + MiniContentPart, } from '@/types/relay' /** 简易唯一 id 生成(miniapp 无 crypto.randomUUID 兼容性顾虑,时间戳+随机即可) */ @@ -59,14 +62,53 @@ function genId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}` } +/** + * 调试日志门控(P1 走查:生产 console.log 每事件过微信 native bridge,热路径成本)。 + * import.meta.env.DEV:uni-app/vite 构建时注入,dev=true / build=false。 + * 门控后仅开发构建输出 [dbg:],生产零成本。 + */ +const DEBUG = import.meta.env.DEV + /** * 全局单例状态(miniapp 进程内一份,多页面共享) * * 与桌面端差异:桌面 state 在 src/stores/ai.ts(pinia-like 单例),miniapp 用模块级 * reactive(uni-app 无 pinia 依赖,模块单例即可)。 */ -const messages = reactive([]) +const messages = ref([]) const currentText = ref('') +/** + * 流式 delta 增量缓冲(P0-1 走查优雅化)。 + * + * 根因:每 delta `currentText.value += delta` 是 O(n²)(Vue ref 赋值重建整个字符串), + * 长回复 + 高频 delta 下接收端拼接成本累计,拖累渲染(用户报"桌面端已答完,小程序端 + * 还一个字一个字蹦")。 + * + * 优雅方案:delta append 到 _deltaBuf(O(1) 字符串拼接),scheduleDeltaFlush 30ms 节流 + * 一次合并进 currentText(一次 `+=`)。渲染端已有 200ms 节流(chat useThrottledRef), + * 缓冲只降接收端拼接成本,currentText 语义不变(渲染端无感)。 + */ +let _deltaBuf = '' +let _deltaFlushTimer: ReturnType | null = null +const DELTA_FLUSH_MS = 30 +function scheduleDeltaFlush(): void { + if (_deltaFlushTimer) return + _deltaFlushTimer = setTimeout(() => { + _deltaFlushTimer = null + if (_deltaBuf) { + currentText.value += _deltaBuf + _deltaBuf = '' + } + }, DELTA_FLUSH_MS) +} +/** 立即 flush 缓冲(终态/停止/新轮前调,确保残余 delta 落盘) */ +function flushDeltaBuffer(): void { + if (_deltaFlushTimer) { clearTimeout(_deltaFlushTimer); _deltaFlushTimer = null } + if (_deltaBuf) { + currentText.value += _deltaBuf + _deltaBuf = '' + } +} /** * 当前流式累积目标 assistant 气泡 id(F3:精确回填,取代启发式「末尾非 error assistant」)。 * send/AiAgentRound push 新占位时记录;flushCurrentText 按 id 精确写入,治 retry/error/ @@ -80,14 +122,14 @@ const agentRound = ref(0) * AiMaxRoundsReached 置 true;continueLoop/stopLoop/send/switch/new 清 false。 */ const maxRoundsActive = ref(false) -const conversations = reactive([]) +const conversations = ref([]) /** 消息内存上限(P3-A:防长会话 OOM;渲染另有 visibleMessages slice 200 兜底,此为存储层) */ const MAX_STORE_MESSAGES = 500 watch( - () => messages.length, + () => messages.value.length, (len) => { - if (len > MAX_STORE_MESSAGES) messages.splice(0, len - MAX_STORE_MESSAGES) + if (len > MAX_STORE_MESSAGES) messages.value.splice(0, len - MAX_STORE_MESSAGES); triggerRef(messages) }, ) @@ -115,8 +157,11 @@ function persistActiveConv(convId: string | null): void { } const activeConversationId = ref(loadPersistedActiveConv()) +const loadingMessages = ref(false) +const loadingConversations = ref(false) +const loading = computed(() => loadingMessages.value || loadingConversations.value) /** - * 最近发起 load_messages 的目标会话(MINIAPP-02 历史丢失竞态修复)。 + * 最近发起 load_messages 的目标会话(历史丢失竞态修复)。 * * 问题:switchConversation 同步置 activeConversationId=convId 后发 load_messages, * 但 AiMessageHistory 回流判定 `event.conversation_id === activeConversationId.value`。 @@ -130,7 +175,7 @@ const lastLoadedConvId = ref(null) const wsStatus = ref('disconnected') const wsStatusDetail = ref('') const tokenUsage = ref(null) -const pendingApprovals = reactive([]) +const pendingApprovals = ref([]) /** * 最近批准(approved=true)的工具调用(P2-E 审批失败回填)。 * approve 成功发出后记录;device 返 AiError(执行失败/已被桌面端处理)时回填 pendingApprovals 让用户重试, @@ -141,17 +186,30 @@ const lastApprovedTc = ref(null) * `/` 联想技能列表(F-#95 扩展,list_skills 命令 → device route_list_skills → AiSkillList 回流)。 * 初始空数组,chat/index.vue inputText 以 / 开头时 listSkills() 拉取后填充浮层。 */ -const skills = reactive([]) +const skills = ref([]) /** * `@` 联想实体列表(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[] }>({ +const entities = ref<{ projects: ProjectRecord[]; tasks: TaskRecord[]; ideas: IdeaRecord[] }>({ projects: [], tasks: [], ideas: [], }) +/** + * 可选模型列表(每会话独立模型:list_models 命令 → device route_list_models → + * AiModelList 事件回流 models)。miniapp 顶栏模型选择器消费。 + */ +const models = ref([]) +/** + * 当前会话选中的模型 id。null = 未选,走后端默认路由(per_conv 持久值/select_model_id)。 + * 每会话独立:send 时透传 model_override,后端写 per_conv;切会话本地复位 null, + * 让后端 per_conv 持久值生效(天然 per-conv 隔离,切模型不干扰其他会话)。 + */ +const activeModel = ref(null) +/** 活跃 provider id + 默认模型(选择器高亮/兜底,AiModelList 事件填充) */ +const defaultModel = ref('') /** * device(桌面端)在线探测(F-#95)。 * @@ -192,25 +250,58 @@ const STREAM_TIMEOUT_MS = 130000 */ let stopping = false +/** + * 跨端新会话确认标记(2026-08-05):send 以 new_conversation=true 发出后置 true, + * 等待 device 广播 AiUserMessage(带真实新 conv id)回灌时更新 activeConversationId 并复位。 + * 用于精确锚定本设备刚发起的新会话(替代宽松 null 放行,防误收他机消息);null 放行分支保留兼容。 + */ +let pendingNewConvConfirm = false + +// —— 发送消息队列(2026-08-05 BUG-260805-02) —— +// 生成中发送的消息入队(替代旧「生成中忽略」),当前轮终态(AiCompleted/AiError)后自动 +// drain 发送下一条,对齐桌面端 queue 机制(useAiSend)。MVP 单会话视图:切会话/新会话清队列 +// (防消息发错会话)。队列项带完整参数(文本/技能/@引用/图片),drain 时原样透传。 +interface PendingSend { + text: string + skill: string | null + spans: MentionSpan[] | null + parts: MiniContentPart[] | null +} +const pendingQueue = ref([]) + /** 看门狗超时回调:中途断兜底(flush + 错误气泡 + reset generating) */ function onStreamTimeout(): void { streamTimer = null + lastWatchdogReset = 0 // 超时后复位,允许下次 resetWatchdog 重新 arm if (generating.value) { flushCurrentText() currentText.value = '' generating.value = false - messages.push({ + messages.value.push({ id: genId('timeout'), role: 'assistant', content: '⚠ 回复中断(桌面端长时间无响应),请检查连接后重试', isError: true, timestamp: Date.now(), - }) + }); triggerRef(messages) } } -/** 重置看门狗(send 后启动 / 活跃事件重置,确保 watchdog 运行计 STREAM_TIMEOUT_MS) */ +/** + * 重置看门狗(send 后启动 / 活跃事件重置,确保 watchdog 运行计 STREAM_TIMEOUT_MS)。 + * + * P2-8 走查:流式高频 delta 每事件 clear+setTimeout(几十次/秒)是纯 timer churn —— + * watchdog 只在 delta **停止**后才有意义,期间反复 arm 浪费。改时间感知: + * - 距上次重置 < WATCHDOG_RESET_THROTTLE(5s) → 跳过不 arm(已有 timer 继续跑,兜底不变) + * - 距上次重置 ≥ 5s → 重新 arm(保证 watchdog 从活跃点重新计 130s,静默期不被误杀) + * 流式持续时 timer 基本不动;流式停止后 130s 无事件仍触发兜底。 + */ +const WATCHDOG_RESET_THROTTLE = 5000 +let lastWatchdogReset = 0 function resetWatchdog(): void { + const now = Date.now() + if (now - lastWatchdogReset < WATCHDOG_RESET_THROTTLE) return + lastWatchdogReset = now if (streamTimer) clearTimeout(streamTimer) streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS) } @@ -227,9 +318,9 @@ function startApprovalTimer(tcId: string, toolName: string): void { const timer = setTimeout(() => { _approvalTimers.delete(tcId) // 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false)) - const idx = pendingApprovals.findIndex((p) => p.id === tcId) + const idx = pendingApprovals.value.findIndex((p) => p.id === tcId) if (idx >= 0) { - pendingApprovals.splice(idx, 1) + pendingApprovals.value.splice(idx, 1) uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' }) // 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false) approve(tcId, false) @@ -262,10 +353,30 @@ function clearWatchdog(): void { /** 已注册事件监听标志(防多次订阅 wsClient) */ let subscribed = false +/** + * 工具执行失败判定(对齐桌面 useToolCard.ts isToolFailure)。 + * + * 后端 AiToolCallCompleted 不带显式 status,失败信息藏在 result 里: + * - result 对象含 exit_code 且 !== 0(run_command 等执行类工具) + * - result 字符串以「执行失败」/「Error:」/「Failed:」开头 + * 匹配则工具卡显红色 failed 态(替代旧实现无条件 completed 绿,失败工具误导为已完成)。 + */ +function isToolFailure(result: unknown): boolean { + if (result === null || result === undefined) return false + if (typeof result === 'string') { + return /^(执行失败|Error:|Failed:)/.test(result.trim()) + } + if (typeof result === 'object') { + const exitCode = (result as { exit_code?: unknown }).exit_code + if (typeof exitCode === 'number' && exitCode !== 0) return true + } + return 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] + for (let i = messages.value.length - 1; i >= 0; i--) { + const m = messages.value[i] if (m.toolCalls) { for (const tc of m.toolCalls) { if (tc.id === id) return tc @@ -275,21 +386,48 @@ function findToolCall(id: string): AiToolCallInfo | null { return null } +/** + * 清除「重试中(N/M)」过渡气泡(成功后残留根治)。 + * + * AiStreamRetry 推的重试提示(isError,content 前缀「重试中(」)是过渡态 —— 下次重试会 + * 覆盖旧内容或 push 新的,但成功(AiCompleted)/失败(AiError)时旧重试气泡不清理, + * flushCurrentText 只写非 error 气泡 → 「重试中(2/3)」永久残留列表。 + * 终态调用:从末尾向前扫,删除所有「重试中(」开头的 isError 气泡(过渡态,无保留价值)。 + */ +function clearRetryBubbles(): void { + for (let i = messages.value.length - 1; i >= 0; i--) { + const m = messages.value[i] + if (m.isError && typeof m.content === 'string' && m.content.startsWith('重试中(')) { + messages.value.splice(i, 1) + } + } + triggerRef(messages) +} + /** 流式文本回填到当前轮 assistant 气泡(F3:按 id 精确,回退启发式) */ function flushCurrentText(): void { - if (!currentText.value) return + // P0-1 优雅化:先把 delta 缓冲合并进 currentText(残余增量落盘),再整体回填气泡。 + // 终态(AiCompleted/AiError/AgentRound/stop)都经此,确保缓冲不丢尾帧。 + flushDeltaBuffer() + // 根因修复(空气泡,对齐桌面 useAiEvents.flushCurrentText):只 guard 空串会漏 whitespace —— + // LLM 工具调用前常推 `\n`/空格,累积成 whitespace-only currentText 后写进占位 content, + // 模板 !m.content 真值判断漏过 → 渲染空气泡。trim 兜底空白,空白流式文本不回填。 + if (!currentText.value || !currentText.value.trim()) { + 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 + for (let i = messages.value.length - 1; i >= 0; i--) { + if (messages.value[i].id === currentAssistantMsgId.value) { + messages.value[i].content = currentText.value return } } } // 回退启发式:取末尾第一个非 error assistant(id 失配时兼容,对齐桌面 useAiEvents.ts:186) - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i] + for (let i = messages.value.length - 1; i >= 0; i--) { + const m = messages.value[i] if (m.role !== 'assistant') break if (!m.isError) { m.content = currentText.value @@ -307,15 +445,17 @@ function flushCurrentText(): void { * - 路由判断:无 conversation_id 或等于 active 时处理,其他忽略 */ export function handleEvent(event: AiChatEvent): void { - // 诊断:事件类型 + 频率(排查 device 持续推流式致 view 层过载 / tap 失效) - console.log('[dbg:event] type=', event.type) + // 诊断:事件类型 + 频率(排查 device 持续推流式致 view 层过载 / tap 失效)。 + // P1 走查:每事件 log 过微信 bridge,热路径成本高,门控到 dev。 + if (DEBUG) 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) + // 整体替换(conversations 是 ref,直接赋值) + conversations.value = event.conversations // 收到响应即 device 在线(探测成功),chat 页状态文案据此切「已连接桌面端」。 deviceOnline.value = true + loadingConversations.value = false return } @@ -323,7 +463,7 @@ export function handleEvent(event: AiChatEvent): void { // 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) + skills.value.splice(0, skills.value.length, ...event.skills) deviceOnline.value = true return } @@ -332,9 +472,21 @@ export function handleEvent(event: AiChatEvent): void { // 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) + entities.value.projects.splice(0, entities.value.projects.length, ...event.projects) + entities.value.tasks.splice(0, entities.value.tasks.length, ...event.tasks) + entities.value.ideas.splice(0, entities.value.ideas.length, ...event.ideas) + deviceOnline.value = true + return + } + + // 每会话独立模型:模型列表同步(全局事件,无 conversation_id,提前处理避免 narrowing)。 + // route_list_models(device 桥接层)读活跃 provider enabled 模型 → publish_event(AiModelList)。 + // 收到即替换 models(顶栏模型选择器消费)+ 记录默认模型 + 置 deviceOnline=true。 + // activeModel 不清空(保持当前会话已选模型,仅首次填充用 default_model 兜底)。 + if (event.type === 'AiModelList') { + models.value.splice(0, models.value.length, ...event.models) + defaultModel.value = event.default_model + if (!activeModel.value) activeModel.value = event.default_model || null deviceOnline.value = true return } @@ -345,7 +497,7 @@ export function handleEvent(event: AiChatEvent): void { // 提前处理(在 convId narrowing 之前),因该事件必带 conversation_id 但语义独立于 watchdog/ // generating 态,不应触发活跃事件 reset。 // - // MINIAPP-01(P1)去重失效修复:旧判定仅看 messages 末条 role==='user',但 send() 乐观 push 后 + // 去重失效修复:旧判定仅看 messages 末条 role==='user',但 send() 乐观 push 后 // 紧跟 assistant 占位气泡(:499-504),AiUserMessage 回灌时末条是 assistant 占位,role!=='user' // 成立 → 误判「未重复」→ push 第二个重复 user 气泡(夹在乐观 user 与 assistant 占位之间错位)。 // 改:从末尾向前扫,跳过非 user 气泡(占位/系统),取最近一条 user 比 content。 @@ -356,26 +508,33 @@ export function handleEvent(event: AiChatEvent): void { // 守卫:事件带 conversation_id 且非当前会话也非最近 load 目标 → 丢弃(乐观 push 已在发送方本地渲染)。 // 保留 null 放行(新会话首条,active 也可能 null,避免误杀自回灌)。 const uc = event.conversation_id ?? null - if (uc && uc !== activeConversationId.value && uc !== lastLoadedConvId.value) { + // 跨端新会话确认(2026-08-05):send new_conversation 后 device 广播 AiUserMessage 带 + // 真实新 conv id,此时 active 仍 null(尚未确认)→ 接受并更新 active(persist 持久化), + // 后续 assistant 事件带同 id 才能通过守卫渲染(否则新会话首条事件被当串话丢弃)。 + if (pendingNewConvConfirm && uc) { + activeConversationId.value = uc + persistActiveConv(uc) + pendingNewConvConfirm = false + } else 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] + for (let i = messages.value.length - 1; i >= 0; i--) { + if (messages.value[i].role === 'user') { + lastUser = messages.value[i] break } } if (!(lastUser && lastUser.content === event.message)) { - messages.push({ + messages.value.push({ id: genId('user'), role: 'user', content: event.message, timestamp: Date.now(), - }) + }); triggerRef(messages) } return } @@ -385,7 +544,7 @@ export function handleEvent(event: AiChatEvent): void { if (event.type === 'AiMessageHistory') { const histConv = event.conversation_id const isActive = histConv === activeConversationId.value - // MINIAPP-02:竞态兜底。用户快速 A→B→A 或切后 newConversation() 置 null, + // 竞态兜底。用户快速 A→B→A 或切后 newConversation() 置 null, // 会使 activeConversationId 在历史回流前已变 → is_active=false → 49 条历史静默丢弃。 // 优先按 active 写入;其次若与最近发起的 load 目标相符,仍写入(用户停留该会话时 // 不应丢);都不符(确实已切走)才丢,并 console.warn 明确原因(非静默)。 @@ -396,8 +555,37 @@ export function handleEvent(event: AiChatEvent): void { `[useAiChat] 历史回流 conv=${histConv} active=${activeConversationId.value} 已切走,但与最近 load 目标相符,仍写入(竞态兜底)`, ) } - // splice 替换(messages 是 reactive 数组,保留响应性) - messages.splice(0, messages.length, ...event.messages) + // 替换数组(整体赋值触发 ref 变更,mp-weixin splice 不可靠)。 + // 对齐桌面端 useAiConversations.ts:132-143:过滤 role='tool' 消息。 + // 根因:后端把工具执行结果持久化为独立 tool 消息(record_to_message 还原),历史回流 + // 时 miniapp 此前不过滤 → tool JSON 被当普通消息渲染(isToolResultJson 折叠)成独立 + // 气泡刷屏(截图实证)。桌面端视图移除 tool 消息 + 结果回填工具卡,miniapp 对齐。 + // miniapp ChatMessage 类型无 'tool' role(events.ts 仅 user/assistant/system),故用 + // 宽松类型读原始字段(运行时 role 字符串可能为 'tool')。 + const rawMsgs = event.messages as unknown as Array<{ + role?: string + content?: string + tool_call_id?: string + toolCalls?: AiToolCallInfo[] + }> + // tool_call_id → tool_result 映射,回填对应工具卡 result(历史回流工具结果不丢) + const toolResultMap = new Map() + for (const m of rawMsgs) { + if (m.role === 'tool' && m.tool_call_id) { + toolResultMap.set(m.tool_call_id, m.content || '') + } + } + const filtered = rawMsgs.filter((m) => m.role !== 'tool') + for (const m of filtered) { + if (m.toolCalls) { + for (const tc of m.toolCalls) { + const r = toolResultMap.get(tc.id) + if (r !== undefined && tc.status === 'completed') tc.result = r + } + } + } + messages.value = filtered as ChatMessage[] + loadingMessages.value = false } else { console.warn( `[useAiChat] 历史 conv=${histConv} 因会话已切换被丢弃(active=${activeConversationId.value} lastLoaded=${lastLoadedConvId.value}) count=${event.messages.length}`, @@ -415,7 +603,15 @@ export function handleEvent(event: AiChatEvent): void { // 路由:非当前会话事件忽略(miniapp MVP 单会话视图,不追踪多 conv 生成态) const isCurrent = !convId || convId === activeConversationId.value - if (!isCurrent) return + if (!isCurrent) { + // 多会话并行隔离(2026-08-05 BUG-260805-02):非当前会话事件**不污染当前视图**。 + // 桌面端并行在其他会话发消息时,仅刷新会话列表(让用户看到该会话有更新, + // 切过去 load_messages 拉完整历史)。限每轮首帧/终态刷新(AiTextDelta 高频跳过)。 + if (event.type === 'AiAgentRound' || event.type === 'AiCompleted') { + refreshConversations() + } + return + } // 收到当前会话活跃事件 = device 在响应,重置看门狗(重新计 130s)。 // 终态/挂起事件在各自 case 内 clearWatchdog 覆盖此 reset。 @@ -426,7 +622,11 @@ export function handleEvent(event: AiChatEvent): void { case 'AiTextDelta': // F9:stopping 期间忽略迟到增量(用户已点停止,device 续推 delta 丢弃,防文本回填) if (stopping) break - currentText.value += event.delta + // 优雅累积(P0-1 走查):每 delta `currentText.value += delta` 是 O(n²)(重建整串)。 + // 改增量缓冲:delta append 到 _deltaBuf(O(1)),30ms flush 一次合并进 currentText。 + // 渲染端已 200ms 节流(chat useThrottledRef),缓冲只降接收端拼接成本,语义不变。 + _deltaBuf += event.delta + scheduleDeltaFlush() break case 'AiAgentRound': { @@ -437,12 +637,12 @@ export function handleEvent(event: AiChatEvent): void { currentText.value = '' const roundPlaceholderId = genId('ai') currentAssistantMsgId.value = roundPlaceholderId // F3:记录新一轮目标气泡 - messages.push({ + messages.value.push({ id: roundPlaceholderId, role: 'assistant', content: '', timestamp: Date.now(), - }) + }); triggerRef(messages) if (event.round > 0) agentRound.value = event.round break } @@ -453,18 +653,18 @@ export function handleEvent(event: AiChatEvent): void { case 'AiStreamRetry': { // 重试提示:复用错误气泡或新建 - const last = messages[messages.length - 1] + const last = messages.value[messages.value.length - 1] const text = `重试中(${event.attempt}/${event.max_attempts})...` if (last && last.isError) { last.content = text } else { - messages.push({ + messages.value.push({ id: genId('retry'), role: 'assistant', content: text, isError: true, timestamp: Date.now(), - }) + }); triggerRef(messages) } break } @@ -472,12 +672,12 @@ export function handleEvent(event: AiChatEvent): void { case 'AiMaxRoundsReached': clearWatchdog() // 达 max 挂起,停看门狗 maxRoundsActive.value = true // P1-E:chat 渲染「继续/停止循环」按钮(替代旧「回桌面处理」) - messages.push({ + messages.value.push({ id: genId('maxrounds'), role: 'system', content: '⚠ 已达最大轮次,可选择继续或停止', timestamp: Date.now(), - }) + }); triggerRef(messages) break case 'AiToolCallStarted': { @@ -488,10 +688,11 @@ export function handleEvent(event: AiChatEvent): void { status: 'running', } if (!findToolCall(event.id)) { - const last = messages[messages.length - 1] + const last = messages.value[messages.value.length - 1] if (last && last.role === 'assistant') { last.toolCalls = last.toolCalls || [] last.toolCalls.push(info) + triggerRef(messages) // P1-5:嵌套 toolCalls 变更不触发 length watch,手动 triggerRef 刷新工具卡 } } break @@ -500,12 +701,15 @@ export function handleEvent(event: AiChatEvent): void { case 'AiToolCallCompleted': { const tc = findToolCall(event.id) if (tc) { - tc.status = 'completed' + // P0-1:failed 态判定(对齐桌面 isToolFailure)。旧实现无条件置 completed, + // 失败工具(exit_code≠0 / 文本前缀)显示绿色「已完成」误导。 + tc.status = isToolFailure(event.result) ? 'failed' : 'completed' tc.result = event.result + triggerRef(messages) // P1-5:tc.status/result 嵌套变更,手动 triggerRef 刷新工具卡状态 } // 移除对应挂起审批(若有) - const idx = pendingApprovals.findIndex((p) => p.id === event.id) - if (idx >= 0) pendingApprovals.splice(idx, 1) + const idx = pendingApprovals.value.findIndex((p) => p.id === event.id) + if (idx >= 0) pendingApprovals.value.splice(idx, 1) // 审批计时器:工具完成 → 清(审批已生效) clearApprovalTimer(event.id) // P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填) @@ -531,8 +735,8 @@ export function handleEvent(event: AiChatEvent): void { diff: event.diff ?? undefined, } // req3 去重:sync_pending 重发 + 实时事件竞态可能同 id 重复,面板只保留一条 - if (!pendingApprovals.some((p) => p.id === info.id)) { - pendingApprovals.push(info) + if (!pendingApprovals.value.some((p) => p.id === info.id)) { + pendingApprovals.value.push(info) } clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启) startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底 @@ -541,6 +745,7 @@ export function handleEvent(event: AiChatEvent): void { tc.status = 'pending_approval' tc.reason = event.reason if (event.diff) tc.diff = event.diff + triggerRef(messages) // P1-5:审批态嵌套变更,刷新工具卡状态 } break } @@ -550,8 +755,8 @@ export function handleEvent(event: AiChatEvent): void { 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) + const idx = pendingApprovals.value.findIndex((p) => p.id === event.id) + if (idx >= 0) pendingApprovals.value.splice(idx, 1) clearApprovalTimer(event.id) // 审批结果落定 → 清计时器 // P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填) if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) { @@ -575,8 +780,8 @@ export function handleEvent(event: AiChatEvent): void { path: event.path, } // req3 去重:同 AiApprovalRequired(sync_pending 重发 + 实时事件竞态防重复) - if (!pendingApprovals.some((p) => p.id === authInfo.id)) { - pendingApprovals.push(authInfo) + if (!pendingApprovals.value.some((p) => p.id === authInfo.id)) { + pendingApprovals.value.push(authInfo) } // 对齐 AiApprovalRequired:同步更新消息内工具卡片为 pending_approval + path 授权信息, // 使 chat 页消息内联渲染 once/always/deny 按钮(单一渲染源,避免消息 tc 卡 running 态)。 @@ -586,6 +791,7 @@ export function handleEvent(event: AiChatEvent): void { authTc.kind = 'path' authTc.dir = event.dir authTc.path = event.path + triggerRef(messages) // P1-5:路径授权态嵌套变更,刷新工具卡 } break } @@ -597,23 +803,26 @@ export function handleEvent(event: AiChatEvent): void { generating.value = false agentRound.value = 0 clearWatchdog() // 终态:停看门狗 + clearRetryBubbles() // 清「重试中」过渡气泡(成功后残留根治) tokenUsage.value = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens, + // token 分项(2026-08-02):cache 命中(低价)/未命中(全价)/思考 cache_hit: event.prompt_cache_hit_tokens, cache_miss: event.prompt_cache_miss_tokens, reasoning: event.reasoning_tokens, } // 不完整标记(网络中断保文) if (event.incomplete) { - messages.push({ + messages.value.push({ id: genId('incomplete'), role: 'assistant', content: '⚠ 响应因网络中断不完整', timestamp: Date.now(), - }) + }); triggerRef(messages) } + drainQueue() // 发送队列:当前轮结束,自动发下一条待发消息 break } @@ -623,22 +832,25 @@ export function handleEvent(event: AiChatEvent): void { currentText.value = '' generating.value = false agentRound.value = 0 + loadingMessages.value = false clearWatchdog() // 终态:停看门狗 - pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers() + clearRetryBubbles() // 清「重试中」过渡气泡(AiError 终态一并清) + pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers() // P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被 // 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。 if (lastApprovedTc.value) { - pendingApprovals.push(lastApprovedTc.value) + pendingApprovals.value.push(lastApprovedTc.value) uni.showToast({ title: '审批操作未生效,请重试', icon: 'none' }) lastApprovedTc.value = null } - messages.push({ + messages.value.push({ id: genId('err'), role: 'assistant', content: friendlyError(event.error), isError: true, timestamp: Date.now(), - }) + }); triggerRef(messages) + drainQueue() // 发送队列:错误终态也续 drain(下一队列项仍尝试,不静默丢) break } @@ -648,12 +860,12 @@ export function handleEvent(event: AiChatEvent): void { flushCurrentText() currentText.value = '' generating.value = false - messages.push({ + messages.value.push({ id: genId('help'), role: 'system', content: `🆘 AI 求助:${event.reason}\n已试:${event.context}\n建议:${event.options.join(' / ')}(请在桌面端选择)`, timestamp: Date.now(), - }) + }); triggerRef(messages) break case 'AiContextCleared': @@ -666,12 +878,12 @@ export function handleEvent(event: AiChatEvent): void { case 'AiManualCompressed': case 'AiAutoCompressed': // 压缩完成:在消息首位插入摘要提示 - messages.unshift({ + messages.value.unshift({ id: genId('compressed'), role: 'system', content: `📋 上下文已压缩(摘要:${event.summary.slice(0, 100)}...)`, timestamp: Date.now(), - }) + }); triggerRef(messages) break case 'AiConvStateChanged': @@ -756,60 +968,78 @@ function subscribeWs(): void { * F-#95 联想扩展:skill(`/` 选中后透传,后端注入对应 SKILL.md 全文到 prompt)+ * spans(`@项目/@任务/@灵感` 选中后透传 mention_spans,后端 resolve 投影成 Augmentation)。 * 二者均可选,空/undefined=普通对话零回归。 + * 每会话独立模型:modelOverride 缺省时用 activeModel.value(选择器选中值), + * 经 model_override 透传后端写 per_conv(会话记住自己的模型);均空则 None 走默认路由。 */ -function send(text: string, skill?: string | null, spans?: MentionSpan[] | null): void { +function send(text: string, skill?: string | null, spans?: MentionSpan[] | null, modelOverride?: string | null, parts?: MiniContentPart[] | null): void { + const effModel = modelOverride ?? activeModel.value ?? null const trimmed = text.trim() - console.log('[dbg:send] enter trimmed=', JSON.stringify(trimmed), 'gen=', generating.value) - if (!trimmed) return + const hasParts = !!parts && parts.length > 0 + if (DEBUG) console.log('[dbg:send] enter trimmed=', JSON.stringify(trimmed), 'gen=', generating.value) + if (!trimmed && !hasParts) return if (generating.value) { - console.warn('[useAiChat] 生成中,忽略新发送') + // 发送消息队列(2026-08-05):生成中入队,当前轮终态后自动发送(替代旧「生成中忽略」丢消息)。 + // 队列项保留完整参数(文本/技能/@引用/图片),drain 时原样透传零丢失。 + pendingQueue.value.push({ text: trimmed, skill: skill ?? null, spans: spans ?? null, parts: parts ?? null }) + uni.showToast({ title: '已加入发送队列', icon: 'none' }) return } stopping = false // F9:新 generation 清停止标志(上次 stop 后重启) maxRoundsActive.value = false // P1-E:新 generation 清 MaxRounds 挂起 // 本地先 push user 气泡(乐观渲染,对齐桌面 sendMessage) - messages.push({ + messages.value.push({ id: genId('user'), role: 'user', content: trimmed, timestamp: Date.now(), - }) + }); triggerRef(messages) // 新建 assistant 占位气泡(等待流式增量填充);记录 id 供 flushCurrentText 精确回填(F3) const aiPlaceholderId = genId('ai') currentAssistantMsgId.value = aiPlaceholderId - messages.push({ + messages.value.push({ id: aiPlaceholderId, role: 'assistant', content: '', timestamp: Date.now(), - }) + }); triggerRef(messages) currentText.value = '' generating.value = true + // 跨端新会话语义(2026-08-05):active 为 null(新建会话后)发送 → new_conversation=true, + // device 强制创建新 conv(不走 active 兜底防串旧会话);并设 pendingNewConvConfirm 等待 + // device 广播 AiUserMessage(带真实新 conv id)回灌更新 activeConversationId。 + const isNewConv = activeConversationId.value === null + if (isNewConv) pendingNewConvConfirm = true + const cmd: MiniCommand = { cmd: 'send_message', args: { message: trimmed, conversation_id: activeConversationId.value, + ...(isNewConv ? { new_conversation: true } : {}), // skill/spans 仅在有值时透传(对齐 ai_chat_send Option 参数,None 走默认路径) ...(skill ? { skill } : {}), ...(spans && spans.length > 0 ? { mention_spans: spans } : {}), + // 图片输入:有 parts(ContentPart Image base64)才透传,None 走纯文本零回归 + ...(parts && parts.length > 0 ? { parts } : {}), + // 每会话独立模型:有选择则透传 model_override(后端持久读 per_conv,会话记住自己的模型) + ...(effModel ? { model_override: effModel } : {}), }, } const _sent = wsClient.send(cmd) - console.log('[dbg:send] wsClient.send 返回=', _sent) + if (DEBUG) console.log('[dbg:send] wsClient.send 返回=', _sent) if (!_sent) { // 发送失败:在 assistant 占位气泡显错误 flushCurrentText() - messages.push({ + messages.value.push({ id: genId('err'), role: 'assistant', content: '未连接桌面端,请检查网络/配置', isError: true, timestamp: Date.now(), - }) + }); triggerRef(messages) generating.value = false } else { // 发送成功:启动流式看门狗(活跃事件 reset,终态/挂起/断开 clear,超时 reset generating 防卡死) @@ -817,6 +1047,21 @@ function send(text: string, skill?: string | null, spans?: MentionSpan[] | null) } } +/** 发送队列 drain(2026-08-05):当前轮终态(AiCompleted/AiError)后调用,取下一条待发消息自动发送。 + * generating 兜底守卫防终态后误 drain;队列空则 no-op。对齐桌面端 drainQueue 语义,单会话 MVP 简化。 */ +function drainQueue(): void { + if (generating.value) return + const next = pendingQueue.value.shift() + if (next) { + send(next.text, next.skill, next.spans, undefined, next.parts) + } +} + +/** 清发送队列(切会话/新会话时调,防队列消息发错会话)。 */ +function clearQueue(): void { + pendingQueue.value.splice(0, pendingQueue.value.length) +} + /** 停止生成(relay → device invoke ai_chat_stop) */ function stop(): void { if (!generating.value) return @@ -857,17 +1102,17 @@ function regenerate(): void { } // 找最后一条 user 消息 let lastUserText = '' - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'user') { - lastUserText = messages[i].content + for (let i = messages.value.length - 1; i >= 0; i--) { + if (messages.value[i].role === 'user') { + lastUserText = messages.value[i].content break } } if (!lastUserText) return // 移除末尾 assistant 占位/错误气泡(若仍是空的) - const last = messages[messages.length - 1] + const last = messages.value[messages.value.length - 1] if (last && last.role === 'assistant' && !last.content) { - messages.pop() + messages.value.pop(); triggerRef(messages) } send(lastUserText) } @@ -894,11 +1139,11 @@ function approve(toolCallId: string, approved: boolean): void { } // 发送成功才乐观移除挂起审批(对齐 AiApprovalResult 事件处理,避免 UI 卡 pending)。 // 旧实现无条件乐观移除:send 失败时用户已批但命令未发,挂起条目消失误导用户以为已批。 - const idx = pendingApprovals.findIndex((p) => p.id === toolCallId) + const idx = pendingApprovals.value.findIndex((p) => p.id === toolCallId) if (idx >= 0) { // P2-E:批准(approved=true)记录,device 返 AiError 时回填让用户重试(执行失败/已被桌面端处理) - if (approved) lastApprovedTc.value = pendingApprovals[idx] - pendingApprovals.splice(idx, 1) + if (approved) lastApprovedTc.value = pendingApprovals.value[idx] + pendingApprovals.value.splice(idx, 1) } } @@ -967,25 +1212,34 @@ function stopLoop(conversationId?: string | null): void { /** 切换会话(switch Command → device 切 active_conversation_id) */ function switchConversation(convId: string): void { activeConversationId.value = convId + pendingNewConvConfirm = false // 切到已有会话,后续发送为普通发送(不带 new_conversation) + clearQueue() // 发送队列:切会话清队列(防旧会话待发消息发到新会话) // P1-8:持久化激活会话,冷启动据此恢复。 persistActiveConv(convId) - // MINIAPP-02:记录本次 load 目标,AiMessageHistory 回流时除 active 外还比对它, + // 记录本次 load 目标,AiMessageHistory 回流时除 active 外还比对它, // 防止用户快速切会话致 active 在回流前已变 → 历史静默丢弃。置位须早于 load_messages 发送。 lastLoadedConvId.value = convId // 清空当前视图,等 AiMessageHistory 事件回流替换(对齐列表链路异步语义)。 - messages.splice(0, messages.length) + messages.value.splice(0, messages.value.length); triggerRef(messages) + // P0-1 优雅化:清 delta 缓冲(防旧会话残帧污染新会话视图)。终态路径走 flushCurrentText + // 已 flush,这里直接清空 + 停 flush timer。 + if (_deltaFlushTimer) { clearTimeout(_deltaFlushTimer); _deltaFlushTimer = null } + _deltaBuf = '' 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); clearAllApprovalTimers() + tokenUsage.value = null // 切会话清 token(防顶栏显示上一会话用量,对齐桌面切会话语义) + activeModel.value = null // 切会话复位本地模型选择,让后端 per_conv 持久值生效(每会话独立) + // 清残留挂起状态,防旧会话审批卡/超时回调污染新会话视图(对齐 newConversation)。 + pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers() clearWatchdog() // F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。 // 不发 switch_conversation(后端 §2.2 决策 a 忽略,无 Tauri command 对应)。 + loadingMessages.value = true const cmd: MiniCommand = { cmd: 'load_messages', args: { conversation_id: convId }, @@ -1005,13 +1259,20 @@ function switchConversation(convId: string): void { * * 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流 * (非 request-reply 同步返回,对齐 relay 纯透传单向语义)。 + * 发送失败时自动复位 loadingConversations,防 loading 卡死。 */ function refreshConversations(): boolean { + loadingConversations.value = true const cmd: MiniCommand = { cmd: 'list_conversations', args: {}, } - return wsClient.send(cmd) + const sent = wsClient.send(cmd) + if (!sent) { + loadingConversations.value = false + console.warn('[useAiChat] refreshConversations 发送失败') + } + return sent } /** @@ -1053,22 +1314,59 @@ function listEntities(): boolean { return wsClient.send(cmd) } +/** + * 拉取模型列表(每会话独立模型)。 + * + * 发 `list_models` Command → device route_list_models 读活跃 provider enabled 模型 → + * 推回 `AiModelList` 事件(handleEvent 替换 models + defaultModel + 置 deviceOnline)。 + * + * 调用时机:ws 握手成功后(ws.ts handleHandshakeResponse 已发 list_conversations, + * chat 页顶栏模型选择器挂载后调用一次)。 + * + * 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流。 + */ +function listModels(): boolean { + const cmd: MiniCommand = { + cmd: 'list_models', + args: {}, + } + return wsClient.send(cmd) +} + +/** + * 设定当前会话模型(每会话独立)。 + * + * 本地置 activeModel(chat 页顶栏选择器显示当前模型名),send() 时经 model_override 透传 + * 后端,后端写 per_conv 持久(会话记住自己的模型)。切会话时 activeModel 复位 null, + * 让后端 per_conv 持久值生效(per-conv 隔离,切模型不干扰其他进行中会话)。 + */ +function setActiveModel(modelId: string | null): void { + activeModel.value = modelId +} + /** 新建会话(本地清空 + 切到 null,下次 send 时 device 创建新 conv) */ function newConversation(): void { activeConversationId.value = null + pendingNewConvConfirm = false // 新会话开始,清上一轮未确认的 new_conversation 标记 + clearQueue() // 发送队列:切新会话清队列(防旧会话待发消息发到新会话) // P1-8:清持久化(新建会话,冷启动不再恢复到旧会话)。 persistActiveConv(null) - // MINIAPP-02:作废最近 load 目标,防切换前已发的 load_messages 历史回流时 + // 作废最近 load 目标,防切换前已发的 load_messages 历史回流时 // 命中宽松兜底(isLastLoaded)污染本应空白的新会话视图。 lastLoadedConvId.value = null - messages.splice(0, messages.length) + messages.value.splice(0, messages.value.length); triggerRef(messages) + // P0-1 优雅化:清 delta 缓冲(同 switchConversation) + if (_deltaFlushTimer) { clearTimeout(_deltaFlushTimer); _deltaFlushTimer = null } + _deltaBuf = '' 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); clearAllApprovalTimers() + tokenUsage.value = null // 新会话清 token(对齐 switchConversation) + activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由) + pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers() } /** @@ -1085,12 +1383,15 @@ function newConversation(): void { * 无 activeConversationId(新建会话/未选会话)则跳过(无目标 conv 可同步)。 */ function syncOnConnect(): void { + // 连接成功即拉模型列表(全局事件,无 convId 依赖;顶栏模型选择器数据源) + wsClient.send({ cmd: 'list_models', args: {} }) const convId = activeConversationId.value if (!convId) return // req2:拉完整历史 + loadingMessages.value = true wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } }) // req3:清本地 pending + 发 sync_pending 重推权威审批状态 - pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers() + pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers() wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } }) } @@ -1103,7 +1404,7 @@ function renameConversation(convId: string, title: string): boolean { const trimmed = title.trim() if (!trimmed) return false // 乐观本地更新(即时反馈,列表事件回流确认) - const conv = conversations.find((c) => c.id === convId) + const conv = conversations.value.find((c) => c.id === convId) if (conv) conv.title = trimmed const cmd: MiniCommand = { cmd: 'rename_conversation', @@ -1129,6 +1430,9 @@ export function useAiChat() { generating, agentRound, maxRoundsActive, + loadingMessages, + loadingConversations, + loading, conversations, activeConversationId, wsStatus, @@ -1140,6 +1444,10 @@ export function useAiChat() { // F-#95 联想扩展:技能/实体列表(`/` 与 `@项目/@任务/@灵感`) skills, entities, + // 每会话独立模型:可选模型列表 + 当前会话选中模型 + 默认模型 + models, + activeModel, + defaultModel, // 方法 connect: () => wsClient.connect(), disconnect: () => wsClient.disconnect(), @@ -1152,6 +1460,8 @@ export function useAiChat() { refreshConversations, listSkills, listEntities, + listModels, + setActiveModel, newConversation, approve, authorizeDir, @@ -1171,4 +1481,4 @@ export function useAiChat() { if (generating.value && !streamTimer) resetWatchdog() }, } -} +} \ No newline at end of file diff --git a/apps/df-miniapp/src/pages.json b/apps/df-miniapp/src/pages.json index 33ec5cc..b0ce0d6 100644 --- a/apps/df-miniapp/src/pages.json +++ b/apps/df-miniapp/src/pages.json @@ -13,6 +13,12 @@ "navigationBarTitleText": "会话列表", "enablePullDownRefresh": true } + }, + { + "path": "pages/settings/index", + "style": { + "navigationBarTitleText": "设置" + } } ], "globalStyle": { @@ -35,6 +41,10 @@ { "pagePath": "pages/conversations/index", "text": "会话" + }, + { + "pagePath": "pages/settings/index", + "text": "设置" } ] } diff --git a/apps/df-miniapp/src/pages/chat/index.vue b/apps/df-miniapp/src/pages/chat/index.vue index 295269a..3e78727 100644 --- a/apps/df-miniapp/src/pages/chat/index.vue +++ b/apps/df-miniapp/src/pages/chat/index.vue @@ -3,14 +3,19 @@ import { computed, ref, watch } from 'vue' import { marked } from 'marked' import { styleMarkdown } from '@/utils/mdRenderer' import { useAiChat } from '@/composables/useAiChat' -import type { MentionSpan } from '@/types/relay' -import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo } from '@/types/events' +import type { MentionSpan, MiniContentPart } from '@/types/relay' +import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events' // 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found, // inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。 -// MdView/MentionInput 源保留备用(工具修复后可拆回)。 -/** md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞) */ +// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。 +/** + * md→html 渲染 + 缓存(避免 v-for 每次 render 重算 marked.parse 同步阻塞)。 + * mdCache 上限 300(P0-2 走查):tabBar 页常驻,无上限会随长会话/切会话 HTML 累积 + * (旧会话 trimmed 后 HTML 仍留内存),加 FIFO 淘汰防内存泄漏。 + */ const mdCache = new Map() +const MD_CACHE_LIMIT = 300 function renderMd(src: string): string { if (!src) return '' const cached = mdCache.get(src) @@ -22,13 +27,202 @@ function renderMd(src: string): string { } catch { html = src } + if (mdCache.size >= MD_CACHE_LIMIT) { + mdCache.delete(mdCache.keys().next().value!) + } mdCache.set(src, html) return html } +// ──────────────────────────────────────────────────────────────── +// 流式 Markdown 块级 memo 渲染(对齐桌面 useStreamRenderer.ts)。 +// +// 核心设计(单缓存,存"最终 styled html"): +// - splitStreamBlocks:marked.lexer 围栏感知切块(code token 整段,非 code 按 \n\n 切段) +// - 单一缓存 Map:已完成块命中即返回(parse+style 都缓存了), +// 末块每次重算 → 每 delta 成本 O(末块),而非 O(全文) +// - 未闭合代码围栏末块降级转义(流式写入中不渲染半截代码) +// +// 与桌面端差异:rich-text 只能整体替换 nodes(非 v-html 分块),故"分块 DOM 稳定"价值 +// 不存在,这里缓存的是**渲染结果**而非中间态,减少重复 parse+style 即可。 +// ──────────────────────────────────────────────────────────────── +/** 单一缓存:块文本 → 最终 styled html。已完成块命中,末块重算。上限 300,超限删最旧 */ +const streamBlockCache = new Map() +const STREAM_BLOCK_CACHE_LIMIT = 300 + +/** HTML 转义 + 换行转
(miniapp 无现成实现;未闭合围栏降级用) */ +function escapeFallback(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\n/g, '
') +} + +/** 切块:marked.lexer() 围栏感知(code token 整段一块,非 code 按 \n\n 切段) */ +function splitStreamBlocks(text: string): string[] { + if (!text) return [] + const blocks: string[] = [] + const tokens = marked.lexer(text) + for (const tok of tokens) { + if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue + const raw = (tok as { raw: string }).raw + if ((tok as { type: string }).type === 'code') { + if (raw.trim()) blocks.push(raw) + continue + } + for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b) + } + return blocks.length ? blocks : [text] +} + +/** 末块未闭合代码围栏检测:行首 ≤3 空格 + 3+ 反引号/波浪计数,奇数=未闭合 */ +function isUnclosedCodeFence(block: string): boolean { + let fenceCount = 0 + for (const line of block.split('\n')) { + if (/^[ ]{0,3}(`{3,}|~{3,})/.exec(line)) fenceCount++ + } + return fenceCount % 2 === 1 +} + +/** 单块 → styled html(parse + styleMarkdown),未闭合围栏降级转义 */ +function parseStreamBlock(block: string): string { + if (isUnclosedCodeFence(block)) return escapeFallback(block) + try { + return styleMarkdown(marked.parse(block, { breaks: true, async: false }) as string) + } catch { + return escapeFallback(block) + } +} + +/** + * 流式渲染入口:切块 → 单缓存命中/写入 → 拼接。 + * 已完成块命中缓存(parse+style 都缓存了),末块重算,每 delta O(末块)。 + */ +function renderStreamingMd(src: string): string { + if (!src) return '' + const blocks = splitStreamBlocks(src) + const n = blocks.length + const parts: string[] = [] + for (let i = 0; i < n; i++) { + const b = blocks[i] + if (i === n - 1) { + // 末块:重算(内容在变),不缓存 + parts.push(parseStreamBlock(b)) + continue + } + // 已完成块:缓存命中直接取,未命中算并写 + const cached = streamBlockCache.get(b) + if (cached !== undefined) { + parts.push(cached) + } else { + const styled = parseStreamBlock(b) + if (streamBlockCache.size >= STREAM_BLOCK_CACHE_LIMIT) { + streamBlockCache.delete(streamBlockCache.keys().next().value!) + } + streamBlockCache.set(b, styled) + parts.push(styled) + } + } + return parts.join('') +} + const ai = useAiChat() const inputText = ref('') +// Ai.messages 是 ref,用计数器 key 强制模板刷新(mp-weixin 数组 computed 追踪不可靠) +const messagesKey = ref(0) +const messages = computed(() => { + messagesKey.value // 依赖计数器 + return ai.messages.value +}) +// 每次 messages 长度变化,计数器递增 +watch(() => ai.messages.value.length, () => { messagesKey.value++ }, { immediate: true }) + +// —— 计算属性:解包 useAiChat 的 ref 供模板使用 —— +const pendingApprovals = computed(() => ai.pendingApprovals.value) +const skills = computed(() => ai.skills.value) +const entities = computed(() => ai.entities.value) +const tokenUsage = computed(() => ai.tokenUsage.value) +const maxRoundsActive = computed(() => ai.maxRoundsActive.value) +const generating = computed(() => ai.generating.value) +const currentText = computed(() => ai.currentText.value) +const deviceOnline = computed(() => ai.deviceOnline.value) +const wsStatus = computed(() => ai.wsStatus.value) +const isWsConnected = computed(() => ai.isWsConnected.value) +const loading = computed(() => ai.loading.value) + +/** + * 节流 ref composable(优雅化):源变化 → 节流窗口合并,输出节流后值。 + * 首帧立即(低延迟),窗口内合并,窗口末取最新。返回 { throttled, cancel, flush }。 + * + * 用途:流式 currentText 高频 delta → 节流渲染源,降低 rich-text 重建频率。 + * 根因(用户报"桌面端已答完,小程序端还一个字一个字蹦"):JS 单线程,每 delta 全量 + * lexer+style+rich-text 重建打爆主线程 → onMessage 接收排队(假"传输慢")。 + * 节流合并后 rich-text 重建降频,主线程释放,接收跟上 → 流式顺滑。 + */ +function useThrottledRef(source: () => T, ms: number) { + const throttled = ref() + let timer: ReturnType | null = null + watch(source, (v) => { + if (timer) return + throttled.value = v // 首帧立即 + timer = setTimeout(() => { + timer = null + throttled.value = source() // 窗口末取最新 + }, ms) + }) + return { + throttled, + cancel: () => { + if (timer) { clearTimeout(timer); timer = null } + throttled.value = undefined + }, + flush: () => { + if (timer) { clearTimeout(timer); timer = null } + throttled.value = source() + }, + } +} + +// 流式渲染节流:currentText → 200ms 节流合并(rich-text 重建降频,主线程释放) +const { throttled: streamRenderText, cancel: cancelStreamThrottle } = useThrottledRef( + () => currentText.value, 200, +) +// 生成结束:清节流(完成态走 renderMd 完整渲染,无需流式块缓存) +watch(() => generating.value, (g) => { if (!g) cancelStreamThrottle() }) +// 每会话独立模型:可选模型列表 + 当前选中 + 默认(顶栏模型选择器) +const models = computed(() => ai.models.value) +const activeModel = computed(() => ai.activeModel.value) + +/** 当前模型展示名(label 优先,缺省 model_id;未选显默认模型) */ +const currentModelLabel = computed(() => { + const id = activeModel.value + if (!id) return '默认模型' + const m = models.value.find((m) => m.model_id === id) + return m?.label || id +}) + +/** 点击模型选择器:弹 ActionSheet 选模型。选中置 activeModel,send 时透传 model_override */ +function onModelSelect(): void { + if (models.value.length === 0) { + // 未拉到模型列表:重拉一次(可能 device 未在线/未响应) + ai.listModels() + uni.showToast({ title: '暂无模型列表,重试中…', icon: 'none' }) + return + } + const itemList = models.value.map((m) => m.label || m.model_id) + uni.showActionSheet({ + itemList, + success: (r) => { + const picked = models.value[r.tapIndex] + if (picked) ai.setActiveModel(picked.model_id) + }, + }) +} + // —— 联想弹层状态(技能 / 与 @ 实体复用同一浮层结构,detectTrigger 决定 trigger) —— // 弹层可见 + 当前触发类型。trigger='skill' = `/` 开头技能联想;trigger='entity' = 末尾 `@` 实体联想。 const mentionVisible = ref(false) @@ -45,6 +239,45 @@ const pendingSkill = ref(null) // 用户可连续 @ 多个实体,发送时整体透传,后端 resolve 投影成 Augmentation 注入。 const pendingMentionSpans = ref([]) +// —— 图片输入(2026-08-05):选图转 base64,发送时透传 ai.send parts 参数 —— +// 存临时文件路径(缩略图预览) + ContentPart(发送),对齐后端 df-ai-core ContentPart serde externally tagged。 +interface PendingImage { + tempPath: string + part: MiniContentPart +} +const pendingImages = ref([]) + +/** 选图:uni.chooseMedia(compressed 压缩小图)→ base64 → 入 pendingImages。 */ +function onPickImage(): void { + uni.chooseMedia({ + count: 1, + mediaType: ['image'], + sizeType: ['compressed'], + success: (res) => { + const file = res.tempFiles && res.tempFiles[0] + if (!file) return + uni.getFileSystemManager().readFile({ + filePath: file.tempFilePath, + encoding: 'base64', + success: (r) => { + const b64 = r.data as string + if (!b64) return + pendingImages.value.push({ + tempPath: file.tempFilePath, + part: { Image: { base64: b64, media_type: file.fileType || 'image/jpeg' } }, + }) + }, + fail: () => uni.showToast({ title: '图片读取失败', icon: 'none' }), + }) + }, + }) +} + +/** 移除已选图片(预览 × 删除)。 */ +function removeImage(idx: number): void { + pendingImages.value.splice(idx, 1) +} + // —— P1-12 自动滚底 + P1-7 生成中关联想 + P1-13 渲染上限 —— /** scroll-into-view 锚点(同值不重滚,故 toggle ''→id 触发) */ const scrollAnchor = ref('') @@ -63,16 +296,39 @@ function throttledScrollToBottom(): void { }, 120) } // 新消息 push / 切会话历史回流 → 立即滚底;流式增量 → throttled 滚底 -watch(() => ai.messages.length, () => scrollToBottom()) -watch(() => ai.currentText.value, throttledScrollToBottom) +watch(() => messages.value.length, () => scrollToBottom()) +watch(() => currentText.value, throttledScrollToBottom) // P1-7:生成中关闭联想弹层,防 input disabled 时仍点插入(input disabled 不阻止弹层点击) -watch(() => ai.generating.value, (g) => { if (g) mentionVisible.value = false }) +watch(() => generating.value, (g) => { if (g) mentionVisible.value = false }) /** * P1-13 渲染上限兜底:仅渲染最近 N 条,防长会话(100+)rich-text 全量渲染卡顿/内存膨胀。 * TODO 真虚拟化(滚动入视口才 marked.parse / 窗口化),当前截断旧消息简化兜底。 */ const MAX_RENDER_MSGS = 200 -const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS)) +const visibleMessages = computed(() => messages.value.slice(-MAX_RENDER_MSGS)) + +/** + * 纯空 assistant 占位气泡过滤(对齐桌面 MessageList.shouldRenderMsg:87-96)。 + * + * send()/AiAgentRound 会先推空 assistant 占位(content=''),流式文本在独立气泡渲染。 + * 工具轮(本轮 LLM 无文本,只有工具调用)结束后,这些空气泡残留 → 深灰空白长条刷屏。 + * 过滤规则(与桌面一致): + * - 有 toolCalls → 渲染(工具卡可见) + * - 有 content/isError → 渲染 + * - 纯空中间条 → 不渲染 + * 注:miniapp 无桌面「末条+流式占位」概念(流式文本走独立 currentText 气泡),故不需该分支。 + */ +function shouldRenderMsg(m: ChatMessage): boolean { + if ( + m.role === 'assistant' && + !m.content?.trim() && + !m.isError && + !(m.toolCalls && m.toolCalls.length) + ) { + return false + } + return true +} /** * 输入变化时检测联想触发条件。 @@ -86,17 +342,26 @@ const visibleMessages = computed(() => ai.messages.slice(-MAX_RENDER_MSGS)) * 二者优先级:技能(行首 /)优先于实体(末尾 @),因 `/` 开头整行属技能模式。 * 已选 pendingSkill 时屏蔽 `/` 重复触发(同上,用户在输入参数)。 */ +/** + * 输入变化时检测联想触发条件。 + * + * P2-12 防抖:每按键 detectTrigger 会重复发 listSkills/listEntities,WS 命令+数组 + * splice 换刷每键一次浪费。用 lastMentionFetch 记录最近一次触发的技能/实体拉取, + * 同一触发类型不重复发(浮层数据已拉到,复用即可)。 + */ +let lastMentionFetch: '' | 'skill' | 'entity' = '' function detectTrigger(): void { const text = inputText.value if (!text) { mentionVisible.value = false + lastMentionFetch = '' return } // 技能触发:行首 / 且未选技能。空格后(/file xxx)不再是行首 /,自动关闭浮层。 if (text.startsWith('/') && !pendingSkill.value) { mentionTrigger.value = 'skill' mentionVisible.value = true - ai.listSkills() + if (lastMentionFetch !== 'skill') { lastMentionFetch = 'skill'; ai.listSkills() } return } // 实体触发:末尾连续非空白段恰好单字符 @ @@ -105,10 +370,11 @@ function detectTrigger(): void { if (last === '@') { mentionTrigger.value = 'entity' mentionVisible.value = true - ai.listEntities() + if (lastMentionFetch !== 'entity') { lastMentionFetch = 'entity'; ai.listEntities() } return } mentionVisible.value = false + if (lastMentionFetch !== '') lastMentionFetch = '' } /** 监听输入:mp-weixin textarea 的 @input 在 v-model 后触发,可直接检测(P3-A input→textarea) */ @@ -182,16 +448,17 @@ function onMentionClose(): void { * 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。 */ function handleSend(): void { - console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', ai.generating.value) + console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length) const text = inputText.value.trim() - if (!text) { - console.log('[v2:send] 空文本,忽略') + const hasImage = pendingImages.value.length > 0 + if (!text && !hasImage) { + console.log('[v2:send] 空文本且无图片,忽略') return } - if (ai.generating.value) { + if (generating.value) { // 审批挂起/流式生成中后端拒新 send:提示用户(去审批或等待),避免点了没反应困惑 uni.showToast({ - title: ai.pendingApprovals.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候', + title: pendingApprovals.value.length > 0 ? '有待审批项,请先处理' : '生成中,请稍候', icon: 'none', }) return @@ -199,10 +466,13 @@ function handleSend(): void { mentionVisible.value = false const skill = pendingSkill.value ? pendingSkill.value.name : undefined const spans = pendingMentionSpans.value.length > 0 ? pendingMentionSpans.value : undefined - ai.send(text, skill, spans) + // 图片输入:有已选图则构造 parts(ContentPart Image base64),无则 undefined 走纯文本零回归 + const parts = hasImage ? pendingImages.value.map((p) => p.part) : undefined + ai.send(text, skill, spans, undefined, parts) inputText.value = '' pendingSkill.value = null pendingMentionSpans.value = [] + pendingImages.value = [] } // —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) —— @@ -259,6 +529,23 @@ function formatResult(result: unknown): string { } } +/** + * 解析统一 diff 文本为行数组(P0-2:工具卡 diff 渲染,对齐桌面 parseDiffLines)。 + * + * 写文件审批的 tc.diff 是行级 diff,按行前缀分类着色: + * - `+` 新增(绿) / `-` 删除(红) / `@@` 块头(蓝) / 其余上下文(灰) + * 原生 view 渲染(非 rich-text),滚动/颜色均可用。 + */ +type DiffLine = { type: 'add' | 'del' | 'hdr' | 'ctx'; text: string } +function parseDiffLines(diff: string): DiffLine[] { + return diff.split('\n').map((line) => { + if (line.startsWith('+')) return { type: 'add', text: line } + if (line.startsWith('-')) return { type: 'del', text: line } + if (line.startsWith('@@')) return { type: 'hdr', text: line } + return { type: 'ctx', text: line } + }) +} + /** * 识别后端审批占位 message(对齐 crates/df-ai/context_helpers.rs:498 PENDING_MARKER_PREFIX)。 * @@ -325,13 +612,13 @@ const STATUS_TEXT: Record = { disconnected: '已断开·点此重连', } const statusText = computed(() => { - if (ai.isWsConnected.value) { - return ai.deviceOnline.value ? '已连接桌面端' : '已连接中继' + if (isWsConnected.value) { + return deviceOnline.value ? '已连接桌面端' : '已连接中继' } - return STATUS_TEXT[ai.wsStatus.value] || ai.wsStatus.value + return STATUS_TEXT[wsStatus.value] || wsStatus.value }) const canManualReconnect = computed( - () => ai.wsStatus.value === 'disconnected' || ai.wsStatus.value === 'reconnecting', + () => wsStatus.value === 'disconnected' || wsStatus.value === 'reconnecting', ) function onStatusTap(): void { if (canManualReconnect.value) ai.resumeIfDisconnected() @@ -365,6 +652,19 @@ function onCopyMessage(m: { content?: string }): void { success: () => uni.showToast({ title: '已复制', icon: 'none' }), }) } + +/** + * 输入 token 计算(对齐桌面 tokenInOf,MessageList.vue:116-120)。 + * cache_miss 存在直接用;否则(provider 不报 cache_miss,如 GLM)回退 prompt-cache_hit + * (全价真实输入),两者都无才 fallback prompt。防顶栏 in 为空。 + */ +function tokenInOf(t: TokenUsage): number { + if (t.cache_miss !== undefined && t.cache_miss > 0) return t.cache_miss + if (t.cache_hit !== undefined && t.prompt !== undefined) { + return Math.max(0, t.prompt - t.cache_hit) + } + return t.prompt +}