- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制 - AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时) - AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定) - 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染) - 超时自动拒绝(approve(id,false))+ toast 提示用户 - 5min 阈值符合移动端使用习惯(桌面端 15min)
1172 lines
46 KiB
TypeScript
1172 lines
46 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, skill?, spans?): 发消息(send_message Command → relay → device invoke ai_chat_send)
|
|
* F-#95 联想扩展:skill(`/<skill>` 选中)+ spans(`@项目/@任务/@灵感` mention_spans)可选透传
|
|
* - stop(): 停止生成(stop Command → device invoke ai_chat_stop)
|
|
* - regenerate(): 重发最后一条 user 消息(重新 send_message)
|
|
* - switchConversation(id): 切换会话(switch Command → device 切 active)
|
|
* - listSkills(): 拉技能列表(list_skills Command → AiSkillList 事件回流 skills)
|
|
* - listEntities(): 拉实体列表(list_entities Command → AiEntityList 事件回流 entities)
|
|
* - 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, watch } from 'vue'
|
|
import { wsClient } from '@/api/ws'
|
|
import type { WsStatus } from '@/api/ws'
|
|
import type {
|
|
AiChatEvent,
|
|
ChatMessage,
|
|
Conversation,
|
|
AiToolCallInfo,
|
|
TokenUsage,
|
|
ProjectRecord,
|
|
TaskRecord,
|
|
IdeaRecord,
|
|
} from '@/types/events'
|
|
import type {
|
|
BroadcastMessage,
|
|
MiniCommand,
|
|
ControlMessage,
|
|
SkillInfo,
|
|
MentionSpan,
|
|
} 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>('')
|
|
/**
|
|
* 当前流式累积目标 assistant 气泡 id(F3:精确回填,取代启发式「末尾非 error assistant」)。
|
|
* send/AiAgentRound push 新占位时记录;flushCurrentText 按 id 精确写入,治 retry/error/
|
|
* compressed 气泡穿插致启发式定位错位(新一轮内容写入旧气泡)。找不到则回退启发式兜底。
|
|
*/
|
|
const currentAssistantMsgId = ref<string | null>(null)
|
|
const generating = ref<boolean>(false)
|
|
const agentRound = ref<number>(0)
|
|
/**
|
|
* 达最大轮次挂起标志(P1-E):true 时 chat 渲染「继续/停止循环」按钮(对齐桌面 MaxRounds)。
|
|
* AiMaxRoundsReached 置 true;continueLoop/stopLoop/send/switch/new 清 false。
|
|
*/
|
|
const maxRoundsActive = ref<boolean>(false)
|
|
const conversations = reactive<Conversation[]>([])
|
|
|
|
/** 消息内存上限(P3-A:防长会话 OOM;渲染另有 visibleMessages slice 200 兜底,此为存储层) */
|
|
const MAX_STORE_MESSAGES = 500
|
|
watch(
|
|
() => messages.length,
|
|
(len) => {
|
|
if (len > MAX_STORE_MESSAGES) messages.splice(0, len - MAX_STORE_MESSAGES)
|
|
},
|
|
)
|
|
|
|
/** 冷启动恢复上次激活会话(P1-8)的 storage key */
|
|
const ACTIVE_CONV_STORAGE_KEY = 'df_active_conv_id'
|
|
|
|
/** 读取持久化的激活会话 id(冷启动恢复,模块加载时调一次) */
|
|
function loadPersistedActiveConv(): string | null {
|
|
try {
|
|
const v = uni.getStorageSync(ACTIVE_CONV_STORAGE_KEY)
|
|
return v || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** 持久化激活会话 id(switch/newConversation 时调,冷启动据此恢复) */
|
|
function persistActiveConv(convId: string | null): void {
|
|
try {
|
|
if (convId) uni.setStorageSync(ACTIVE_CONV_STORAGE_KEY, convId)
|
|
else uni.removeStorageSync(ACTIVE_CONV_STORAGE_KEY)
|
|
} catch (e) {
|
|
console.warn('[useAiChat] 持久化 active conv 失败', e)
|
|
}
|
|
}
|
|
|
|
const activeConversationId = ref<string | null>(loadPersistedActiveConv())
|
|
/**
|
|
* 最近发起 load_messages 的目标会话(MINIAPP-02 历史丢失竞态修复)。
|
|
*
|
|
* 问题:switchConversation 同步置 activeConversationId=convId 后发 load_messages,
|
|
* 但 AiMessageHistory 回流判定 `event.conversation_id === activeConversationId.value`。
|
|
* 用户快速切 A→B→A,或切后立即 newConversation() 置 null,致 activeConversationId
|
|
* 在历史回流前已变 → is_active=false → 49 条历史静默丢弃 → 点进会话空白。
|
|
*
|
|
* 修复:用此变量记录最近一次 load 目标,回流时除 active 外还比对它,命中则写入
|
|
* (避免用户停留目标会话时被竞态丢弃);不命中则 console.warn 明确丢弃原因(非静默)。
|
|
*/
|
|
const lastLoadedConvId = ref<string | null>(null)
|
|
const wsStatus = ref<WsStatus>('disconnected')
|
|
const wsStatusDetail = ref<string>('')
|
|
const tokenUsage = ref<TokenUsage | null>(null)
|
|
const pendingApprovals = reactive<AiToolCallInfo[]>([])
|
|
/**
|
|
* 最近批准(approved=true)的工具调用(P2-E 审批失败回填)。
|
|
* approve 成功发出后记录;device 返 AiError(执行失败/已被桌面端处理)时回填 pendingApprovals 让用户重试,
|
|
* 避免 UI 误导「已批准」实际未生效。AiApprovalResult/AiToolCallCompleted 确认 → 清;切会话/新建/断连 → 清。
|
|
*/
|
|
const lastApprovedTc = ref<AiToolCallInfo | null>(null)
|
|
/**
|
|
* `/` 联想技能列表(F-#95 扩展,list_skills 命令 → device route_list_skills → AiSkillList 回流)。
|
|
* 初始空数组,chat/index.vue inputText 以 / 开头时 listSkills() 拉取后填充浮层。
|
|
*/
|
|
const skills = reactive<SkillInfo[]>([])
|
|
/**
|
|
* `@` 联想实体列表(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[] }>({
|
|
projects: [],
|
|
tasks: [],
|
|
ideas: [],
|
|
})
|
|
/**
|
|
* device(桌面端)在线探测(F-#95)。
|
|
*
|
|
* 问题:wsStatus=connected 仅表 relay 握手成功(中继链路通),relay 纯透传不广播 device presence,
|
|
* 故「device 是否在线」无法从 ws 状态直接得 —— device 离线时 miniapp 点发无响应,用户体验差。
|
|
*
|
|
* 探测口径:收到 `AiConversationList` 响应 = device 在线(route_list_conversations 由 device 桥接层发出)。
|
|
* - list_conversations 命令经 ws→relay→device,device 响应 AiConversationList 透传回 miniapp。
|
|
* - 收到即置 true;device 离线/命令未达则永不置 true,文案保持「已连接中继」诚实表述。
|
|
*
|
|
* 注:MVP 不做超时复位(device 离线后不回退 deviceOnline=false),因 device 离线时连 send 超时兜底
|
|
* 已覆盖(15s 后 reset generating + 错误提示),deviceOnline 仅影响状态文案,容许滞后。
|
|
*/
|
|
const deviceOnline = ref<boolean>(false)
|
|
|
|
/**
|
|
* 流式看门狗(miniapp 状态机健壮性,对齐桌面 STREAM_TIMEOUT_MS — memory devflow-generating-statemachine)
|
|
*
|
|
* 治「发送不了」根因:旧 15s send 超时首事件即 clearSendTimeout(:189),首事件到达后中途断
|
|
* (后端 hang/网络断不发 AiCompleted/AiError)无兜底 → generating 永卡 true → 后续 handleSend 被
|
|
* `generating` 守卫拦截 → 点发无响应(消息停留输入框)。
|
|
*
|
|
* 现机制(130s 持续 watchdog,非首事件清除):
|
|
* - send 后 resetWatchdog 启动
|
|
* - 活跃事件(AiTextDelta/AiAgentRound/AiToolCallStarted/Heartbeat/Retry)resetWatchdog 重置 130s
|
|
* - 终态(AiCompleted/AiError)/挂起(Approval/DirAuth/MaxRounds/Help)/断连 clearWatchdog 清除
|
|
* - 超时(130s 无活跃事件)= 中途断 → flush + 错误气泡 + reset generating,用户可重发
|
|
*/
|
|
let streamTimer: ReturnType<typeof setTimeout> | null = null
|
|
// 130s(对齐桌面 STREAM_TIMEOUT_MS,≥后端 120s idle timeout + 余量)。
|
|
const STREAM_TIMEOUT_MS = 130000
|
|
|
|
/**
|
|
* 停止标志(F9:stop 终态兜底)。治「点了停止还在生成」:stop 命令在途期间 device 可能续推
|
|
* AiTextDelta(持续 reset 看门狗致 generating 卡 true)。stop() 成功后乐观复位 generating + 置
|
|
* stopping,handleEvent 对 AiTextDelta/AiAgentRound 提前 return 忽略迟到增量;终态事件清 stopping。
|
|
* 无超时(布尔无泄漏,至下次 send/终态自然清)。
|
|
*/
|
|
let stopping = false
|
|
|
|
/** 看门狗超时回调:中途断兜底(flush + 错误气泡 + reset generating) */
|
|
function onStreamTimeout(): void {
|
|
streamTimer = null
|
|
if (generating.value) {
|
|
flushCurrentText()
|
|
currentText.value = ''
|
|
generating.value = false
|
|
messages.push({
|
|
id: genId('timeout'),
|
|
role: 'assistant',
|
|
content: '⚠ 回复中断(桌面端长时间无响应),请检查连接后重试',
|
|
isError: true,
|
|
timestamp: Date.now(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/** 重置看门狗(send 后启动 / 活跃事件重置,确保 watchdog 运行计 STREAM_TIMEOUT_MS) */
|
|
function resetWatchdog(): void {
|
|
if (streamTimer) clearTimeout(streamTimer)
|
|
streamTimer = setTimeout(onStreamTimeout, STREAM_TIMEOUT_MS)
|
|
}
|
|
|
|
// ── 审批超时计时器(miniapp 补齐,对齐桌面 aiShared.startApprovalTimer)──
|
|
// 治「审批永久挂起」:用户没在 5min 内处理审批 → 自动拒绝(对应桌面端 APPROVAL_TIMEOUT_DEFAULT_MS=900000,
|
|
// miniapp 取 5min=300000 更符合移动端使用习惯)。每条审批独立计时,approve/拒绝/完成/切会话 清除。
|
|
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
|
|
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
|
|
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
|
|
function startApprovalTimer(tcId: string, toolName: string): void {
|
|
if (_approvalTimers.has(tcId)) return // 幂等
|
|
const timer = setTimeout(() => {
|
|
_approvalTimers.delete(tcId)
|
|
// 超时自动拒绝(对齐桌面端:到点调 aiApi.approve(id,false))
|
|
const idx = pendingApprovals.findIndex((p) => p.id === tcId)
|
|
if (idx >= 0) {
|
|
pendingApprovals.splice(idx, 1)
|
|
uni.showToast({ title: `审批超时已自动拒绝:${toolName}`, icon: 'none' })
|
|
// 发拒绝命令(device 收到后走 reject 链路,工具不执行,emit AiApprovalResult approved=false)
|
|
approve(tcId, false)
|
|
}
|
|
}, APPROVAL_TIMEOUT_MS)
|
|
_approvalTimers.set(tcId, timer)
|
|
}
|
|
|
|
/** 清除审批超时计时器(approve/拒绝/完成/切会话/断连调用)。 */
|
|
function clearApprovalTimer(tcId: string): void {
|
|
const t = _approvalTimers.get(tcId)
|
|
if (t) {
|
|
clearTimeout(t)
|
|
_approvalTimers.delete(tcId)
|
|
}
|
|
}
|
|
|
|
/** 清除所有审批计时器(切会话/断连/重连/终态)。 */
|
|
function clearAllApprovalTimers(): void {
|
|
for (const t of _approvalTimers.values()) clearTimeout(t)
|
|
_approvalTimers.clear()
|
|
}
|
|
|
|
/** 清除看门狗(终态/挂起/断连触发,timer 彻底停) */
|
|
function clearWatchdog(): void {
|
|
if (streamTimer) clearTimeout(streamTimer)
|
|
streamTimer = null
|
|
}
|
|
|
|
/** 已注册事件监听标志(防多次订阅 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 气泡(F3:按 id 精确,回退启发式) */
|
|
function flushCurrentText(): void {
|
|
if (!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
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// 回退启发式:取末尾第一个非 error assistant(id 失配时兼容,对齐桌面 useAiEvents.ts:186)
|
|
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 {
|
|
// 诊断:事件类型 + 频率(排查 device 持续推流式致 view 层过载 / tap 失效)
|
|
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)
|
|
// 收到响应即 device 在线(探测成功),chat 页状态文案据此切「已连接桌面端」。
|
|
deviceOnline.value = true
|
|
return
|
|
}
|
|
|
|
// F-#95 联想扩展:`/` 技能列表同步(全局事件,提前处理避免 conversation_id narrowing —— 该变体无此字段)。
|
|
// 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)
|
|
deviceOnline.value = true
|
|
return
|
|
}
|
|
|
|
// F-#95 联想扩展:`@` 实体列表同步(全局事件,提前处理避免 conversation_id narrowing —— 该变体无此字段)。
|
|
// 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)
|
|
deviceOnline.value = true
|
|
return
|
|
}
|
|
|
|
// F-260622-02 跨端用户消息同步:device route_send_message 放行后 publish_event 广播,
|
|
// miniapp 自身也订阅 ai_event_bus,故此事件会回灌 miniapp 自己。去重防双气泡:
|
|
// 最近一条 user 气泡已是同 content(send 时本地乐观 push 过)则跳过,否则补 user 气泡。
|
|
// 提前处理(在 convId narrowing 之前),因该事件必带 conversation_id 但语义独立于 watchdog/
|
|
// generating 态,不应触发活跃事件 reset。
|
|
//
|
|
// MINIAPP-01(P1)去重失效修复:旧判定仅看 messages 末条 role==='user',但 send() 乐观 push 后
|
|
// 紧跟 assistant 占位气泡(:499-504),AiUserMessage 回灌时末条是 assistant 占位,role!=='user'
|
|
// 成立 → 误判「未重复」→ push 第二个重复 user 气泡(夹在乐观 user 与 assistant 占位之间错位)。
|
|
// 改:从末尾向前扫,跳过非 user 气泡(占位/系统),取最近一条 user 比 content。
|
|
if (event.type === 'AiUserMessage') {
|
|
// P1-4/P1-5:会话守卫。AiUserMessage 由 device route_send_message publish_event 广播,
|
|
// miniapp 自身也订阅 ai_event_bus → 自回灌;多设备同 relay 时他机消息也广播到本机。
|
|
// 切会话/新建会话后旧会话的 AiUserMessage 异步回流会污染新会话视图(串话)。
|
|
// 守卫:事件带 conversation_id 且非当前会话也非最近 load 目标 → 丢弃(乐观 push 已在发送方本地渲染)。
|
|
// 保留 null 放行(新会话首条,active 也可能 null,避免误杀自回灌)。
|
|
const uc = event.conversation_id ?? null
|
|
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]
|
|
break
|
|
}
|
|
}
|
|
if (!(lastUser && lastUser.content === event.message)) {
|
|
messages.push({
|
|
id: genId('user'),
|
|
role: 'user',
|
|
content: event.message,
|
|
timestamp: Date.now(),
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
// F-#95 扩展:历史消息同步(全局事件,提前处理避免 conversation_id narrowing)。
|
|
// 仅当消息属当前活跃会话才替换(防串会话:device 可能推非当前 conv 历史)。
|
|
if (event.type === 'AiMessageHistory') {
|
|
const histConv = event.conversation_id
|
|
const isActive = histConv === activeConversationId.value
|
|
// MINIAPP-02:竞态兜底。用户快速 A→B→A 或切后 newConversation() 置 null,
|
|
// 会使 activeConversationId 在历史回流前已变 → is_active=false → 49 条历史静默丢弃。
|
|
// 优先按 active 写入;其次若与最近发起的 load 目标相符,仍写入(用户停留该会话时
|
|
// 不应丢);都不符(确实已切走)才丢,并 console.warn 明确原因(非静默)。
|
|
const isLastLoaded = histConv !== null && histConv === lastLoadedConvId.value
|
|
if (isActive || isLastLoaded) {
|
|
if (!isActive) {
|
|
console.warn(
|
|
`[useAiChat] 历史回流 conv=${histConv} active=${activeConversationId.value} 已切走,但与最近 load 目标相符,仍写入(竞态兜底)`,
|
|
)
|
|
}
|
|
// splice 替换(messages 是 reactive 数组,保留响应性)
|
|
messages.splice(0, messages.length, ...event.messages)
|
|
} else {
|
|
console.warn(
|
|
`[useAiChat] 历史 conv=${histConv} 因会话已切换被丢弃(active=${activeConversationId.value} lastLoaded=${lastLoadedConvId.value}) count=${event.messages.length}`,
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
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
|
|
|
|
// 收到当前会话活跃事件 = device 在响应,重置看门狗(重新计 130s)。
|
|
// 终态/挂起事件在各自 case 内 clearWatchdog 覆盖此 reset。
|
|
// F9:stopping 期间不重置(stop 已 clearWatchdog,迟到 delta 不应续命看门狗)
|
|
if (!stopping) resetWatchdog()
|
|
|
|
switch (event.type) {
|
|
case 'AiTextDelta':
|
|
// F9:stopping 期间忽略迟到增量(用户已点停止,device 续推 delta 丢弃,防文本回填)
|
|
if (stopping) break
|
|
currentText.value += event.delta
|
|
break
|
|
|
|
case 'AiAgentRound': {
|
|
// F9:stopping 期间忽略新一轮(用户已停,device 未及时停的续推不新建气泡)
|
|
if (stopping) break
|
|
// 新一轮:flush 当前文本到上一条 assistant,新建空 assistant 占位
|
|
flushCurrentText()
|
|
currentText.value = ''
|
|
const roundPlaceholderId = genId('ai')
|
|
currentAssistantMsgId.value = roundPlaceholderId // F3:记录新一轮目标气泡
|
|
messages.push({
|
|
id: roundPlaceholderId,
|
|
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':
|
|
clearWatchdog() // 达 max 挂起,停看门狗
|
|
maxRoundsActive.value = true // P1-E:chat 渲染「继续/停止循环」按钮(替代旧「回桌面处理」)
|
|
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)
|
|
// 审批计时器:工具完成 → 清(审批已生效)
|
|
clearApprovalTimer(event.id)
|
|
// P2-E:工具执行完成 → 清回填标志(审批已生效执行,不再回填)
|
|
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
|
lastApprovedTc.value = null
|
|
}
|
|
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,
|
|
}
|
|
// req3 去重:sync_pending 重发 + 实时事件竞态可能同 id 重复,面板只保留一条
|
|
if (!pendingApprovals.some((p) => p.id === info.id)) {
|
|
pendingApprovals.push(info)
|
|
}
|
|
clearWatchdog() // 挂起等审批,停看门狗(用户操作后续生成会 reset 重启)
|
|
startApprovalTimer(event.id, event.name) // 5min 超时自动拒绝兜底
|
|
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)
|
|
clearApprovalTimer(event.id) // 审批结果落定 → 清计时器
|
|
// P2-E:审批结果确认(批准/拒绝均落定)→ 清回填标志(该 id 已定,不再回填)
|
|
if (lastApprovedTc.value && lastApprovedTc.value.id === event.id) {
|
|
lastApprovedTc.value = null
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'AiDirAuthRequired': {
|
|
clearWatchdog() // 挂起等授权,停看门狗
|
|
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
|
|
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
|
|
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
|
|
const authInfo: AiToolCallInfo = {
|
|
id: event.id,
|
|
name: event.tool,
|
|
args: {},
|
|
status: 'pending_approval',
|
|
kind: 'path',
|
|
dir: event.dir,
|
|
path: event.path,
|
|
}
|
|
// req3 去重:同 AiApprovalRequired(sync_pending 重发 + 实时事件竞态防重复)
|
|
if (!pendingApprovals.some((p) => p.id === authInfo.id)) {
|
|
pendingApprovals.push(authInfo)
|
|
}
|
|
// 对齐 AiApprovalRequired:同步更新消息内工具卡片为 pending_approval + path 授权信息,
|
|
// 使 chat 页消息内联渲染 once/always/deny 按钮(单一渲染源,避免消息 tc 卡 running 态)。
|
|
const authTc = findToolCall(event.id)
|
|
if (authTc) {
|
|
authTc.status = 'pending_approval'
|
|
authTc.kind = 'path'
|
|
authTc.dir = event.dir
|
|
authTc.path = event.path
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'AiCompleted': {
|
|
stopping = false // F9:终态清停止标志
|
|
flushCurrentText()
|
|
currentText.value = ''
|
|
generating.value = false
|
|
agentRound.value = 0
|
|
clearWatchdog() // 终态:停看门狗
|
|
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': {
|
|
stopping = false // F9:终态清停止标志
|
|
flushCurrentText()
|
|
currentText.value = ''
|
|
generating.value = false
|
|
agentRound.value = 0
|
|
clearWatchdog() // 终态:停看门狗
|
|
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
|
// P2-E:审批失败回填 —— 刚 approve(approved=true)成功发出但 device 返 AiError(执行失败/已被
|
|
// 桌面端处理),回填 pendingApprovals 让用户重试,避免 UI 误导「已批准」实际未生效。
|
|
if (lastApprovedTc.value) {
|
|
pendingApprovals.push(lastApprovedTc.value)
|
|
uni.showToast({ title: '审批操作未生效,请重试', icon: 'none' })
|
|
lastApprovedTc.value = null
|
|
}
|
|
messages.push({
|
|
id: genId('err'),
|
|
role: 'assistant',
|
|
content: friendlyError(event.error),
|
|
isError: true,
|
|
timestamp: Date.now(),
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'AiHelpRequired':
|
|
clearWatchdog() // 求助挂起,停看门狗
|
|
// 断路器熔断求助: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
|
|
|
|
// 治 Task#1:手动/自动两变体都插摘要气泡(miniapp 无独立压缩按钮,
|
|
// 两路径都代表"对端发生压缩",插摘要气泡告知用户合理)。
|
|
case 'AiManualCompressed':
|
|
case 'AiAutoCompressed':
|
|
// 压缩完成:在消息首位插入摘要提示
|
|
messages.unshift({
|
|
id: genId('compressed'),
|
|
role: 'system',
|
|
content: `📋 上下文已压缩(摘要:${event.summary.slice(0, 100)}...)`,
|
|
timestamp: Date.now(),
|
|
})
|
|
break
|
|
|
|
case 'AiConvStateChanged':
|
|
// 统一状态机:generating/stopping → generating=true(看门狗已在 :340 reset 续计 130s);
|
|
// idle/error → false + clearWatchdog(终态停看门狗,兑现 :156 注释承诺,防 timer 泄漏假超时)。
|
|
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
|
|
clearWatchdog()
|
|
stopping = false // F9:终态清停止标志
|
|
}
|
|
// compressed 是派生态,保持当前 generating(压缩完回原态)
|
|
break
|
|
|
|
default:
|
|
// 生产可观测兜底:后端新增事件变体时前端可见(:229 dbg:event 是开发日志,不够)
|
|
console.warn('[useAiChat] 未处理事件类型', (event as { type?: string }).type)
|
|
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) => {
|
|
// 心跳 pong 响应:relay 收到 miniapp ping 后直接回 pong(不经 device),
|
|
// miniapp 据此更新 lastPongTime 看门狗(ws.ts 已维护 lastInboundAt,但该路径不经过
|
|
// onControl,这里记录到变量供日志诊断)。pong 闭环治移动网络 TCP 半连接挂死。
|
|
if (msg.control_kind === 'pong') {
|
|
console.debug('[useAiChat] 收到心跳 pong(relay 响应,连接健康)')
|
|
return
|
|
}
|
|
// 其他 control 消息(hello_ack / pair 等)仅 console 不维持在线状态 UI
|
|
console.log('[useAiChat] control', msg)
|
|
},
|
|
onStatus: (status: WsStatus, detail?: string) => {
|
|
wsStatus.value = status
|
|
wsStatusDetail.value = detail ?? ''
|
|
if (status === 'connected') {
|
|
// req2/req3:重连/初始连接从远端恢复完整状态(拉完整消息 + 同步挂起审批)
|
|
syncOnConnect()
|
|
} else if (status === 'disconnected' || status === 'reconnecting') {
|
|
// 断开/重连中:清看门狗 + reset generating + device 离线复位
|
|
// (连接断肯定停,防 generating 卡死拦截后续 send;deviceOnline 置位后需回退,
|
|
// 否则文案恒显「已连接桌面端」误导用户 —— 旧实现:76-77 注释自认「离线不复位」置位永不回退)
|
|
clearWatchdog()
|
|
generating.value = false
|
|
stopping = false // F9:断连清停止标志
|
|
lastApprovedTc.value = null // P2-E:断连清审批回填标志
|
|
deviceOnline.value = false
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 发 send_message Command(relay → device invoke ai_chat_send)
|
|
*
|
|
* F-#95 联想扩展:skill(`/<skill>` 选中后透传,后端注入对应 SKILL.md 全文到 prompt)+
|
|
* spans(`@项目/@任务/@灵感` 选中后透传 mention_spans,后端 resolve 投影成 Augmentation)。
|
|
* 二者均可选,空/undefined=普通对话零回归。
|
|
*/
|
|
function send(text: string, skill?: string | null, spans?: MentionSpan[] | null): void {
|
|
const trimmed = text.trim()
|
|
console.log('[dbg:send] enter trimmed=', JSON.stringify(trimmed), 'gen=', generating.value)
|
|
if (!trimmed) return
|
|
if (generating.value) {
|
|
console.warn('[useAiChat] 生成中,忽略新发送')
|
|
return
|
|
}
|
|
stopping = false // F9:新 generation 清停止标志(上次 stop 后重启)
|
|
maxRoundsActive.value = false // P1-E:新 generation 清 MaxRounds 挂起
|
|
|
|
// 本地先 push user 气泡(乐观渲染,对齐桌面 sendMessage)
|
|
messages.push({
|
|
id: genId('user'),
|
|
role: 'user',
|
|
content: trimmed,
|
|
timestamp: Date.now(),
|
|
})
|
|
|
|
// 新建 assistant 占位气泡(等待流式增量填充);记录 id 供 flushCurrentText 精确回填(F3)
|
|
const aiPlaceholderId = genId('ai')
|
|
currentAssistantMsgId.value = aiPlaceholderId
|
|
messages.push({
|
|
id: aiPlaceholderId,
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: Date.now(),
|
|
})
|
|
currentText.value = ''
|
|
generating.value = true
|
|
|
|
const cmd: MiniCommand = {
|
|
cmd: 'send_message',
|
|
args: {
|
|
message: trimmed,
|
|
conversation_id: activeConversationId.value,
|
|
// skill/spans 仅在有值时透传(对齐 ai_chat_send Option 参数,None 走默认路径)
|
|
...(skill ? { skill } : {}),
|
|
...(spans && spans.length > 0 ? { mention_spans: spans } : {}),
|
|
},
|
|
}
|
|
const _sent = wsClient.send(cmd)
|
|
console.log('[dbg:send] wsClient.send 返回=', _sent)
|
|
if (!_sent) {
|
|
// 发送失败:在 assistant 占位气泡显错误
|
|
flushCurrentText()
|
|
messages.push({
|
|
id: genId('err'),
|
|
role: 'assistant',
|
|
content: '未连接桌面端,请检查网络/配置',
|
|
isError: true,
|
|
timestamp: Date.now(),
|
|
})
|
|
generating.value = false
|
|
} else {
|
|
// 发送成功:启动流式看门狗(活跃事件 reset,终态/挂起/断开 clear,超时 reset generating 防卡死)
|
|
resetWatchdog()
|
|
}
|
|
}
|
|
|
|
/** 停止生成(relay → device invoke ai_chat_stop) */
|
|
function stop(): void {
|
|
if (!generating.value) return
|
|
const cmd: MiniCommand = {
|
|
cmd: 'stop',
|
|
args: {
|
|
conversation_id: activeConversationId.value,
|
|
},
|
|
}
|
|
if (!wsClient.send(cmd)) {
|
|
// P1-6:发送失败(ws.send 返 false,如 relay 中断但 wsStatus 尚未翻转)必须复位 generating,
|
|
// 否则后端永不推 AiCompleted(命令未达)→ generating 永卡 → 后续 send 被守卫拦截。
|
|
// 对齐 send() 发送失败分支:clearWatchdog + flush + 错误提示 + reset generating。
|
|
uni.showToast({ title: '未连接,操作未发出', icon: 'none' })
|
|
clearWatchdog()
|
|
flushCurrentText()
|
|
currentText.value = ''
|
|
generating.value = false
|
|
return
|
|
}
|
|
// F9:发送成功乐观停止反馈。不等 device 推 AiConvStateChanged idle(在途 delta 续 reset 看门狗
|
|
// 致「点了停止还在生成」)。立即 flush 当前文本(用户可见已生成部分)+ 复位 generating +
|
|
// clearWatchdog + 置 stopping(忽略迟到 delta/新轮)。终态事件/下次 send/切会话清 stopping。
|
|
flushCurrentText()
|
|
currentText.value = ''
|
|
generating.value = false
|
|
clearWatchdog()
|
|
stopping = true
|
|
}
|
|
|
|
/** 重发最后一条 user 消息(重新 send_message) */
|
|
function regenerate(): void {
|
|
// 生成中守卫:send() 内虽有 generating 守卫但静默 return 无反馈,这里显式 toast 提示。
|
|
// (避免用户点重发后末尾气泡被 pop 但无新消息生成,以为卡死)
|
|
if (generating.value) {
|
|
uni.showToast({ title: '生成中,请先停止', icon: 'none' })
|
|
return
|
|
}
|
|
// 找最后一条 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,
|
|
},
|
|
}
|
|
if (!wsClient.send(cmd)) {
|
|
// 发送失败:wsClient.send 返 false(未连接),提示用户操作未发出,挂起审批保留(下次可重批)
|
|
uni.showToast({ title: '未连接,操作未发出', icon: 'none' })
|
|
return
|
|
}
|
|
// 发送成功才乐观移除挂起审批(对齐 AiApprovalResult 事件处理,避免 UI 卡 pending)。
|
|
// 旧实现无条件乐观移除:send 失败时用户已批但命令未发,挂起条目消失误导用户以为已批。
|
|
const idx = pendingApprovals.findIndex((p) => p.id === toolCallId)
|
|
if (idx >= 0) {
|
|
// P2-E:批准(approved=true)记录,device 返 AiError 时回填让用户重试(执行失败/已被桌面端处理)
|
|
if (approved) lastApprovedTc.value = pendingApprovals[idx]
|
|
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,
|
|
},
|
|
}
|
|
if (!wsClient.send(cmd)) {
|
|
// 发送失败:wsClient.send 返 false(未连接),提示用户操作未发出
|
|
uni.showToast({ title: '未连接,操作未发出', icon: 'none' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 继续循环(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)
|
|
maxRoundsActive.value = false // P1-E:用户已选继续,收起按钮
|
|
}
|
|
|
|
/**
|
|
* 停止循环(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)
|
|
maxRoundsActive.value = false // P1-E:用户已选停止,收起按钮
|
|
}
|
|
|
|
/** 切换会话(switch Command → device 切 active_conversation_id) */
|
|
function switchConversation(convId: string): void {
|
|
activeConversationId.value = convId
|
|
// P1-8:持久化激活会话,冷启动据此恢复。
|
|
persistActiveConv(convId)
|
|
// MINIAPP-02:记录本次 load 目标,AiMessageHistory 回流时除 active 外还比对它,
|
|
// 防止用户快速切会话致 active 在回流前已变 → 历史静默丢弃。置位须早于 load_messages 发送。
|
|
lastLoadedConvId.value = convId
|
|
// 清空当前视图,等 AiMessageHistory 事件回流替换(对齐列表链路异步语义)。
|
|
messages.splice(0, messages.length)
|
|
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()
|
|
clearWatchdog()
|
|
|
|
// F-#95 扩展:发 load_messages 拉历史(device route_load_messages 读库 → AiMessageHistory 回流)。
|
|
// 不发 switch_conversation(后端 §2.2 决策 a 忽略,无 Tauri command 对应)。
|
|
const cmd: MiniCommand = {
|
|
cmd: 'load_messages',
|
|
args: { conversation_id: convId },
|
|
}
|
|
wsClient.send(cmd)
|
|
}
|
|
|
|
/**
|
|
* 拉取会话列表(F-#95)。
|
|
*
|
|
* 发 `list_conversations` Command → device route_list_conversations 读库 →
|
|
* 推回 `AiConversationList` 事件(handleEvent 替换 conversations + 置 deviceOnline=true)。
|
|
*
|
|
* 调用时机:
|
|
* - ws 握手成功后(ws.ts handleHandshakeResponse,device 在线探测 + 首屏列表)
|
|
* - 会话列表页下拉刷新(conversations/index.vue onPullDownRefresh)
|
|
*
|
|
* 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流
|
|
* (非 request-reply 同步返回,对齐 relay 纯透传单向语义)。
|
|
*/
|
|
function refreshConversations(): boolean {
|
|
const cmd: MiniCommand = {
|
|
cmd: 'list_conversations',
|
|
args: {},
|
|
}
|
|
return wsClient.send(cmd)
|
|
}
|
|
|
|
/**
|
|
* 拉取技能列表(F-#95 `/` 联想扩展)。
|
|
*
|
|
* 发 `list_skills` Command → device route_list_skills 调 ai_list_skills →
|
|
* 推回 `AiSkillList` 事件(handleEvent 替换 skills reactive 数组)。
|
|
*
|
|
* 调用时机:chat 页输入框文本以 `/` 开头时(技能联想浮层渲染前)。
|
|
*
|
|
* 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流
|
|
* (非 request-reply 同步返回,对齐 relay 纯透传单向语义)。
|
|
*/
|
|
function listSkills(): boolean {
|
|
const cmd: MiniCommand = {
|
|
cmd: 'list_skills',
|
|
args: {},
|
|
}
|
|
return wsClient.send(cmd)
|
|
}
|
|
|
|
/**
|
|
* 拉取实体列表(F-#95 `@` 联想扩展,项目/任务/灵感三类聚合)。
|
|
*
|
|
* 发 `list_entities` Command → device route_list_entities 并行调
|
|
* list_projects/list_tasks/list_ideas → 推回 `AiEntityList` 事件
|
|
* (handleEvent 替换 entities.projects/tasks/ideas 三个数组)。
|
|
*
|
|
* 调用时机:chat 页输入框末尾触发 `@` 时(实体联想浮层渲染前)。
|
|
*
|
|
* 注:返回值仅表「命令是否发出」(wsClient.send bool),列表实际更新经事件异步回流
|
|
* (非 request-reply 同步返回,对齐 relay 纯透传单向语义)。
|
|
*/
|
|
function listEntities(): boolean {
|
|
const cmd: MiniCommand = {
|
|
cmd: 'list_entities',
|
|
args: {},
|
|
}
|
|
return wsClient.send(cmd)
|
|
}
|
|
|
|
/** 新建会话(本地清空 + 切到 null,下次 send 时 device 创建新 conv) */
|
|
function newConversation(): void {
|
|
activeConversationId.value = null
|
|
// P1-8:清持久化(新建会话,冷启动不再恢复到旧会话)。
|
|
persistActiveConv(null)
|
|
// MINIAPP-02:作废最近 load 目标,防切换前已发的 load_messages 历史回流时
|
|
// 命中宽松兜底(isLastLoaded)污染本应空白的新会话视图。
|
|
lastLoadedConvId.value = null
|
|
messages.splice(0, messages.length)
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* 重连/初始连接同步(req2 断网不丢消息 + req3 审批状态恢复)。
|
|
*
|
|
* 连接成功后从远端恢复完整权威状态:
|
|
* - req2:发 load_messages 拉当前会话完整历史(断网期间 missed 消息恢复;兼修冷启动空白 P1-C)。
|
|
* - req3:清本地 pendingApprovals + 发 sync_pending,device 重推挂起审批事件重建卡片
|
|
* (断网期间可能错过审批事件,或桌面端已处理;清+重推拿权威状态,避本地陈旧)。
|
|
*
|
|
* 审批卡从 pendingApprovals 独立面板渲染(与 messages 解耦),故 load_messages 替换 messages
|
|
* 不影响审批卡(避重连时两异步响应乱序竞态:load_messages 替换 vs sync_pending 重建)。
|
|
*
|
|
* 无 activeConversationId(新建会话/未选会话)则跳过(无目标 conv 可同步)。
|
|
*/
|
|
function syncOnConnect(): void {
|
|
const convId = activeConversationId.value
|
|
if (!convId) return
|
|
// req2:拉完整历史
|
|
wsClient.send({ cmd: 'load_messages', args: { conversation_id: convId } })
|
|
// req3:清本地 pending + 发 sync_pending 重推权威审批状态
|
|
pendingApprovals.splice(0, pendingApprovals.length); clearAllApprovalTimers()
|
|
wsClient.send({ cmd: 'sync_pending', args: { conversation_id: convId } })
|
|
}
|
|
|
|
/**
|
|
* 重命名会话(rename_conversation Command → relay → device invoke ai_conversation_rename)。
|
|
*
|
|
* 成功后 device 推 AiConversationList 刷新(列表 reactive 见新标题)。本地乐观更新标题即时反馈。
|
|
*/
|
|
function renameConversation(convId: string, title: string): boolean {
|
|
const trimmed = title.trim()
|
|
if (!trimmed) return false
|
|
// 乐观本地更新(即时反馈,列表事件回流确认)
|
|
const conv = conversations.find((c) => c.id === convId)
|
|
if (conv) conv.title = trimmed
|
|
const cmd: MiniCommand = {
|
|
cmd: 'rename_conversation',
|
|
args: { conversation_id: convId, title: trimmed },
|
|
}
|
|
return wsClient.send(cmd)
|
|
}
|
|
|
|
/**
|
|
* useAiChat 单例 hook
|
|
*
|
|
* 多次调用返回同一状态(模块级 reactive 单例)。
|
|
*/
|
|
export function useAiChat() {
|
|
subscribeWs()
|
|
|
|
const isWsConnected = computed(() => wsStatus.value === 'connected')
|
|
|
|
return {
|
|
// 状态
|
|
messages,
|
|
currentText,
|
|
generating,
|
|
agentRound,
|
|
maxRoundsActive,
|
|
conversations,
|
|
activeConversationId,
|
|
wsStatus,
|
|
wsStatusDetail,
|
|
isWsConnected,
|
|
tokenUsage,
|
|
pendingApprovals,
|
|
deviceOnline,
|
|
// F-#95 联想扩展:技能/实体列表(`/<skill>` 与 `@项目/@任务/@灵感`)
|
|
skills,
|
|
entities,
|
|
// 方法
|
|
connect: () => wsClient.connect(),
|
|
disconnect: () => wsClient.disconnect(),
|
|
resumeIfDisconnected: () => wsClient.resumeIfDisconnected(),
|
|
send,
|
|
stop,
|
|
regenerate,
|
|
switchConversation,
|
|
renameConversation,
|
|
refreshConversations,
|
|
listSkills,
|
|
listEntities,
|
|
newConversation,
|
|
approve,
|
|
authorizeDir,
|
|
continueLoop,
|
|
stopLoop,
|
|
handleEvent,
|
|
friendlyError,
|
|
// P1-9/P1-10:后台定时器清理(App.vue onHide/onShow 调)
|
|
pauseBackground: () => {
|
|
// 停后台定时器(心跳 + 流式看门狗),保留连接
|
|
clearWatchdog()
|
|
wsClient.pauseBackground()
|
|
},
|
|
resumeBackground: () => {
|
|
// 前台恢复:重启心跳/重连 + 若仍在生成重启看门狗
|
|
wsClient.resumeBackground()
|
|
if (generating.value && !streamTimer) resetWatchdog()
|
|
},
|
|
}
|
|
}
|