chore:AI智能显示思考过程
This commit is contained in:
@@ -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();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user