chore:AI智能显示思考过程
This commit is contained in:
@@ -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