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

@@ -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()