From 782a26f446517f28b1d848fbef734a4b437f573a Mon Sep 17 00:00:00 2001 From: jing <1683105490@qq.com> Date: Mon, 14 Sep 2026 17:49:30 +0800 Subject: [PATCH] =?UTF-8?q?chore=EF=BC=9AAI=E6=99=BA=E8=83=BD=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=80=9D=E8=80=83=E8=BF=87=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/js/ai-service.js | 379 ++++++++--- pages_tool/ai-chat/ai-chat-message.vue | 595 +++++++++++++++--- .../components/ai-chat-tools-panel.vue | 112 ++-- 3 files changed, 872 insertions(+), 214 deletions(-) diff --git a/common/js/ai-service.js b/common/js/ai-service.js index c3efd29..e54b1d7 100644 --- a/common/js/ai-service.js +++ b/common/js/ai-service.js @@ -13,10 +13,28 @@ let currentConversationId = null; // - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu) // - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent) // - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由 +// 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口 +// /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。 export const DEFAULT_AGENT_ID = 'agent_aikefu'; let currentAgentId = DEFAULT_AGENT_ID; -export const CONVERSATION_KEY = 'ai_conversation_id'; + +// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存 +export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_'; +export function getConversationKey(agentId) { + return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID); +} +// 兼容旧代码: 单 key 别名(指向默认 agent) +export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID); + +// 统一 SSE/WS 事件分发器 (透出所有事件类型) +let eventHandlers = { onEvent: null }; +function dispatchEvent(onEvent, event, data) { + if (typeof onEvent === 'function') { + try { onEvent(event, data); } + catch (e) { console.error('[AI Service] onEvent handler threw:', e); } + } +} /** * PR-7: agent_id -> 后端 addon 路由映射 @@ -65,9 +83,9 @@ function getDomainParams() { }; } -// 初始化时从本地读取会话ID +// 初始化时从本地读取会话ID(按当前 agent 隔离) try { - const saved = uni.getStorageSync(CONVERSATION_KEY); + const saved = uni.getStorageSync(getConversationKey(currentAgentId)); if (saved) { currentConversationId = saved; } @@ -198,41 +216,99 @@ function ensureWebSocketConnected() { // 以下为聊天消息响应,只在有活跃消息时处理 if (!wsMessageActive) return; - if (data.event === 'message') { - var text = data.answer || data.content || data.text || ''; - // 兼容嵌套 data.data.content 格式 - if (!text && data.data) { - text = data.data.content || data.data.answer || ''; + // 优先走 onEvent 事件分发(透出 thought/tool_call/question 等) + var onEvt = eventHandlers.onEvent; + + switch (data.event) { + case 'message': { + var text = data.answer || data.content || data.text || ''; + // 兼容嵌套 data.data.content 格式 + if (!text && data.data) { + text = data.data.content || data.data.answer || ''; + } + // B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识 + if (text && text.startsWith('> ')) { + dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id }); + break; + } + wsMessageContent += text; + if (wsMessageOnChunk) wsMessageOnChunk(text); + dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id }); + break; } - wsMessageContent += text; - if (wsMessageOnChunk) wsMessageOnChunk(text); - } else if (data.event === 'message_end' || data.event === 'done') { - // 兼容两种结束信号:message_end(流结束)或 done(服务端完成) - // 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中 - var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || ''; - console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId); - // 保存会话ID(独立函数,不使用 this) - if (conversationId) { - if (conversationId !== currentConversationId) { - currentConversationId = conversationId; - try { - uni.setStorageSync(CONVERSATION_KEY, conversationId); - console.log('[AI WS] 会话ID已持久化到 storage:', conversationId); - } catch (e) { - console.error('保存会话ID失败:', e); + case 'thought': + case 'thinking': + case 'reasoning': { + // 兼容字段: thought / thinking / reasoning / content / answer + var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || ''; + console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id); + dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id }); + break; + } + case 'tool_call': + dispatchEvent(onEvt, 'tool_call', data.tool_call || {}); + break; + case 'question': + dispatchEvent(onEvt, 'question', { + pending_question: data.pending_question || (data.data && data.data.pending_question), + message_id: data.message_id, + conversation_id: data.conversation_id + }); + break; + case 'message_end': + case 'done': { + // 兼容两种结束信号:message_end(流结束)或 done(服务端完成) + var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || ''; + console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId); + // 保存会话ID(按当前 agent 隔离) + if (conversationId) { + if (conversationId !== currentConversationId) { + currentConversationId = conversationId; + try { + uni.setStorageSync(getConversationKey(currentAgentId), conversationId); + console.log('[AI WS] 会话ID已持久化到 storage:', conversationId); + } catch (e) { + console.error('保存会话ID失败:', e); + } } } + var content = wsMessageContent; + var convId = conversationId; + // 重置消息状态,但不断开连接 + var endPayload = { + conversation_id: convId, + message_id: data.message_id, + content: data.content || content, + thoughts: data.thoughts || '', + tool_calls: data.tool_calls || [], + tokens: data.tokens || null, + phase: data.phase || 'completed', + stop_reason: data.stop_reason || '', + effective_model: data.effective_model || '' + }; + wsMessageActive = false; + wsMessageContent = ''; + dispatchEvent(onEvt, 'message_end', endPayload); + if (wsMessageOnComplete) wsMessageOnComplete(endPayload); + if (wsMessageResolve) wsMessageResolve(endPayload); + break; } - var content = wsMessageContent; - var convId = conversationId; - // 重置消息状态,但不断开连接 - wsMessageActive = false; - wsMessageContent = ''; - if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId }); - if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId }); - } else { - // 兜底日志:打印所有未识别的消息类型 - console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200)); + case 'error': + dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' }); + break; + case 'ping': + // keep-alive, 忽略 + break; + default: + // 兜底: 老格式 data.data.content + if (data.data && data.data.content) { + var defText = data.data.content; + wsMessageContent += defText; + if (wsMessageOnChunk) wsMessageOnChunk(defText); + dispatchEvent(onEvt, 'message', { content: defText }); + } else { + console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200)); + } } } catch (e) { console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data); @@ -329,6 +405,7 @@ export default { response_mode: 'blocking', user: getUserId(), conversation_id: this.getConversationId() || '', + attachments: (options && options.attachments) || [], ...getDomainParams() }, header: { @@ -362,7 +439,7 @@ export default { * 流式消息入口(自动适配平台) * 微信小程序:复用长连接 WebSocket */ - async sendStreamMessage(message, onChunk, onComplete) { + async sendStreamMessage(message, onEvent, onComplete, opts = {}) { // #ifdef MP try { await ensureWebSocketConnected(); @@ -370,13 +447,15 @@ export default { // WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署) // 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口 console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message); - return this._fallbackToHttp(message, onChunk, onComplete); + return this._fallbackToHttp(message, onEvent, onComplete, opts); } wsMessageContent = ''; wsMessageActive = true; - wsMessageOnChunk = onChunk || null; - wsMessageOnComplete = onComplete || null; + eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null; + // onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete + wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null); + wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null); return new Promise((resolve, reject) => { wsMessageResolve = resolve; @@ -391,13 +470,14 @@ export default { query: message, user_id: getUserId(), conversation_id: this.getConversationId() || '', + attachments: opts.attachments || [], ...getDomainParams() }) }); } catch (sendErr) { console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message); // 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡 - this._fallbackToHttp(message, onChunk, onComplete) + this._fallbackToHttp(message, onEvent, onComplete, opts) .then((res) => resolve(res)) .catch((err) => reject(err)); } @@ -409,14 +489,26 @@ export default { * [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复 * 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名 */ - async _fallbackToHttp(message, onChunk, onComplete) { + async _fallbackToHttp(message, onEvent, onComplete, opts = {}) { try { - const result = await this.sendMessage(message); + const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [] }); const content = result && result.content ? result.content : ''; - if (content) { - if (onChunk) onChunk(content); - if (onComplete) onComplete({ content, conversation_id: result.conversationId }); - return { content, conversation_id: result.conversationId }; + // 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)" + const fallbackPayload = { + content, + conversation_id: result.conversationId, + thoughts: result.thoughts || '', + tokens: result.tokens || null + }; + if (content || result.thoughts) { + if (typeof onEvent === 'function') { + if (result.thoughts) onEvent('thought', { content: result.thoughts }); + onEvent('message', { content }); + onEvent('message_end', fallbackPayload); + } else { + if (onComplete) onComplete(fallbackPayload); + } + return fallbackPayload; } const emptyErr = new Error(lang.lang('aiService.serviceUnavailable')); if (onComplete) onComplete({ error: emptyErr.message }); @@ -432,7 +524,7 @@ export default { * HTTP 流式请求(仅 H5 使用) * PR-7: URL 按 agent_id 路由 */ - async sendHttpStream(message, onChunk, onComplete) { + async sendHttpStream(message, onEvent, onComplete, opts = {}) { // #ifdef H5 try { var chatUrl = resolveApiUrl('chat'); @@ -452,6 +544,7 @@ export default { agent_id: getAgentId(), user_id: getUserId(), conversation_id: this.getConversationId() || '', + attachments: (opts && opts.attachments) || [], ...getDomainParams() }) }); @@ -465,6 +558,7 @@ export default { let buffer = ''; let content = ''; let conversationId = ''; + const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); }; while (true) { const { done, value } = await reader.read(); @@ -477,54 +571,76 @@ export default { for (const line of lines) { const trimmed = line.trim(); - if (trimmed.startsWith('data:')) { - try { - const jsonStr = trimmed.slice(5).trim(); - if (jsonStr && jsonStr !== '[DONE]') { - const data = JSON.parse(jsonStr); - if (data.event === 'message') { - var text = data.answer || data.text || ''; - if (!text && data.data) { - text = data.data.content || data.data.answer || ''; - } - content += text; - if (onChunk) onChunk(text); - } - var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; - if (cid) { - conversationId = cid; - } - if (data.event === 'message_end' || data.event === 'done') { - console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid); - } - } - } catch (e) { - console.warn('解析失败:', e, line); - } - } - } - } - - if (buffer.trim().startsWith('data:')) { - try { - const jsonStr = buffer.trim().slice(5); - if (jsonStr) { + if (!trimmed.startsWith('data:')) continue; + const jsonStr = trimmed.slice(5).trim(); + if (!jsonStr || jsonStr === '[DONE]') continue; + try { const data = JSON.parse(jsonStr); - if (data.event === 'message') { - var text = data.answer || data.text || ''; - if (!text && data.data) { - text = data.data.content || data.data.answer || ''; + switch (data.event) { + case 'message': { + var text = data.answer || data.text || data.content || ''; + if (!text && data.data) text = data.data.content || data.data.answer || ''; + // B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识 + if (text && text.startsWith('> ')) { + fire('thought', { content: text, message_id: data.message_id }); + break; + } + content += text; + fire('message', { content: text, message_id: data.message_id }); + break; } - content += text; - if (onChunk) onChunk(text); - } - var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; - if (cid) { - conversationId = cid; + case 'thought': + case 'thinking': + case 'reasoning': { + // 兼容字段: thought / thinking / reasoning / content / answer + var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || ''; + fire('thought', { content: thoughtText2, message_id: data.message_id }); + break; + } + case 'tool_call': + fire('tool_call', data.tool_call || {}); + break; + case 'question': + fire('question', { + pending_question: data.pending_question || (data.data && data.data.pending_question), + message_id: data.message_id, + conversation_id: data.conversation_id + }); + break; + case 'message_end': + case 'done': { + const cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; + if (cid) conversationId = cid; + console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid); + fire('message_end', { + conversation_id: cid, + message_id: data.message_id, + content: data.content || content, + thoughts: data.thoughts || '', + tool_calls: data.tool_calls || [], + tokens: data.tokens || null, + phase: data.phase || 'completed', + stop_reason: data.stop_reason || '', + effective_model: data.effective_model || '' + }); + break; + } + case 'error': + fire('error', { code: data.code || -1, message: data.message || 'unknown' }); + break; + default: + // B 路 Dify 把思考塞 answer 里 (> 前缀) + if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) { + fire('thought', { content: data.answer }); + } else if (data.event === 'ping') { + // keep-alive + } else { + console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200)); + } } + } catch (e) { + console.warn('解析失败:', e, line); } - } catch (e) { - console.warn('最后 buffer 解析失败:', e); } } @@ -549,7 +665,9 @@ export default { success: true, content: response.data?.answer || response.data?.content || response.data?.reply || response.data, conversationId: response.data?.conversation_id, - messageId: response.data?.message_id + messageId: response.data?.message_id, + thoughts: response.data?.thoughts || '', + tokens: response.data?.tokens || null }; } else { throw new Error(response.message || lang.lang('aiService.serviceResponseError')); @@ -570,7 +688,7 @@ export default { if (id && id !== currentConversationId) { currentConversationId = id; try { - uni.setStorageSync(CONVERSATION_KEY, id); + uni.setStorageSync(getConversationKey(currentAgentId), id); } catch (e) { console.error('保存会话ID失败:', e); } @@ -583,7 +701,7 @@ export default { clearConversationId() { currentConversationId = null; try { - uni.removeStorageSync(CONVERSATION_KEY); + uni.removeStorageSync(getConversationKey(currentAgentId)); } catch (e) { console.error('清除会话ID失败:', e); } @@ -694,6 +812,41 @@ export default { } }, + /** + * 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项 + * @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers } + * @returns {Promise} + */ + async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) { + // #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY + try { + await ensureWebSocketConnected(); + } catch (e) { + console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message); + return false; + } + wsSocketTask.send({ + data: JSON.stringify({ + action: 'elicit_response', + conversation_id, + message_id, + tool_call_id, + rpc_id, + answers, + uniacid: this.getUniacid(), + token: store.state.token || 'test_token' + }) + }); + return true; + // #endif + // #ifdef H5 + // H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点 + // 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底 + console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底'); + return false; + // #endif + }, + /** * 获取会话历史 * PR-7: URL 按 agent_id 路由 @@ -847,6 +1000,46 @@ export default { if (oldAddon !== newAddon) { try { disconnectWebSocket(); } catch (e) { /* ignore */ } } + // 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离) + currentConversationId = null; + try { + const saved = uni.getStorageSync(getConversationKey(currentAgentId)); + if (saved) currentConversationId = saved; + } catch (e) { /* ignore */ } + }, + + /** + * 获取当前 agent 的会话存储 key (per-agent 隔离) + */ + getConversationKey(agentId) { + return getConversationKey(agentId); + }, + + /** + * 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选) + */ + getAllConversations() { + const out = {}; + try { + const info = uni.getStorageInfoSync(); + (info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0) + .forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); }); + } catch (e) { /* ignore */ } + return out; + }, + + /** + * 获取当前 config_uuid (媒体上传路由用) + */ + getCurrentConfigUuid() { + return uni.getStorageSync('current_config_uuid') || ''; + }, + + /** + * 获取用户 ID (媒体上传字段用) + */ + getUserId() { + return getUserId(); }, /** diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index 33dd9a1..bf037b6 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -11,6 +11,15 @@ @keyword-change="onSearchKeywordChange" /> + + + {{ $lang('ai-chat.index.aiNickname') }} + + 🔄 + 新对话 + + + - + + + 上传中... + {{ message.uploadError }} + + {{ message.content }} @@ -60,6 +74,18 @@ 🤖 + + + + {{ message.isStreaming ? '🧠' : '💡' }} + {{ message.isStreaming ? '思考中...' : ('思考过程 (' + (message.thoughtSteps || 1) + ' 步)') }} + {{ message.thoughtExpanded ? '▼' : '▶' }} + + + {{ message.thoughtsText }} + + + {{ $lang('ai-chat.index.aiNickname') }} @@ -203,6 +229,44 @@ + + + + 🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? (' (' + tc.duration_ms + 'ms)') : '' }} + + + + + + + + + 📎 + {{ att.name || '文件' }} + + + + + + + + {{ q.header }} + {{ q.question }} + ⏱ {{ message.questionTimeout }}s + + + {{ opt.label }} + {{ opt.description }} + + + + + + + + @@ -484,7 +548,10 @@ export default { searchFlashTimer: null, // flash 动画定时器 // 用户发出消息时记录的开始时间戳, AI 回复完成时算 elapsed (已完成 XmXXs) - _aiRequestStart: 0 + _aiRequestStart: 0, + + // Question 选项倒计时定时器集合 (per message) + questionTimers: {} } }, computed: { @@ -527,6 +594,11 @@ export default { clearTimeout(this.searchFlashTimer); this.searchFlashTimer = null; } + // 清理 Question 选项倒计时定时器 + if (this.questionTimers) { + Object.values(this.questionTimers).forEach(t => clearInterval(t)); + this.questionTimers = {}; + } // 清理所有加载状态定时器 this.messages.forEach(msg => { if (msg._rotationTimer) { @@ -886,14 +958,31 @@ async loadChatHistoryIfExist() { if (!hasContent && !hasEmoji) return + // 收集已上传、尚未随消息发送的附件 (用户图片/文件消息) + const readyAttachments = this.messages + .filter(m => m.role === 'user' && m.uploaded && !m.sendWithBatch) + .map(m => { + m.sendWithBatch = true + return { + type: m.uploaded.kind || (m.type === 'image' ? 'image' : 'file'), + url: m.uploaded.url, + mime: m.uploaded.mime, + name: m.uploaded.name, + size: m.uploaded.size + } + }) + // 记录用户发出消息的时间戳, AI 回复完成时算 elapsed this._aiRequestStart = Date.now() + const queryText = this.inputText && this.inputText.trim() + ? this.inputText.trim() + : (readyAttachments.length ? '[附件]' : '') const userMessage = { id: ++this.messageId, role: 'user', type: 'text', - content: this.inputText, + content: queryText, timestamp: Date.now() } this.shouldScrollToBottom = true @@ -925,9 +1014,9 @@ async loadChatHistoryIfExist() { // 如果服务不可用,实际发送时会抛出异常,由 catch 块处理 const enhancedMessage = this.enhanceEmojiMessage(userMessage.content) if (this.enableStreaming) { - await this.sendStreamMessage(enhancedMessage) + await this.sendStreamMessage(enhancedMessage, readyAttachments) } else { - await this.sendNormalMessage(enhancedMessage) + await this.sendNormalMessage(enhancedMessage, readyAttachments) } } catch (error) { @@ -957,10 +1046,11 @@ async loadChatHistoryIfExist() { }, // 发送普通消息 - async sendNormalMessage(userMessage) { + async sendNormalMessage(userMessage, attachments = []) { const response = await aiService.sendMessage(userMessage, { conversationId: this.currentConversationId, - stream: false + stream: false, + attachments }) // 移除加载状态 @@ -973,6 +1063,8 @@ async loadChatHistoryIfExist() { this.saveConversationIdToLocal(response.conversationId); } + // 普通响应也可能在 result.thoughts 里带思考过程 (如 aikefu 阻塞接口) + const fallbackThoughts = response.thoughts || '' const aiMessage = { id: ++this.messageId, role: 'ai', @@ -981,6 +1073,9 @@ async loadChatHistoryIfExist() { timestamp: Date.now(), conversationId: response.conversationId, messageId: response.messageId, + thoughtsText: fallbackThoughts, + thoughtSteps: fallbackThoughts ? (fallbackThoughts.split(/\n+/).filter(s => s.trim()).length || 1) : 0, + tokens: response.tokens || null, // 耗时(秒): 用户发消息到 AI 回复完成的耗时 elapsed: this._aiRequestStart > 0 ? (Date.now() - this._aiRequestStart) / 1000 : 0, _finished: true, @@ -990,106 +1085,168 @@ async loadChatHistoryIfExist() { this.messages.push(aiMessage) this.$emit('ai-response', aiMessage) }, - // 发送流式消息(自动适配 H5 / 微信小程序) -async sendStreamMessage(userMessage) { + // 发送流式消息(自动适配 H5 / 微信小程序), 支持思考流 / 选项 / 工具调用 / 附件 +async sendStreamMessage(userMessage, attachments = []) { + // 标记进入流式接收, 禁止重复发送, 同时让思考面板处于"思考中"展开状态 + this.isStreaming = true // 找到已有的 loading 消息,复用它在 DOM 中的位置,实现无缝过渡 const loadingIdx = this.messages.findIndex(msg => msg.type === 'loading') + let targetMsg if (loadingIdx === -1) { // 安全回退:没有 loading 消息时创建新消息 - const fallback = { + targetMsg = { id: ++this.messageId, role: 'ai', type: 'text', content: '', timestamp: Date.now(), - isStreaming: true + isStreaming: true, + thoughtsText: '', + toolCalls: [], + pendingQuestion: null, + questionAnswered: false, + tokens: null, + thoughtExpanded: false, + selectedAnswers: {}, + questionTimeout: 0 } - this.isStreaming = true - this.messages.push(fallback) + this.messages.push(targetMsg) this.scrollToBottom() - - try { - // #ifdef H5 - await aiService.sendHttpStream(userMessage, - (chunk) => { fallback.content += chunk; this.scrollToBottom() }, - (result) => this._finishStream(fallback, result)) - // #endif - // #ifdef MP-WEIXIN - await aiService.sendStreamMessage(userMessage, - (chunk) => { fallback.content += chunk; this.scrollToBottom() }, - (result) => this._finishStream(fallback, result)) - // #endif - } catch (error) { - this._handleStreamError(fallback) - } - return + } else { + targetMsg = this.messages[loadingIdx] + this._stopLoadingTextRotation(targetMsg) + targetMsg.loadingText = this.$lang('ai-chat.index.receivingReply') + // 进入流式接收状态: 让思考面板在收到 thought 事件时自动展开并显示"思考中..." + this.$set(targetMsg, 'isStreaming', true) + if (!targetMsg.thoughtsText) this.$set(targetMsg, 'thoughtsText', '') + if (!targetMsg.toolCalls) this.$set(targetMsg, 'toolCalls', []) + if (!targetMsg.selectedAnswers) this.$set(targetMsg, 'selectedAnswers', {}) } - this.isStreaming = true - const loadingMsg = this.messages[loadingIdx] - this._stopLoadingTextRotation(loadingMsg) - loadingMsg.loadingText = this.$lang('ai-chat.index.receivingReply') + const findMsg = () => this.messages.find(m => m.id === targetMsg.id) - let transformed = false + const onEvent = (event, data) => { + const msg = findMsg() + if (!msg) return + switch (event) { + case 'message': { + // 首次收到正文: 把 loading 转成正式文本消息 + if (msg.type === 'loading') { + msg.type = 'text' + msg.content = '' + this.$set(msg, 'isStreaming', true) + this.$delete(msg, 'loadingText') + this.$delete(msg, '_loadingIndex') + } + msg.content = (msg.content || '') + (data.content || '') + this.scrollToBottom() + break + } + case 'thought': + this.$set(msg, 'thoughtsText', (msg.thoughtsText || '') + (data.content || '')) + // 文档 §1.4: "思考过程 (N 步)" 折叠标签需要的步数计数 + this.$set(msg, 'thoughtSteps', (msg.thoughtSteps || 0) + 1) + this.scrollToBottom() + break + case 'tool_call': { + if (!msg.toolCalls) this.$set(msg, 'toolCalls', []) + const list = msg.toolCalls + const tcId = data.tool_call_id || data.id + const idx = list.findIndex(t => t.tool_call_id === tcId) + if (idx >= 0) this.$set(list, idx, Object.assign({}, list[idx], data)) + else list.push(data) + break + } + case 'question': + this.$set(msg, 'pendingQuestion', data.pending_question) + this.$set(msg, 'questionAnswered', false) + this.$set(msg, 'rpc_id', data.pending_question && data.pending_question.rpc_id) + this.$set(msg, 'questionMessageId', data.message_id) + this.$set(msg, 'questionConversationId', data.conversation_id) + this.$set(msg, 'selectedAnswers', {}) + this.startQuestionTimer(msg) + break + case 'message_end': { + msg.isStreaming = false + this.isStreaming = false + if (data.content) msg.content = data.content + if (data.thoughts) { + this.$set(msg, 'thoughtsText', data.thoughts) + // 如果后端只在 message_end 一次返回完整 thoughts(如 HTTP 降级/aiagent HTTP), + // 按段落数反推步数, 让"思考过程 (N 步)"标签更准确 + if (!msg.thoughtSteps || msg.thoughtSteps <= 1) { + const steps = data.thoughts.split(/\n+/).filter(s => s.trim()).length || 1 + this.$set(msg, 'thoughtSteps', steps) + } + } + if (data.tool_calls && data.tool_calls.length) this.$set(msg, 'toolCalls', data.tool_calls) + if (data.tokens) this.$set(msg, 'tokens', data.tokens) + if (data.phase) this.$set(msg, 'phase', data.phase) + if (data.stop_reason === 'pending_stale') { + this.$set(msg, 'questionAnswered', true) + this.$set(msg, 'questionTimedOut', true) + } + if (data.conversation_id) { + this.currentConversationId = data.conversation_id + aiService.setConversationId(data.conversation_id) + this.saveConversationIdToLocal(data.conversation_id) + } + // 纯思考/工具无正文时, 保留思考面板; 完全空则移除 + if (!msg.content || !msg.content.trim()) { + if (!msg.thoughtsText) { + const i = this.messages.findIndex(m => m.id === msg.id) + if (i >= 0) this.messages.splice(i, 1) + } + } + this.$nextTick(() => this.scrollToBottom()) + this.$emit('ai-response', msg) + break + } + case 'error': + msg.isStreaming = false + this.isStreaming = false + msg.content = (msg.content || '') + '\n[错误] ' + (data.message || '未知错误') + break + case 'attachments': + if (!msg.attachments) this.$set(msg, 'attachments', []) + msg.attachments.push(...(data || [])) + break + } + } - const transformOnFirstChunk = () => { - if (transformed) return this.messages[loadingIdx] - transformed = true - const msg = this.messages[loadingIdx] - msg.type = 'text' - msg.content = '' - this.$set(msg, 'isStreaming', true) - this.$set(msg, 'actions', []) - delete msg.loadingText - delete msg._loadingIndex - return msg + const onComplete = (result) => { + const msg = findMsg() + if (!msg) return + if (msg.isStreaming === false) return // 已由 message_end 处理 + if (result && result.conversation_id) { + this.currentConversationId = result.conversation_id + aiService.setConversationId(result.conversation_id) + this.saveConversationIdToLocal(result.conversation_id) + } + if (!msg.content) msg.content = (result && result.content) || '' + msg.isStreaming = false + this.isStreaming = false + this.$nextTick(() => this.scrollToBottom()) + this.$emit('ai-response', msg) } try { // #ifdef H5 - await aiService.sendHttpStream( - userMessage, - (chunk) => { - const msg = transformOnFirstChunk() - msg.content += chunk - this.scrollToBottom() - }, - (completeResult) => { - const msg = this.messages[loadingIdx] - if (!this._finishStream(msg, completeResult)) return - } - ) + await aiService.sendHttpStream(userMessage, onEvent, onComplete, { attachments }) // #endif - // #ifdef MP-WEIXIN - await aiService.sendStreamMessage( - userMessage, - (chunk) => { - const msg = transformOnFirstChunk() - msg.content += chunk - this.scrollToBottom() - }, - (completeResult) => { - const msg = this.messages[loadingIdx] - if (!this._finishStream(msg, completeResult)) return - } - ) + await aiService.sendStreamMessage(userMessage, onEvent, onComplete, { attachments }) // #endif } catch (error) { console.error('流式请求失败:', error) this.isStreaming = false - - if (!transformed) { - // 在第一个 chunk 到达前出错 → 移除 loading 消息 + const msg = findMsg() + if (msg && msg.type === 'loading') { this._removeLoadingMessages() - } else { - // 流传输中出错 → 移除空的流式消息 - const msg = this.messages[loadingIdx] - if (msg && (!msg.content || msg.content.trim() === '')) { - this.messages.splice(loadingIdx, 1) - } + } else if (msg && (!msg.content || !msg.content.trim()) && !msg.thoughtsText) { + const i = this.messages.findIndex(m => m.id === msg.id) + if (i >= 0) this.messages.splice(i, 1) } - const errorMsg = { id: ++this.messageId, role: 'ai', @@ -1103,6 +1260,123 @@ async sendStreamMessage(userMessage) { } }, +// 思考流面板: 点击折叠/展开 (流式进行中不可收起) +toggleThought(msg) { + if (!msg.isStreaming) this.$set(msg, 'thoughtExpanded', !msg.thoughtExpanded) +}, + +// Question 选项: 单选立即提交, 多选切换 +toggleOption(msg, qi, q, opt) { + if (!msg.selectedAnswers) this.$set(msg, 'selectedAnswers', {}) + const key = 'q' + qi + if (q.multiSelect) { + const arr = msg.selectedAnswers[key] || [] + const idx = arr.indexOf(opt.label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(opt.label) + this.$set(msg.selectedAnswers, key, arr) + } else { + this.$set(msg.selectedAnswers, key, opt.label) + this.submitQuestionAnswer(msg) + } +}, + +confirmMultiSelect(msg) { + this.submitQuestionAnswer(msg) +}, + +async submitQuestionAnswer(msg) { + if (!msg.rpc_id) { + uni.showToast({ title: 'rpc_id 缺失, 无法回传', icon: 'none' }) + return + } + this.$set(msg, 'questionAnswered', true) + this.$set(msg, 'questionTimedOut', false) + if (msg.id && this.questionTimers[msg.id]) { + clearInterval(this.questionTimers[msg.id]) + this.$delete(this.questionTimers, msg.id) + } + try { + await aiService.elicitResponse({ + conversation_id: msg.questionConversationId || aiService.getConversationId(), + message_id: msg.questionMessageId, + tool_call_id: msg.pendingQuestion && msg.pendingQuestion.tool_call_id, + rpc_id: msg.rpc_id, + answers: msg.selectedAnswers + }) + // 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理 + } catch (e) { + uni.showToast({ title: '选项提交失败: ' + (e.message || ''), icon: 'none' }) + this.$set(msg, 'questionAnswered', false) + } +}, + +startQuestionTimer(msg) { + this.$set(msg, 'questionTimeout', 30) + if (!this.questionTimers) this.questionTimers = {} + if (this.questionTimers[msg.id]) clearInterval(this.questionTimers[msg.id]) + this.questionTimers[msg.id] = setInterval(() => { + if (!msg.pendingQuestion || msg.questionAnswered) { + if (this.questionTimers[msg.id]) { + clearInterval(this.questionTimers[msg.id]) + this.$delete(this.questionTimers, msg.id) + } + return + } + msg.questionTimeout -= 1 + if (msg.questionTimeout <= 0) { + if (this.questionTimers[msg.id]) { + clearInterval(this.questionTimers[msg.id]) + this.$delete(this.questionTimers, msg.id) + } + // 超时: 自动选第一个选项, 触发后端 pending_stale + const firstQ = msg.pendingQuestion.questions[0] + if (firstQ && firstQ.options[0]) { + this.$set(msg, 'selectedAnswers', { q0: firstQ.options[0].label }) + this.$set(msg, 'questionTimedOut', true) + this.submitQuestionAnswer(msg) + } + } + }, 1000) +}, + +previewAttachment(att) { + if (att.type === 'image') { + uni.previewImage({ urls: [att.url], current: att.url }) + } else if (att.type === 'video') { + uni.navigateTo({ url: '/pages_tool/file-preview/file-preview?url=' + encodeURIComponent(att.url) }) + } else { + // #ifdef H5 + window.open(att.url, '_blank') + // #endif + // #ifdef MP-WEIXIN + uni.downloadFile({ + url: att.url, + success: (res) => { + if (res.statusCode === 200) { + uni.openDocument({ filePath: res.tempFilePath, fail: () => uni.showToast({ title: '暂不支持预览该文件', icon: 'none' }) }) + } + }, + fail: () => uni.showToast({ title: '文件打开失败', icon: 'none' }) + }) + // #endif + } +}, + +// 新对话: 清后端会话 + 清前端 +async onNewChat() { + const cid = aiService.getConversationId() + if (cid) { + try { + await aiService.clearConversation(cid) + } catch (e) { + console.warn('[AI Chat] clearConversation 后端失败, 继续清前端:', e) + } + } + this.clearMessages() + uni.showToast({ title: '已开始新对话', icon: 'success' }) +}, + // 完成流式消息处理(共用逻辑) _finishStream(msg, result) { @@ -3982,4 +4256,163 @@ _handleStreamError(msg) { padding: 20rpx 22rpx 20rpx 24rpx !important; } /* #endif */ + +/* ========== 思考流 / 工具调用 / 选项卡片 / 附件 样式 ========== */ + +/* 顶部栏: 标题 + 新对话 */ +.chat-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16rpx 24rpx; + border-bottom: 1rpx solid rgba(17, 24, 39, 0.06); + background: #ffffff; + .chat-title { + font-size: 30rpx; + font-weight: 600; + color: #1f1f1f; + } + .new-chat-btn { + display: flex; + align-items: center; + gap: 6rpx; + padding: 10rpx 22rpx; + border-radius: 999rpx; + background: #f4f5f7; + color: #1f6cff; + font-size: 24rpx; + .new-chat-icon { font-size: 26rpx; } + } +} + +/* 思考流折叠面板 */ +.thought-panel { + background: #f6f8fa; + border-left: 4rpx solid #8b8b8b; + padding: 12rpx 16rpx; + margin-bottom: 12rpx; + border-radius: 8rpx; + font-size: 22rpx; + color: #666; + // 流式思考过程保持和完成后一致的灰色背景, 不出现黄色 + &.streaming { + background: #f6f8fa; + border-left-color: #8b8b8b; + .thought-label { color: #666; } + } + .thought-header { display: flex; align-items: center; } + .thought-icon { margin-right: 8rpx; } + .thought-label { flex: 1; } + .thought-toggle { font-size: 20rpx; color: #999; margin-left: 8rpx; } + .thought-content { + margin-top: 8rpx; + white-space: pre-wrap; + line-height: 1.6; + font-size: 22rpx; + max-height: 400rpx; + overflow-y: auto; + } +} + +/* 工具调用 */ +.tool-calls { + margin-top: 8rpx; + padding: 8rpx 12rpx; + background: #f0f4f8; + border-radius: 4rpx; + font-size: 22rpx; + .tool-call-item { padding: 4rpx 0; color: #666; } + .status-completed { color: #2e7d32; } + .status-failed { color: #c62828; } + .status-pending, .status-in_progress, .status-pending { color: #f57c00; } +} + +/* 附件 */ +.attachments { + display: flex; + flex-wrap: wrap; + gap: 12rpx; + margin-top: 12rpx; + .attachment { + width: 160rpx; + height: 160rpx; + border-radius: 12rpx; + overflow: hidden; + background: #f0f4f8; + display: flex; + align-items: center; + justify-content: center; + .att-image { width: 100%; height: 100%; } + .att-file { display: flex; align-items: center; gap: 8rpx; padding: 12rpx; font-size: 22rpx; color: #666; + .file-icon { font-size: 32rpx; } + } + } +} + +/* Question 选项卡片 */ +.question-card { + background: #ffffff; + border: 1rpx solid #e0e0e0; + border-radius: 12rpx; + padding: 16rpx; + margin-top: 12rpx; + .question-header-chip { + display: inline-block; + background: #f0f4f8; + color: #666; + padding: 4rpx 12rpx; + border-radius: 16rpx; + font-size: 22rpx; + margin-bottom: 8rpx; + } + .question-text { font-size: 28rpx; color: #333; margin-bottom: 12rpx; } + .question-timer { font-size: 22rpx; color: #f57c00; float: right; } + .question-options { + display: flex; + flex-wrap: wrap; + gap: 12rpx; + .option-btn { + background: #f6f8fa; + border: 1rpx solid #e0e0e0; + border-radius: 8rpx; + padding: 12rpx 20rpx; + font-size: 26rpx; + &.selected { + background: #e3f2fd; + border-color: #1976d2; + color: #1976d2; + } + .option-label { display: block; } + .option-desc { display: block; font-size: 22rpx; color: #999; margin-top: 4rpx; } + } + } + .confirm-row { + margin-top: 12rpx; + text-align: right; + .confirm-btn { + background: #1976d2; + color: #fff; + border-radius: 8rpx; + padding: 8rpx 24rpx; + font-size: 26rpx; + } + } +} + +/* 用户图片气泡 */ +.user-image-bubble { + padding: 6rpx !important; + overflow: hidden; + .user-image { width: 280rpx; height: 280rpx; border-radius: 12rpx; display: block; } + .upload-mask { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + .upload-tip { color: #fff; font-size: 24rpx; } + &.upload-error .upload-tip { color: #ffb3b3; } + } +} diff --git a/pages_tool/ai-chat/components/ai-chat-tools-panel.vue b/pages_tool/ai-chat/components/ai-chat-tools-panel.vue index dbcbdd5..a8c5546 100644 --- a/pages_tool/ai-chat/components/ai-chat-tools-panel.vue +++ b/pages_tool/ai-chat/components/ai-chat-tools-panel.vue @@ -39,6 +39,7 @@