chore:AI智能显示思考过程

This commit is contained in:
2026-09-14 17:49:30 +08:00
parent 278645230a
commit 782a26f446
3 changed files with 872 additions and 214 deletions

View File

@@ -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<boolean>}
*/
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();
},
/**

View File

@@ -11,6 +11,15 @@
@keyword-change="onSearchKeywordChange"
/>
<!-- 顶部栏: 标题 + 新对话 -->
<view class="chat-topbar">
<text class="chat-title">{{ $lang('ai-chat.index.aiNickname') }}</text>
<view class="new-chat-btn" @click="onNewChat">
<text class="new-chat-icon">🔄</text>
<text>新对话</text>
</view>
</view>
<!-- 聊天消息列表 - scroll-into-view 用于多端滚动定位(兼容微信小程序) -->
<scroll-view
class="chat-messages"
@@ -28,7 +37,7 @@
<!-- 消息列表 -->
<view
v-for="(message, index) in messages.filter(msg => msg.type === 'loading' || (msg.content && msg.content.trim() !== ''))"
v-for="(message, index) in messages.filter(msg => msg.type === 'loading' || msg.imageUrl || (msg.content && msg.content.trim() !== ''))"
:key="getMessageKey(message, index)"
:id="getMessageKey(message, index)"
class="message-item"
@@ -37,7 +46,12 @@
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
<view v-if="message.role === 'user'" class="user-message">
<view class="message-content">
<view class="message-bubble">
<view v-if="message.type === 'image'" class="message-bubble user-image-bubble">
<image :src="message.imageUrl" mode="aspectFill" class="user-image" />
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
</view>
<view v-else class="message-bubble">
<rich-text v-if="searchKeyword" :nodes="highlightContent(message.content)" class="message-rich-text" />
<text v-else class="message-text" selectable>{{ message.content }}</text>
</view>
@@ -60,6 +74,18 @@
<text v-else class="avatar-emoji">🤖</text>
</view>
<view class="message-content">
<!-- 思考流折叠面板 (AI 思考过程) -->
<view v-if="message.role === 'ai' && message.thoughtsText" class="thought-panel" :class="{ streaming: message.isStreaming && message.thoughtsText }">
<view class="thought-header" @click="toggleThought(message)">
<text class="thought-icon">{{ message.isStreaming ? '🧠' : '💡' }}</text>
<text class="thought-label">{{ message.isStreaming ? '思考中...' : ('思考过程 (' + (message.thoughtSteps || 1) + ' 步)') }}</text>
<text v-if="!message.isStreaming" class="thought-toggle">{{ message.thoughtExpanded ? '▼' : '▶' }}</text>
</view>
<view v-if="message.isStreaming || message.thoughtExpanded" class="thought-content">
<text>{{ message.thoughtsText }}</text>
</view>
</view>
<!-- AI昵称固定,不可修改 - 左移到气泡上方 -->
<view class="message-nickname ai-nickname">
<text class="ai-nickname-text">{{ $lang('ai-chat.index.aiNickname') }}</text>
@@ -203,6 +229,44 @@
</view>
</view>
<!-- 工具调用 (调试可见性) -->
<view v-if="message.toolCalls && message.toolCalls.length" class="tool-calls">
<view v-for="tc in message.toolCalls" :key="tc.tool_call_id || tc.id" class="tool-call-item" :class="['status-' + (tc.status || 'pending')]">
<text>🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? (' (' + tc.duration_ms + 'ms)') : '' }}</text>
</view>
</view>
<!-- 附件 (图片/文件) -->
<view v-if="message.attachments && message.attachments.length" class="attachments">
<view v-for="att in message.attachments" :key="att.url" class="attachment" @click="previewAttachment(att)">
<image v-if="att.type === 'image'" :src="att.url" mode="aspectFill" class="att-image" />
<view v-else class="att-file">
<text class="file-icon">📎</text>
<text class="file-name">{{ att.name || '文件' }}</text>
</view>
</view>
</view>
<!-- Question 选项卡片 -->
<view v-if="message.pendingQuestion && !message.questionAnswered" class="question-card">
<view v-for="(q, qi) in message.pendingQuestion.questions" :key="qi" class="question-block">
<view v-if="q.header" class="question-header-chip">{{ q.header }}</view>
<view class="question-text">{{ q.question }}</view>
<view v-if="message.questionTimeout > 0" class="question-timer">⏱ {{ message.questionTimeout }}s</view>
<view class="question-options">
<view v-for="opt in q.options" :key="opt.label" class="option-btn"
:class="{ selected: q.multiSelect ? ((message.selectedAnswers && message.selectedAnswers['q'+qi]) || []).includes(opt.label) : (message.selectedAnswers && message.selectedAnswers['q'+qi] === opt.label) }"
@click="toggleOption(message, qi, q, opt)">
<text class="option-label">{{ opt.label }}</text>
<text v-if="opt.description" class="option-desc">{{ opt.description }}</text>
</view>
</view>
<view v-if="q.multiSelect && (message.selectedAnswers && (message.selectedAnswers['q'+qi] || []).length)" class="confirm-row">
<button class="confirm-btn" @click="confirmMultiSelect(message, qi)">确认 ({{ (message.selectedAnswers['q'+qi] || []).length }})</button>
</view>
</view>
</view>
<!-- AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 更多 -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading'">
<view class="ai-action-btn" @click="onAiActionCopy(message)">
@@ -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; }
}
}
</style>

View File

@@ -39,6 +39,7 @@
</template>
<script>
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
/**
* ai-chat-tools-panel - 更多工具面板组件
*
@@ -74,46 +75,57 @@ export default {
},
selectImage() {
const that = this
uni.chooseImage({
count: 1,
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const imageMessage = {
id: ++this.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: res.tempFilePaths[0],
timestamp: Date.now()
success: async (res) => {
that.hideToolsPanel()
for (const p of res.tempFilePaths) {
const pending = {
id: ++that.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: p,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
that.$emit('image-selected', pending)
try {
const uploaded = await uploadImage(p)
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploaded', uploaded)
that.$set(pending, 'imageUrl', uploaded.url)
} catch (e) {
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '图片上传失败: ' + (e.message || ''), icon: 'none' })
}
}
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选图失败', icon: 'none' })
}
this.hideToolsPanel()
this.$emit('image-selected', imageMessage)
}
})
},
selectFile() {
const that = this
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = (e) => {
input.onchange = async (e) => {
const file = e.target.files[0]
if (file) {
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
}
input.click()
// #endif
@@ -121,26 +133,46 @@ export default {
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
success: (res) => {
success: async (res) => {
that.hideToolsPanel()
const file = res.tempFiles[0]
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
url: file.path,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
await that._uploadOneFile(file, 'file')
}
})
// #endif
},
async _uploadOneFile(file, kind) {
const path = file.path || (file.name ? URL.createObjectURL(file) : '')
const pending = {
id: ++this.messageId,
role: 'user',
type: kind,
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
imageUrl: path,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
this.$emit('file-selected', pending)
try {
const uploaded = kind === 'image'
? await uploadImage(file.path || file)
: await uploadMedia(file.path || file, kind)
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploaded', uploaded)
if (uploaded.url) this.$set(pending, 'imageUrl', uploaded.url)
} catch (e) {
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '上传失败: ' + (e.message || ''), icon: 'none' })
}
},
startRecord() {
this.$emit('voice-start')
this.hideToolsPanel()