Files
lucky_shop/pages_tool/ai-chat/ai-chat-message.vue

7704 lines
298 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="ai-chat-container" :style="aiChatContainerStyle">
<!-- 搜索组件 -->
<ai-chat-search
ref="searchRef"
:messages="messages"
:show-label="false"
@scroll-to="onSearchScrollTo"
@active-change="onSearchActiveChange"
@result-change="onSearchResultChange"
@keyword-change="onSearchKeywordChange"
/>
<!-- 顶部栏: 标题 + 新对话 -->
<view class="chat-topbar">
<!-- PR-CHAT-UI-CONFIG-V2 (2026-09-24): 智能体显示名走后端配置, 空则回落 i18n 默认
PR-CHAT-UI-CONFIG-V3 (2026-09-24): ui_show_assistant_name 控制顶栏名称显隐 -->
<text class="chat-title" v-if="chatUiConfig.ui_show_assistant_name">{{ chatUiConfig.ui_assistant_name || $lang('ai-chat.index.aiNickname') }}</text>
<view class="new-chat-btn" @click="onNewChat">
<!-- 气泡+加号线性图标 (base64 SVG: 小程序不支持内联 SVG) -->
<view class="new-chat-icon"></view>
<text>新对话</text>
</view>
</view>
<!-- 聊天消息列表 - scroll-into-view 用于多端滚动定位(兼容微信小程序) -->
<scroll-view
class="chat-messages"
scroll-y
:scroll-into-view="scrollIntoViewId"
:scroll-top="scrollTop"
:scroll-with-animation="true"
:style="chatMessagesStyle"
@scroll="onScroll">
<!-- 加载更多历史消息 -->
<view class="load-more" v-if="hasMoreHistory">
<ns-loading :down-text="loadingText" :is-rotate="true" />
</view>
<!-- 消息列表 -->
<!-- PR-USER-MULTI-VOICE-FILTER (2026-09-28): 补 voiceList / videoList / attachments 字段检查
原过滤只识别顶层 fileName / audioUrl / imageUrl / videoUrl,
多条语音/视频消息顶层无这些字段 (在 voiceList/videoList 里嵌套), 会被误过滤掉不渲染
修复: 显式判断 voiceList / videoList 非空 + attachments 非空
PR-LOCATION-FILTER (2026-09-28): 位置消息顶层无上述字段, 但有 latitude/longitude,
单独判断以确保位置气泡不被过滤掉 -->
<view
v-for="(message, index) in messages.filter(msg =>
msg.type === 'loading'
|| msg.imageUrl
|| msg.fileName
|| msg.audioUrl
|| msg.videoUrl
|| (msg.voiceList && msg.voiceList.length)
|| (msg.videoList && msg.videoList.length)
|| (msg.attachments && msg.attachments.length)
|| (msg.latitude !== undefined && msg.longitude !== undefined)
|| (msg.content && msg.content.trim() !== '')
)"
:key="getMessageKey(message, index)"
:id="getMessageKey(message, index)"
class="message-item"
:class="[message.role, { 'first-message': index === 0, 'is-welcome': message.isWelcome, 'search-current': showSearchBar && searchResultFilteredIndex === index, 'search-flash': showSearchBar && searchFlashAnimatingIndex === index }]"
v-if="!(message.isWelcome && (inputFocused || hasUserMessage))">
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
<view v-if="message.role === 'user'" class="user-message">
<view class="message-content">
<view v-if="message.type === 'image'" class="user-media-group">
<!-- 图片气泡 (在上, 与下方文字气泡同列右对齐)
PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 上传几张就显示几张, 横向排列 -->
<view class="user-image-row" :class="'n' + Math.min(messageImages(message).length || 1, 4)">
<view
v-for="(img, imgIndex) in messageImages(message)"
:key="imgIndex"
class="message-bubble user-image-bubble">
<image
:src="img.url"
mode="aspectFill"
class="user-image"
@click="previewUserImage(message, imgIndex)" />
<view v-if="imgIndex === 0 && message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
<view v-if="imgIndex === 0 && message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
</view>
</view>
<!-- 图片配文: 独立气泡, 显示在图片下方 -->
<view v-if="message.content" class="message-bubble image-caption-bubble">
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
tools-panel _uploadOneFile 选 kind='file'/'video'/'audio' 时,
m.type + m.fileName/size + m.imageUrl=本地预览(若有) / m.uploaded.url 上传后 URL
PR-USER-MEDIA-TYPE-BUBBLE (2026-09-28): 文件 + 文字混合时, 配文显示在文件下方 -->
<view v-else-if="message.type === 'file'" class="user-media-group">
<view class="message-bubble user-file-bubble" @click="previewUserFile(message)">
<view class="file-item">
<view class="file-icon"><text class="file-icon-emoji">{{ message.type === 'video' ? '🎬' : (message.type === 'audio' ? '🎵' : '📄') }}</text></view>
<view class="file-info">
<text class="file-name">{{ message.fileName || '文件' }}</text>
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
</view>
<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>
<!-- 文件配文: 独立气泡, 显示在文件下方 -->
<view v-if="message.content" class="message-bubble image-caption-bubble">
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- 用户视频消息气泡 (视频上传): 内嵌播放器
PR-USER-MEDIA-TYPE-BUBBLE (2026-09-28): 视频 + 文字混合时, 配文显示在视频下方
(与图片的 image-caption-bubble 风格一致)
PR-USER-MULTI-VIDEO (2026-09-28): 支持多条视频同时发送,
每条视频独占一个 user-video-bubble, v-for 渲染多个 <video> 播放器
(与 voiceList 的多语音渲染方式一致) -->
<view v-else-if="message.type === 'video'" class="user-media-group">
<view v-for="(v, idx) in (message.videoList || [])" :key="idx" class="message-bubble user-video-bubble">
<view class="video-item">
<video
class="user-video"
:src="v.url"
controls
:autoplay="false"
preload="none"
object-fit="contain"></video>
</view>
</view>
<!-- 视频配文: 独立气泡, 显示在视频下方 -->
<view v-if="message.content" class="message-bubble image-caption-bubble">
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- PR-LOCATION-PENDING (2026-09-28): 用户位置气泡
显示在用户消息列表, 同时通过 context.location 字段透传给 WB.
📍 emoji + 名称 + 地址 + 坐标 (备选) -->
<view v-else-if="message.type === 'location'" class="message-bubble user-location-bubble">
<view class="location-item">
<text class="location-icon">📍</text>
<view class="location-info">
<text class="location-name">{{ message.name || $lang('ai-chat.index.location') || '位置' }}</text>
<text class="location-address" v-if="message.address">{{ message.address }}</text>
<text class="location-coord" v-else>{{ message.latitude }}, {{ message.longitude }}</text>
</view>
</view>
<view v-if="message.content" class="voice-bubble-text">
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- PR-USER-VOICE-BUBBLE (2026-09-29): 用户语音消息气泡 —— 微信原生风格
- 左侧显示时长 X", 右侧显示扬声器 + 声波动画
- 气泡右侧带小三角尾巴
- 宽度按时长比例伸缩 (短语音窄、长语音宽)
PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
每条 voice 项都有自己的 url/duration/playing 字段,
模板 v-for 渲染多个 .voice-msg 行, 每行点击 → openVoiceOverlay(v)
→ 中央播放面板, 不同语音独立可播放互不干扰 -->
<view
v-else-if="message.type === 'audio'"
class="message-bubble user-audio-bubble">
<view v-for="(v, idx) in (message.voiceList || [])" :key="idx" class="voice-msg" :style="{ width: getVoiceBubbleWidth(v.duration) }" @click.stop="openVoiceOverlay(v)">
<text class="voice-duration">{{ getVoiceDurationText(v.duration) }}</text>
<view class="voice-speaker" :class="{ playing: v.playing }">
<view class="voice-speaker-body"></view>
<view class="voice-speaker-cone"></view>
<view class="voice-speaker-waves">
<view class="voice-speaker-wave"></view>
<view class="voice-speaker-wave"></view>
</view>
</view>
</view>
<!-- 语音 + 文字混合时, 文字写在语音列表下方 -->
<view v-if="message.content" class="voice-bubble-text">
<text class="message-text" selectable>{{ message.content }}</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>
<!-- 用户发送的附件(文件/视频/音频)预览: 与文字/图片同气泡下方展示。
图片附件不在此重复渲染(messageImages 已在上方图片气泡展示), 这里只渲染非图片附件 -->
<view v-if="message.attachments && message.attachments.length && message.role === 'user'" class="attachments user-attachments">
<template v-for="att in message.attachments">
<view v-if="!(att.type === 'image' || att.kind === 'image')" :key="att.url" class="attachment" @click="previewAttachment(att)">
<text class="file-icon">{{ att.type === 'video' ? '🎬' : (att.type === 'audio' ? '🎵' : '📎') }}</text>
<text class="file-name">{{ att.name || '文件' }}</text>
</view>
</template>
</view>
</view>
<view class="avatar" @tap="showAvatarSelector = true">
<image :src="localUserAvatar" mode="aspectFill" show-menu-by-longpress/>
</view>
</view>
<!-- AI消息 -->
<view v-else class="ai-message">
<!-- PR-CHAT-UI-CONFIG-V3 (2026-09-24): ui_show_assistant_avatar 控制头像显隐; 自定义头像在 aiAvatarUrl computed 优先 -->
<view class="avatar" v-if="chatUiConfig.ui_show_assistant_avatar">
<!-- 智能客服头像 -->
<image
v-if="aiAvatarUrl && !avatarError"
:src="aiAvatarUrl"
mode="aspectFill"
@error="handleAvatarError"
show-menu-by-longpress/>
<text v-else class="avatar-emoji">🤖</text>
</view>
<view class="message-content">
<!-- 思考流折叠面板 (AI 思考过程) -->
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 后端可关闭 ui_show_thinking_process, 默认 true 不变体感 -->
<view v-if="message.role === 'ai' && message.thoughtsText && chatUiConfig.ui_show_thinking_process" class="thought-panel" :class="{ streaming: message.isStreaming && message.thoughtsText }">
<view class="thought-header" @tap.stop="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 === false ? '▶' : '▼' }}</text>
</view>
<view v-if="message.isStreaming || message.thoughtExpanded !== false" class="thought-content">
<!-- PR-CHAT-SELECTABLE (2026-09-23): 思考过程文本同样支持长按选中复制 -->
<text selectable>{{ message.thoughtsText }}</text>
</view>
</view>
<!-- AI 回答上方只显示时间(昵称"智能助手"已按需求移除);欢迎语消息不显示时间 -->
<view class="message-nickname ai-nickname" v-if="chatUiConfig.ui_show_message_time && !message.isWelcome">
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
</view>
<view class="message-bubble">
<!-- 文本消息:小程序用 text selectable,H5 用可选择的 HTML 渲染 -->
<view v-if="message.type === 'text'" class="text-content">
<!-- #ifdef MP-WEIXIN -->
<!-- PR-CHAT-SELECTABLE (2026-09-23): rich-text 不支持文本选中, 改用 mp-html + selectable
开启原生长按选中(剪切/复制/粘贴/全选); force 同时兼容微信 iOS -->
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content, message.isWelcome)" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="ai-message-text selectable-md" v-html="getAIDisplayContent(message.content, message.isWelcome)"></view>
<!-- #endif -->
</view>
<!-- Markdown文档:小程序用 text selectable,H5 用可选择的 HTML 渲染 -->
<view v-else-if="message.type === 'markdown'" class="markdown-content">
<view class="markdown-header">
<text class="iconfont icon-markdown"></text>
<text class="markdown-title"></text>
</view>
<view class="markdown-body">
<!-- #ifdef MP-WEIXIN -->
<!-- PR-CHAT-SELECTABLE (2026-09-23): 同上, mp-html + selectable 支持长按选中复制 -->
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content, message.isWelcome)" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="selectable-md" v-html="getAIDisplayContent(message.content, message.isWelcome)"></view>
<!-- #endif -->
</view>
</view>
<!-- PR-AI-STREAMING-TAIL (2026-09-28): AI 流式回答中, 在已显示内容下方追加 "思考中..." 指示
仅当消息处于 streaming 状态且非 loading 占位时显示;
流式结束 (isStreaming=false) 时自动消失, 与 .ai-actions 操作栏互补呈现 -->
<view
v-if="message.isStreaming && (message.type === 'text' || message.type === 'markdown')"
class="streaming-tail">
<view class="typing-dots-inline">
<text class="dot"></text>
<text class="dot"></text>
<text class="dot"></text>
</view>
<text class="streaming-tail-text">{{ $lang('ai-chat.index.aiThinking') || '思考中' }}</text>
<text class="streaming-tail-ellipsis">
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
</text>
</view>
<!-- 文件链接 -->
<view v-else-if="message.type === 'file'" class="file-content">
<view class="file-item" @click="previewFile(message)">
<view class="file-icon">
<text class="iconfont icon-file"></text>
</view>
<view class="file-info">
<text class="file-name">{{ message.fileName }}</text>
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
</view>
<view class="file-action">
<text class="iconfont icon-download"></text>
</view>
</view>
</view>
<!-- 音频消息 -->
<view v-else-if="message.type === 'audio'" class="audio-content" @click="openVoiceOverlay(message)">
<view class="audio-player">
<view class="audio-info">
<text class="audio-title">{{ message.title || $lang('ai-chat.index.audioMessage') }}</text>
<text class="audio-duration">{{ formatDuration(message.duration) }}</text>
</view>
<view class="audio-controls">
<button
class="play-btn"
:class="{ playing: message.playing }"
@click.stop="openVoiceOverlay(message)">
<text class="icondiy" :class="message.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
</button>
<slider
class="audio-slider"
:value="message.currentTime || 0"
:max="message.duration || 0"
@changing="onAudioSliderChange"
@change="onAudioSliderChangeEnd"
activeColor="#8a9fb8"
backgroundColor="#e8edf3"
block-size="12" />
</view>
</view>
</view>
<!-- 视频消息 -->
<view v-else-if="message.type === 'video'" class="video-content">
<view class="video-player">
<video
ref="videoRef"
:src="message.url"
:poster="message.cover"
:controls="true"
:autoplay="false"
class="video-element"
@loadedmetadata="onVideoLoaded(message, $event)"
@play="onVideoPlay(message)"
@pause="onVideoPause(message)">
</video>
<view class="video-info">
<text class="video-title">{{ message.title || $lang('ai-chat.index.videoMessage') }}</text>
<text class="video-duration">{{ formatDuration(message.duration) }}</text>
</view>
</view>
</view>
<!-- 跳转链接 -->
<view v-else-if="message.type === 'link'" class="link-content">
<view class="link-card" @click="openLink(message)">
<view class="link-image" v-if="message.image">
<image :src="message.image" mode="aspectFill" show-menu-by-longpress/>
</view>
<view class="link-info">
<text class="link-title">{{ message.title }}</text>
<text class="link-desc">{{ message.description }}</text>
<text class="link-url">{{ message.url }}</text>
</view>
</view>
</view>
<!-- 加载/处理中状态 -->
<view v-else-if="message.type === 'loading'" class="loading-content">
<view class="loading-typing">
<view class="typing-dots">
<text class="dot"></text>
<text class="dot"></text>
<text class="dot"></text>
</view>
<text class="loading-text">
<text class="loading-status">{{ message.loadingText || $lang('ai-chat.index.aiThinking') }}</text>
<text class="loading-ellipsis">
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
</text>
</text>
</view>
</view>
<!-- PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题
欢迎语气泡下方显示快捷问题 chips, 点击直接作为用户消息发送
一次性,点了之后从 message 上摘除 (避免重复点) -->
<view v-if="message.quickQuestions && message.quickQuestions.length > 0 && !inputFocused" class="quick-questions-row">
<view
v-for="(q, qi) in message.quickQuestions"
:key="qi"
class="quick-question-chip"
hover-class="quick-question-chip--hover"
@click="onQuickQuestionClick(message, q)">
<view
class="quick-question-icon"
:style="{ backgroundImage: 'url(' + quickQuestionIconList[(qi + (message._qqBatchIndex || 0) * 4) % quickQuestionIconList.length] + ')' }">
</view>
<text class="quick-question-text">{{ getQuickQuestionText(q) }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="message-actions" v-if="message.actions && message.actions.length > 0">
<button
v-for="action in message.actions"
:key="action.id"
class="action-btn"
:class="[`iconfont`, action.icon, action.type]"
@click="handleAction(action, message)">
{{ action.text }}
</button>
</view>
</view>
<!-- PR-CHAT-UI-HIDE-TOOL-CALLS (2026-09-23): 工具调用列表(Skill/Terminal: completed 等)
已按需求永久移除, AI 每次回答都不再显示该模块.
后端仍会下发 tool_calls 字段(仅存于 message.toolCalls 数据里, 不参与渲染) -->
<!-- 附件 (图片/文件) -->
<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 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 更多 -->
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 后端可关闭 ui_enable_feedback (隐藏点赞/点踩/更多), 默认 true -->
<!-- PR-AI-ACTIONS-AFTER-DONE (2026-09-28): 仅在 AI 完全回答完成后才显示操作栏 (排除流式中), 避免一边打字一边出现复制/点赞按钮的"半成品"感 -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading' && !message.isStreaming && (chatUiConfig.ui_enable_feedback || message.isWelcome)">
<!-- 欢迎语下方操作栏: 换成"换一换" -->
<view v-if="message.isWelcome" class="ai-action-btn refresh-welcome-btn" @click="onAiActionRefreshWelcome(message)">
<view class="refresh-icon"></view>
<text class="refresh-text">换一换</text>
</view>
<template v-else>
<view class="ai-action-btn" @click="onAiActionCopy(message)">
<text class="iconfont icon-copy">⎘</text>
</view>
<view class="ai-action-btn" :class="{ active: message._liked }" @click="onAiActionLike(message)">
<!-- PR-FRONTEND-ACTION-SVG-ICON (2026-09-18): 点赞/点踩改用线性 SVG 矢量图, 不再用彩色 emoji -->
<view class="svg-icon icon-like"></view>
</view>
<view class="ai-action-btn" :class="{ active: message._disliked }" @click="onAiActionDislike(message)">
<view class="svg-icon icon-dislike"></view>
</view>
<view class="ai-action-btn more-btn" @click="onAiActionMore(message)">
<text class="iconfont icon-more">···</text>
</view>
</template>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
<view class="input-area" :style="inputAreaStyle">
<view class="input-card" :class="{ 'is-voice-mode': voiceMode, 'is-text-mode': showTextMode }">
<!-- 待发送附件预览(图片 / 文件 / 视频 / 音频): 挂在输入条内部顶端(文字行上方),
出现时输入框自动向上撑高, 删除后恢复原高度。
图片与文件走同一套 pending 逻辑: 选完先在这里预览, 等用户点发送再随文字一起发出 -->
<view v-if="pendingImages.length || pendingFiles.length" class="pending-attachment">
<view v-for="p in pendingImages" :key="p.localId" class="pending-item">
<image :src="p.uploaded ? p.uploaded.url : p.tempPath" mode="aspectFill" class="pending-image" />
<view v-if="p.uploading" class="pending-mask"><text>上传中</text></view>
<view v-else-if="p.uploadError" class="pending-mask error"><text>失败</text>
<!-- PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败时给 "删除" 按钮, 解决用户卡死 -->
<view class="pending-retry-delete" @tap.stop="removePendingImage(p.localId)">删除</view>
</view>
<view class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
</view>
<view v-for="f in pendingFiles" :key="f.localId" class="pending-item" :class="{ 'pending-item-voice': f.type === 'audio' }">
<image v-if="f.type === 'image' && (f.uploaded ? f.uploaded.url : f.imageUrl)" :src="f.uploaded ? f.uploaded.url : f.imageUrl" mode="aspectFill" class="pending-image" />
<!-- PR-PENDING-VOICE-PREVIEW (2026-09-28): 待发送语音预览也支持播放 + 显示时长
复用既有 innerAudioCtx (与发送出去的语音气泡共享同一引擎),
直接调 toggleAudio(f) 即可, pending 文件已有 url/playing/currentTime/duration 字段 -->
<view v-else-if="f.type === 'audio'" class="pending-voice-thumb" @tap.stop="openVoiceOverlay(f)">
<view class="pending-voice-play" :class="{ playing: f.playing, disabled: f.uploading || f.uploadError }">
<text class="icondiy" :class="f.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
</view>
<view class="pending-voice-info">
<text class="pending-voice-label">{{ $lang('ai-chat.index.audioMessage') || '语音' }}</text>
<view class="pending-voice-progress-track">
<view class="pending-voice-progress-bar" :style="{ width: getVoiceProgress(f) + '%' }"></view>
</view>
</view>
<text class="pending-voice-duration">{{ formatDuration((f.duration || 0) / 1000) }}</text>
</view>
<view v-else-if="f.type === 'location'" class="pending-location-thumb">
<text class="pending-location-icon">📍</text>
<view class="pending-location-info">
<text class="pending-location-name">{{ f.name || $lang('ai-chat.index.location') || '位置' }}</text>
<text class="pending-location-addr" v-if="f.address">{{ f.address }}</text>
</view>
</view>
<view v-else class="pending-file-thumb">
<text class="pending-file-icon">{{ f.type === 'video' ? '🎬' : (f.type === 'audio' ? '🎵' : '📄') }}</text>
<text class="pending-file-name">{{ f.fileName }}</text>
</view>
<view v-if="f.uploading" class="pending-mask"><text>上传中</text></view>
<view v-else-if="f.uploadError" class="pending-mask error"><text>失败</text>
<view class="pending-retry-delete" @tap.stop="removePendingFile(f.localId)">删除</view>
</view>
<view class="pending-remove" @tap="removePendingFile(f.localId)">×</view>
</view>
</view>
<!-- PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态
① 默认(未输入/未聚焦): 单行胶囊 —— 语音图标 + 输入框 + 加号
② 语音模式(点语音图标进入): 「按住说话」按钮 + 底部工具栏(表情/模型/加号)
③ 文字输入(聚焦或有文字): 输入框独占一行 + 底部工具栏(语音/模型/加号/发送) -->
<!-- 语音模式 (PR-VOICE-MODE-ONE-ROW-V2): 整行即白色胶囊(flex),
键盘图标在左、+ 在右、按住说话占中间 —— 不依赖绝对定位, 结构上保证单行 -->
<view
class="voice-mode-row"
:class="{ 'is-holding': voiceHolding, 'is-cancelling': voiceHoldCancelling, 'hidden-row': !voiceMode }">
<!-- 左侧: 键盘图标 (点击回到文字输入) -->
<view class="voice-side-btn voice-side-left" @click="exitVoiceMode">
<view class="emoji-icon-bg"></view>
</view>
<!-- 中间: 按住说话 (按住录音, 松开发送) -->
<view
class="voice-hold-btn"
@touchstart.stop.prevent="onVoiceHoldStart"
@touchmove.stop.prevent="onVoiceHoldMove"
@touchend.stop.prevent="onVoiceHoldEnd"
@touchcancel.stop.prevent="onVoiceHoldCancel"
@click.stop="onVoiceHoldEnd">
<text class="voice-hold-text">{{ voiceHoldText }}</text>
</view>
<!-- 右侧: 加号 -->
<button class="top-plus-btn voice-side-right" @click="showMoreTools">
<text class="plus-icon">+</text>
</button>
</view>
<!-- 按住时的「上滑取消」提示 (H5/MP 共用) -->
<view v-if="voiceHolding" class="voice-cancel-hint">{{ voiceHoldCancelling ? $lang('ai-chat.index.slideUpToCancelHint') : $lang('ai-chat.index.slideUpToCancel') }}</view>
<!-- PR-FIX-V-IF-ELSE-ADJACENT (2026-09-28): 原 v-else 配对被上方 voice-cancel-hint 打断,
Vue 2 要求 v-if/v-else 紧邻, 否则 v-else 会错配到上一个 v-if (voiceHolding),
导致 voiceMode=true 时 input-row-top 也被显示.
改成显式 v-if="!voiceMode", 避免依赖 v-else 的隐式配对 -->
<view class="input-row-top" :class="{ 'hidden-row': voiceMode }">
<!-- 默认态左侧: 点击进入语音(按住说话)模式 -->
<!-- PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 兄弟节点加 key, 防止 H5 下 Vue 无 key 复用
同标签 view 导致 textarea 被销毁重建、聚焦即失焦(塌回胶囊无法输入) -->
<view v-if="!showTextMode" key="voice-icon" class="input-row-icon" @click="enterVoiceMode">
<!-- 微信小程序不支持内联 SVG,改用 base64 SVG 背景图 -->
<view class="voice-icon-bg"></view>
</view>
<!-- 输入区: textarea 三态共用同一个节点, 避免切换时丢焦点 -->
<view key="input-content" class="input-left-content" :class="{ 'with-voice-icon': !showTextMode }">
<textarea
ref="messageInput"
v-model="inputText"
class="message-input"
:placeholder="inputPlaceholder"
placeholder-class="message-input-placeholder"
:maxlength="chatUiConfig.ui_input_max_length"
:auto-height="true"
:show-confirm-bar="false"
:adjust-position="adjustPosition"
:cursor-spacing="150"
@confirm="sendMessage"
@input="onInput"
@touchstart="onInputPointerDown"
@mousedown="onInputPointerDown"
@focus="onInputFocus"
@blur="onInputBlur"
@linechange="onLineChange" />
</view>
<!-- 默认态右侧: 只有加号 -->
<view v-if="!showTextMode" key="right-actions" class="input-right-actions">
<button class="top-plus-btn" @click="showMoreTools">
<text class="plus-icon">+</text>
</button>
</view>
</view>
<!-- 底部工具栏行: 仅文字输入时显示 (语音模式的键盘/加号已并入按住说话同一行) -->
<view v-if="showTextMode" class="input-toolbar-row">
<!-- 文字输入: 语音图标 (切到按住说话) -->
<view class="toolbar-round-btn" @click="enterVoiceMode">
<view class="voice-icon-bg"></view>
</view>
<!-- PR-HIDE-MODEL-SELECTOR (2026-09-24): 暂时隐藏模型选择器,统一只用 Auto;恢复时把 v-if 改回 true 或去掉 -->
<view v-if="false" class="model-selector" @click="onToggleModelSelector">
<view class="model-icon-circle">
<text class="model-icon-text">@</text>
</view>
<text class="model-name">{{ selectedModel }}</text>
<text class="model-arrow">▾</text>
</view>
<view class="toolbar-flex-spacer"></view>
<button class="top-plus-btn" @click="showMoreTools">
<text class="plus-icon">+</text>
</button>
<!-- 有文字或图片附件上传完成时显示发送按钮 -->
<button class="top-send-btn" :class="{ 'is-disabled': !canSend }" @click="sendMessage">
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
<text class="send-plane-text">➤</text>
</button>
</view>
<!-- 底部工具栏: 5 个图标按钮均匀分布(当前隐藏,保留代码) -->
<view class="input-toolbar" style="display: none;">
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('task')">
<view class="toolbar-icon icon-task"></view>
<text class="toolbar-text">任务</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('automation')">
<view class="toolbar-icon icon-automation"></view>
<text class="toolbar-text">自动化</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('library')">
<view class="toolbar-icon icon-library"></view>
<text class="toolbar-text">资料库</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('expert')">
<view class="toolbar-icon icon-expert"></view>
<text class="toolbar-text">专家</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('more')">
<view class="toolbar-icon icon-more"></view>
<text class="toolbar-text">更多</text>
</view>
</view>
</view>
</view>
<!-- 更多工具面板 -->
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 透传后端 ui_enable_attachment_types 控制各 tab 显隐 -->
<ai-chat-tools-panel
:visible.sync="showToolsPanel"
:attachment-types="chatUiConfig.ui_enable_attachment_types"
@image-picked="onImagePicked"
@file-selected="onFileSelected"
@voice-start="showVoicePanel"
@location-selected="onLocationSelected"
/>
<!-- Emoji面板 -->
<ai-chat-emoji-panel
:visible.sync="showEmojiPanel"
@emoji-selected="insertEmoji"
/>
<!-- 扩大编辑器弹窗 -->
<view v-if="showExpandEditor" class="expand-editor-popup">
<view class="expand-editor-mask" @click="closeExpandEditor"></view>
<view class="expand-editor-panel">
<view class="expand-editor-header">
<text>{{ $lang('ai-chat.index.expandEditorTitle') }}</text>
<button class="close-btn" @click="closeExpandEditor">
<text>×</text>
</button>
</view>
<textarea
v-model="expandInputText"
class="expand-editor-input"
:placeholder="$lang('ai-chat.index.inputPlaceholder')"
:maxlength="chatUiConfig.ui_input_max_length"
:auto-height="true"
:show-confirm-bar="false"
:adjust-position="true"
:focus="true" />
<view class="expand-editor-footer">
<button class="expand-cancel-btn" @click="closeExpandEditor">{{ $lang('ai-chat.index.cancel') }}</button>
<button class="expand-send-btn" @click="sendFromExpandEditor" :disabled="!expandInputText.trim()">{{ $lang('ai-chat.index.send') }}</button>
</view>
</view>
</view>
<!-- 键盘垫高层:键盘弹出时平滑撑开空间将输入框上推 -->
<view class="keyboard-spacer" :style="{ height: keyboardHeight + 'px' }"></view>
<!-- 语音输入面板 -->
<ai-chat-voice-panel
:visible.sync="enableVoicePanelShow"
@close="onVoicePanelClose"
@audio-ready="_handleRecordedAudio"
/>
<!-- 头像选择弹窗 -->
<ai-chat-avatar-selector
:visible.sync="showAvatarSelector"
:avatar="localUserAvatar"
@avatar-change="onAvatarChange"
/>
<!-- 昵称编辑弹窗 -->
<ai-chat-nickname-editor
:visible.sync="showNicknameEditor"
:nickname="localUserNickname"
@nickname-change="onNicknameChange"
/>
<!-- PR-VOICE-OVERLAY (2026-09-28): 中央语音播放面板
点击任意语音气泡/预览时弹出, 半透明背景 + 中央控制卡
(大播放按钮 + 时长 + 进度条), 覆盖在所有内容之上 -->
<view v-if="voiceOverlayVisible && voiceOverlay" class="voice-overlay" @tap="closeVoiceOverlay">
<view class="voice-overlay-mask"></view>
<view class="voice-overlay-panel" @tap.stop>
<view class="voice-overlay-card">
<view class="voice-overlay-close" @tap="closeVoiceOverlay">×</view>
<view class="voice-overlay-icon">
<text class="icondiy" :class="voiceOverlay.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
</view>
<text class="voice-overlay-title">{{ $lang('ai-chat.index.audioMessage') || '语音' }}</text>
<view class="voice-overlay-time">
<text class="time-current">{{ formatDuration(voiceOverlay.currentTime || 0) }}</text>
<text class="time-sep"> / </text>
<text class="time-total">{{ formatDuration((voiceOverlay.duration || 0) / 1000) }}</text>
</view>
<view class="voice-overlay-progress-track">
<view class="voice-overlay-progress-bar" :style="{ width: getVoiceProgress(voiceOverlay) + '%' }"></view>
</view>
<view class="voice-overlay-play-btn" :class="{ playing: voiceOverlay.playing }" @tap="toggleOverlayAudio">
<text class="icondiy" :class="voiceOverlay.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
</view>
<text class="voice-overlay-hint">点击外部关闭</text>
</view>
</view>
</view>
</view>
</template>
<script>
import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js'
import { uploadImage, uploadMedia, fileToBase64 } from '@/common/js/media-upload.js'
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
import IMAGE_CONFIG from '@/common/js/image-config.js'
import { isClipboardPrivacyError } from '@/common/js/uniapp.utils.js'
import AiChatSearch from './components/ai-chat-search.vue'
import AiChatAvatarSelector from './components/ai-chat-avatar-selector.vue'
import AiChatNicknameEditor from './components/ai-chat-nickname-editor.vue'
import AiChatVoicePanel from './components/ai-chat-voice-panel.vue'
import AiChatToolsPanel from './components/ai-chat-tools-panel.vue'
import AiChatEmojiPanel from './components/ai-chat-emoji-panel.vue'
export default {
name: 'ai-chat-message',
props: {
// 初始消息列表
initialMessages: {
type: Array,
default: () => []
},
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面渲染前预取的聊天 UI 配置
// 传入后 created() 立即用作首帧值, 消除"默认值渲染 → 异步配置到达跳变"的闪烁;
// 之后 _loadChatUiConfig() 仍会拉最新值覆盖 (与父页面共享 60s TTL 缓存)
preloadedUiConfig: {
type: Object,
default: null
},
// 初始用户头像(外部传入,可选)
initUserAvatar: {
type: String,
default: ''
},
// 初始用户昵称(外部传入,可选)
initUserNickname: {
type: String,
default: ''
},
// 是否显示加载更多
showLoadMore: {
type: Boolean,
default: true
},
// 最大消息数量
maxMessages: {
type: Number,
default: 100
},
// 是否启用流式响应
enableStreaming: {
type: Boolean,
default: true
},
// 流式响应速度(字符/秒)
streamSpeed: {
type: Number,
default: 20
},
// API 获取欢迎语失败时的降级欢迎语
fallbackWelcome: {
type: String,
default: ''
}
},
components: {
AiChatSearch,
AiChatAvatarSelector,
AiChatNicknameEditor,
AiChatVoicePanel,
AiChatToolsPanel,
AiChatEmojiPanel
},
data(){
return {
messages: [],
inputText: '',
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): pendingImages 数组支持 1-9 张图
// 原 pendingImage 单数对象, 只存第一张, 选 5 张只发 1 张
// 每项: { localId, tempPath, uploading, uploaded, uploadError, sentWithBatch }
// ⚠️ Vue 2 会过滤掉 _/$ 开头的数据属性, _pendingIdSeq → undefined → ++undefined = NaN
// 所有图 localId 都变 NaN, Vue "Duplicate keys" 警告, 数组错乱.
// 必须用非下划线前缀. PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16)
pendingIdSeq: 0,
pendingImages: [],
// PR-FRONTEND-MEDIA-PENDING-FILE (2026-09-17): 文件/视频/音频与图片同一套 pending 逻辑
// 选完先挂在这里预览, 等用户点发送再随文字一起发出 (不再选完直接进聊天区)
pendingFiles: [],
scrollTop: 0,
scrollIntoViewId: null,
hasMoreHistory: false,
loadingText: '',
voiceInputing: false,
// PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态
// voiceMode 语音模式(按住说话) / voiceHolding 按住中 / inputFocused 输入框是否聚焦
voiceMode: false,
voiceHolding: false,
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上滑取消手势标志
voiceHoldCancelling: false,
inputFocused: false,
// 录音器句柄 + 振幅 (供可视化/调试; UI 现在没用到 voiceLevel, 留作后续接入动画)
_voiceRecorder: null,
_voiceHoldStartY: 0,
_voiceHoldLastLevel: 0,
voiceLevel: 0,
// PR-WELCOME-QUICK-ICONS: 快捷问题前面的随机图标库 (base64 SVG)
quickQuestionIconList: [
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48Y2lyY2xlIGN4PSIxMSIgY3k9IjExIiByPSI3Ii8+PHBhdGggZD0iTTIxIDIxbC00LjM1LTQuMzUiLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNCA0aDE2djE2SDR6Ii8+PHBhdGggZD0iTTggOWg4TTggMTNoOE04IDE3aDUiLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMyAzdjE4aDE4Ii8+PHBhdGggZD0iTTcgMTZsNC00IDMgMyA1LTYiLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTYgMThsNi02LTYtNk04IDZsLTYgNiA2IDYiLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTQuNyA2LjNhMSAxIDAgMCAwIDAgMS40bDEuNiAxLjZhMSAxIDAgMCAwIDEuNCAwbDMuNzctMy43N2E2IDYgMCAwIDEtNy45NCA3Ljk0bC02LjkxIDYuOTFhMi4xMiAyLjEyIDAgMCAxLTMtM2w2LjkxLTYuOTFhNiA2IDAgMCAxIDcuOTQtNy45NGwtMy43NiAzLjc2eiIvPjwvc3ZnPg==',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWdvbiBwb2ludHM9IjEyIDIgMTUuMDkgOC4yNiAyMiA5LjI3IDE3IDE0LjE0IDE4LjE4IDIxLjAyIDEyIDE3Ljc3IDUuODIgMjEuMDIgNyAxNC4xNCAyIDkuMjcgOC45MSA4LjI2IDEyIDIiLz48L3N2Zz4=',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwIi8+PHBhdGggZD0iTTEyIDJhMTUuMyAxNS4zIDAgMCAxIDQgMTAgMTUuMyAxNS4zIDAgMCAxLTQgMTAgMTUuMyAxNS4zIDAgMCAxLTQtMTAgMTUuMyAxNS4zIDAgMCAxIDQtMTB6Ii8+PC9zdmc+',
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNOSAxOGg2Ii8+PHBhdGggZD0iTTEwIDIyaDQiLz48cGF0aCBkPSJNMTIgMmE3IDcgMCAwIDAtNyA3YzAgMi41IDEuNSA0LjUgMyA2djJoOHYtMmMxLjUtMS41IDMtMy41IDMtNmE3IDcgMCAwIDAtNy03eiIvPjwvc3ZnPg=='
],
// 失焦定时器: 用于延迟收起文字输入态, 避免 H5 布局变化触发的瞬时 blur 误收起
_blurTimer: null,
// 平台相关: H5 让浏览器自动把输入框顶起(adjust-position=true), 小程序用键盘垫高自行处理
adjustPosition: false,
audioContext: null,
currentAudio: null,
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放引擎
// 单例 createInnerAudioContext, 切换 message 时按需重建 (src 变了才重建)
innerAudioCtx: null,
_innerAudioSrc: '',
messageId: 0,
showToolsPanel: false,
enableVoicePanelShow: false,
showEmojiPanel: false,
// PR-VOICE-OVERLAY (2026-09-28): 中央语音播放面板状态
// 点击任意语音气泡/预览 → 弹出半透明面板, 显示大播放按钮 + 实时进度
// voiceOverlay 持有当前正在面板播放的对象 (可能是 message 或 pending file)
voiceOverlay: null,
voiceOverlayVisible: false,
// 扩大编辑器相关
showExpandEditor: false,
expandInputText: '',
inputLineCount: 0,
streamingMessage: null,
streamInterval: null,
streamContent: '',
isStreaming: false,
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): AI 正在回答时又发新问题, 用户选"单独处理"
// → 该消息排队, 等本轮流式回答结束后自动发送
// (避免后端把"上一轮未完成的请求"和"新问题"并行/合并处理, 导致答非所问串题)
pendingSendAfterStream: false,
// 本地用户头像(优先使用缓存,无缓存则用默认图)
localUserAvatar: '',
showAvatarSelector: false, // 头像选择弹窗控制
localUserNickname: '', // 当前用户昵称
defaultNickname: '', // 默认昵称
showNicknameEditor: false, // 昵称编辑弹窗控制
// AI头像错误状态
avatarError: false,
// 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
selectedModel: 'Auto',
// PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20): availableModels 由后端 chat_ui_config.ui_available_models 注入
// 初始仅 'Auto' 兜底, created() 里后端 /info 返回后异步覆盖完整列表
// 数据源 (后端维护): https://docs.openclaw.ai/providers
availableModels: ['Auto'],
// PR-CHAT-UI-CONFIG (2026-09-20): 前端 UI 行为开关
// 字段与 ai-service.js::CHAT_UI_DEFAULTS 一一对应, 是后端 getChatUiConfig() 的缓存副本
// 5 个 P0 开关:
// ui_show_thinking_process - 控制是否显示 AI 思考过程面板
// ui_enable_attachment_types - 控制 tools-panel 4 类附件 tab 显隐 (image/file/audio/video)
// ui_input_max_length - 输入框最大字符数, 替代硬编 500
// ui_available_models - 模型选择器列表 (PR-CHAT-UI-CONFIG-SERVER-ONLY: 后端注入)
// ui_enable_feedback - 控制是否显示 AI 消息下方操作栏 (复制/点赞/点踩/更多)
// 加载失败/未配置时全走前端默认值 (ai-service.js 兜底), 这里手填一份做双重保险
// - ui_available_models 兜底仅 'Auto', 实际清单完全来自后端 (Config.php::DEFAULT_UI_CONFIG)
chatUiConfig: {
ui_show_thinking_process: true,
ui_enable_attachment_types: ['image', 'file', 'audio', 'video'],
ui_input_max_length: 500,
ui_available_models: ['Auto'],
ui_enable_feedback: true,
// PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_assistant_name: '',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_show_assistant_name: true,
ui_show_assistant_avatar: true,
ui_assistant_avatar: '',
ui_show_message_time: true,
ui_time_format: 'auto',
ui_time_humanize: false
},
chatUiConfigLoaded: false,
// AiChat API相关
currentConversationId: null,
isAIServiceAvailable: true,
aiServiceError: null,
isLoadingHistory: false,
shouldScrollToBottom: false,
scrollTimer: null,
lastScrollTop: 0,
historyOffset: 0,
isFetchingHistory: false,
keyboardHeight: 0,
// 聊天记录搜索状态(由 AiChatSearch 组件事件同步)
showSearchBar: false,
searchKeyword: '',
searchResultFilteredIndex: -1,
searchFlashAnimatingIndex: -1, // 当前正在播放 flash 动画的消息索引
searchFlashTimer: null, // flash 动画定时器
// 用户发出消息时记录的开始时间戳, AI 回复完成时算 elapsed (已完成 XmXXs)
_aiRequestStart: 0,
// Question 选项倒计时定时器集合 (per message)
questionTimers: {}
}
},
computed: {
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 后台配置了自定义头像则优先 ($util.img 兼容 pic_path 与完整 URL)
aiAvatar() {
const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar;
if (custom) return this.$util.img(custom);
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
defaultAvatar() {
return this.$util.img(IMAGE_CONFIG.userAvatar);
},
aiAvatarUrl() {
const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar;
if (custom) return this.$util.img(custom);
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
chatMessagesStyle() {
// 键盘弹出时增加底部padding
return {
'padding-bottom': this.keyboardHeight > 0 ? this.keyboardHeight + 'px' : '0px'
};
},
aiChatContainerStyle() {
// 键盘高度由末尾垫高 view 处理,容器无需额外样式
return {};
},
inputAreaStyle() {
// 输入框在 flex 流中自然定位,无需额外样式
return {};
},
canSendMessage() {
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
return hasContent || hasEmoji
},
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 数组版: 至少 1 张且所有都上传成功 (无 uploading + 无 uploadError)
canSendPendingImage() {
if (!this.pendingImages.length) return false
return this.pendingImages.every(p => !p.uploading && !p.uploadError && p.uploaded)
},
// PR-FRONTEND-MEDIA-PENDING-FILE (2026-09-17): 待发送文件附件是否均可发送
canSendPendingFile() {
if (!this.pendingFiles || !this.pendingFiles.length) return false
return this.pendingFiles.every(p => !p.uploading && !p.uploadError && p.uploaded)
},
hasInputText() {
return this.inputText && this.inputText.trim() !== ''
},
// PR-WELCOME-HIDE-AFTER-SEND: 一旦用户发送过消息, 欢迎语消息就不再显示
hasUserMessage() {
return this.messages.some(m => m.role === 'user' && m.content && m.content.trim() !== '')
},
// PR-FRONTEND-INPUT-3STATES (2026-09-17): 文字输入态
// 聚焦 或 已有文字时展开为"输入框一行 + 工具栏一行", 否则回到单行胶囊。
// 用聚焦(而不是只判断文字)是为了: 删除最后一个字时布局不会突然塌回胶囊。
// 另外: 有"上传完成的待发送图片/文件"时也必须展开, 否则发送按钮(在工具栏行)不可见。
showTextMode() {
return !this.voiceMode && (this.inputFocused || this.hasInputText || this.canSendPendingImage || this.canSendPendingFile)
},
// 发送按钮可用态 (无内容时按钮保留但置灰)
canSend() {
return this.hasInputText || this.canSendPendingImage || this.canSendPendingFile
},
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话按钮文案 (三态)
voiceHoldText() {
if (this.voiceHoldCancelling) return this.$lang('ai-chat.index.releaseToCancel')
if (this.voiceHolding) return this.$lang('ai-chat.index.releaseToSend')
return this.$lang('ai-chat.index.holdToTalk')
},
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): 输入框 placeholder 按上下文动态切换
// - 有待发语音附件 → 提示"给语音加说明"
// - 有待发图片/文件/视频附件 → 提示"添加文字说明"
// - 默认态 → 提示"发消息或按住说话"
// 优先级: 语音 > 其他媒体 > 默认
inputPlaceholder() {
const files = this.pendingFiles || []
const hasAudio = files.some(f => f && f.type === 'audio')
if (hasAudio) {
return this.$lang('ai-chat.index.placeholderWithAudio') || '给语音留言加个说明(可选)'
}
const hasMedia = (this.pendingImages && this.pendingImages.length) || files.length > 0
if (hasMedia) {
return this.$lang('ai-chat.index.placeholderWithMedia') || '添加文字说明(可选)'
}
return this.$lang('ai-chat.index.placeholderDefault') || '发消息或按住说话'
}
},
// 新增:组件销毁时清除所有定时器
beforeDestroy() {
if (this._blurTimer) clearTimeout(this._blurTimer);
// #ifdef H5
this._teardownH5KeyboardObserver && this._teardownH5KeyboardObserver();
// #endif
if (this.scrollTimer) clearTimeout(this.scrollTimer);
if (this.searchFlashTimer) {
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) {
clearInterval(msg._rotationTimer);
}
});
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 销毁 innerAudioCtx
if (this.innerAudioCtx) {
try { this.innerAudioCtx.stop(); this.innerAudioCtx.destroy(); } catch (e) {}
this.innerAudioCtx = null;
}
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 销毁前丢弃语音录音器 (Stream/MediaRecorder/RecorderManager)
this._discardVoiceRecorder && this._discardVoiceRecorder()
},
created() {
this.messages = [...this.initialMessages]
this.messageId = this.messages.length
// 初始化 i18n 字段(在 created 中同步赋值,避免 remote-only 首屏 data() 缓存空串)
this.loadingText = this.$lang('ai-chat.index.loadMoreHistory')
this.defaultNickname = this.$lang('ai-chat.index.defaultNickname')
this.initAudioContext()
// 初始化用户头像(缓存优先)
this.initUserAvatarData()
// 初始化用户昵称(缓存优先)
this.initUserNicknameData()
this.currentConversationId = aiService.getConversationId();
console.log('[AI Chat] created, currentConversationId:', this.currentConversationId, 'messages.length:', this.messages.length);
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面预取配置优先作为首帧值
// 父页面已保证渲染本组件时配置就绪 (缓存或预取), 此处同步落位;
// _loadChatUiConfig() 随后仍拉最新值 (TTL 缓存命中则同值, 无视觉跳变)
if (this.preloadedUiConfig && typeof this.preloadedUiConfig === 'object') {
this.chatUiConfig = Object.assign({}, this.chatUiConfig, this.preloadedUiConfig)
if (Array.isArray(this.preloadedUiConfig.ui_available_models) && this.preloadedUiConfig.ui_available_models.length) {
this.availableModels = this.preloadedUiConfig.ui_available_models.slice()
if (!this.availableModels.includes(this.selectedModel)) {
this.selectedModel = this.availableModels[0] || 'Auto'
}
}
this.chatUiConfigLoaded = true
console.log('[AI Chat] chatUiConfig 使用父页面预取值 (首帧即真实配置)')
}
// 异步获取服务配置(含欢迎语)
this._loadWelcomeMessage()
// PR-CHAT-UI-CONFIG (2026-09-20): 异步加载 UI 开关 (与欢迎语并行, 不阻塞 created)
this._loadChatUiConfig()
},
mounted() {
// 初始加载时滚底(仅一次)
this.$nextTick(() => {
this.scrollToBottom()
// 确保 H5 在组件挂载后也尝试加载历史(兜底)
this.loadHistoryIfNeeded()
})
// #ifdef H5
// H5 下 uni.onKeyboardHeightChange 不会触发, 改用 visualViewport/window 监听键盘高度,
// 通过 keyboard-spacer 把输入框顶起, 行为与小程序一致(否则键盘会遮住输入框/输入区)。
this._setupH5KeyboardObserver()
// #endif
},
watch: {
messages: {
handler(newVal, oldVal) {
// 仅当不是加载历史且不是流式响应时,执行滚底
if (!this.isLoadingHistory && !this.isStreaming && newVal.length > oldVal.length) {
this.$nextTick(() => {
this.scrollToBottom()
})
}
},
deep: true
}
},
methods: {
// ==================== 搜索组件事件处理 ====================
onSearchScrollTo(messageKey) {
this.scrollIntoViewId = ''
if (this.searchFlashTimer) {
clearTimeout(this.searchFlashTimer)
this.searchFlashTimer = null
}
this.searchFlashAnimatingIndex = -1
this.$nextTick(() => {
this.scrollIntoViewId = messageKey
// 延迟触发 flash 动画,让 scroll-into-view 滚动先完成
this.searchFlashTimer = setTimeout(() => {
if (this.searchResultFilteredIndex >= 0) {
this.searchFlashAnimatingIndex = this.searchResultFilteredIndex
this.searchFlashTimer = setTimeout(() => {
this.searchFlashAnimatingIndex = -1
this.searchFlashTimer = null
}, 1200)
}
}, 350)
})
},
onSearchActiveChange(isActive) {
this.showSearchBar = isActive
},
onSearchResultChange(filteredIndex) {
this.searchResultFilteredIndex = -1
this.$nextTick(() => {
this.searchResultFilteredIndex = filteredIndex
})
},
onSearchKeywordChange(keyword) {
this.searchKeyword = keyword
},
// ==================== 面板组件事件处理 ====================
showVoicePanel() {
this.enableVoicePanelShow = true
},
onVoicePanelClose() {
this.voiceInputing = false
},
onAvatarChange(newAvatar) {
this.localUserAvatar = newAvatar
this.$emit('avatar-changed', newAvatar)
},
onNicknameChange(newNickname) {
this.localUserNickname = newNickname
this.$emit('nickname-changed', newNickname)
},
// 选图后: 不直接进聊天区, 作为输入框上方附件预览并立即开始上传
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 数组版支持 1-9 张图, 各自独立上传独立失败独立重试
onImagePicked(paths) {
const list = Array.isArray(paths) ? paths : (paths ? [paths] : [])
if (!list.length) return
list.forEach((path) => {
const localId = ++this.pendingIdSeq
const pending = {
localId,
tempPath: path,
uploading: true,
uploaded: null,
uploadError: null,
}
this.pendingImages.push(pending)
uploadImage(path).then((uploaded) => {
// 防止用户已删除 (removePendingImage) 后回调才到
const cur = this.pendingImages.find(p => p.localId === localId)
if (!cur) return
cur.uploading = false
cur.uploaded = uploaded
}).catch((e) => {
const cur = this.pendingImages.find(p => p.localId === localId)
if (!cur) return
cur.uploading = false
cur.uploadError = (e && e.message) || '上传失败'
uni.showToast({ title: '图片上传失败: ' + ((e && e.message) || ''), icon: 'none' })
})
})
},
// 移除待发送图片附件 (按 localId)
removePendingImage(localId) {
if (localId === undefined || localId === null) {
this.pendingImages = []
return
}
this.pendingImages = this.pendingImages.filter(p => p.localId !== localId)
},
onFileSelected(fileMessage) {
// PR-FRONTEND-MEDIA-PENDING-FILE (2026-09-17): 文件与图片同一逻辑 ——
// 选完不直接进聊天区, 挂到 pendingFiles 在输入框上方预览, 等点发送再随问题一起发出
const localId = ++this.pendingIdSeq
this.$set(fileMessage, 'localId', localId)
this.pendingFiles.push(fileMessage)
this.$emit('file-selected', fileMessage)
},
// 移除待发送文件附件 (按 localId)
removePendingFile(localId) {
if (localId === undefined || localId === null) {
// 全部清空: 先停掉任何正在播放的语音预览
this._stopPendingAudioIfPlaying()
this.pendingFiles = []
return
}
// 单个删除: 如果删的是正在播放的, 先停掉
const target = this.pendingFiles.find(p => p.localId === localId)
if (target && target.playing) {
this._stopPendingAudioIfPlaying(target)
}
this.pendingFiles = this.pendingFiles.filter(p => p.localId !== localId)
},
// PR-PENDING-VOICE-PREVIEW (2026-09-28): 停掉正在播放的待发送语音预览
// 共用 innerAudioCtx, 只可能有一个 pending voice 在播放
// target 不传时停掉任意正在播放的 pending voice
_stopPendingAudioIfPlaying(target) {
const playing = target
|| this.pendingFiles.find(p => p && p.playing)
if (!playing) return
// 走 pauseAudio 共用引擎: 它会 innerAudioCtx.pause() 并 playing=false
this.pauseAudio(playing)
},
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 构建发给 WbAgentOsClient 的 context
// 4 类基础元素:
// 1. timestamp - 客户端时间 (ms), AI 可识别"几分钟前"类问题
// 2. timezone - 客户端时区 (e.g. "Asia/Shanghai"), 避免时区歧义
// 3. location - 用户位置 (可选, 仅当 pending 含位置时填, 否则 null)
// 4. page - 当前所在页面 (route/title), 让 AI 知道用户在干什么
// 5. metadata - 会话级设备/平台信息 (platform/screen/locale), 一次性建立
// 跨平台兼容: H5 用 Intl/navigator, MP/鸿蒙用 uni.getSystemInfoSync 兜底
_buildSendContext({ locationFiles }) {
const ctx = this._initContext({ locationFiles })
// 异步探测, 不阻塞消息渲染; 在发送前 await 拿到坐标再补到 ctx
this._fillAutoLocation(ctx).catch(() => {})
return ctx
},
// 异步版本: 等自动定位完成 (或 8 秒超时) 再返回
// 适用于 sendMessage 路径, 因为 context 会立即发给后端
// 8s 给首次弹原生权限框 + 弹模态引导去设置 留够时间
// 极端情况 (用户在系统设置里徘徊超过 8s) 才走超时, 此时 ctx.location 仍为 null,
// 后续消息发送前会复用 5 分钟缓存, 不会反复打扰用户
async _buildSendContextAsync({ locationFiles }) {
const ctx = this._initContext({ locationFiles })
await Promise.race([
this._fillAutoLocation(ctx).catch(() => {}),
new Promise((r) => setTimeout(r, 8000))
])
return ctx
},
// 构建基础 context (timestamp/timezone/page/metadata + 手动位置)
_initContext({ locationFiles }) {
const ctx = {
timestamp: Date.now(),
timezone: this._detectTimezone(),
location: null,
page: this._getCurrentPageRoute(),
metadata: this._getSessionMetadata()
}
// 位置优先级: 用户手动选的 > 自动探测
// 手动选的位置一定有 name/address, 自动探测只有坐标
if (locationFiles && locationFiles.length) {
const loc = locationFiles[0]
ctx.location = {
latitude: loc.latitude,
longitude: loc.longitude,
address: loc.address || '',
name: loc.name || '',
source: 'manual'
}
}
return ctx
},
// 异步把自动探测到的位置合并到 ctx (不影响 sendMessage 的同步流程)
// 不会覆盖手动选的位置 (source === 'manual' 时跳过)
async _fillAutoLocation(ctx) {
try {
const loc = await this._tryAutoLocation()
if (!loc) return
if (ctx.location && ctx.location.source === 'manual') return
ctx.location = {
latitude: loc.latitude,
longitude: loc.longitude,
address: '',
name: '',
source: 'auto',
accuracy: loc.accuracy || 0
}
} catch (e) { /* 静默: 失败不进 location, 不打扰用户 */ }
},
// 检测客户端时区 (H5 优先用 Intl, MP/鸿蒙走 fallback)
_detectTimezone() {
try {
// H5 / 鸿蒙 WebView 一般有 Intl
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
if (tz) return tz
}
} catch (e) { /* fallback */ }
// MP / 鸿蒙 fallback: 直接读本机偏移 (粗略, 单位小时)
try {
const offset = new Date().getTimezoneOffset()
const sign = offset > 0 ? '-' : '+'
const abs = Math.abs(offset)
const h = Math.floor(abs / 60)
const m = abs % 60
return 'UTC' + sign + (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m)
} catch (e) {
return 'UTC'
}
},
// 当前页面路由 (H5 用 window.location, MP/鸿蒙用 getCurrentPages)
_getCurrentPageRoute() {
try {
// #ifdef H5
if (typeof window !== 'undefined' && window.location) {
return {
route: window.location.pathname || '',
hash: window.location.hash || '',
query: window.location.search || ''
}
}
// #endif
// #ifdef MP-WEIXIN || MP-HARMONY
const pages = getCurrentPages && getCurrentPages()
if (pages && pages.length) {
const last = pages[pages.length - 1]
return {
route: last && last.route ? last.route : '',
options: last && last.options ? last.options : {}
}
}
// #endif
} catch (e) { /* ignore */ }
return { route: '', options: {} }
},
// 会话级 metadata: 平台/屏幕/locale, 第一次取后缓存 (会话期间不变)
_getSessionMetadata() {
if (this._sessionMetaCache) return this._sessionMetaCache
let platform = 'h5'
// #ifdef MP-WEIXIN
platform = 'mp-weixin'
// #endif
// #ifdef MP-HARMONY
platform = 'mp-harmony'
// #endif
// #ifdef APP-PLUS
platform = 'app'
// #endif
let screen = { width: 0, height: 0, dpr: 1 }
let locale = 'zh-cn'
let ua = ''
try {
if (typeof uni !== 'undefined' && uni.getSystemInfoSync) {
const sys = uni.getSystemInfoSync()
if (sys) {
screen = {
width: sys.screenWidth || sys.windowWidth || 0,
height: sys.screenHeight || sys.windowHeight || 0,
dpr: sys.pixelRatio || 1
}
locale = sys.language || sys.locale || 'zh-cn'
if (sys.uniPlatform) platform = sys.uniPlatform
}
}
// H5 平台补充 UA (uni.getSystemInfoSync 也提供)
if (typeof navigator !== 'undefined') {
ua = navigator.userAgent || ''
if (!screen.width && window.screen) {
screen = {
width: window.screen.width || 0,
height: window.screen.height || 0,
dpr: window.devicePixelRatio || 1
}
}
}
} catch (e) { /* ignore */ }
this._sessionMetaCache = { platform, screen, locale, userAgent: ua }
return this._sessionMetaCache
},
// ========== PR-AUTO-LOCATION (2026-09-28): 自动位置获取 ==========
// 隐私敏感, 必须用户授权. 流程:
// 1. 检查权限状态 (granted/denied/undetermined)
// 2. granted → 直接 getLocation, 缓存 5 分钟
// 3. denied → 弹模态引导去设置页 (uni.openSetting)
// 4. undetermined → 直接调 authorize 弹原生权限框
// 拒绝一次后不再静默弹窗, 必须走"去设置"模态. 用户感受:
// 首次进入聊天 → 不打扰
// 发消息时 → 静默获取 (首次弹原生权限框)
// 用户拒绝过 → 弹"去设置"模态, 取消则不进 context.location
// 跨平台兼容:
// H5 : navigator.permissions + navigator.geolocation
// MP/鸿蒙 : uni.getSetting + uni.authorize + uni.openSetting + uni.getLocation
// APP : 同 MP, manifest 需声明 ACCESS_FINE_LOCATION
// 自动位置缓存: 5 分钟内复用, 避免重复获取
_getCachedAutoLocation() {
const cache = this._autoLocationCache
if (!cache) return null
const FIVE_MIN = 5 * 60 * 1000
if (Date.now() - cache.ts > FIVE_MIN) return null
return cache.data
},
_setCachedAutoLocation(data) {
this._autoLocationCache = { data, ts: Date.now() }
},
_clearCachedAutoLocation() {
this._autoLocationCache = null
},
// 主入口: 尝试获取用户当前位置, 不打扰的前提下
// 返回: { latitude, longitude, accuracy } 或 null
async _tryAutoLocation() {
// 1. 优先用缓存 (5 分钟内有效)
const cached = this._getCachedAutoLocation()
if (cached) return cached
// 2. 检查权限状态
let status = 'undetermined'
try { status = await this._getLocationAuthStatus() } catch (e) { /* ignore */ }
// 3. 已拒绝过 → 弹"去设置"模态
if (status === 'denied') {
const granted = await this._promptOpenLocationSetting()
if (!granted) return null
// 用户去设置后回到页面, 重新检测状态
try { status = await this._getLocationAuthStatus() } catch (e) {}
if (status !== 'granted') return null
// 用户在设置里开了权限, 重置"尝试过"标志, 下次回到 undetermined 路径
this.triedLocationAuth = false
} else if (status === 'undetermined') {
// 4. 首次: 调 authorize 弹原生权限框
// MP/鸿蒙/App 行为: 若用户曾在原生弹窗里点过拒绝,
// 后续 authorize 不再弹窗, 直接 fail, 此时 status 仍是 undetermined
// 用 triedLocationAuth 标记位区分"首次进入"与"曾拒绝过":
// false → 首次, 调 authorize 弹原生框, 不管成功失败都置 true
// true → 跳到 denied 路径, 弹"去设置"模态
if (this.triedLocationAuth) {
const granted = await this._promptOpenLocationSetting()
if (!granted) return null
try { status = await this._getLocationAuthStatus() } catch (e) {}
if (status !== 'granted') return null
this.triedLocationAuth = false
} else {
try {
await this._authorizeLocation()
this.triedLocationAuth = true
} catch (e) {
// 首次拒绝: 标记已尝试, 下次直接走"去设置"模态, 不再弹原生框
this.triedLocationAuth = true
return null
}
}
}
// 5. 已授权 → 获取坐标
const loc = await this._getCurrentLocation()
if (loc) this._setCachedAutoLocation(loc)
return loc
},
// 检测位置权限状态: 'granted' | 'denied' | 'undetermined'
// H5: navigator.permissions.query → 'prompt' 当 undetermined 处理
// MP/鸿蒙/APP: uni.getSetting 看 scope.userLocation
_getLocationAuthStatus() {
// 用 var 是因为 ESLint 不识别 uni-app 的条件编译注释
var isH5 = false
// #ifdef H5
isH5 = true
// #endif
if (isH5) {
return new Promise((resolve) => {
if (typeof navigator !== 'undefined'
&& navigator.permissions
&& navigator.permissions.query) {
navigator.permissions.query({ name: 'geolocation' })
.then((r) => resolve(r.state === 'granted' ? 'granted' : (r.state === 'denied' ? 'denied' : 'undetermined')))
.catch(() => resolve('undetermined'))
} else {
resolve('undetermined')
}
})
}
return new Promise((resolve) => {
if (typeof uni !== 'undefined' && uni.getSetting) {
uni.getSetting({
success: (res) => {
const scope = res.authSetting && res.authSetting['scope.userLocation']
if (scope === true) resolve('granted')
else if (scope === false) resolve('denied')
else resolve('undetermined')
},
fail: () => resolve('undetermined')
})
} else {
resolve('undetermined')
}
})
},
// 申请位置权限 (调原生权限框)
// H5: 没单独的 authorize, getCurrentPosition 时浏览器自己弹
// MP/鸿蒙/APP: uni.authorize(scope.userLocation)
_authorizeLocation() {
return new Promise((resolve, reject) => {
if (typeof uni !== 'undefined' && uni.authorize) {
uni.authorize({
scope: 'scope.userLocation',
success: resolve,
fail: reject
})
} else {
// H5 直接通过, 浏览器会在 getCurrentPosition 时弹权限框
resolve()
}
})
},
// 获取当前位置坐标
// H5: navigator.geolocation.getCurrentPosition
// MP/鸿蒙/APP: uni.getLocation (gcj02 国内坐标系)
_getCurrentLocation() {
var isH5 = false
// #ifdef H5
isH5 = true
// #endif
if (isH5) {
return new Promise((resolve) => {
const ok = (loc) => resolve(loc)
const fail = () => resolve(null)
if (typeof navigator !== 'undefined' && navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => ok({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy
}),
fail,
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60000 }
)
} else {
fail()
}
})
}
return new Promise((resolve) => {
const ok = (loc) => resolve(loc)
const fail = () => resolve(null)
if (typeof uni !== 'undefined' && uni.getLocation) {
uni.getLocation({
type: 'gcj02', // 国内坐标系 (腾讯/高德), 海外用 'wgs84'
geocode: false,
success: (res) => ok({
latitude: res.latitude,
longitude: res.longitude,
accuracy: res.accuracy || 0
}),
fail
})
} else {
fail()
}
})
},
// 弹"去设置"模态, 用户确认后调 uni.openSetting 引导去系统设置
// 返回: true = 用户在设置里开了权限, false = 取消或开了又关
_promptOpenLocationSetting() {
// H5 没 openSetting, 浏览器权限只能去 chrome://settings 手动开
// 用 var 是因为 ESLint 不识别 uni-app 的条件编译注释,
// 两个分支都会进 AST, 不能重复 const 声明
var isH5 = false
// #ifdef H5
isH5 = true
// #endif
const $t = (k, fallback) => {
try {
const v = this.$lang && this.$lang(k)
return v || fallback
} catch (e) { return fallback }
}
const title = $t('ai-chat.index.locationPermissionTitle', '需要位置权限')
if (isH5) {
return new Promise((resolve) => {
uni.showModal({
title,
content: $t('ai-chat.index.locationPermissionH5', '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。'),
showCancel: false,
confirmText: $t('ai-chat.index.locationH5GuideBtn', '我知道了'),
success: () => resolve(false)
})
})
}
return new Promise((resolve) => {
uni.showModal({
title,
content: $t('ai-chat.index.locationPermissionMp', '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。'),
confirmText: $t('ai-chat.index.locationSettingBtn', '去设置'),
cancelText: $t('ai-chat.index.locationSkipBtn', '暂不开启'),
success: (res) => {
if (!res.confirm) {
resolve(false)
return
}
uni.openSetting({
success: (sres) => {
const granted = sres.authSetting && sres.authSetting['scope.userLocation'] === true
resolve(granted)
},
fail: () => resolve(false)
})
},
fail: () => resolve(false)
})
})
},
onLocationSelected(locationMessage) {
// PR-LOCATION-PENDING (2026-09-28): 位置消息走 pending 流程
// 原实现直接 push 到 messages, 用户点发送后位置根本不会传给 WB
// 改为挂到 pendingFiles, 等用户点发送时:
// 1) 进 messages 显示位置气泡 (用户视觉)
// 2) 坐标/地址塞到 context.location 透传给 WB (AI 知晓)
// 位置不算附件, 不参与 _uploadOneFile 上传链路, type='location' 标识
const localId = ++this.pendingIdSeq
this.$set(locationMessage, 'localId', localId)
this.pendingFiles.push(locationMessage)
this.$emit('location-selected', locationMessage)
},
// 插入 Emoji 到输入框
insertEmoji(emoji) {
this.inputText += emoji
},
/**
* 供父页面调用的加载历史方法
* 在 page onShow 中通过 ref 调用
*/
loadHistoryIfNeeded() {
const localConvId = this.getConversationIdFromLocal();
const shouldReload = this.currentConversationId !== localConvId;
console.log('[AI Chat] loadHistoryIfNeeded | localConvId:', localConvId, '| currentConvId:', this.currentConversationId, '| shouldReload:', shouldReload, '| messages.length:', this.messages.length, '| initialMessages.length:', this.initialMessages.length);
if (localConvId && shouldReload) {
console.log('[AI Chat] → 分支1: ID变化,加载历史');
this.currentConversationId = localConvId;
aiService.setConversationId(localConvId);
this.loadChatHistoryIfExist();
} else if (localConvId && !shouldReload && this.messages.length <= this.initialMessages.length) {
console.log('[AI Chat] → 分支2: ID不变但只有初始消息,从服务端恢复');
this.currentConversationId = localConvId;
this.loadChatHistoryIfExist();
} else if (!localConvId && !this.currentConversationId) {
console.log('[AI Chat] → 分支3: 全新会话');
this.historyOffset = this.messages.length;
} else {
console.log('[AI Chat] → 分支4: 跳过加载,保留当前消息, messages.length:', this.messages.length);
}
},
// 加载欢迎语:优先 API,API 失败或禁用时用父页面传入的 fallbackWelcome
async _loadChatUiConfig() {
// PR-CHAT-UI-CONFIG (2026-09-20): 拉取前端 UI 行为配置 (5 个 P0 开关)
// ai-service.getChatUiConfig() 内部已带 60s TTL + 与 getServiceConfig 共享 /info 调用
// 这里失败不影响主流程, 失败时保留 data() 里的初始默认值
try {
const cfg = await aiService.getChatUiConfig()
if (cfg && typeof cfg === 'object') {
this.chatUiConfig = cfg
// ui_available_models 同步覆盖 availableModels (UI 模型选择器用)
if (Array.isArray(cfg.ui_available_models) && cfg.ui_available_models.length) {
this.availableModels = cfg.ui_available_models.slice()
// 当前 selectedModel 已不在新列表里 → 回落 'Auto' 或第一项
if (!this.availableModels.includes(this.selectedModel)) {
this.selectedModel = this.availableModels[0] || 'Auto'
}
}
this.chatUiConfigLoaded = true
}
} catch (e) {
console.warn('[AI Chat] 加载 chatUiConfig 失败, 使用前端默认:', (e && e.message) || e)
}
},
async _loadWelcomeMessage() {
// PR-FIX (2026-09-20): 防重入 (created 异步加载期间 mounted/loadHistoryIfNeeded
// 等路径再触发会造成欢迎气泡重复 push; 网络慢时尤其容易)
if (this._welcomeLoading) return
this._welcomeLoading = true
// 只要当前没有消息(或历史为空)就展示欢迎语;已有消息/历史时会话 ID 不影响
if (this.messages.length > 0) {
console.log('[AI Chat] 已有消息,跳过欢迎语')
this._welcomeLoading = false
return
}
let welcomeText = ''
let isMarkdown = false
let welcomeLoaded = false
// PR-FIX (2026-09-20): welcomeInfo 原为 if 块内 const,第 3 步拼 quickQuestions
// 时在块外引用 → ReferenceError, 提升到函数作用域
// PR-WELCOME-INFO-CONSOLIDATE (2026-09-20): 字段名统一为 welcome, 移除 welcome_info
// 后端 KefuGetInfo 只下发 welcome 一个字段 (结构不变)
let welcomeInfo = null
// 1. 尝试从 API 获取远程欢迎语
try {
const config = await aiService.getServiceConfig()
console.log('[AI Chat] API getServiceConfig 返回:', JSON.stringify(config))
if (config) {
welcomeInfo = config.welcome
console.log('[AI Chat] welcomeInfo:', JSON.stringify(welcomeInfo))
const remoteText = welcomeInfo?.welcome || ''
const isEnabled = welcomeInfo?.is_enabled
console.log('[AI Chat] remoteText:', remoteText, 'isEnabled:', isEnabled)
if (remoteText && isEnabled !== 0 && isEnabled !== false) {
welcomeText = remoteText
isMarkdown = true
welcomeLoaded = true
console.log('[AI Chat] 使用远程欢迎语:', welcomeText.substring(0, 50))
}
}
} catch (e) {
console.warn('[AI Chat] 获取服务配置失败:', e)
}
// 2. API 未返回有效欢迎语时,使用降级方案
if (!welcomeLoaded) {
welcomeText = this.fallbackWelcome
isMarkdown = false
if (welcomeText) {
console.log('[AI Chat] 使用降级欢迎语')
} else {
console.log('[AI Chat] 无可用欢迎语')
this._welcomeLoading = false
return
}
}
// 3. 插入欢迎语:只要当前消息列表仍为空就展示(兼容真机残留会话 ID 但无历史消息)
if (this.messages.length === 0) {
// PR-WELCOME-QUICK-BATCH: 所有快捷问题先存起来, 前端每 4 个一批轮播
const allQuickQuestions = this.normalizeQuickQuestions(welcomeInfo && welcomeInfo.quick_questions)
const welcomeMsg = {
id: ++this.messageId,
role: 'ai',
type: isMarkdown ? 'markdown' : 'text',
content: welcomeText,
timestamp: Date.now(),
actions: [],
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 (chips)
// 后端可存 string[] 或 {id,text}[] ,前端统一归一化为 string[]
// message.quickQuestions 数组非空 → 模板渲染 chips;点击调 onQuickQuestionClick
// PR-WELCOME-QUICK-BATCH: 每 4 个一批展示, 点击换一换循环切换
quickQuestions: (allQuickQuestions || []).slice(0, 4),
allQuickQuestions: allQuickQuestions || [],
_qqBatchIndex: 0,
// PR-WELCOME-HIDE-AFTER-SEND: 标记这是欢迎语消息, 发送第一条用户消息后永久隐藏
isWelcome: true
}
this.messages.push(welcomeMsg)
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', welcomeMsg)
}
this._welcomeLoading = false
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 归一化 quick_questions 数据格式
// 后端可能存 string[] ("如何下单?" / "退换货流程")
// 也可能存 {id, text}[] ({id:'q1',text:'如何下单?'})
// PR-WELCOME-QUICK-NORMALIZE (2026-09-20): 兼容 textarea 时代的 string 格式
// 老数据 PR-WELCOME-SAVE-SANITIZE 上线前存的是 newline-separated string
// 例如 "如何下单?\n退换货流程\n价格说明",前端按 \n 切分兼容
// 后端 KefuGetInfo 已做归一化 (2026-09-20),前端是双保险
// 前端统一抽出 text,空值过滤
normalizeQuickQuestions(raw) {
if (Array.isArray(raw)) {
return raw
.map(function (q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
})
.filter(function (s) { return s && s.trim() !== '' })
}
// 兼容老数据 string 格式 (PR-WELCOME-SAVE-SANITIZE 上线前)
if (typeof raw === 'string' && raw !== '') {
return raw.split(/\r\n|\r|\n/)
.map(function (s) { return s.trim() })
.filter(function (s) { return s !== '' })
}
return []
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 渲染文本提取
getQuickQuestionText(q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 点击 chip
// 1. 把文本塞进 inputText,模拟"用户已输入"
// 2. 清空 welcome bubble 的 chips (一次性)
// 3. 调 sendMessage() 走正常 chat 流 (建 user bubble + AI 回复)
async onQuickQuestionClick(message, q) {
const text = this.getQuickQuestionText(q)
if (!text || !text.trim()) return
try {
// 一次性:点了就摘掉 chips,避免重复点
message.quickQuestions = []
// 走现有 sendMessage 流程 (inputText → 校验 → sendStream/Normal)
this.inputText = text
// 让 DOM 先更新再发,避免用户视觉上"消息凭空出现"
await this.$nextTick()
await this.sendMessage()
} catch (e) {
console.warn('[AI Chat] onQuickQuestionClick failed:', e && e.message)
}
},
// 生成消息的唯一 key(兼容非 H5 平台)
getMessageKey(message, index) {
const rawId = message.id || message.timestamp || index;
// 标准化 ID:转为字符串并替换非法字符(. 和其他非 HTML-safe 字符)
const id = String(rawId).replace(/[^a-zA-Z0-9_-]/g, '-');
return 'msg-' + id + '-' + index;
},
// 更新键盘高度
updateKeyboardHeight(height) {
this.keyboardHeight = height;
if (height > 0) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
// #ifdef H5
// H5 软键盘高度检测: 监听 visualViewport(优先) / window.resize(降级)。
// 键盘弹起时可视区域高度变小, 差值即为键盘高度, 写入 keyboardHeight 由视图垫高层顶起输入框。
_setupH5KeyboardObserver() {
try {
const vv = window.visualViewport
if (vv && typeof vv.addEventListener === 'function') {
this._h5KbOnResize = () => {
const kb = Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0))
this.updateKeyboardHeight(kb)
}
vv.addEventListener('resize', this._h5KbOnResize)
vv.addEventListener('scroll', this._h5KbOnResize)
// 初始记录一次基准高度
this._h5KbOnResize()
} else {
this._h5WinH = window.innerHeight
this._h5KbOnResize = () => {
const kb = Math.max(0, this._h5WinH - window.innerHeight)
this.updateKeyboardHeight(kb)
}
window.addEventListener('resize', this._h5KbOnResize)
}
} catch (e) { /* H5 键盘监听失败不阻断输入 */ }
},
_teardownH5KeyboardObserver() {
try {
const vv = window.visualViewport
if (vv && this._h5KbOnResize) {
vv.removeEventListener('resize', this._h5KbOnResize)
vv.removeEventListener('scroll', this._h5KbOnResize)
}
if (this._h5KbOnResize) {
window.removeEventListener('resize', this._h5KbOnResize)
}
} catch (e) { /* noop */ }
this._h5KbOnResize = null
},
// #endif
// 【新增:会话 ID 本地缓存方法】
saveConversationIdToLocal(convId) {
if (convId) {
uni.setStorageSync(CONVERSATION_KEY, convId);
}
},
getConversationIdFromLocal() {
return uni.getStorageSync(CONVERSATION_KEY) || null;
},
clearConversationIdFromLocal() {
uni.removeStorageSync(CONVERSATION_KEY);
},
// 初始化音频上下文
initAudioContext() {
// #ifdef H5
if (window.AudioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
// #endif
},
onScroll(e) {
const currentScrollTop = e.detail.scrollTop;
this.lastScrollTop = currentScrollTop;
// 防抖处理:50ms内只执行一次,避免频繁触发
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = setTimeout(() => {
// 滚动到顶部附近(<20px)、不在加载中、还有更多历史时,触发加载
if (currentScrollTop < 20 && !this.isLoadingHistory && this.hasMoreHistory) {
this.loadMoreHistory();
}
}, 50);
},
// 初始化用户头像(支持缓存持久化)
initUserAvatarData() {
// 1. 优先读取本地缓存的头像
const cachedAvatar = uni.getStorageSync('user_avatar')
if (cachedAvatar) {
this.localUserAvatar = cachedAvatar
}
// 2. 无缓存时,使用外部传入的初始头像
else if (this.initUserAvatar) {
this.localUserAvatar = this.initUserAvatar
// 缓存初始头像
uni.setStorageSync('user_avatar', this.initUserAvatar)
}
// 3. 最后使用默认头像
else {
this.localUserAvatar = this.defaultAvatar
}
},
// 新增:加载历史记录
async loadChatHistoryIfExist() {
const convId = aiService.getConversationId();
console.log('[AI Chat] loadChatHistoryIfExist 被调用, convId:', convId);
if (convId) {
try {
// 调用正确的方法,并传入会话ID
console.log('[AI Chat] 请求历史 API...');
const response = await aiService.getConversationHistory({
conversation_id: convId
});
console.log('[AI Chat] 历史 API 返回:', JSON.stringify(response).substring(0, 200));
if (!response.success || !Array.isArray(response.messages)) {
console.warn('[AI Chat] 历史记录为空或格式无效, success:', response.success, 'messages:', Array.isArray(response.messages));
return;
}
console.log('[AI Chat] 原始消息数:', response.messages.length, 'total:', response.total);
// 需要根据实际返回的 response.data.messages 来映射
this.messages = response.messages.map(msg => {
let content = '';
if (msg.role === 'user') {
content = msg.query || msg.content || (typeof msg.inputs === 'string' ? msg.inputs : JSON.stringify(msg.inputs || '')) || '';
} else {
content = msg.answer || msg.content || '';
}
// 只使用后端返回的真实时间,不使用假时间
const realTimestamp = msg.created_at || msg.create_time || msg.time;
return {
id: msg.message_id || (Date.now() + Math.random() * 10000),
role: msg.role === 'user' ? 'user' : 'ai',
type: 'text',
content: content,
timestamp: realTimestamp,
actions: []
};
}).filter(msg => msg.content.trim() !== ''); // 过滤掉内容为空的消息
console.log('[AI Chat] 过滤后消息数:', this.messages.length, '内容摘要:', this.messages.map(m => ({role: m.role, content: m.content.substring(0, 30)})));
// 根据服务端 total 判断是否还有更多历史可加载
const msgCount = this.messages.length;
this.hasMoreHistory = response.total > msgCount;
this.historyOffset = msgCount;
} catch (error) {
console.error('[AI Chat] 加载历史记录失败:', error.message, error.stack);
// 只需调用服务层的清除方法,它会处理本地存储
aiService.clearConversationId();
this.currentConversationId = null;
}
}
},
// 新增:初始化用户昵称(支持缓存持久化)
initUserNicknameData() {
// 1. 优先读取本地缓存的昵称
const cachedNickname = uni.getStorageSync('user_nickname')
if (cachedNickname) {
this.localUserNickname = cachedNickname
}
// 2. 无缓存时,使用外部传入的初始昵称
else if (this.initUserNickname) {
this.localUserNickname = this.initUserNickname
// 缓存初始昵称
uni.setStorageSync('user_nickname', this.initUserNickname)
}
// 3. 最后使用默认昵称
else {
this.localUserNickname = this.defaultNickname
}
},
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): AI 还在回答上一个问题时又发新消息的"是否合并"确认
// 背景: AI 是"并行处理"的智能体, 上一轮未完成的任务 + 新问题一起丢给它时,
// 它会自作主张把两件事并行处理(思考流里会出现 "do these in parallel"),
// 结果就是答非所问/串题(例如问"王维怎么玩"却回答上一个公司的信息)。
// 策略: 发出去之前先问用户 —— 合并处理(立即发, 与当前回答并行) / 单独处理(排队, 本轮结束后自动发)
_confirmParallelMerge() {
return new Promise((resolve) => {
uni.showModal({
title: 'AI 正在回答上一个问题',
content: '是否把这条新消息与当前回答合并处理?\n· 合并处理:立即发送,AI 会并行处理两个问题\n· 单独处理:等当前回答结束后自动发送',
confirmText: '合并处理',
cancelText: '单独处理',
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false)
})
})
},
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): 本轮回答结束后, 自动发送排队中的消息
// 多个结束路径(正常结束/报错/降级)都会调, 用标记位保证只触发一次
_flushQueuedSend() {
if (!this.pendingSendAfterStream) return
this.pendingSendAfterStream = false
// 排队期间用户可能把输入框清空了/删掉了附件 → 没有可发内容就放弃, 不弹空消息
const hasText = this.inputText && this.inputText.trim() !== ''
const hasAttach = (this.pendingImages && this.pendingImages.length) || (this.pendingFiles && this.pendingFiles.length)
if (!hasText && !hasAttach) return
uni.showToast({ title: '正在发送排队中的消息', icon: 'none' })
this.$nextTick(() => { this.sendMessage() })
},
// 发送消息
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): pendingImages 数组版
// 原 pendingImage 单数, 改支持 1-9 张图一起发
async sendMessage() {
// 确保有内容、表情或待发送附件(图片/文件)
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
const pendingImagesList = this.pendingImages
const pendingFilesList = this.pendingFiles
const allPending = [...pendingImagesList, ...pendingFilesList]
// 附件必须上传完成才能发送 (任何一张/个还在 uploading/出错都拦)
if (allPending.length) {
if (allPending.some(p => p.uploading)) {
uni.showToast({ title: '附件上传中,请稍候', icon: 'none' })
return
}
if (allPending.some(p => p.uploadError || !p.uploaded)) {
uni.showToast({ title: '附件上传失败,请删除后重试', icon: 'none' })
return
}
}
if (!hasContent && !hasEmoji && !allPending.length) return
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): AI 还在回答时又发新消息 → 先问用户是否合并处理
// 合并: 立即发出(与当前回答并行, 可能出现串题) 单独: 排队, 本轮流式回答结束后自动发送
if (this.isStreaming) {
const mergeNow = await this._confirmParallelMerge()
if (!mergeNow) {
// 输入框内容与待发附件原样保留, 等本轮回答结束由 _flushQueuedSend 自动发出
this.pendingSendAfterStream = true
uni.showToast({ title: '已排队,本轮回答结束后自动发送', icon: 'none' })
return
}
}
// PR-FIX (2026-09-20): 手动发送消息后清掉欢迎气泡上的快捷问题 chips
// 原来只有点击 chip 才摘除, 用户手动打字发消息后 chips 一直挂着,
// 与"引导一次性"的语义不符
this.messages.forEach(msg => {
if (msg.quickQuestions && msg.quickQuestions.length) msg.quickQuestions = []
})
// 待发送文件/视频/音频附件 (与图片一并带出给后端)
// PR-FRONTEND-VOICE-INPUT (2026-09-24): audio 也补 base64 内联, 走与图片同样的
// backend MediaGuard 旁路 (≤5MB 时直接送 data+encoding, 不依赖 URL 公网可达)。
// 这与图片的 enrichedPendingAttachments 行为对齐, 后端 WbAgentOsClient::buildMediaBlocks
// 已经按 docs §3.1.5 处理 audio + data 内联。
// 顺带把录音时长 (ms) 一起塞给后端, 方便 query 文案生成 ("语音消息 (12 秒)")
const fileAttachments = await Promise.all(
pendingFilesList
.filter(p => p.uploaded)
.map(async (p) => {
const base = {
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 不再把 audio/video 强转 file
// p.uploaded.kind 是后端响应 (image/file/audio/video); p.type 是前端分类
type: p.uploaded.kind || p.type || 'file',
url: p.uploaded.url,
mime: p.uploaded.mime,
name: p.uploaded.name,
size: p.uploaded.size,
// PR-FRONTEND-VOICE-INPUT: 录音时长 (ms) 仅对 audio 有意义; 文件/视频缺省 0
duration: p.duration || 0
}
// audio 走 inline-base64 路径 (≤5MB) → 避免 LAN URL 被 MediaGuard SSRF 闸门拦截
if (base.type === 'audio' && p.tempPath) {
try {
const b64 = await fileToBase64(p.tempPath, { mimeHint: p.uploaded.mime })
if (b64 && b64.data) {
base.data = b64.data
base.encoding = 'base64'
}
} catch (e) { /* skip data 字段, 退回 URL 路径 */ }
}
return base
})
)
// 输入框待发送图片: 全部 push, 第一张作为 userMessage.imageUrl 显示
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 同时给附件补 data: <base64> 字段,
// 避开 backend MediaGuard SSRF 闸门 (LAN URL 被识别为内网 IP 直接丢).
// 注意: 必须 await 让 base64 生成后再 build userMessage / sendStreamMessage, 否则附件没 data 字段
const enrichedPendingAttachments = await Promise.all(
pendingImagesList
.filter(p => p.uploaded)
.map(async p => {
const base = {
type: p.uploaded.kind || 'image',
url: p.uploaded.url,
mime: p.uploaded.mime,
name: p.uploaded.name,
size: p.uploaded.size,
}
if (p.tempPath) {
try {
const b64 = await fileToBase64(p.tempPath, { mimeHint: p.uploaded.mime })
if (b64 && b64.data) {
base.data = b64.data
base.encoding = 'base64'
}
} catch (e) { /* skip data 字段, 退回 URL 路径 */ }
}
return base
})
)
// 实际发送给后端的内容 = 图片(含 base64) + 文件/视频/音频
const readyAttachments = [...fileAttachments, ...enrichedPendingAttachments]
// PR-FRONTEND-VOICE-INPUT (2026-09-25): 不再给 attachment 挂自定义 instruction 字段.
// 实测反馈: 后端 Agent framework 不识别 attachment.instruction 字段,
// 会把它当成"事件标识"误读, 反而干扰 STT/视觉链路触发.
// 改为纯 queryText 携带指令 (见下方 _buildAttachmentQuery + merge 逻辑).
// 将来后端若支持 instruction 字段, 再单独 PR 加回.
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now()
// PR-FRONTEND-VOICE-INPUT (2026-09-25): content 只发用户文本, 不再拼接指令 hint
// 历史: 之前给 query 拼 "请听一下 / 请查看图片..." 指令, 实测 AI 会把这条 hint
// 当成"事件标识"误读 (raw i18n key 漏出 / 后端透传逻辑不一致),
// 还不如信任后端 Agent framework 自己按 type 分发处理附件 (语音→STT, 图片→视觉).
// 现在: text + 语音 时 content 只有 text, 音频附件独立传给后端, AI 自行判断.
// 纯附件场景 (无用户文本) 保留 _buildAttachmentQuery 的回退逻辑作为类型标识兜底,
// 后续如果后端 Agent 完全不识别某类附件, 再单独 PR 加回 hint.
const userText = hasContent ? this.inputText.trim() : ''
const queryText = userText || this._buildAttachmentQuery(readyAttachments)
// 带图片时消息类型为 image, 图片 + 配文一起显示; 纯文件/文字时仍为 text
const firstPendingImage = pendingImagesList.length ? pendingImagesList[0] : null
// 用户气泡内展示用的附件: 图片(供 messageImages 渲染多图) + 文件/视频/音频
// 注: 图片附件由上方图片气泡渲染, 文件附件由用户消息 attachments 块渲染, 互不重复
const displayAttachments = [
...enrichedPendingAttachments.map(a => ({
url: a.url,
mime: a.mime,
name: a.name,
size: a.size,
kind: a.type || 'image',
...(a.data ? { data: a.data, encoding: a.encoding } : {}),
})),
...fileAttachments.map(a => ({
url: a.url,
mime: a.mime,
name: a.name,
size: a.size,
kind: a.type || 'file',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): audio 内联 base64 一并塞到消息附件, 模板播放不会用, 仅给后端 Agent 复用
...(a.data ? { data: a.data, encoding: a.encoding } : {}),
})),
]
// PR-USER-VOICE-MESSAGE-TYPE (2026-09-28): 识别"语音"场景
// 原代码对所有非图片消息都设 type='text', 把音频当成普通附件 (📎文件名) 渲染,
// 用户根本看不到自己发的语音. 改为:
// - 有图片 → type='image' (图片气泡为主, 其他走 attachments)
// - 有视频 → type='video' (视频气泡为主, 内嵌播放器)
// - 有语音(可多条)→ type='audio' (语音气泡为主, 复用 voice-msg 样式)
// - 有文件 → type='file' (文件气泡为主, 带文件名/大小)
// - 其他 → type='text' (纯文字)
// PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
// 把 pendingFilesList 里的音频拆出来 → voiceList 数组, 每条独立可播放.
// PR-USER-MEDIA-TYPE-BUBBLE (2026-09-28): 修复视频/文件被错误归到 type='text'
// 渲染成空文字气泡 + 🎬/📎 卡片的 bug. 现在每种 kind 都有专属气泡.
// 优先级: 图片 > 视频 > 语音 > 文件 > 位置 > 文字 (高优先级类型独占气泡, 其余挂到 attachments)
// 位置 (location) 单独走 context 字段透传给 WB, 不进 attachments.
// PR-LOCATION-PENDING (2026-09-28): 位置从 pendingFiles 里挑出来,
// type='location' 标识 (不进 upload 链路, uploaded=null)
const voiceFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'audio')
const videoFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'video')
const fileFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'file')
const locationFiles = pendingFilesList.filter(p => p.type === 'location')
const hasVoice = voiceFiles.length > 0
const hasVideo = videoFiles.length > 0
const hasFile = fileFiles.length > 0
const hasLocation = locationFiles.length > 0
const userMessage = firstPendingImage
? {
id: ++this.messageId,
role: 'user',
type: 'image',
imageUrl: firstPendingImage.uploaded.url || firstPendingImage.tempPath,
// PR-FRONTEND-MEDIA-UPLOAD-FIX: 多图时把其他图片 URL 也带在消息上, 模板侧可循环展示
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 复用上面 enrichedPendingAttachments 的 data 字段
// (同一份 base64 计算, 不要再读一次 file → 节省内存 + CPU)
attachments: displayAttachments,
content: hasContent ? this.inputText.trim() : '',
timestamp: Date.now()
}
: hasVideo
? (() => {
// PR-USER-MULTI-VIDEO (2026-09-28): 支持多条视频同时发送
// 视频全部走 videoList 数组, 模板 v-for 渲染多个 <video> 播放器,
// 每条视频独占一个气泡, 跟 voiceList 类似.
// 修复了之前只取首个视频、其他被 filter 掉丢失的 bug.
return {
id: ++this.messageId,
role: 'user',
type: 'video',
// 用户文本(若有)挂到 content
content: userText,
// 多条视频逐条渲染, 每条独立内嵌播放器
videoList: videoFiles.map(v => ({
url: (v.uploaded && v.uploaded.url) || v.url || '',
fileName: v.fileName || '',
fileSize: v.fileSize || 0,
duration: v.duration || 0,
mime: (v.uploaded && v.uploaded.mime) || v.fileType || ''
})),
// 非视频文件(语音/普通文件)挂到 attachments 由下方文件卡渲染
// 注意: videoList 已覆盖所有视频, 这里 attachments 不放视频, 避免重复渲染
attachments: videoFiles.length === pendingFilesList.length
? []
: displayAttachments.filter(a => a.kind !== 'video' && a.kind !== 'image'),
timestamp: Date.now()
}
})()
: hasVoice
? (() => {
// PR-USER-MULTI-VOICE: 每条 voice 一个独立播放对象, 不再共用 message.url
// 模板 v-for 渲染多行 voice-msg, 每行点击 openVoiceOverlay(v)
return {
id: ++this.messageId,
role: 'user',
type: 'audio',
// 用户文本(若有)挂到 content, 模板里渲染在语音列表下方
content: userText,
// 多条语音逐条渲染, 每条独立可播放
voiceList: voiceFiles.map(a => ({
url: (a.uploaded && a.uploaded.url) || a.url || '',
fileName: a.fileName || '',
fileSize: a.fileSize || 0,
mime: (a.uploaded && a.uploaded.mime) || a.fileType || '',
// duration 保持录音器原始单位 (ms), 模板里 /1000 转换显示
duration: a.duration || 0,
playing: false,
currentTime: 0
})),
// 纯语音时 attachments 留空; 语音+其他文件时把非音频文件挂到 attachments
// (voiceList 已覆盖音频, 避免重复渲染 voice-msg + 📎)
// 音频的 base64 数据已通过 fileAttachments → readyAttachments 传给后端,
// 这里 attachments 不放音频不影响后端通信.
attachments: voiceFiles.length === pendingFilesList.length
? []
: displayAttachments.filter(a => a.kind !== 'audio' && a.kind !== 'image'),
timestamp: Date.now()
}
})()
: hasFile
? (() => {
// PR-USER-MEDIA-TYPE-BUBBLE: 文件气泡, 带文件名 + 大小
// 取首个文件为主 (一个气泡放不下多文件, 其余走 attachments 文件卡)
const file = fileFiles[0]
return {
id: ++this.messageId,
role: 'user',
type: 'file',
// 用户文本(若有)挂到 content
content: userText,
url: (file.uploaded && file.uploaded.url) || file.url || '',
fileName: file.fileName || '',
fileSize: file.fileSize || 0,
mime: file.fileType || '',
// 非文件项挂到 attachments (但图片已在第一分支,实际只可能视频/音频混入)
attachments: displayAttachments.filter(a => a.kind !== 'file' && a.kind !== 'image'),
timestamp: Date.now()
}
})()
: hasLocation
? (() => {
// PR-LOCATION-PENDING (2026-09-28): 位置气泡, 同时作为消息体展示 + WB 上下文
// 取首个位置为主; 用户文本挂到 content 与位置一起显示
// 坐标/地址不重复进 attachments (附件是文件流媒体), 走 context.location 字段
const loc = locationFiles[0]
return {
id: ++this.messageId,
role: 'user',
type: 'location',
content: userText,
latitude: loc.latitude,
longitude: loc.longitude,
address: loc.address || '',
name: loc.name || '',
// 位置不挂 attachments (附件是文件类, 位置是 metadata-like)
// 若用户位置 + 文件混发, 文件归到对应分支优先
attachments: [],
timestamp: Date.now()
}
})()
: {
id: ++this.messageId,
role: 'user',
type: 'text',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 用户气泡只显示用户自己输入的文字
// 给 AI 的指令 hint 在 queryText 里, 这里不要污染用户气泡
content: userText,
// 纯文字(无任何附件)时 attachments 留空
attachments: [],
timestamp: Date.now()
}
this.shouldScrollToBottom = true
this.messages.push(userMessage)
this.inputText = ''
// PR-PENDING-VOICE-PREVIEW (2026-09-28): 清空 pending 前先停掉预览中的语音
// 不停的话 innerAudioCtx 会继续播, 用户听觉上"语音还在响"但气泡已发出去, 体感很怪
this._stopPendingAudioIfPlaying()
this.pendingImages = []
this.pendingFiles = []
// 显示AI正在处理 - 带状态轮换提示
const loadingMessage = {
id: ++this.messageId,
role: 'ai',
type: 'loading',
content: '',
timestamp: Date.now(),
loadingText: this.$lang('ai-chat.index.aiThinking'),
_loadingIndex: 0
}
this.shouldScrollToBottom = true
this.messages.push(loadingMessage)
this.scrollToBottom()
// 启动处理状态文字轮换
this._startLoadingTextRotation(loadingMessage)
// 等待 Vue 先渲染 loading 动画,再发起请求
await this.$nextTick()
try {
// 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判)
// 如果服务不可用,实际发送时会抛出异常,由 catch 块处理
// 纯图片无文字时, 给 AI 的 query 用占位文案, 避免空 query
// PR-FRONTEND-VOICE-INPUT (2026-09-25): content 只发用户文本 (有文本时),
// 纯附件时 fallback 到 _buildAttachmentQuery 的类型标识, 不再 append 指令 hint.
const enhancedMessage = this.enhanceEmojiMessage(queryText)
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面/会话 metadata 给 WbAgentOsClient
// 让 AI 知道用户在哪、什么时候发的、当前在哪个页面、设备/平台信息
// context 字段在 ai-service.js 三个发送路径(sendMessage/sendStreamMessage/sendHttpStream)
// 都已添加, 缺失则后端拿到 null, 不影响旧版后端兼容
const context = await this._buildSendContextAsync({ locationFiles })
if (this.enableStreaming) {
await this.sendStreamMessage(enhancedMessage, readyAttachments, context)
} else {
await this.sendNormalMessage(enhancedMessage, readyAttachments, context)
}
} catch (error) {
console.error('AI服务调用失败:', error)
// 移除加载状态
this._removeLoadingMessages()
// 显示错误消息
const errorMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceUnavailable'),
timestamp: Date.now(),
actions: [
{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }
]
}
this.shouldScrollToBottom = true
this.messages.push(errorMessage)
this.$emit('ai-response', errorMessage)
}
// 触发消息发送事件
this.$emit('message-sent', userMessage)
},
// 发送普通消息
async sendNormalMessage(userMessage, attachments = [], context = null) {
const response = await aiService.sendMessage(userMessage, {
conversationId: this.currentConversationId,
stream: false,
attachments,
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传 context 给 WB
context
})
// 移除加载状态
this._removeLoadingMessages()
// 更新会话ID
if (response.conversationId) {
this.currentConversationId = response.conversationId;
aiService.setConversationId(response.conversationId);
this.saveConversationIdToLocal(response.conversationId);
}
// 普通响应也可能在 result.thoughts 里带思考过程 (如 aikefu 阻塞接口)
const fallbackThoughts = response.thoughts || ''
const aiMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: response.content,
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,
actions: []
}
this.shouldScrollToBottom = true
this.messages.push(aiMessage)
this.$emit('ai-response', aiMessage)
},
// 发送流式消息(自动适配 H5 / 微信小程序), 支持思考流 / 选项 / 工具调用 / 附件
async sendStreamMessage(userMessage, attachments = [], context = null) {
// 标记进入流式接收, 禁止重复发送, 同时让思考面板处于"思考中"展开状态
this.isStreaming = true
// 找到已有的 loading 消息,复用它在 DOM 中的位置,实现无缝过渡
const loadingIdx = this.messages.findIndex(msg => msg.type === 'loading')
let targetMsg
if (loadingIdx === -1) {
// 安全回退:没有 loading 消息时创建新消息
targetMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: '',
timestamp: Date.now(),
isStreaming: true,
thoughtsText: '',
toolCalls: [],
pendingQuestion: null,
questionAnswered: false,
tokens: null,
thoughtExpanded: true,
selectedAnswers: {},
questionTimeout: 0
}
this.messages.push(targetMsg)
this.scrollToBottom()
} else {
targetMsg = this.messages[loadingIdx]
this._stopLoadingTextRotation(targetMsg)
// 固定文案, 不走 $lang: 远端语言覆写会覆盖本地 lang 文件, 导致改了 lang/zh-cn 也不生效
targetMsg.loadingText = '思考中'
// 进入流式接收状态: 让思考面板在收到 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', {})
}
const findMsg = () => this.messages.find(m => m.id === targetMsg.id)
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)
// PR-CHAT-PARALLEL-CONFIRM: 本轮结束 → 若有排队消息, 自动发出
this._flushQueuedSend()
break
}
case 'error':
msg.isStreaming = false
this.isStreaming = false
msg.content = (msg.content || '') + '\n[错误] ' + (data.message || '未知错误')
// PR-CHAT-PARALLEL-CONFIRM: 报错也算本轮结束, 排队消息照常发出
this._flushQueuedSend()
break
case 'attachments':
if (!msg.attachments) this.$set(msg, 'attachments', [])
msg.attachments.push(...(data || []))
break
}
}
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)
// PR-CHAT-PARALLEL-CONFIRM: 本轮结束 → 若有排队消息, 自动发出
this._flushQueuedSend()
}
try {
// #ifdef H5
await aiService.sendHttpStream(userMessage, onEvent, onComplete, { attachments, context })
// #endif
// #ifdef MP-WEIXIN
await aiService.sendStreamMessage(userMessage, onEvent, onComplete, { attachments, context })
// #endif
} catch (error) {
console.error('流式请求失败:', error)
this.isStreaming = false
const msg = findMsg()
if (msg && msg.type === 'loading') {
this._removeLoadingMessages()
} 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',
type: 'text',
content: this.$lang('ai-chat.index.serviceTemporaryUnavailable'),
timestamp: Date.now(),
actions: [{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
// PR-CHAT-PARALLEL-CONFIRM: 请求失败也算本轮结束, 排队消息照常发出
this._flushQueuedSend()
}
},
// 思考流面板: 点击折叠/展开 (流式进行中不可收起)
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
}
// PR-FA-PRE (2026-09-18): 仅在 elicitResponse 真返回 true 时才置 questionAnswered=true
// 老逻辑先置 true 再 await,后端返回 false (H5 后端未就绪) 时也已被 UI 标记为已答,
// 用户看不到正确答案的继续流 → 体感"假性中断"。修:成功后才标记;失败提示并保留未答状态
// 由 30s pending_stale 兜底超时。
this.$set(msg, 'questionTimedOut', false)
if (msg.id && this.questionTimers[msg.id]) {
clearInterval(this.questionTimers[msg.id])
this.$delete(this.questionTimers, msg.id)
}
let ok = false
try {
ok = 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
})
} catch (e) {
ok = false
uni.showToast({ title: '选项提交失败: ' + (e.message || ''), icon: 'none' })
}
if (ok) {
this.$set(msg, 'questionAnswered', true)
// 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理
} else {
// H5 后端暂未就绪 或 网络失败:保留 questionAnswered=false,30s 由 pending_stale 兜底
uni.showToast({ title: '选项已展示,等待后端确认…', icon: 'none' })
}
},
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)
},
// PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 取出用户消息里所有图片 (按发送顺序)
// 优先用 attachments (多图批量发送), 只有单张时退回 imageUrl
messageImages(message) {
const list = (message.attachments || []).filter(a => !a.kind || a.kind === 'image' || a.type === 'image')
if (list.length) return list
return message.imageUrl ? [{ url: message.imageUrl }] : []
},
// 点击用户图片: 在全部图片中预览 (左右可滑动切换)
previewUserImage(message, index) {
const urls = this.messageImages(message).map(img => img.url).filter(Boolean)
if (!urls.length) return
uni.previewImage({ urls, current: urls[index] || urls[0] })
},
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) + '&fileType=video' })
} else if (att.type === 'audio') {
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频附件 — 跳到 file-preview 复用音频播放器
// file-preview.vue 加了 audio fileType 分支, 用 createInnerAudioContext 播放
uni.navigateTo({
url: '/pages_tool/file-preview/file-preview?url=' + encodeURIComponent(att.url)
+ '&fileType=audio'
+ (att.name ? '&fileName=' + encodeURIComponent(att.name) : '')
})
} 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()
// 新对话后重新加载欢迎语
this._welcomeLoading = false
this._loadWelcomeMessage()
uni.showToast({ title: '已开始新对话', icon: 'success' })
},
// 完成流式消息处理(共用逻辑)
_finishStream(msg, result) {
msg.isStreaming = false
this.isStreaming = false
if (!msg.content || msg.content.trim() === '') {
this._removeLoadingMessages()
return false
}
if (result) {
const convId = typeof result === 'string' ? '' : (result.conversation_id || '')
if (convId) {
this.currentConversationId = convId
aiService.setConversationId(convId)
this.saveConversationIdToLocal(convId)
}
}
msg.actions = []
// 计算并写入耗时 (秒), 用于状态行 "已完成 XmXXs"
if (this._aiRequestStart > 0) {
msg.elapsed = (Date.now() - this._aiRequestStart) / 1000
msg._finished = true
}
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
return true
},
// 流式请求出错处理
_handleStreamError(msg) {
console.error('流式请求失败')
this.isStreaming = false
this.messages = this.messages.filter(m => m.id !== msg.id)
const errorMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceTemporaryUnavailable'),
timestamp: Date.now(),
actions: [{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
},
// 加载状态文字轮换 - 让AI看起来在处理不同阶段
_startLoadingTextRotation(loadingMsg) {
const statusTexts = [
this.$lang('ai-chat.index.aiThinkingStatus1'),
this.$lang('ai-chat.index.aiThinkingStatus2'),
this.$lang('ai-chat.index.aiThinkingStatus3'),
this.$lang('ai-chat.index.aiThinkingStatus4')
]
loadingMsg._rotationTimer = setInterval(() => {
loadingMsg._loadingIndex = (loadingMsg._loadingIndex + 1) % statusTexts.length
loadingMsg.loadingText = statusTexts[loadingMsg._loadingIndex]
}, 2000)
},
// 停止加载状态文字轮换
_stopLoadingTextRotation(loadingMsg) {
if (loadingMsg && loadingMsg._rotationTimer) {
clearInterval(loadingMsg._rotationTimer)
loadingMsg._rotationTimer = null
}
},
// 移除加载消息并清理定时器
_removeLoadingMessages() {
const loadingMsgs = this.messages.filter(msg => msg.type === 'loading');
loadingMsgs.forEach(msg => this._stopLoadingTextRotation(msg));
this.messages = this.messages.filter(msg => msg.type !== 'loading');
},
generateAIResponse(userMessage) {
const responses = {
'你好': this.$lang('ai-chat.index.autoReplyGreeting'),
'价格': this.$lang('ai-chat.index.autoReplyPrice'),
'发货': this.$lang('ai-chat.index.autoReplyShipping'),
'退货': this.$lang('ai-chat.index.autoReplyReturn'),
'客服': this.$lang('ai-chat.index.autoReplyService')
};
for (let key in responses) {
if (userMessage.includes(key)) {
return responses[key];
}
}
return this.$lang('ai-chat.index.autoReplyDefault', { question: userMessage });
},
// 增强版Markdown解析(无第三方依赖)
parseMarkdown(content) {
if (!content) return ''
// 兜底: 后端流式响应有时会把每个字用 \n 拼接 ("你\n好\n!"), 导致最终渲染一字一行.
// 检测"每行 1 个字符"的比例, 若超过 70% 则认为是退化格式, 先合并成连续字符串再走 markdown.
try {
const rawLines = String(content).split('\n')
if (rawLines.length >= 5) {
const singleCharLines = rawLines.filter(l => l.trim().length === 1).length
if (singleCharLines / rawLines.length > 0.7) {
content = rawLines.join('').replace(/\s+/g, '')
}
}
} catch (e) { /* 忽略, 走原始 content */ }
// 主文字色 / 链接色 (偏豆包风格的近黑 + 蓝)
const TEXT = '#1a1a1a'
const LINK = '#3a7afe'
// 微信小程序端: 回答更紧凑 (行高/段距收紧, 不改变颜色与字号)
let COMPACT = false
/* #ifdef MP-WEIXIN */
COMPACT = true
/* #endif */
const LH = COMPACT ? '1.6' : '1.75'
const LI_LH = COMPACT ? '1.55' : '1.75'
const P_MARGIN = COMPACT ? '1rpx 0' : '2rpx 0'
const HR_MARGIN = COMPACT ? '10rpx 0' : '16rpx 0'
const BQ_MARGIN = COMPACT ? '6rpx 0' : '8rpx 0'
const BQ_LH = COMPACT ? '1.6' : '1.7'
// ===== HTML 白名单还原 (对齐后台预览 markedParse 的 WC_ALLOWED_TAGS / WC_ALLOWED_STYLE) =====
// 后台预览支持受限 HTML(染色/排版), 终端此前无条件转义所有 < >, 商家配置的
// <span style="..."> 在终端显示成字面文本。策略: 转义 → 识别白名单 → 只还原白名单。
// - 标签/属性/CSS 属性三张白名单, 其余(含 on* 事件、position、url())一律丢弃
// - 非白名单标签保持转义态, 照常显示为字面文本
const WC_TAGS = ['span', 'div', 'b', 'i', 'u', 's', 'em', 'strong', 'mark', 'small', 'sub', 'sup', 'br', 'hr', 'p', 'blockquote', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']
const WC_VOID = { br: true, hr: true } // 自闭合标签, 还原时不带结束标签
const WC_STYLES = {
color: 1, 'background-color': 1, background: 1,
'font-size': 1, 'font-weight': 1, 'font-style': 1, 'font-family': 1,
'text-align': 1, 'text-decoration': 1, 'text-indent': 1, 'text-transform': 1,
'line-height': 1, 'letter-spacing': 1, 'vertical-align': 1, 'white-space': 1,
'list-style-type': 1, 'word-break': 1, 'word-wrap': 1,
margin: 1, 'margin-top': 1, 'margin-bottom': 1, 'margin-left': 1, 'margin-right': 1,
padding: 1, 'padding-top': 1, 'padding-bottom': 1, 'padding-left': 1, 'padding-right': 1,
'border-color': 1, 'border-style': 1, 'border-width': 1, 'border-radius': 1,
width: 1, height: 1, 'min-width': 1, 'max-width': 1,
display: 1, opacity: 1, 'text-shadow': 1
}
const WC_STYLE_BAD = /expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@import|url\s*\(/i
const wcFilterStyle = (s) => {
return s.split(';').map((p) => {
const i = p.indexOf(':')
if (i === -1) return ''
const prop = p.slice(0, i).trim().toLowerCase()
const val = p.slice(i + 1).trim()
// 值里不允许引号; 命中黑名单(expression/javascript/url( 等)整条丢弃
if (!WC_STYLES[prop] || WC_STYLE_BAD.test(val) || val.indexOf('"') !== -1) return ''
return prop + ': ' + val
}).filter(Boolean).join('; ')
}
const wcRestoreHtml = (escaped) => {
// 转义后标签形如 &lt;span style="..."&gt;, 引号未被转义仍是字面 "
return escaped.replace(/&lt;(\/?)([a-zA-Z0-9]+)([\s\S]*?)&gt;/g, (m, slash, rawTag, attrs) => {
const tag = rawTag.toLowerCase()
if (WC_TAGS.indexOf(tag) === -1) return m // 非白名单: 保持转义, 显示为字面文本
if (slash) return WC_VOID[tag] ? '' : '</' + tag + '>'
if (WC_VOID[tag]) return '<' + tag + '>'
const kept = []
const attrRe = /([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g
let am
while ((am = attrRe.exec(attrs)) !== null) {
const name = am[1].toLowerCase()
const val = am[2] !== undefined ? am[2] : am[3]
if (name === 'style') {
const fs = wcFilterStyle(val)
if (fs) kept.push('style="' + fs + '"')
} else if (name === 'href' || name === 'src') {
// 协议白名单: 挡 javascript:/vbscript:/data: 等
if (/^(https?:|mailto:|tel:|\/|#)/i.test(val)) kept.push(name + '="' + val + '"')
} else if (name === 'alt' || name === 'title' || name === 'colspan' || name === 'rowspan') {
kept.push(name + '="' + val + '"')
}
// 其余属性(含 on* 事件)一律丢弃
}
return '<' + tag + (kept.length ? ' ' + kept.join(' ') : '') + '>'
})
}
// 辅助:内联格式处理
// 思路: 先把"链接/图片/裸URL/电话"整体抽成占位符保护起来, 再做加粗/斜体等,
// 最后还原, 避免各规则互相串改对方的 href / 数字。
const parseInline = (text) => {
const stash = []
const hold = (html) => { stash.push(html); return '\u0000' + (stash.length - 1) + '\u0000' }
let t = text
// 1) 已有的 markdown 链接 / 图片先抽出
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (m) => hold(m))
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m) => hold(m))
// 2) 裸 URL(含 www.) -> 蓝色链接, 并抽离保护
.replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi, (m) => {
let url = m
// 去掉结尾可能被一并匹配的中英文标点
const trail = url.match(/[,。、!?;:,!?;:'")\]】》」』]+$/)
if (trail) url = url.slice(0, url.length - trail[0].length)
const href = /^https?:\/\//i.test(url) ? url : 'https://' + url
return hold('<a href="' + href + '" style="color:' + LINK + ';text-decoration:none;">' + url + '</a>')
})
// 3) 手机号 / 400 电话 -> 蓝色链接, 抽离保护
.replace(/\b(1[3-9]\d{9})\b/g, (m) => hold('<a href="tel:' + m + '" style="color:' + LINK + ';text-decoration:none;">' + m + '</a>'))
.replace(/\b(400[\d-]{7,10})\b/g, (m) => hold('<a href="tel:' + m + '" style="color:' + LINK + ';text-decoration:none;">' + m + '</a>'))
// 4) 还原抽出的 markdown, 转成图片 / 蓝色锚点
t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => {
const orig = stash[Number(i)]
if (orig.charAt(0) === '!') {
return orig.replace(/!\[([^\]]*)\]\(([^)]+)\)/, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8rpx;margin:8rpx 0;display:block;" />')
}
return orig.replace(/\[([^\]]+)\]\(([^)]+)\)/, '<a href="$2" style="color:' + LINK + ';text-decoration:none;">$1</a>')
})
// 5) 行内代码 / 加粗 / 斜体 / 删除线 (此时链接已抽离, 不会被误改)
// PR-FIX-AI-TEXT-COLOR (2026-09-24): AI 回答统一配色 —— 正文/标题/加粗/行内代码/引用/删除线
// 一律黑色(TEXT), 只有"网址/链接"是蓝色(LINK)。
// 之前行内代码是粉色(#d63384)、删除线是灰色(#999), 回答里颜色太杂。
t = t
.replace(/`([^`]+)`/g, '<code style="background:#f2f3f5;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:' + TEXT + ';">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<span style="color:' + TEXT + ';">$1</span>')
.replace(/\*(.+?)\*/g, '<em style="font-style:italic;color:' + TEXT + ';">$1</em>')
.replace(/~~(.+?)~~/g, '<del style="text-decoration:line-through;color:' + TEXT + ';">$1</del>')
return t
}
// 1. 转义HTML特殊字符
let html = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 2. 提取代码块(保护其内容不被后续处理污染)
const codeBlocks = []
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, lang, code) => {
const idx = codeBlocks.length
const safe = code.replace(/</g, '&lt;').replace(/>/g, '&gt;')
codeBlocks.push(
`<pre style="background:#f7f8fa;padding:20rpx;border-radius:8rpx;overflow-x:auto;font-size:24rpx;line-height:1.6;margin:12rpx 0;white-space:pre-wrap;">` +
`<code style="background:transparent;padding:0;font-family:monospace;color:${TEXT};font-size:24rpx;">${safe}</code></pre>`
)
return `%%CODEBLOCK_${idx}%%`
})
// 2.5 还原白名单内 HTML (放在代码块抽离之后: 代码块里的示例 HTML 保持字面文本)
html = wcRestoreHtml(html)
// 3. 按行处理,用 <br> 连接单换行,双换行分割段落块
const lines = html.split('\n')
const out = []
let inList = false
let buf = [] // 普通文本行缓冲区
let inTable = false
let tableRows = [] // 表格行缓冲区
const closeList = () => {
if (inList === 'ul') { inList = false; return '</ul>' }
if (inList === 'ol') { inList = false; return '</ol>' }
return ''
}
const flushBuf = () => {
if (buf.length === 0) return
// 去掉首尾的空串,避免产生尾随 <br>
while (buf.length > 0 && buf[0] === '') buf.shift()
while (buf.length > 0 && buf[buf.length - 1] === '') buf.pop()
if (buf.length === 0) return
const content = buf.map(l => parseInline(l)).join('<br>')
out.push(`<div style="line-height:${LH};font-size:30rpx;color:${TEXT};margin:${P_MARGIN};">${content}</div>`)
buf = []
}
const flushTable = () => {
if (!inTable || tableRows.length === 0) return
const thead = tableRows[0].map(c =>
`<th style="padding:8rpx 12rpx;border:1rpx solid #eaecef;font-weight:400;background:#f6f8fa;text-align:left;font-size:24rpx;color:${TEXT};">${parseInline(c)}</th>`
).join('')
const tbody = tableRows.slice(1).map(row =>
'<tr>' + row.map(c =>
`<td style="padding:8rpx 12rpx;border:1rpx solid #eaecef;line-height:1.6;font-size:24rpx;color:${TEXT};">${parseInline(c)}</td>`
).join('') + '</tr>'
).join('')
out.push(
`<table style="border-collapse:collapse;margin:12rpx 0;width:100%;font-size:24rpx;color:${TEXT};">` +
`<thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`
)
tableRows = []
inTable = false
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmed = line.trim()
// 代码块占位符
if (/^%%CODEBLOCK_\d+%%$/.test(trimmed)) {
flushBuf(); flushTable(); out.push(closeList())
out.push(line)
continue
}
// 表格行(以 | 开头)
if (trimmed.charAt(0) === '|') {
flushBuf(); out.push(closeList())
// 去除首尾空 cell
const rawCells = trimmed.split('|').filter((c, j, a) => j > 0 && j < a.length - 1)
if (rawCells.length === 0) continue
// 跳过分隔行 | --- | :--- | :---: | ---: |
const isSep = rawCells.every(c => /^[\s:\-|]+$/.test(c.trim()))
if (isSep) continue
if (!inTable) { inTable = true; tableRows = [] }
tableRows.push(rawCells.map(c => c.trim()))
continue
}
// 非表格行 → 刷新已积累的表格
if (inTable) flushTable()
// 标题
const hMatch = line.match(/^(#{1,6})\s+(.+)$/)
if (hMatch) {
flushBuf(); out.push(closeList())
const lv = hMatch[1].length
const size = ['', '36rpx', '32rpx', '30rpx', '28rpx', '26rpx', '24rpx'][lv]
const wgt = ['', '400', '400', '400', '400', '400', '400'][lv]
const mg = COMPACT ? ['', '14rpx 0 7rpx', '12rpx 0 6rpx', '10rpx 0 5rpx', '8rpx 0 5rpx', '7rpx 0 3rpx', '6rpx 0 3rpx'][lv] : ['', '20rpx 0 10rpx', '16rpx 0 8rpx', '14rpx 0 6rpx', '12rpx 0 6rpx', '10rpx 0 4rpx', '8rpx 0 4rpx'][lv]
out.push(`<h${lv} style="font-size:${size};font-weight:${wgt};margin:${mg};line-height:1.35;color:${TEXT};">${parseInline(hMatch[2])}</h${lv}>`)
continue
}
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
flushBuf(); out.push(closeList())
out.push('<hr style="border:none;border-top:2rpx solid #eaecef;margin:${HR_MARGIN};">')
continue
}
// 引用
const bqMatch = line.match(/^>\s?(.+)$/)
if (bqMatch) {
flushBuf(); out.push(closeList())
out.push(`<blockquote style="border-left:4rpx solid #d0d7de;padding:8rpx 16rpx;margin:${BQ_MARGIN};background:#f6f8fa;color:${TEXT};line-height:${BQ_LH};font-size:28rpx;border-radius:0 6rpx 6rpx 0;">${parseInline(bqMatch[1])}</blockquote>`)
continue
}
// 无序列表
const ulMatch = line.match(/^[-*+]\s+(.+)$/)
if (ulMatch) {
flushBuf()
if (!inList) { out.push('<ul style="padding-left:30rpx;margin:8rpx 0;color:' + TEXT + ';">'); inList = 'ul' }
out.push(`<li style="margin:5rpx 0;line-height:${LI_LH};font-size:30rpx;">${parseInline(ulMatch[1])}</li>`)
continue
}
// 有序列表
const olMatch = line.match(/^\d+\.\s+(.+)$/)
if (olMatch) {
flushBuf()
if (!inList) { out.push('<ol style="padding-left:30rpx;margin:8rpx 0;color:' + TEXT + ';">'); inList = 'ol' }
out.push(`<li style="margin:5rpx 0;line-height:${LI_LH};font-size:30rpx;">${parseInline(olMatch[1])}</li>`)
continue
}
// 空行 → 加入缓冲区作段落分隔(<br><br>),同时关闭列表
if (trimmed === '') {
// 关闭列表(标准Markdown行为)
if (inList) {
flushBuf()
out.push(closeList())
}
// 避免连续空行推入多个空串
if (buf.length > 0 && buf[buf.length - 1] !== '') {
buf.push('')
}
continue
}
// 普通文本行 => 加入缓冲区
buf.push(line)
}
// 扫尾
flushBuf(); flushTable(); out.push(closeList())
html = out.join('')
// 4. 还原代码块
html = html.replace(/%%CODEBLOCK_(\d+)%%/g, (m, idx) => codeBlocks[parseInt(idx)])
return html
},
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 时间格式化支持后端配置
// ui_time_format = 'auto' 智能分段(默认, 与历史行为一致): 今天 HH:mm / 昨天 / 前天 / 本年 M/D / 跨年 Y/M/D
// ui_time_format = 'full' 始终完整 YYYY-MM-DD HH:mm
// ui_time_humanize = true 微信式相对时间(优先于 format): 刚刚 / N分钟前 / N小时前 / 昨天 / 前天 / M月D日 / Y年M月D日
formatTime(timestamp) {
if (!timestamp) return '';
let date;
if (typeof timestamp === 'string') {
const parts = timestamp.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/);
if (parts) {
const [, year, month, day, hour, minute, second] = parts;
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second));
date.setHours(date.getHours() + 8);
} else {
date = new Date(timestamp);
}
} else {
let timeValue = timestamp;
if (typeof timeValue === 'number' && timeValue < 10000000000) {
timeValue = timeValue * 1000;
}
date = new Date(timeValue);
}
if (isNaN(date.getTime())) return '';
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const beforeYesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
const msgDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const timeStr = `${hours}:${minutes}`;
// 人性化相对时间 (开启后优先于 ui_time_format)
if (this.chatUiConfig && this.chatUiConfig.ui_time_humanize) {
return this.humanizeTime(date, msgDate, today, timeStr);
}
// 完整时间格式: 始终 YYYY-MM-DD HH:mm
if (this.chatUiConfig && this.chatUiConfig.ui_time_format === 'full') {
const mm = (date.getMonth() + 1).toString().padStart(2, '0');
const dd = date.getDate().toString().padStart(2, '0');
return `${date.getFullYear()}-${mm}-${dd} ${timeStr}`;
}
// auto: 智能分段 (原有逻辑)
if (msgDate.getTime() === today.getTime()) {
return timeStr;
}
if (msgDate.getTime() === yesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatYesterday')} ${timeStr}`;
}
if (msgDate.getTime() === beforeYesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatBeforeYesterday')} ${timeStr}`;
}
const isCurrentYear = date.getFullYear() === now.getFullYear();
const monthStr = (date.getMonth() + 1).toString();
const dayStr = date.getDate().toString();
if (isCurrentYear) {
return `${monthStr}/${dayStr} ${timeStr}`;
}
const yearStr = date.getFullYear().toString();
return `${yearStr}/${monthStr}/${dayStr} ${timeStr}`;
},
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 微信式人性化相对时间
// 刚刚 / N分钟前 / 今天内N小时前 / 昨天 HH:mm / 前天 HH:mm / 本年 M月D日 HH:mm / 跨年 Y年M月D日 HH:mm
humanizeTime(date, msgDate, today, timeStr) {
const diff = Date.now() - date.getTime();
const oneMin = 60 * 1000;
const oneHour = 60 * oneMin;
if (diff >= 0 && diff < oneMin) {
return this.$lang('ai-chat.index.timeJustNow');
}
if (diff >= 0 && diff < oneHour) {
return this.$lang('ai-chat.index.timeMinutesAgo', { n: Math.floor(diff / oneMin) });
}
// 今天内且已超过 1 小时
if (msgDate.getTime() === today.getTime()) {
if (diff < 24 * oneHour) {
return this.$lang('ai-chat.index.timeHoursAgo', { n: Math.floor(diff / oneHour) });
}
return timeStr; // 兜底(理论不可达: 同一天 diff 不可能 >= 24h)
}
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
if (msgDate.getTime() === yesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatYesterday')} ${timeStr}`;
}
const beforeYesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
if (msgDate.getTime() === beforeYesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatBeforeYesterday')} ${timeStr}`;
}
const m = date.getMonth() + 1;
const d = date.getDate();
if (date.getFullYear() === new Date().getFullYear()) {
return `${this.$lang('ai-chat.index.timeMonthDay', { m, d })} ${timeStr}`;
}
return `${this.$lang('ai-chat.index.timeYearMonthDay', { y: date.getFullYear(), m, d })} ${timeStr}`;
},
// 格式化文件大小
formatFileSize(bytes) {
if (!bytes) return this.$lang('ai-chat.index.about')
const k = 1024
const sizes = [this.$lang('ai-chat.index.about'), 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i]
},
// 格式化时长
formatDuration(seconds) {
if (!seconds) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return mins.toString().padStart(2, '0') + ':' + secs.toString().padStart(2, '0')
},
// PR-USER-VOICE-BUBBLE (2026-09-28): 语音消息播放进度 (0~100)
// message.duration 是录音器返回的毫秒数, message.currentTime 由 innerAudioCtx.onTimeUpdate 更新 (秒)
// 进度条宽度用 % 表示, 边界情况 (无 duration / currentTime=0) 返回 0
getVoiceProgress(message) {
if (!message) return 0
const dur = Number(message.duration) || 0
if (dur <= 0) return 0
// currentTime 由 innerAudioCtx 返回秒; duration 是毫秒, 统一为毫秒比较
const cur = (Number(message.currentTime) || 0) * 1000
return Math.min(100, Math.max(0, (cur / dur) * 100))
},
// PR-USER-VOICE-BUBBLE-WECHAT (2026-09-29): 微信风格语音气泡宽度按时长比例伸缩,
// 短语音窄、长语音宽, 更像微信原生体验。
getVoiceBubbleWidth(durationMs) {
const dur = Number(durationMs) || 0
const seconds = dur / 1000
const minW = 200
const maxW = 420
const maxDuration = 60
const ratio = Math.min(1, Math.max(0, seconds / maxDuration))
return `${Math.round(minW + ratio * (maxW - minW))}rpx`
},
// PR-USER-VOICE-BUBBLE-WECHAT (2026-09-29): 微信语音气泡时长显示为 X" 格式
getVoiceDurationText(durationMs) {
const seconds = Math.round((Number(durationMs) || 0) / 1000)
return seconds + '"'
},
// 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s")
formatElapsed(seconds) {
if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
seconds = Math.max(0, Math.floor(seconds))
if (seconds < 1) return '1s'
const m = Math.floor(seconds / 60)
const s = seconds % 60
if (m === 0) return s + 's'
return m + 'm' + (s < 10 ? '0' + s : s) + 's'
},
// 滚动到底部 - 流式长回答时持续跟随最新文字
// 关键: 之前用 scroll-into-view 把最后一条消息滚入视野, 但流式过程中 AI 消息 id 不变,
// scroll-into-view 只在 id 变化/重新赋值时触发, 且会把元素"顶到顶部对齐"而非贴底,
// 导致长回答越写越长时, 最新文字被挤出可视区、看不到(你反馈的问题)。
// 改法: 改用 scroll-top 赋一个不断递增且恒大于内容高度的值, 强制滚动条贴底,
// 每个新文字块到达都重设 → 视口随答案增长缓缓上移, 始终显示 AI 最新回复。
scrollToBottom() {
if (this.messages.length === 0) return
// 取消可能冲突的 scroll-into-view (搜索跳转用, 不走这里)
this.scrollIntoViewId = ''
this.$nextTick(() => {
// 递增超大值: 保证每次调用都触发重新滚动(同值在部分平台不重排), 且恒大于内容高度=贴底
this._bottomCounter = (this._bottomCounter || 0) + 100000
this.scrollTop = this._bottomCounter
})
},
// 加载更多历史消息
async loadMoreHistory() {
if (!this.currentConversationId) {
this.currentConversationId = this.getConversationIdFromLocal();
aiService.setConversationId(this.currentConversationId);
}
// 防止重复加载
if (!this.hasMoreHistory || !this.currentConversationId || this.isLoadingHistory) {
return;
}
this.isFetchingHistory = true;
this.isLoadingHistory = true;
this.loadingText = this.$lang('ai-chat.index.loadingHistory');
try {
const response = await aiService.getConversationHistory({
conversation_id: this.currentConversationId,
limit: 20,
offset: this.historyOffset
});
if (response.success && Array.isArray(response.messages) && response.messages.length > 0) {
const historyMessages = response.messages.map(msg => {
const realTimestamp = msg.create_time || msg.created_at || msg.time;
return {
id: msg.id || `history-${Date.now()}-${Math.random().toString(36).slice(2)}`,
role: msg.role === 'user' ? 'user' : 'ai',
type: 'text',
content: msg.content,
timestamp: realTimestamp
};
});
const newMsgCount = historyMessages.length;
this.messages = [...historyMessages, ...this.messages];
this.historyOffset += newMsgCount;
// 根据服务端 total 判断是否还有更多
if (response.total !== undefined) {
this.hasMoreHistory = this.historyOffset < response.total;
}
await this.$nextTick();
const query = uni.createSelectorQuery().in(this);
const heightPromises = [];
for (let i = 0; i < newMsgCount; i++) {
heightPromises.push(new Promise(resolve => {
query.select(`.message-item:nth-child(${i + 1})`).boundingClientRect(rect => {
resolve(rect ? rect.height + 32 : 0);
}).exec();
}));
}
const heights = await Promise.all(heightPromises);
const totalNewHeight = heights.reduce((sum, h) => sum + h, 0);
this.scrollTop = totalNewHeight;
} else {
// 无更多历史
this.hasMoreHistory = false;
}
} catch (error) {
console.error('加载历史失败:', error);
uni.showToast({ title: this.$lang('ai-chat.index.loadHistoryFail'), icon: 'none' });
this.hasMoreHistory = false;
} finally {
this.isLoadingHistory = false;
this.isFetchingHistory = false;
this.loadingText = this.$lang('ai-chat.index.loadMoreHistory');
}
},
// 显示更多工具
showMoreTools() {
this.showToolsPanel = true
},
// 预览文件
previewFile(message) {
uni.showLoading({ title: this.$lang('ai-chat.index.openingFile') })
// #ifdef H5
window.open(message.url, '_blank')
// #endif
// #ifdef APP-PLUS
plus.runtime.openFile(message.url, {
error: (e) => {
uni.showToast({
title: this.$lang('ai-chat.index.fileOpenFail'),
icon: 'none'
})
}
})
// #endif
uni.hideLoading()
this.$emit('file-preview', message)
},
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡点击预览
// 用户刚选完文件时可能还没上传完成, 此时 uploaded=null, imageUrl 是本地 blob/tempFilePath
// - 已上传: 用 uploaded.url (云端公网 URL)
// - 上传中/失败: 不响应点击 (避免点开失效链接)
previewUserFile(message) {
const url = message.uploaded && message.uploaded.url
if (!url) {
if (message.uploadError) {
uni.showToast({ title: message.uploadError, icon: 'none' })
} else {
uni.showToast({ title: '文件上传中...', icon: 'none' })
}
return
}
// 复用 AI 侧文件预览: H5 new tab; APP plus.runtime.openFile
this.previewFile({ url })
},
// 用户上传的音频消息: 点击播放/暂停 (上传中/未就绪时给出提示)
playUserAudio(message) {
if (message.uploading) {
uni.showToast({ title: '上传中,请稍候', icon: 'none' })
return
}
if (!message.url) {
uni.showToast({ title: this.$lang('ai-chat.index.audioNotReady'), icon: 'none' })
return
}
// PR-VOICE-OVERLAY (2026-09-28): 点击语音气泡弹出中央面板
// 替代原来直接调 toggleAudio 的紧凑交互, 面板里能看实时进度
this.openVoiceOverlay(message)
},
// PR-VOICE-OVERLAY (2026-09-28): 打开中央语音播放面板
// 同一个 target 重复点击: 若已在播放则只打开面板 (不重启);
// 若在暂停则打开面板并继续播放
openVoiceOverlay(target) {
if (!target || (!target.url && !target.imageUrl)) return
const src = target.url || target.imageUrl
const isSameTarget = this.voiceOverlay === target
&& this.innerAudioCtx
&& this._innerAudioSrc === src
this.voiceOverlay = target
this.voiceOverlayVisible = true
// 已经在播放同一个: 保持播放, 只开面板
if (isSameTarget && target.playing) return
// 不在播放: 通过 toggleAudio 启动 (会自动暂停其他正在播放的)
this.toggleAudio(target)
},
// PR-VOICE-OVERLAY (2026-09-28): 关闭面板
closeVoiceOverlay() {
if (!this.voiceOverlayVisible) return
// 关闭时停止播放, 避免后台音频继续
if (this.voiceOverlay && this.voiceOverlay.playing) {
this.pauseAudio(this.voiceOverlay)
}
this.voiceOverlayVisible = false
// 延迟清空对象, 避免关闭动画期间数据为 null 触发其他 UI 闪烁
setTimeout(() => { this.voiceOverlay = null }, 300)
},
// PR-VOICE-OVERLAY (2026-09-28): 面板里的播放/暂停切换
toggleOverlayAudio() {
if (!this.voiceOverlay) return
this.toggleAudio(this.voiceOverlay)
},
// 切换音频播放
toggleAudio(message) {
if (message.playing) {
this.pauseAudio(message)
} else {
this.playAudio(message)
}
},
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频播放 — 接 createInnerAudioContext
// 单例 ctx, src 变化时重建 (不同 message 不同 url)
// H5 + MP-WEIXIN 都原生支持
playAudio(message) {
if (!message || !message.url) {
uni.showToast({ title: '音频地址为空', icon: 'none' })
return
}
// 切换 message 时先停掉旧的
if (this.currentAudio && this.currentAudio !== message) {
this.pauseAudio(this.currentAudio)
}
// src 变了才重建 ctx (避免重复创建, 微信小程序 createInnerAudioContext 单实例可复用但设 src 必须先 stop)
if (!this.innerAudioCtx || this._innerAudioSrc !== message.url) {
if (this.innerAudioCtx) {
try { this.innerAudioCtx.stop(); this.innerAudioCtx.destroy(); } catch (e) { /* ignore */ }
}
try {
this.innerAudioCtx = uni.createInnerAudioContext()
this.innerAudioCtx.src = message.url
this._innerAudioSrc = message.url
this.innerAudioCtx.onTimeUpdate(() => {
if (!this.innerAudioCtx) return
message.currentTime = this.innerAudioCtx.currentTime
// PR-USER-VOICE-BUBBLE-FIX (2026-09-28): 不再覆盖 message.duration
// 原写法: message.duration = innerAudioCtx.duration (秒)
// 问题: 用户语音消息的 duration 是录音器返回的 ms (单位约定),
// innerAudioCtx.duration 是 s, 直接覆盖会破坏气泡时长显示
// (5000ms → 5s → formatDuration(5/1000) → "00:00").
// AI 音频时长已由后端 / 录音器在创建消息时设好, 不需要 innerAudioCtx 修正.
// 进度计算在 getVoiceProgress 里统一处理单位转换.
})
this.innerAudioCtx.onEnded(() => {
message.playing = false
message.currentTime = 0
this.currentAudio = null
// PR-VOICE-OVERLAY (2026-09-28): 播放结束自动关闭中央面板
// 用户点开后想听完整段, 听完后不需要再保持面板占视野
if (this.voiceOverlay === message) {
this.closeVoiceOverlay()
}
})
this.innerAudioCtx.onError((e) => {
message.playing = false
uni.showToast({ title: '音频播放失败', icon: 'none' })
console.error('[AI audio] play error:', e)
})
} catch (e) {
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' })
console.error('[AI audio] createInnerAudioContext failed:', e)
return
}
}
// seek 到上次位置 (暂停后继续播放)
if (message.currentTime && message.currentTime > 0 && this.innerAudioCtx) {
try { this.innerAudioCtx.seek(message.currentTime) } catch (e) { /* ignore */ }
}
message.playing = true
try {
this.innerAudioCtx.play()
} catch (e) {
message.playing = false
uni.showToast({ title: '音频播放失败', icon: 'none' })
return
}
this.currentAudio = message
this.$emit('audio-play', message)
},
// 暂停音频
pauseAudio(message) {
if (this.innerAudioCtx) {
try { this.innerAudioCtx.pause(); } catch (e) { /* ignore */ }
}
if (message) message.playing = false
this.$emit('audio-pause', message)
},
// 音频滑块变化
onAudioSliderChange(e) {
if (this.currentAudio) {
this.currentAudio.currentTime = e.detail.value
}
},
onAudioSliderChangeEnd(e) {
if (this.currentAudio) {
this.currentAudio.currentTime = e.detail.value
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 同步 seek 到 innerAudioCtx
if (this.innerAudioCtx) {
try { this.innerAudioCtx.seek(e.detail.value) } catch (e) { /* ignore */ }
}
this.$emit('audio-seek', this.currentAudio)
}
},
// 视频加载完成事件
onVideoLoaded(message, event) {
this.$nextTick(() => {
let width = 0;
let height = 0;
// H5端:通过video元素获取真实视频宽高
// #ifdef H5
const video = this.$refs.videoRef;
if (video) {
width = video.videoWidth || 0;
height = video.videoHeight || 0;
}
// #endif
// 微信小程序端:通过事件对象获取视频宽高
// #ifdef MP-WEIXIN
if (event && event.detail) {
width = event.detail.width || 0;
height = event.detail.height || 0;
}
// #endif
if (width && height) {
// 判断视频方向:宽度 >= 高度为横屏,否则为竖屏
const videoRatio = width >= height ? 'landscape' : 'portrait';
// 动态设置视频样式
// 这里可以添加逻辑来动态调整视频播放器的样式
// 例如:message.videoRatio = videoRatio;
// 然后在模板中根据message.videoRatio调整样式
}
});
},
// 视频播放
onVideoPlay(message) {
this.$emit('video-play', message)
},
// 视频暂停
onVideoPause(message) {
this.$emit('video-pause', message)
},
// 打开链接
openLink(message) {
// #ifdef H5
window.open(message.url, '_blank')
// #endif
// #ifdef APP-PLUS
plus.runtime.openURL(message.url)
// #endif
this.$emit('link-open', message)
},
// 处理操作
handleAction(action, message) {
// 处理重试操作
if (action.type === 'retry') {
this.retryMessage(message)
return
}
this.$emit('action-click', { action, message })
},
// ====== AI 消息下方操作栏 (复制 / 点赞 / 不喜欢 / 朗读 / 更多) ======
// 复制 AI 回复
onAiActionCopy(message) {
this.copyMessage(message)
},
// 点赞 (toggle, 与不喜欢互斥)
onAiActionLike(message) {
try {
this.$set(message, '_liked', !message._liked)
if (message._liked) this.$set(message, '_disliked', false)
uni.showToast({ title: message._liked ? '已点赞' : '已取消点赞', icon: 'none', duration: 1200 })
} catch (e) { /* noop */ }
},
// 不喜欢 (toggle, 与点赞互斥)
onAiActionDislike(message) {
try {
this.$set(message, '_disliked', !message._disliked)
if (message._disliked) this.$set(message, '_liked', false)
uni.showToast({ title: message._disliked ? '已反馈' : '已取消反馈', icon: 'none', duration: 1200 })
} catch (e) { /* noop */ }
},
// 朗读 / 停止朗读 (用 uni.createInnerAudioContext 简单播放, 占位实现)
onAiActionSpeak(message) {
try {
const text = this._stripMarkdown(message && (message.content || ''))
if (!text) {
uni.showToast({ title: '暂无可朗读内容', icon: 'none' })
return
}
if (this._ttsPlaying && this._ttsCtx) {
// 暂停/停止
try { this._ttsCtx.stop() } catch (e) {}
this._ttsPlaying = false
if (this._ttsMsg === message) this.$set(message, '_playing', false)
return
}
// #ifdef MP-WEIXIN
// 微信小程序优先用 wx.createInnerAudioContext + 走 TTS, 这里给个最小占位
try {
this._ttsCtx = uni.createInnerAudioContext()
this._ttsCtx.onError(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
uni.showToast({ title: '朗读失败', icon: 'none' })
})
// 真实项目应接入 TTS 服务 (微信同声传译插件 / 第三方 API)
// 此处仅占位, 让 _playing 状态可切换
this._ttsPlaying = true
this._ttsMsg = message
this.$set(message, '_playing', true)
setTimeout(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
}, 1500)
} catch (e) { /* noop */ }
// #endif
// 非微信环境: 仅切换状态, 不实际朗读
// #ifndef MP-WEIXIN
this._ttsPlaying = true
this._ttsMsg = message
this.$set(message, '_playing', true)
setTimeout(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
}, 1500)
// #endif
} catch (e) { /* noop */ }
},
// 更多菜单: 只保留"复制" (uni.showActionSheet 自带底部"取消"按钮, 无需额外添加)
onAiActionMore(message) {
try {
uni.showActionSheet({
itemList: ['复制'],
success: (res) => {
if (res.tapIndex === 0) {
this.copyMessage(message)
}
},
fail: () => { /* 用户取消 */ }
})
} catch (e) { /* noop */ }
},
// 欢迎语"换一换": 快捷问题每 4 个一批, 点击后切换到下一批, 末尾循环回第一批
onAiActionRefreshWelcome(message) {
try {
const all = message.allQuickQuestions || []
if (!all.length) {
uni.showToast({ title: '暂无更多问题', icon: 'none' })
return
}
const batchSize = 4
const total = all.length
const batchCount = Math.ceil(total / batchSize) || 1
const current = message._qqBatchIndex || 0
const next = (current + 1) % batchCount
const start = next * batchSize
const end = Math.min(start + batchSize, total)
this.$set(message, '_qqBatchIndex', next)
this.$set(message, 'quickQuestions', all.slice(start, end))
} catch (e) { /* noop */ }
},
// 去掉 markdown 标记, 用于 TTS 朗读
_stripMarkdown(text) {
if (!text) return ''
return String(text)
.replace(/```[\s\S]*?```/g, '')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/[ \t]+/g, ' ')
.trim()
},
// 复制消息内容到剪贴板(兼容 H5 / 微信小程序)
copyMessage(message) {
let copyText = message.content || ''
if (!copyText && message.type === 'text') {
copyText = message.content || ''
}
if (!copyText) {
uni.showToast({ title: this.$lang('ai-chat.index.noContentToCopy'), icon: 'none' })
return
}
// 由"选取文字复制"弹窗传入的已选范围,直接按传入内容复制,不再读取浏览器选区
const isSelected = !!message._selected
// #ifdef H5
// H5:检测是否有局部文本被选中,有则复制选区文本,无则复制全文
let finalText = copyText
let selectedText = ''
if (!isSelected) {
try {
const selection = window.getSelection()
selectedText = selection && selection.toString().trim()
finalText = selectedText || copyText
} catch (e) { /* noop */ }
} else {
selectedText = copyText
}
// H5 优先使用 Clipboard API,降级使用 document.execCommand
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(finalText).then(() => {
uni.showToast({ title: selectedText ? this.$lang('ai-chat.index.copySelected', '已复制选中内容') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
}).catch(() => {
this._fallbackCopyH5(finalText)
})
} else {
this._fallbackCopyH5(finalText)
}
} catch (e) {
this._fallbackCopyH5(finalText)
}
// #endif
// #ifdef MP-WEIXIN
// 微信小程序专用(含小米等 Android 兼容)
console.log('[AI Chat] 开始复制, 内容长度:', copyText.length, '内容前50字:', copyText.substring(0, 50))
uni.setClipboardData({
data: copyText,
success: () => {
console.log('[AI Chat] 复制成功')
uni.showToast({ title: isSelected ? this.$lang('ai-chat.index.copySelected', '已复制选中内容') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
},
fail: (err) => {
console.error('[AI Chat] 复制失败, 详细错误:', JSON.stringify(err))
// 剪贴板隐私声明缺失(errno 112)属平台配置问题, 不把微信原始英文错误暴露给用户
const reason = isClipboardPrivacyError(err)
? this.$lang('ai-chat.index.copyFailPrivacyReason')
: (err.errMsg || this.$lang('ai-chat.index.serviceUnavailable'))
uni.showToast({ title: this.$lang('ai-chat.index.copyFailWithReason', { reason }), icon: 'none', duration: 3000 })
}
})
// #endif
},
// H5 降级复制方案(document.execCommand + textarea)
_fallbackCopyH5(text) {
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
const success = document.execCommand('copy')
document.body.removeChild(textarea)
if (success) {
uni.showToast({ title: this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
} else {
uni.showToast({ title: this.$lang('ai-chat.index.copyFail'), icon: 'none' })
}
} catch (e) {
uni.showToast({ title: this.$lang('ai-chat.index.copyFail'), icon: 'none' })
}
},
// ========================================
// 聊天记录搜索
// ========================================
// 用户消息关键词高亮(仅当消息内容包含关键词时)
highlightContent(content) {
if (!this.searchKeyword || !content) {
return content
}
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
return content
}
return this._wrapHighlight(content, this.searchKeyword)
},
// AI 消息渲染(含关键词高亮)
// isWelcome: 欢迎语消息 — 小程序端 mp-html 组件有样式隔离, 页面样式(::v-deep)进不去,
// 图片间距只能在内容层面改内联样式(机器人图片与下方标题的间距, 对齐 WorkBuddy 参考稿)
getAIDisplayContent(content, isWelcome) {
let html
if (!this.searchKeyword || !content) {
html = this.parseMarkdown(content)
} else if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
html = this.parseMarkdown(content)
} else {
// 先做 markdown 渲染,再做关键词高亮
const mdHtml = this.parseMarkdown(content)
html = this._wrapHighlightInHtml(mdHtml, this.searchKeyword)
}
if (isWelcome && html) {
// 仅替换 img 的 margin(机器人图片), 不影响列表等其他元素的 margin:8rpx 0
// 注意: 原始样式是 "margin:8rpx 0;" (8rpx 上下 / 0 左右), 正则必须匹配这个写法,
// 只改这里的 "0 0 0rpx 0" 最后一段调间距; 前面的搜索正则不要动, 改错会匹配失效
html = html.replace(/(<img[^>]*style="[^"]*?)margin:\s*8rpx 0;/, '$1margin:0 0 0rpx 0;')
// 图片是 display:block, 欢迎语里图片和标题之间有空行, parseMarkdown 会生成
// 连续多个 <br> (每个占一行行高), 之前只删一个导致间距看起来"没反应";
// 这里把 img 后面紧跟的所有 <br> 一次性清掉, 标题紧贴图片
html = html.replace(/(<img[^>]*(?:\/>|>))(?:\s*<br>)+/, '$1')
}
return html
},
// 在纯文本中高亮关键词(用于用户消息)
_wrapHighlight(text, keyword) {
if (!keyword || !text) return text
const parts = text.split(new RegExp('(' + this._escapeRegExp(keyword) + ')', 'gi'))
return parts.map(part => {
if (part.toLowerCase() === keyword.toLowerCase()) {
return `<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">${part}</span>`
}
return part
}).join('')
},
// 在 HTML 中高亮关键词(用于 AI 消息的 markdown 结果)
_wrapHighlightInHtml(html, keyword) {
if (!keyword || !html) return html
// 只高亮文本节点,不破坏 HTML 标签
const escaped = this._escapeRegExp(keyword)
// 匹配 <xxx> 之外的文本
return html.replace(/(>|^)([^<]+)(?=<|$)/g, (match, prefix, text) => {
if (!text) return match
const highlighted = text.replace(
new RegExp('(' + escaped + ')', 'gi'),
'<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">$1</span>'
)
return prefix + highlighted
})
},
// 转义正则特殊字符
_escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
},
// 重试消息
async retryMessage(message) {
// 找到对应的用户消息
const userMessageIndex = this.messages.findIndex(msg =>
msg.role === 'user' && msg.id < message.id
)
if (userMessageIndex !== -1) {
const userMessage = this.messages[userMessageIndex]
// 移除错误消息
this.messages = this.messages.filter(msg => msg.id !== message.id)
// 重新发送消息
await this.sendMessageWithContent(userMessage.content)
}
},
// 使用指定内容发送消息
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 增加 attachments 参数
// 之前 sendStreamMessage/sendNormalMessage 已经接收 attachments 但这里没传,
// 导致商品页 / 重发 路径丢附件
async sendMessageWithContent(content, attachments = []) {
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now()
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 重发/商品页也透传 context (无位置)
// 用同步版: fire-and-forget, 立即返回, 重发不阻塞
// 自动定位失败 (用户授权) 也无所谓, 重试主要是文本/AI 没听懂, 跟位置关系不大
const context = this._buildSendContext({ locationFiles: [] })
const userMessage = {
id: ++this.messageId,
role: 'user',
type: 'text',
content: content,
timestamp: Date.now()
}
this.messages.push(userMessage)
// 显示AI正在输入
const loadingMessage = {
id: ++this.messageId,
role: 'ai',
type: 'loading',
content: '',
timestamp: Date.now(),
loadingText: this.$lang('ai-chat.index.aiThinking'),
_loadingIndex: 0
}
this.messages.push(loadingMessage)
this._startLoadingTextRotation(loadingMessage)
// 等待 Vue 渲染 loading 动画
await this.$nextTick()
try {
// 使用AI服务获取回复
if (this.enableStreaming) {
// 流式响应
await this.sendStreamMessage(userMessage.content, attachments, context)
} else {
// 普通响应
await this.sendNormalMessage(userMessage.content, attachments, context)
}
} catch (error) {
console.error('AI服务调用失败:', error)
// 移除加载状态
this._removeLoadingMessages()
// 显示错误消息
const errorMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceUnavailable'),
timestamp: Date.now(),
actions: [
{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }
]
}
this.messages.push(errorMessage)
this.$emit('ai-response', errorMessage)
}
// 触发消息发送事件
this.$emit('message-sent', userMessage)
},
// 输入事件
onInput(e) {
this.$emit('input-change', e.detail.value)
},
// 输入框行数变化事件
onLineChange(e) {
this.inputLineCount = e.detail.lineCount || 0
},
// 切换Emoji面板
toggleEmojiPanel() {
this.showEmojiPanel = !this.showEmojiPanel
this.showToolsPanel = false
},
// 快捷指令占位提示点击: 在输入框插入 @ 或 / 触发后续指令面板 (此处仅展示交互, 后续按需扩展)
onQuickTipClick(type) {
try {
if (type === 'quote') {
this.inputText = (this.inputText || '') + '@'
} else if (type === 'skill') {
this.inputText = (this.inputText || '') + '/'
}
} catch (e) { /* noop */ }
},
// 底部 5 图标工具栏点击: 任务 / 专家 / 资料库 / 自动化 / 更多
// - task / expert / library / automation: 触发 toolbar-click 事件(占位)
// - more: 复用 showMoreTools 触发更多功能弹窗
onToolbarItem(type) {
try {
switch (type) {
case 'task':
case 'expert':
case 'library':
case 'automation':
this.$emit('toolbar-click', { type })
break
case 'more':
this.showMoreTools()
break
}
} catch (e) { /* noop */ }
},
// 默认权限下拉点击: 占位交互, 后续接入权限弹窗
onTogglePermission() {
try {
uni.showToast({ title: '权限设置待接入', icon: 'none' })
} catch (e) { /* noop */ }
},
// ==================== PR-FRONTEND-INPUT-3STATES (2026-09-17) ====================
// 进入语音模式(按住说话): 收起键盘, 输入区从"单行胶囊"切成"按住说话 + 工具栏"
enterVoiceMode() {
if (this.voiceMode) return
try { uni.hideKeyboard() } catch (e) { /* noop */ }
this.voiceMode = true
this.voiceHolding = false
this.voiceHoldCancelling = false
this.inputFocused = false
// 进入语音模式时清掉文字输入态, 避免白卡片背景 + 工具栏残留(切换时闪烁)
this.showTextMode = false
},
// 退出语音模式, 回到文字输入态
exitVoiceMode() {
this.voiceMode = false
this.voiceHolding = false
this.voiceHoldCancelling = false
// 退出时若还在录音,主动作废
this._discardVoiceRecorder()
},
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话
// - touchstart 启动录音器
// - touchmove 上滑 80rpx+ 视为取消手势
// - touchend 提交产物 (取消则丢弃)
// - H5 鼠标不会触发 touch, click 也走 onVoiceHoldEnd 兜底 (无录音产物就当没按住)
onVoiceHoldStart(e) {
console.log('[ai-chat] onVoiceHoldStart fired', this._voiceRecorder ? 'recorder already exists' : 'creating recorder')
if (this._voiceRecorder) return
this.voiceHolding = true
this.voiceHoldCancelling = false
this._voiceHoldStartTime = Date.now()
try {
const touch = (e && e.touches && e.touches[0]) || null
this._voiceHoldStartY = touch ? touch.clientY : 0
} catch (err) { this._voiceHoldStartY = 0 }
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
this._voiceRecorder = rec
this._onHoldProgress = ({ level }) => {
// 平滑 (用于后续波形/振幅 UI)
this._voiceHoldLastLevel = this._voiceHoldLastLevel * 0.4 + level * 0.6
this.voiceLevel = this._voiceHoldLastLevel
}
this._onHoldError = (err) => {
console.warn('[ai-chat] voice hold error:', err)
console.error('[ai-chat] voice hold error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message)
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
this._discardVoiceRecorder()
}
rec.on('progress', this._onHoldProgress)
rec.on('error', this._onHoldError)
rec.start()
.catch((err) => {
// 启动失败 (无权限/无设备/被用户拒绝)
this._discardVoiceRecorder()
this.voiceHolding = false
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
})
},
onVoiceHoldMove(e) {
if (!this.voiceHolding) return
try {
const touch = (e && e.touches && e.touches[0]) || null
if (!touch) return
// 手指上移 = 远离用户, deltaY 为正
const deltaY = this._voiceHoldStartY - touch.clientY
// 阈值 80px (≈80rpx on H5);超过则进入取消态
if (deltaY > 80 && !this.voiceHoldCancelling) this.voiceHoldCancelling = true
else if (deltaY <= 80 && this.voiceHoldCancelling) this.voiceHoldCancelling = false
} catch (err) { /* noop */ }
},
async onVoiceHoldEnd() {
console.log('[ai-chat] onVoiceHoldEnd fired, voiceHolding=', this.voiceHolding, 'recorder=', !!this._voiceRecorder)
if (!this.voiceHolding) {
// 非按住触发的 click (H5 鼠标等), 不做任何事, 避免双触发
return
}
const wasCancelling = this.voiceHoldCancelling
this.voiceHolding = false
this.voiceHoldCancelling = false
if (!this._voiceRecorder) {
// 启动录音器失败 (无 touch 触发的 click) → 退回弹窗路径, 保留功能不丢
console.log('[ai-chat] onVoiceHoldEnd: no recorder, fallback to voice panel')
this.showVoicePanel()
return
}
if (wasCancelling) {
// 取消手势: 主动作废, 不发 audio-ready
this._discardVoiceRecorder()
return
}
try {
const result = await this._voiceRecorder.stop()
const pressDuration = this._voiceHoldStartTime ? Date.now() - this._voiceHoldStartTime : 0
console.log('[ai-chat] voice hold stop result:', JSON.stringify(result), 'pressDuration=', pressDuration)
this._teardownVoiceRecorder()
if (!result || !result.filePath) {
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
return
}
// PR-FRONTEND-VOICE-MIN-DURATION (2026-09-29): 过滤过短录音, 像微信一样提示"说话时间太短",
// 避免发出去 100ms 的空语音导致播放失败/后端无法识别。
if (result.duration < 500) {
console.log('[ai-chat] voice too short, discarded:', result.duration, 'ms')
uni.showToast({ title: this.$lang('ai-chat.index.voiceTooShort'), icon: 'none' })
return
}
await this._handleRecordedAudio(result)
// PR-FRONTEND-VOICE-AUTO-SEND (2026-09-29): 按住说话像微信一样, 松手后自动发送,
// 不需要用户再点一次发送按钮。
console.log('[ai-chat] voice hold auto-send triggered')
this.sendMessage()
} catch (e) {
console.error('[ai-chat] voice hold end error:', e && e.errMsg, e && e.message, JSON.stringify(e))
this._teardownVoiceRecorder()
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
}
},
// 按住说话: 手势被系统打断 (来电/滑动取消)
onVoiceHoldCancel() {
console.log('[ai-chat] onVoiceHoldCancel fired')
if (!this.voiceHolding) return
this.voiceHolding = false
this.voiceHoldCancelling = false
this._discardVoiceRecorder()
},
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按附件类型生成给后端 AI 的 query 文案
// 纯图片: "我发送了一张图片,请查看图片内容并回复我"
// 纯语音: "我发送了一段语音,请听一下并回复我" / "…({n} 秒)…"
// 纯视频: "我发送了一段视频,请查看视频内容并回复我"
// 纯文件: "我发送了一个文件,请查看文件内容并回复我"
// 多类型混合: "我发送了附件,请查看附件内容并回复我"
// 空: ''
// 必须是带动作的指令 (请听 / 请查看), 不是单纯的类型标签.
// AI 只看到 "语音消息" 不会主动调 STT, 必须有 "请听一下" 才会触发.
//
// 返回合并后的单一字符串, 作为 query text 的 hint 兜底 (老 Agent 不识
// attachment.instruction 字段时仍能据此判断如何处理附件).
_buildAttachmentQuery(attachments) {
if (!attachments || !attachments.length) return ''
// 取首个非空 type; 多类型时降级到 attachmentInstruction
const seen = new Set()
let firstAudio = null
for (const a of attachments) {
const kind = a.type || ''
if (!seen.has(kind)) seen.add(kind)
if (kind === 'audio' && !firstAudio) firstAudio = a
}
if (seen.size === 1) {
const onlyKind = attachments[0].type
if (onlyKind === 'image') return this.$lang('ai-chat.index.imageInstruction')
if (onlyKind === 'video') return this.$lang('ai-chat.index.videoInstruction')
if (onlyKind === 'file') return this.$lang('ai-chat.index.fileInstruction')
if (onlyKind === 'audio') {
// 音频带时长 (毫秒 → 秒, 至少 1s 起步避免 0s 看起来像出 bug)
const dur = (firstAudio && Number(firstAudio.duration)) || 0
if (dur >= 1000) {
const seconds = Math.max(1, Math.round(dur / 1000))
return this.$lang('ai-chat.index.audioInstructionWithDuration', { seconds })
}
return this.$lang('ai-chat.index.audioInstruction')
}
}
return this.$lang('ai-chat.index.attachmentInstruction')
},
// ============== PR-FRONTEND-VOICE-INPUT: 录音产物处理 ==============
/**
* 通用入口: 弹窗 / 按住说话 都把产物塞进来
* result = { filePath, blob, fileName, duration, mime, size }
* 负责: push 进 pendingFiles → uploadMedia 上传 → 显示在输入区
*/
async _handleRecordedAudio(result) {
if (!result || !result.filePath) return
// H5 下 filePath 是 blob URL, 用 blob URL 当预览 (uni-app 在 H5 上传走 uni.uploadFile 时会转)
// MP/APP/鸿蒙下 filePath 是 tempFilePath
const pending = {
localId: 'voice_' + Date.now() + '_' + Math.floor(Math.random() * 1e6),
id: ++this.messageId,
role: 'user',
type: 'audio',
content: '',
fileName: result.fileName || ('voice_' + Date.now() + (result.mime ? '.' + (result.mime.split('/')[1] || 'mp3') : '.mp3')),
fileSize: result.size || 0,
fileType: result.mime || '',
// H5: blob URL (供 audio 标签预览); MP: tempFilePath (InnerAudioContext 也支持)
url: result.filePath,
// PR-FRONTEND-VOICE-INPUT: 录音产物需要被 fileToBase64 读取 → 保留 record 原始路径 (blob URL 在 H5 也能 fetch)
tempPath: result.filePath,
// 录音原始 Blob (H5),用于 fileToBase64 直接 fetch blob: URL;
// 非 H5 平台为 null, fileToBase64 走 fsm.readFileSync(filePath, 'base64')
_recorderBlob: result.blob || null,
duration: result.duration || 0,
playing: false,
currentTime: 0,
imageUrl: result.filePath,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
this.pendingFiles.push(pending)
// 录完自动退出语音模式, 让发送按钮显出来 (用户可加文字或直接发)
if (this.voiceMode) this.exitVoiceMode()
try {
// uploadMedia 要求 inputArg 是 URL/path 字符串:
// H5 → blob URL (URL.createObjectURL), 内部 fetch → Blob → File
// MP / APP / 鸿蒙 → tempFilePath (uni.uploadFile 直传)
// 不能传 raw Blob — uploadMedia 内部 pathToFile 会调 filePath.split(), Blob 没该方法
const inputArg = result.filePath
const uploaded = await uploadMedia(inputArg, 'audio', {
name: pending.fileName,
mime: result.mime,
size: pending.fileSize
})
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploaded', uploaded)
if (uploaded && uploaded.url) {
this.$set(pending, 'imageUrl', uploaded.url)
this.$set(pending, 'url', uploaded.url)
}
// PR-FRONTEND-VOICE-INPUT: 不主动 revoke blob URL — sendMessage 时
// 还要 fileToBase64(tempPath) 再 fetch 一次 (走 base64 内联避开 MediaGuard),
// revoke 后 fetch 失效, 退回 URL-only 路径, LAN 环境会被后端 SSRF 闸门丢。
// blob URL 生命周期跟随 pendingFiles, 用户「删除附件 / 发送后」自然随 JS GC 释放。
} catch (e) {
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploadError', e.message || this.$lang('ai-chat.index.uploadFailed'))
uni.showToast({ title: this.$lang('ai-chat.index.uploadFailedWithReason', { reason: e.message || '' }), icon: 'none' })
}
},
_discardVoiceRecorder() {
console.log('[ai-chat] _discardVoiceRecorder called')
if (!this._voiceRecorder) return
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
this._teardownVoiceRecorder()
},
_teardownVoiceRecorder() {
console.log('[ai-chat] _teardownVoiceRecorder called')
if (this._voiceRecorder) {
if (this._onHoldProgress) this._voiceRecorder.off('progress', this._onHoldProgress)
if (this._onHoldError) this._voiceRecorder.off('error', this._onHoldError)
}
this._voiceRecorder = null
this._onHoldProgress = null
this._onHoldError = null
this._voiceHoldLastLevel = 0
this.voiceLevel = 0
},
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
// voice-recorder.js 自身不带 Vue 上下文, 通过 err.code 映射到 $lang() key
_voiceErrMsg(err) {
if (!err) return ''
const msg = (err && (err.errMsg || err.message || err.errMsg)) || ''
// 微信录音单例仍被占用 (通常因上一次 onStop 尚未触发就被下一次 start 抢占)
if (msg && (msg.includes("is recording") || msg.includes("operateRecorder:fail:audio"))) {
return this.$lang('ai-chat.index.voiceRecorderBusy')
}
switch (err.code) {
case 'NOT_SUPPORTED':
case 'NO_RECORDER_MANAGER':
return this.$lang('ai-chat.index.voiceNotSupported')
case 'H5_NO_MIC_API':
return this.$lang('ai-chat.index.voiceStartFailed')
case 'ALREADY_STARTED':
return this.$lang('ai-chat.index.voiceStartFailed')
case 'UNI_RECORD_ERROR':
// 诊断期: 直接透出微信原始错误 (errMsg/message), 便于截图定位根因
return msg || this.$lang('ai-chat.index.voiceStopFailed')
default:
return msg || ''
}
},
// 输入框聚焦 / 失焦: 驱动"文字输入态"布局
onInputFocus() {
// 聚焦时取消尚未触发的失焦收起定时器(处理 H5 布局变化导致的瞬时 blur->focus)
if (this._blurTimer) {
clearTimeout(this._blurTimer)
this._blurTimer = null
}
this._focusTs = Date.now()
// 幻影失焦恢复触发的 focus 不重置计数, 避免失去防循环保护
if (!this._refocusing) this._refocusCount = 0
this.inputFocused = true
if (this.voiceMode) this.voiceMode = false
},
// PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 按下阶段预展开
// H5 上聚焦会触发布局从中途展开(语音图标移除/工具栏行插入), 该变动会导致 textarea 被动失焦,
// 表现为"短暂展开后立刻塌回胶囊, 无法输入"。在 touchstart/mousedown 阶段先置 inputFocused=true,
// 让布局在原生 focus 到来之前就完成展开, 聚焦过程中不再有布局变动。
onInputPointerDown() {
if (this._blurTimer) {
clearTimeout(this._blurTimer)
this._blurTimer = null
}
this._lastInputPointerTs = Date.now()
if (!this.voiceMode && !this.inputFocused) this.inputFocused = true
},
onInputBlur() {
// 延迟收起: 先让工具栏按钮的 click 派发完, 避免点"发送/加号"时布局先塌回胶囊。
// 同时防止 H5 因布局变化(出现/隐藏工具栏行)触发的瞬时 blur 误将输入态收起:
// 到时若输入框仍是真实焦点(document.activeElement 仍是本 textarea), 则不收起。
// #ifdef H5
if (this._blurTimer) clearTimeout(this._blurTimer)
this._blurTimer = setTimeout(() => {
this._blurTimer = null
const el = this.$refs.messageInput && this.$refs.messageInput.$el
const ta = el && el.querySelector ? el.querySelector('textarea') : null
const stillFocused = ta
? (document.activeElement === ta || el.contains(document.activeElement))
: false
if (stillFocused) return
// 幻影失焦恢复: 聚焦后极短时间内(键盘弹起/布局重排导致)失焦, 且用户刚在输入框上按下,
// 说明用户意图是输入而非点别处 —— 主动把焦点拉回, 不塌回胶囊。最多恢复 2 次防死循环。
const withinBounceWindow = this._focusTs && (Date.now() - this._focusTs < 800)
const tappedInside = this._lastInputPointerTs && (Date.now() - this._lastInputPointerTs < 800)
if (withinBounceWindow && tappedInside && (this._refocusCount || 0) < 2) {
this._refocusCount = (this._refocusCount || 0) + 1
this._refocusing = true
this._focusTs = Date.now()
try { ta && ta.focus({ preventScroll: true }) } catch (e) { try { ta && ta.focus() } catch (e2) { /* noop */ } }
setTimeout(() => { this._refocusing = false }, 0)
return
}
this.inputFocused = false
}, 200)
// #endif
// #ifndef H5
if (this._blurTimer) clearTimeout(this._blurTimer)
this._blurTimer = setTimeout(() => {
this._blurTimer = null
this.inputFocused = false
}, 200)
// #endif
},
// 语音按钮点击: 兼容旧入口 (打开语音输入面板)
onVoiceClick() {
try {
this.showVoicePanel && this.showVoicePanel()
} catch (e) {
try { this.showToolsPanel = true } catch (e2) { /* noop */ }
}
},
// 切换模型选择器: 在 availableModels 列表中循环选择
onToggleModelSelector() {
const list = this.availableModels || ['Auto']
const idx = list.indexOf(this.selectedModel)
const nextIdx = (idx + 1) % list.length
this.selectedModel = list[nextIdx]
},
// 打开扩大编辑器
openExpandEditor() {
this.expandInputText = this.inputText
this.showExpandEditor = true
this.showEmojiPanel = false
this.showToolsPanel = false
},
// 关闭扩大编辑器
closeExpandEditor() {
this.showExpandEditor = false
this.expandInputText = ''
},
// 从扩大编辑器发送消息
async sendFromExpandEditor() {
if (!this.expandInputText.trim()) {
return
}
this.inputText = this.expandInputText
this.closeExpandEditor()
await this.sendMessage()
},
// 处理头像加载失败
handleAvatarError() {
this.avatarError = true
},
// 清空聊天记录
clearMessages() {
// 清理所有加载状态定时器
this.messages.forEach(msg => {
if (msg._rotationTimer) {
clearInterval(msg._rotationTimer)
msg._rotationTimer = null
}
})
// 清空消息数组
this.messages = []
// 重置消息 ID
this.messageId = 0
// 清除会话 ID
this.currentConversationId = null
aiService.clearConversationId()
this.clearConversationIdFromLocal()
// 重置历史偏移量和加载状态
this.historyOffset = 0
this.hasMoreHistory = false
// 触发清空事件
this.$emit('messages-cleared')
},
// 检测字符串中是否包含表情
containsEmoji(str) {
const emojiReg = /[\u{1f300}-\u{1f9ff}\u{1f1e0}-\u{1f1ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{200d}\u{2640}-\u{2642}\u{2300}-\u{23ff}\u{2b50}-\u{2bff}]/gu
return emojiReg.test(str)
},
// 根据表情生成适当的提示文本,帮助 AI 更好地理解
enhanceEmojiMessage(content) {
// 常见表情的含义映射
const emojiResponses = {
'😀': '[开心]',
'😂': '[大笑]',
'😊': '[微笑]',
'❤️': '[爱心]',
'👍': '[点赞]',
'😭': '[哭泣]',
'🙏': '[感谢/拜托]',
'😍': '[喜欢]',
'🎉': '[庆祝]',
'🤔': '[思考]',
'😅': '[尴尬]',
'😁': '[开心]',
'🥰': '[喜爱]',
'😘': '[飞吻]',
'😏': '[坏笑]',
'🥳': '[庆祝]',
'😔': '[难过]',
'😢': '[哭泣]',
'😤': '[生气]',
'😴': '[困]',
'🤗': '[拥抱]',
'🙂': '[微笑]',
'😃': '[开心]',
'😄': '[大笑]',
'😆': '[开心]',
'😇': '[天使]',
'🤩': '[惊喜]',
'😋': '[好吃]',
'😛': '[调皮]',
'😜': '[调皮]',
'🤪': '[搞怪]',
'😝': '[开心]',
'🤑': '[发财]',
'🤭': '[害羞]',
'🤫': '[嘘]',
'🤐': '[闭嘴]',
'🤨': '[疑惑]',
'😐': '[无语]',
'😑': '[无语]',
'😶': '[沉默]',
'😒': '[不屑]',
'🙄': '[白眼]',
'😬': '[尴尬]',
'🤥': '[说谎]',
'😌': '[安心]',
'😪': '[困]',
'🤤': '[流口水]',
'😷': '[生病]',
'🤒': '[发烧]',
'🤕': '[受伤]',
'🤢': '[恶心]',
'🤮': '[呕吐]',
'🤧': '[感冒]',
'🥵': '[热]',
'🥶': '[冷]',
'🥴': '[头晕]',
'😵': '[晕]',
'🤯': '[爆炸]',
'🤠': '[酷]',
'😎': '[酷]',
'🤓': '[书呆子]',
'🧐': '[疑惑]',
'😕': '[困惑]',
'😟': '[担心]',
'🙁': '[难过]',
'😮': '[惊讶]',
'😯': '[惊讶]',
'😲': '[震惊]',
'😳': '[脸红]',
'🥺': '[恳求]',
'😦': '[惊讶]',
'😧': '[惊讶]',
'😨': '[害怕]',
'😰': '[紧张]',
'😥': '[失望]',
'😱': '[恐惧]',
'😖': '[困扰]',
'😣': '[痛苦]',
'😞': '[难过]',
'😓': '[汗]',
'😩': '[累]',
'😫': '[累]',
'🥱': '[困]',
'😡': '[愤怒]',
'😠': '[生气]',
'🤬': '[咒骂]',
'😈': '[恶魔]',
'👿': '[生气]',
'💀': '[骷髅]',
'💩': '[便便]',
'👻': '[鬼]',
'👽': '[外星人]',
'🤖': '[机器人]',
'🎃': '[万圣节]',
'😺': '[开心猫]',
'😸': '[微笑猫]',
'😹': '[大笑猫]',
'😻': '[花痴猫]',
'😼': '[得意猫]',
'😽': '[亲亲猫]',
'🙀': '[惊讶猫]',
'😿': '[哭泣猫]',
'😾': '[生气猫]',
'👋': '[挥手]',
'🤚': '[举手]',
'🖐': '[手掌]',
'✋': '[手掌]',
'🖖': '[V]',
'👌': '[OK]',
'🤏': '[捏]',
'✌️': '[耶]',
'🤞': '[好运]',
'🤟': '[爱你]',
'🤘': '[摇滚]',
'🤙': '[打电话]',
'👈': '[左边]',
'👉': '[右边]',
'👆': '[上边]',
'🖕': '[竖中指]',
'👇': '[下边]',
'☝': '[向上]',
'👎': '[鄙视]',
'✊': '[拳头]',
'👊': '[拳头]',
'🤛': '[左拳]',
'🤜': '[右拳]',
'👏': '[鼓掌]',
'🙌': '[举手庆祝]',
'👐': '[张开手]',
'🤲': '[摊手]',
'🤝': '[握手]',
'✍️': '[写字]',
'💅': '[美甲]',
'🤳': '[自拍]',
'💪': '[肌肉]',
'💗': '[爱心]',
'💓': '[心跳]',
'💕': '[两颗心]',
'💖': '[闪亮心]',
'💝': '[礼物心]',
'💞': '[旋转心]',
'💘': '[丘比特]',
'💌': '[情书]',
'💟': '[心形]',
'💛': '[黄心]',
'💙': '[蓝心]',
'💜': '[紫心]',
'🖤': '[黑心]',
'💔': '[心碎]',
'💢': '[愤怒]',
'💣': '[炸弹]',
'💥': '[爆炸]',
'💫': '[头晕]',
'💦': '[汗]',
'💨': '[风]',
'👁️': '[眼睛]',
'👀': '[看]',
'👅': '[舌头]',
'👄': '[嘴巴]',
'👂': '[耳朵]',
'👃': '[鼻子]'
}
// 如果是纯表情消息,添加一些提示
if (this.containsEmoji(content) && !content.match(/[\u4e00-\u9fa5a-zA-Z]/)) {
let enhancedMessage = content
// 为表情添加描述
for (let emoji in emojiResponses) {
if (content.includes(emoji)) {
enhancedMessage += ` (${emojiResponses[emoji]})`
}
}
return enhancedMessage
}
return content
}
}
}
</script>
<style lang="scss" scoped>
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
@import './styles/ai-chat-global.scss';
/* 页面样式 */
.ai-chat-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
/* WorkBuddy IDE 风格: 纯白底, 无气泡, 文字直接渲染其上 */
background: #ffffff;
color: #1f1f1f;
/* 禁止浏览器强制暗色模式(夜间模式)把聊天页反色成深色 */
color-scheme: light only;
overflow: hidden;
box-sizing: border-box;
.chat-messages {
flex: 1;
padding: 16rpx 24rpx;
overflow-y: auto;
box-sizing: border-box;
min-height: 0; /* 重要:防止flex元素溢出 */
background: transparent;
/* 微信小程序专属:聊天消息区域增加底部内边距,确保键盘弹出时最后一条消息可见 */
/* 由 chatMessagesStyle 动态控制 */
.load-more {
text-align: center;
padding: 16rpx 0;
color: #999;
}
.message-item {
margin-bottom: 20rpx;
scroll-margin-top: 300rpx;
scroll-margin-bottom: 200rpx;
// 欢迎语消息: 气泡背景纯白, 让快捷问题更明显
&.is-welcome .ai-message .message-bubble {
background: #ffffff !important;
}
// 欢迎语消息: 图片间距由内容内联样式控制(getAIDisplayContent 里改成 0 0 8rpx 0),
// 这里不再用 CSS 强制 48rpx —— 样式表 !important 会覆盖内联样式, 导致 H5 上调距无效
// 欢迎语消息: 隐藏上方时间戳
&.is-welcome .ai-message .message-nickname.ai-nickname {
display: none;
}
// 欢迎语消息: 标题与快捷问题的间距, 与"图片→标题"保持一致 (48rpx)
// 下方 5326 行的 .quick-questions-row 嵌套链更长、特异性更高, 必须 !important 覆盖
&.is-welcome .quick-questions-row {
margin-top: 48rpx !important;
}
// 搜索定位 - 当前结果: 用柔和的浅绿底色提示 (WorkBuddy 风格, 无气泡 box-shadow)
&.search-current {
position: relative;
z-index: 1;
border-radius: 12rpx;
background: rgba(7, 193, 96, 0.08);
.user-message .message-bubble,
.ai-message .message-bubble {
background: transparent !important;
transition: background 0.3s ease;
}
}
// 搜索定位 - Flash 脉冲动画(导航到新结果时触发)
&.search-flash {
animation: searchPulse 1.2s ease-out;
}
&.first-message {
margin-top: 16rpx;
}
.user-message, .ai-message {
display: flex;
align-items: flex-start;
.avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
/* 白底风格: 取消外圈与阴影 */
box-shadow: none;
background-color: transparent;
border: none;
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&:hover {
transform: scale(1.03);
}
image {
width: 100%;
height: 100%;
object-fit: cover;
}
text {
font-size: 32rpx;
line-height: 1;
}
.avatar-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
background-color: transparent;
}
}
.message-content {
flex: 1;
margin-left: 16rpx;
max-width: calc(100% - 56rpx);
min-width: 0;
width: 0; /* 添加这个属性防止flex元素溢出 */
.message-bubble {
/* 去掉所有气泡视觉: 背景透明, 无边框, 无阴影, 无内边距, 无小三角 */
padding: 0 !important;
max-width: 100%;
word-break: break-word;
box-shadow: none !important;
position: relative;
border: none !important;
background: transparent !important;
&::before {
display: none !important;
content: none !important;
}
.message-text {
font-size: 30rpx;
line-height: 1.5;
position: relative;
z-index: 1;
letter-spacing: 0.3rpx;
color: #1f1f1f;
}
// 微信小程序: AI 文本消息(H5 端走 v-html 时的外层容器)
.ai-message-text {
font-size: 30rpx;
line-height: 1.75;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
word-break: break-word;
}
}
.message-time {
font-size: 22rpx;
/* 白底风格: 浅灰色时间戳 */
color: #999;
margin-top: 8rpx;
opacity: 1;
letter-spacing: 0.5rpx;
}
/* 各种消息类型样式 */
.markdown-content {
.markdown-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
.icon-markdown {
color: $color-primary;
margin-right: 12rpx;
font-size: 28rpx;
}
.markdown-title {
font-weight: 600;
color: $color-text;
font-size: 28rpx;
}
}
.markdown-body {
line-height: 1.8;
font-size: 26rpx;
/* PR-FIX-AI-TEXT-COLOR (2026-09-24): 统一配色 —— 正文/加粗/斜体/代码全部黑色,
只有链接(a)是蓝色; 之前正文是灰蓝($color-text)、代码是蓝灰($color-primary) */
color: #1a1a1a;
strong {
font-weight: 400;
color: #1a1a1a;
}
em {
font-style: italic;
color: #1a1a1a;
}
code {
background-color: #f0f4f8;
padding: 4rpx 8rpx;
border-radius: $radius-sm;
font-family: monospace;
font-size: 24rpx;
color: #1a1a1a;
}
a {
/* 网址/链接: 黑色, 不加粗 */
color: #1f1f1f !important;
font-weight: 400 !important;
text-decoration: underline;
text-underline-offset: 4rpx;
transition: color 0.2s ease;
&:hover {
color: #2b6cf0;
}
}
}
}
.file-content {
.file-item {
display: flex;
align-items: center;
padding: 24rpx;
background-color: $bg-card;
border-radius: $radius-md;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
background-color: #fafbfc;
box-shadow: $shadow-md;
}
.file-icon {
margin-right: 24rpx;
.icon-file {
font-size: 52rpx;
color: $color-primary;
}
}
.file-info {
flex: 1;
.file-name {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.file-size {
font-size: 24rpx;
color: $color-text-light;
}
}
.file-action {
.icon-download {
font-size: 38rpx;
color: $color-primary;
transition: transform 0.2s ease;
}
&:hover .icon-download {
transform: scale(1.1);
}
}
}
}
.audio-content {
.audio-player {
.audio-info {
margin-bottom: 16rpx;
.audio-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.audio-duration {
font-size: 24rpx;
color: $color-text-light;
}
}
.audio-controls {
display: flex;
align-items: center;
.play-btn {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
background-color: $color-primary;
color: white;
margin-right: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.3);
transition: all 0.2s ease;
.icondiy {
color: #ffffff;
font-size: 36rpx;
line-height: 1;
}
&.playing {
background-color: $color-primary-light;
box-shadow: 0 2rpx 8rpx rgba(138, 159, 184, 0.2);
}
&:hover {
transform: scale(1.05);
}
}
.audio-slider {
flex: 1;
}
}
}
}
.video-content {
.video-player {
.video-element {
width: 100%;
height: 420rpx;
border-radius: $radius-md;
overflow: hidden;
box-shadow: $shadow-md;
}
.video-info {
margin-top: 16rpx;
.video-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.video-duration {
font-size: 24rpx;
color: $color-text-light;
}
}
}
}
.link-content {
.link-card {
display: flex;
background-color: $bg-card;
border-radius: $radius-md;
overflow: hidden;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2rpx);
}
.link-image {
width: 130rpx;
height: 130rpx;
flex-shrink: 0;
border-radius: $radius-md 0 0 $radius-md;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.link-info {
flex: 1;
padding: 24rpx;
.link-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.link-desc {
display: block;
font-size: 24rpx;
color: $color-text-light;
margin-bottom: 6rpx;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.link-url {
display: block;
font-size: 22rpx;
color: #1f1f1f;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.product-content {
.product-card {
display: flex;
background-color: $bg-card;
border-radius: $radius-md;
overflow: hidden;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2rpx);
}
.product-image {
width: 130rpx;
height: 130rpx;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.product-info {
flex: 1;
padding: 24rpx;
.product-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-price {
display: block;
font-size: 28rpx;
font-weight: 600;
margin-bottom: 6rpx;
color: $color-user;
}
.product-desc {
display: block;
font-size: 24rpx;
color: $color-text-light;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
}
.loading-content {
display: flex;
align-items: center;
padding: 32rpx 0;
.loading-typing {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12rpx;
.typing-dots {
/* 隐藏三个圆点动画 */
display: none !important;
align-items: center;
.dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background-color: $color-primary;
margin: 0 6rpx;
animation: dotPulse 1.5s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.loading-text {
color: $color-text-light;
font-size: 24rpx;
display: flex;
align-items: center;
.loading-status {
color: $color-primary;
font-weight: 500;
transition: opacity 0.3s ease;
}
.loading-ellipsis {
display: inline-flex;
margin-left: 4rpx;
.ellipsis-dot {
animation: ellipsisBlink 1.4s infinite ease-in-out;
opacity: 0;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
}
}
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 chip 样式
// PR-FIX (2026-09-20): flex gap 在低版本微信基础库/部分安卓 WebView(小米) 不生效,
// 间距全部改用 margin 实现, 不依赖 gap
.quick-questions-row {
margin-top: 16rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 100%;
.quick-question-chip {
display: inline-flex;
align-items: center;
margin-bottom: 16rpx;
padding: 12rpx 28rpx;
background: #ffffff;
border: 1rpx solid #eef0f3;
border-radius: 999rpx;
font-size: 26rpx;
color: #1a1a1a;
cursor: pointer;
transition: all 0.2s ease;
max-width: 100%;
line-height: 1.4;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.14);
&:last-child {
margin-bottom: 0;
}
.quick-question-icon {
width: 28rpx;
height: 28rpx;
flex-shrink: 0;
margin-right: 8rpx;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.quick-question-text {
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
}
.quick-question-chip--hover {
background: #e4e5e7;
color: #1a1a1a;
}
}
.message-actions {
margin-top: 24rpx;
display: flex;
gap: 16rpx;
justify-content: flex-start;
flex-wrap: nowrap;
width: 100%;
.action-btn {
border-radius: $radius-lg;
font-size: 22rpx;
background-color: $bg-card;
padding: 10rpx 20rpx;
display: flex;
align-items: center;
gap: 6rpx;
transition: all 0.2s ease;
border: 1rpx solid #f0f4f8;
color: $color-text;
white-space: nowrap;
&.like {
color: $color-user;
border-color: #e8e0f2;
&:hover {
background-color: #f9f6fc;
transform: translateY(-2rpx);
}
}
&.dislike {
color: $color-text-light;
border-color: #e9edf2;
&:hover {
background-color: #f8fafc;
transform: translateY(-2rpx);
}
}
}
}
}
}
/* ========== 关键修复:拆分 .message-nickname 样式 ========== */
.message-nickname {
font-size: 24rpx;
color: $color-text-light;
margin-bottom: 8rpx;
letter-spacing: 0.5rpx;
display: flex;
align-items: baseline;
gap: 12rpx;
line-height: 1;
.message-time {
font-size: 24rpx;
color: #999;
line-height: 1;
top:-15rpx;
}
}
/* 用户昵称:右对齐 */
.user-message .message-nickname {
text-align: right;
justify-content: flex-end;
display: flex;
align-items: baseline;
}
/* AI 昵称:使用 .ai-nickname 类控制 */
.ai-nickname {
text-align: left;
margin-left: 0;
padding-left: 0;
align-self: flex-start;
margin-bottom: 4rpx;
justify-content: flex-start;
display: flex;
align-items: center;
width: 100%;
position: relative;
gap: 12rpx;
.message-time {
/* AI 回答上方只保留时间, 左对齐显示 */
position: static;
margin-top: 0;
margin-left: 0;
}
}
/* 用户消息特有样式 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右)
整体不再贴左, 头像显示且位于气泡右侧 */
.user-message {
flex-direction: row;
justify-content: flex-end;
.avatar {
/* 隐藏用户头像: 消息保持右对齐气泡, 但不再显示头像 */
display: none !important;
}
.message-content {
margin-left: 0;
margin-right: 0;
text-align: right;
max-width: calc(100% - 64rpx);
display: flex;
flex-direction: column;
align-items: flex-end;
/* 昵称 + 时间戳行, 右对齐 */
.message-nickname {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 6rpx;
font-size: 22rpx;
color: #999;
.message-time {
margin-right: 8rpx;
margin-top: 0;
}
.nickname-text {
color: #666;
}
}
/* 气泡: 浅灰背景 + 轻微圆角, 内容在气泡内左对齐(中文阅读体验更好) */
.message-bubble {
background: #f5f5f5 !important;
color: #1f1f1f !important;
border: none !important;
border-radius: 16rpx !important;
box-shadow: none !important;
padding: 20rpx 30rpx !important; // 加大内边距,让气泡更"大"
display: inline-block;
word-break: break-word;
white-space: pre-wrap;
line-height: 1.7;
text-align: left;
max-width: 100%;
.message-text {
font-size: 25rpx !important; // 还原为用户消息原字号
}
&::before {
display: none !important;
content: none !important;
}
/* 语音气泡保持与文字气泡一致的颜色 */
&.user-audio-bubble {
background: #f5f5f5 !important;
padding: 14rpx 22rpx !important;
}
}
}
}
/* #ifdef H5 */
/* H5 用户【纯文字】消息气泡: 气泡要明显比文字大(与小程序一致)
- 内边距加大 -> 气泡比文字大, 文字与气泡边缘留出间距
- 宽度 auto + inline-block -> 气泡宽度贴着内容, 不拉满整行
- 只调整气泡自身, 气泡内文字字号不变
- 用 :not() 排除图片/文件/视频/音频气泡, 避免影响它们原有的紧凑样式
PR-FIX-CAPTION-BUBBLE-SIZE (2026-09-24): 不再排除 .image-caption-bubble ——
图文一起发时的配文气泡要和普通文字气泡一样大(之前被排除 → 内边距只有 30rpx) */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.user-file-bubble):not(.user-video-bubble):not(.user-audio-bubble) {
padding: 10rpx 30rpx !important;
border-radius: 16rpx !important;
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
text-align: left !important;
}
/* #endif */
/* AI消息特有样式 (微信风格: 头像 + 名称 + 时间同行, 下方内容)
与你截图样式一致 */
.ai-message {
.avatar {
/* 隐藏 AI 头像 */
display: none !important;
}
.message-content {
/* 确保昵称在内容上方的布局 */
display: flex;
flex-direction: column;
align-items: flex-start;
.message-nickname.ai-nickname {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 4rpx;
.message-time {
margin-top: 0;
font-size: 22rpx;
color: #999;
line-height: 1;
}
}
.message-bubble {
background: transparent !important;
color: #1f1f1f !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 0 !important;
display: block;
/* 容器不够宽时正常换行, 不主动按字断开 */
overflow-wrap: break-word;
word-wrap: break-word;
/* pre-line: 保留后端 \n 的段落分隔, 但合并多个连续空白, 配合 parseMarkdown normalize 避免每字一行 */
white-space: pre-line;
line-height: 1.5;
&::before {
display: none !important;
content: none !important;
}
}
// AI 回答文字字号统一为 25rpx
.message-text,
.ai-message-text {
font-size: 25rpx !important;
line-height: 1.7;
// H5 / 微信 AI 回答里的链接统一黑色
a {
color: #1f1f1f !important;
font-weight: 400 !important;
text-decoration: underline;
text-underline-offset: 4rpx;
}
}
// 微信端 AI 富文本(豆包风格)容器: 行高/字号/字色统一兜底
.ai-message-rich {
display: block;
font-size: 25rpx;
line-height: 1.75;
color: #1a1a1a;
letter-spacing: 0.2rpx;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
word-break: break-word;
overflow-wrap: break-word;
margin: 2rpx 0;
}
// 富文本模式下隐藏 markdown 角标,更接近豆包观感
.markdown-header {
display: none;
}
// PR-AI-STREAMING-TAIL (2026-09-28): AI 流式回答中追加在内容末尾的"思考中..."指示
// 与 .ai-actions 互补: streaming 时只显示 tail, 操作栏等流式结束才显示
.streaming-tail {
display: flex;
align-items: center;
margin-top: 12rpx;
padding-top: 4rpx;
color: #1f1f1f;
font-size: 24rpx;
font-weight: 500;
line-height: 1;
.typing-dots-inline {
display: inline-flex;
align-items: center;
margin-right: 10rpx;
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: $color-primary;
margin: 0 4rpx;
animation: dotPulse 1.5s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
.streaming-tail-text {
color: #1f1f1f;
font-weight: 500;
}
.streaming-tail-ellipsis {
display: inline-flex;
margin-left: 4rpx;
.ellipsis-dot {
animation: ellipsisBlink 1.4s infinite ease-in-out;
opacity: 0;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
}
/* AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 朗读 / 更多 */
.ai-actions {
margin-top: 12rpx;
display: flex;
align-items: center;
gap: 8rpx;
flex-wrap: nowrap;
.ai-action-btn {
min-width: 56rpx;
height: 56rpx;
padding: 0 14rpx;
border-radius: 28rpx;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease, color 0.2s ease;
.iconfont {
font-size: 30rpx;
color: #666;
line-height: 1;
}
/* 隐藏"更多(···)"按钮: 只保留 复制 / 点赞 / 不喜欢 */
&.more-btn {
display: none !important;
}
/* PR-FRONTEND-ACTION-SVG-ICON (2026-09-18): 点赞/点踩线性 SVG 矢量图标
微信小程序不支持内联 SVG, 沿用 voice-icon-bg 的 base64 背景图方案;
默认灰色描边, active 态切换为蓝色描边版本 */
.svg-icon {
width: 32rpx;
height: 32rpx;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
&.icon-like {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiLz48L3N2Zz4=");
/* 屏蔽全局 iconfont.css 的 .icon-like:before { content:"\e041" },
避免点赞图标额外渲染出一个心形/方框伪元素;此处不设置 content 值,
只让 ::before 不显示 */
&::before {
display: none !important;
}
}
&.icon-dislike {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiIHRyYW5zZm9ybT0icm90YXRlKDE4MCAxMiAxMikiLz48L3N2Zz4=");
}
}
&.active {
background: #e6f0ff;
.iconfont {
color: #1f6cff;
}
.svg-icon.icon-like {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiLz48L3N2Zz4=");
}
.svg-icon.icon-dislike {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiIHRyYW5zZm9ybT0icm90YXRlKDE4MCAxMiAxMikiLz48L3N2Zz4=");
}
}
/* 欢迎语操作栏: 换一换按钮 */
&.refresh-welcome-btn {
height: 56rpx;
padding: 0 20rpx;
gap: 8rpx;
margin-top: 20rpx;
.refresh-icon {
width: 28rpx;
height: 28rpx;
flex-shrink: 0;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTJhOSA5IDAgMSAxLTktOSA5Ljc1IDkuNzUgMCAwIDEgNi4zNiAyLjM2TDIxIDMiLz48cGF0aCBkPSJNMjEgM3Y2aC02Ii8+PC9zdmc+");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.refresh-text {
font-size: 24rpx;
color: #666;
line-height: 1;
}
}
&:active {
background: #ebebeb;
}
}
}
}
}
}
}
/* 键盘垫高层:高度变化不做过渡 —— 切换语音/文字时 hideKeyboard 触发高度变化,
若加 transition 会播放高度动画, 输入区整体跳动, 视觉上就是「频闪/看到上次样式」 */
.keyboard-spacer {
flex-shrink: 0;
}
/* 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标均匀分布) */
.input-area {
background: transparent;
padding: 12rpx 20rpx 16rpx;
flex-shrink: 0;
box-sizing: border-box;
.input-card {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
box-shadow: none;
box-sizing: border-box;
}
/* 顶部输入横条: 胶囊形,左侧输入区 + 右侧操作按钮 */
.input-row-top {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
border-radius: 999rpx;
/* border-box: 让 min-height 把上下 padding(10rpx*2)算进去, 否则 content-box 下
实际高度 = 96+20 = 116rpx, 与语音胶囊(96rpx)差 20rpx → 小程序里上下高度不一致 */
box-sizing: border-box;
/* 高度与语音模式胶囊(.voice-mode-row: height 96rpx)保持一致:
内部图标容器 76rpx + 上下 padding 10rpx*2 = 96rpx */
padding: 10rpx 14rpx 10rpx 32rpx;
min-height: 96rpx;
/* 与快捷问题按钮(.quick-question-chip)同款阴影, 白底更突出 */
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.14);
/* 无文字时左侧语音图标,点击打开语音面板
PR-INPUT-LAYOUT-SYMMETRY (2026-09-28): 容器从 52rpx 扩大到 76rpx,
与右侧 + (76rpx) 对称;内部 SVG 同步从 36rpx → 48rpx, 视觉权重平衡 */
.input-row-icon {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4rpx;
.voice-icon-bg {
width: 48rpx;
height: 48rpx;
}
}
/* 输入区: textarea 在上,模型选择器在下 */
.input-left-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 72rpx;
padding-right: 12rpx;
&.with-voice-icon {
margin-left: 0;
}
.message-input {
width: 100%;
min-height: 44rpx;
max-height: 240rpx;
background: transparent !important;
border: none !important;
padding: 0 0 6rpx 0;
font-size: 34rpx;
line-height: 1.5;
color: #1f1f1f;
box-sizing: border-box;
}
.message-input-placeholder {
color: #999 !important;
font-size: 32rpx !important;
}
.model-selector {
align-self: flex-start;
display: flex;
align-items: center;
gap: 6rpx;
height: 36rpx;
padding: 0;
margin-top: 4rpx;
background: transparent;
.model-icon-circle {
width: 28rpx;
height: 28rpx;
border-radius: 50%;
border: 2rpx solid #666666;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
.model-icon-text {
font-size: 18rpx;
color: #666666;
line-height: 1;
margin-top: 2rpx;
}
}
.model-name {
font-size: 26rpx;
color: #666666;
font-weight: 400;
line-height: 1;
}
.model-arrow {
font-size: 22rpx;
color: #999999;
line-height: 1;
margin-top: 2rpx;
}
}
}
/* 右侧操作按钮: + 号 和 发送 */
.input-right-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8rpx;
}
}
/* ===== PR-FRONTEND-INPUT-3STATES (2026-09-17): 输入区三态 =====
① 默认(未聚焦无文字): 单行胶囊 .input-row-top
② 语音模式 .is-voice-mode: 「按住说话」+ 工具栏行(表情/模型/加号)
③ 文字输入 .is-text-mode: 输入框一行 + 工具栏行(语音/模型/加号/发送) */
/* 语音图标 / 键盘图标 (base64 SVG: 小程序不支持内联 SVG)
PR-INPUT-LAYOUT-SYMMETRY (2026-09-28): 容器 64→76 后, 内部 SVG 40→48 同比放大,
保持图标与按钮的视觉比例 (48/76 ≈ 0.63, 与原 36/52 ≈ 0.69 接近) */
.voice-icon-bg {
width: 48rpx;
height: 48rpx;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48Y2lyY2xlIGN4PSIzLjUiIGN5PSIxMiIgcj0iMi4yIiBmaWxsPSIjMWYxZjFmIiBzdHJva2U9Im5vbmUiLz48cGF0aCBkPSJNMTAgNy4xIEE3IDcgMCAwIDEgMTAgMTYuOSIvPjxwYXRoIGQ9Ik0xNS4zIDEuNyBBMTQuNSAxNC41IDAgMCAxIDE1LjMgMjIuMyIvPjwvc3ZnPg==");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
/* 键盘图标: 语音模式下点击切回文字输入 */
.emoji-icon-bg {
width: 48rpx;
height: 48rpx;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSIzLjUiIHk9IjkiIHdpZHRoPSIyNSIgaGVpZ2h0PSIxNSIgcng9IjIuNSIvPjxwYXRoIGQ9Ik04IDE0aC4wMU0xMi41IDE0aC4wMU0xNyAxNGguMDFNMjEuNSAxNGguMDFNOCAxOC41aC4wMU0xMiAxOC41aC4wMU0xNiAxOC41aC4wMU0yMCAxOC41aC4wMU0yNCAxOC41aC4wMU0xMCAyMi41aDEyIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xKSIvPjwvc3ZnPg==");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
/* 展开态: 文字输入时卡片变白底圆角容器;
语音模式卡片保持透明, 白色胶囊由 .voice-mode-row 自身呈现 (避免双层白底) */
.input-card {
&.is-text-mode {
background: #ffffff;
border-radius: 28rpx;
padding: 14rpx 16rpx 6rpx;
/* 与快捷问题按钮(.quick-question-chip)同款阴影 */
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.14);
}
&.is-voice-mode {
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
}
&.is-text-mode .input-row-top {
background: transparent;
border-radius: 0;
box-shadow: none;
min-height: 76rpx;
padding: 6rpx 0;
}
}
/* 语音/文字两态胶囊始终在 DOM 中, 用显隐切换而非 v-if 重建节点。
v-if 在微信小程序会触发节点卸载/重建, 切换瞬间旧节点那帧会闪(看到上次样式)。
!important 用于盖过 .voice-mode-row / .input-row-top 自身的 display:flex */
.hidden-row {
display: none !important;
}
/* 语音模式单行 (PR-VOICE-MODE-ONE-ROW-V2): 整行即白色胶囊(flex布局,不依赖绝对定位)
键盘图标在左 / 按住说话占中间 / + 在右, 与参考稿一致 */
.voice-mode-row {
position: relative; /* 「上滑取消」提示 .voice-cancel-hint 锚定到这里 */
display: flex;
align-items: center;
/* 与 .input-row-top 一致用 border-box, 保证两者 96rpx 计算口径相同, 高度一致 */
box-sizing: border-box;
height: 96rpx;
/* 上下 margin 为 0: 与默认输入胶囊(.input-row-top margin 0)整块高度一致,
否则语音态多占 12rpx, 切换时聊天列表被顶/拉 12rpx 产生"看到上次样式"的频闪 */
margin: 0;
padding: 0 10rpx 0 24rpx;
border-radius: 999rpx;
background: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.14);
transition: background-color 0.15s ease-out;
&.is-holding {
background: #f2f2f3;
}
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上滑到 80px+ 视为取消手势
&.is-cancelling {
background: #fdecec;
.voice-hold-text {
color: #d23f3f;
}
}
.voice-side-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.voice-side-right {
margin-left: 8rpx;
}
/* 按住说话区域: 透明, 占据中间剩余全部空间 */
.voice-hold-btn {
flex: 1;
height: 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
.voice-hold-text {
font-size: 30rpx;
color: #1f1f1f;
font-weight: 500;
}
}
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话时上滑提示气泡
.voice-cancel-hint {
position: absolute;
right: 20rpx;
bottom: 100%;
margin-bottom: 8rpx;
padding: 8rpx 16rpx;
background: rgba(0, 0, 0, 0.65);
color: #ffffff;
font-size: 22rpx;
border-radius: 12rpx;
pointer-events: none;
z-index: 10;
}
/* 底部工具栏行: 语音模式 / 文字输入态 */
.input-toolbar-row {
display: flex;
align-items: center;
min-height: 76rpx;
padding: 0 2rpx 4rpx;
/* PR-INPUT-LAYOUT-SYMMETRY (2026-09-28): 工具栏内语音/键盘按钮 64→76,
与右侧 +/➤ 同圆心同直径, 三者在同一基线视觉对齐 */
.toolbar-round-btn {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4rpx;
}
.toolbar-flex-spacer {
flex: 1;
}
/* 模型选择器 (从输入框内部移到工具栏行) */
.model-selector {
display: flex;
align-items: center;
gap: 6rpx;
height: 44rpx;
padding: 0 6rpx;
.model-icon-circle {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
border: 2rpx solid #666666;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
.model-icon-text {
font-size: 18rpx;
color: #666666;
line-height: 1;
margin-top: 2rpx;
}
}
.model-name {
font-size: 26rpx;
color: #1f1f1f;
font-weight: 400;
line-height: 1;
}
.model-arrow {
font-size: 22rpx;
color: #999999;
line-height: 1;
margin-top: 2rpx;
}
}
}
/* 默认态右侧操作按钮: + / 发送 (展开态工具栏行共用同一套样式) */
.top-plus-btn {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
border-radius: 50%;
background: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
.plus-icon {
font-size: 52rpx;
color: #1f1f1f;
font-weight: 300;
line-height: 1;
}
&::after {
border: none;
}
}
.top-send-btn {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
border-radius: 50%;
background: #1f1f1f;
border: none;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
/* PR-INPUT-LAYOUT-SYMMETRY (2026-09-28): + 与 ➤ 都是 76rpx 圆形,
加 8rpx 左边距,避免两个等大圆紧贴 (微信/抖音基础库不保证 flex gap) */
margin-left: 8rpx;
.send-plane-text {
color: #ffffff;
font-size: 34rpx;
line-height: 1;
transform: rotate(-90deg); // 箭头向上,呈向上纸飞机
display: inline-block;
margin-left: -2rpx; // 视觉居中
margin-top: -2rpx;
}
/* 无可发送内容时置灰 (仍有 sendMessage 内部兜底拦截) */
&.is-disabled {
background: #e6e6e8;
}
&::after {
border: none;
}
}
/* 底部工具栏: 5 个图标按钮均匀分布 */
.input-toolbar {
display: flex;
align-items: center;
justify-content: space-around;
margin-top: 24rpx;
padding: 4rpx 0;
.toolbar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 4rpx 0;
&.toolbar-item--hover,
&:active {
opacity: 0.55;
}
.toolbar-icon {
width: 44rpx;
height: 44rpx;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
line-height: 1;
&.icon-task {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3QgeD0iMiIgeT0iNiIgd2lkdGg9IjI2IiBoZWlnaHQ9IjE4IiByeD0iNiIgZmlsbD0iIzFmMWYxZiIvPjxjaXJjbGUgY3g9IjEwIiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==");
}
&.icon-automation {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTEiLz48cGF0aCBkPSJNMTEgMTYgbDQgNCBsNyAtOCIvPjwvc3ZnPg==");
}
&.icon-library {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgNyBjNSAtMiAxMCAtMiAxMyAxIGMzIC0zIDggLTMgMTMgLTEgdjE4IGMtNSAtMiAtMTAgLTIgLTEzIDEgYy0zIC0zIC04IC0zIC0xMyAtMSB6Ii8+PHBhdGggZD0iTTE2IDggdjE4Ii8+PC9zdmc+");
}
&.icon-expert {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTEiLz48cGF0aCBkPSJNMTYgOSB2NyBsNSAzIi8+PC9zdmc+");
}
&.icon-more {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjkiIGhlaWdodD0iOSIgcng9IjEuNSIvPjxyZWN0IHg9IjE5IiB5PSI0IiB3aWR0aD0iOSIgaGVpZ2h0PSI5IiByeD0iMS41Ii8+PHJlY3QgeD0iNCIgeT0iMTkiIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHJ4PSIxLjUiLz48cmVjdCB4PSIxOSIgeT0iMTkiIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHJ4PSIxLjUiLz48L3N2Zz4=");
}
}
.toolbar-text {
font-size: 22rpx;
color: #1f1f1f;
line-height: 1;
}
}
}
}
}
/* 动画定义 */
@keyframes dotPulse {
0%, 100% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
@keyframes ellipsisBlink {
0%, 100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes wave {
0%, 100% {
transform: scaleY(0.5);
opacity: 0.7;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
/* 搜索定位脉冲动画 */
@keyframes searchPulse {
0% {
transform: translateY(0);
}
15% {
transform: translateY(-12rpx);
}
30% {
transform: translateY(4rpx);
}
45% {
transform: translateY(-4rpx);
}
60% {
transform: translateY(0);
}
0%, 60%, 100% {
filter: brightness(1);
}
15%, 45% {
filter: brightness(1.15);
}
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 200%;
}
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 滚动条样式优化 */
::-webkit-scrollbar {
width: 8rpx;
height: 8rpx;
}
::-webkit-scrollbar-track {
background: $bg-main;
border-radius: 4rpx;
}
::-webkit-scrollbar-thumb {
background: $color-primary-light;
border-radius: 4rpx;
opacity: 0.7;
&:hover {
background: $color-primary;
opacity: 1;
}
}
/* 商品选择弹窗样式 */
.product-selector-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
.product-selector-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.product-selector-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 800rpx;
max-height: 80vh;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
.product-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
border-bottom: 1rpx solid #e8edf3;
text {
font-size: 32rpx;
font-weight: bold;
color: #4e5969;
}
.close-btn {
padding: 0;
background: none;
.iconfont {
font-size: 32rpx;
color: #8492a6;
}
}
}
.product-selector-content {
display: flex;
height: 60vh;
.category-list {
width: 200rpx;
border-right: 1rpx solid #e8edf3;
.category-item {
padding: 20rpx;
text-align: center;
border-bottom: 1rpx solid #e8edf3;
&.active {
background-color: #f5f7fa;
font-weight: bold;
color: #6c8ebf;
}
}
}
.product-list {
flex: 1;
padding: 20rpx;
.loading {
text-align: center;
padding: 40rpx;
color: #8492a6;
}
.empty {
text-align: center;
padding: 40rpx;
color: #8492a6;
}
.product-item {
display: flex;
padding: 15rpx;
border-bottom: 1rpx solid #e8edf3;
.product-image {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
margin-right: 15rpx;
}
.product-info {
flex: 1;
.product-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 10rpx;
color: #4e5969;
}
.product-price {
color: #f56c6c;
font-weight: bold;
}
}
}
}
}
}
}
/* 响应式适配 */
@media (max-width: 750rpx) {
.ai-chat-container {
.chat-messages {
padding: 16rpx 24rpx;
.message-item {
margin-bottom: 16rpx;
.user-message, .ai-message {
.avatar {
width: 40rpx;
height: 40rpx;
}
.message-content {
margin-left: 16rpx;
max-width: calc(100% - 56rpx);
.message-bubble {
/* 移动端也保持无气泡样式, 文字直接渲染在深色背景上 */
padding: 0 !important;
.message-text {
font-size: 28rpx;
}
}
}
/* 用户消息气泡: 覆盖上面的 padding:0
- H5: 气泡要明显比文字大(与小程序一致), 只改气泡, 不改文字字号;
用 :not() 排除图片/文件/视频/音频气泡
- 小程序: 保持原有浅灰气泡 + 加大内边距(不动)
PR-FIX-CAPTION-BUBBLE-SIZE (2026-09-24): 同上层, 配文气泡不再排除, 与普通文字气泡同尺寸 */
/* #ifdef H5 */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.user-file-bubble):not(.user-video-bubble):not(.user-audio-bubble) {
padding: 20rpx 40rpx !important;
border-radius: 16rpx !important;
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
text-align: left !important;
}
/* #endif */
/* #ifndef H5 */
.user-message .message-content .message-bubble {
padding: 20rpx 40rpx !important;
}
/* #endif */
}
}
}
.input-area {
padding: 10rpx 16rpx 12rpx;
/* 卡片内边距/圆角只对文字输入态(白卡片)生效; 默认/语音胶囊保持 flush,
否则默认胶囊会多吃 12+10rpx, 与语音胶囊整块高度差 22rpx, 切换时跳动频闪 */
.input-card.is-text-mode {
padding: 12rpx 14rpx 10rpx;
border-radius: 18rpx;
}
/* 语音模式胶囊: 与默认输入胶囊左右对齐。
默认胶囊在 .input-card 内(左右各收进 14rpx), 而语音模式卡片 padding 被
.is-voice-mode 强制为 0, 胶囊全宽 —— 用同样的水平 margin 补齐, 两端长短一致。
上下 margin 保持 0, 与默认胶囊整块等高, 避免切换时聊天列表跳动 */
.voice-mode-row {
margin: 0 14rpx;
}
.input-row-top {
min-height: 64rpx;
.message-input {
min-height: 52rpx;
font-size: 28rpx;
}
.message-input-placeholder {
font-size: 28rpx !important;
}
}
.input-toolbar {
min-height: 56rpx;
.send-btn {
width: 56rpx;
height: 56rpx;
.send-icon {
font-size: 26rpx;
}
}
}
}
}
}
/* 扩大按钮 */
.expand-btn {
width: 72rpx;
height: 72rpx;
border-radius: 12rpx;
margin-left: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: #f2f2f2;
padding: 0;
left:60rpx;
.iconfont {
font-size: 36rpx;
line-height: 1;
}
}
/* 扩大编辑器弹窗 */
.expand-editor-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
flex-direction: column;
.expand-editor-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.expand-editor-panel {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 0;
margin-top: 100rpx;
.expand-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #e5e5e5;
background-color: #f9f9f9;
text {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.close-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
border: none;
padding: 0;
text {
font-size: 48rpx;
color: #999;
line-height: 1;
}
}
}
.expand-editor-input {
flex: 1;
min-height: 300rpx;
max-height: none;
padding: 32rpx;
font-size: 30rpx;
line-height: 1.6;
background-color: #fff;
border: none;
color: #333;
}
.expand-editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-top: 1rpx solid #e5e5e5;
background-color: #f9f9f9;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
.expand-cancel-btn {
width: 160rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #f2f2f2;
color: #666;
font-size: 28rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.expand-send-btn {
width: 160rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #07c160;
color: #fff;
font-size: 28rpx;
font-weight: 500;
border: none;
display: flex;
align-items: center;
justify-content: center;
&[disabled] {
background-color: #ccc;
opacity: 0.7;
}
}
}
}
// H5 下允许 rich-text / v-html 内容文本选择(鼠标/长按拖选, 复制、全选等由浏览器原生菜单提供)
.text-content,
.markdown-content,
.markdown-body,
.ai-message-text,
.selectable-md {
user-select: text;
-webkit-user-select: text;
}
.selectable-md {
font-size: 30rpx;
line-height: 1.75;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
// 保证 v-html 内部段落、列表等不影响选择
p, ul, ol, li, h1, h2, h3, h4, h5, h6, code, pre {
user-select: text;
-webkit-user-select: text;
}
}
// 用户消息 rich-text 高亮(搜索时替代 <text>)
.message-rich-text {
font-size: 28rpx;
line-height: 1.7;
letter-spacing: 0.8rpx;
}
// AI 回答不加粗(兜底),同时统一 H5 markdown 容器字号为 25rpx
.ai-message {
.message-bubble,
.selectable-md {
strong, b, h1, h2, h3, h4, h5, h6, th {
font-weight: normal !important;
}
}
.selectable-md {
font-size: 25rpx !important;
}
}
}
/* ========== AI 回答: 无气泡 ==========
AI 回答不做任何卡片/气泡, 背景透明、无边框、无阴影、无内边距, 文字直接渲染在白色页面底上 */
/* ========== 思考流 / 工具调用 / 选项卡片 / 附件 样式 ========== */
/* 顶部栏: 标题 + 新对话 */
.chat-topbar {
display: flex;
align-items: center;
justify-content: space-between;
/* 右侧预留搜索入口(56rpx 圆形按钮, right:16rpx)的宽度,
把"新对话"按钮左移, 避免被搜索按钮遮挡 */
padding: 16rpx 88rpx 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: #1f1f1f;
font-size: 24rpx;
/* 新对话图标: 圆角气泡 + 中间加号 + 左下角小尾巴的线性图标 (已改为黑色) */
.new-chat-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNOC41IDIuNUgxNkE1LjUgNS41IDAgMCAxIDIxLjUgOFYxMi41QTUuNSA1LjUgMCAwIDEgMTYgMThIOC42TDMgMjJWOEE1LjUgNS41IDAgMCAxIDguNSAyLjVaIi8+PHBhdGggZD0iTTEyIDcuNVYxMy41TTkuMiAxMC41SDE0LjgiLz48L3N2Zz4=");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
}
}
/* 思考流折叠面板 */
.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;
}
}
/* 工具调用列表样式已随模板一起移除 (PR-CHAT-UI-HIDE-TOOL-CALLS 2026-09-23) */
/* 附件 */
.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-media-group {
display: flex;
flex-direction: column;
align-items: flex-end;
max-width: 100%;
min-width: 0;
}
/* PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 用户多图横向排列容器 */
.user-image-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-end;
align-items: flex-end;
max-width: 600rpx;
/* 图片数量越多单张越小, 保证一行放得下; 超过 4 张自动换行 */
&.n1 .user-image { width: 280rpx; height: 280rpx; }
&.n2 .user-image { width: 200rpx; height: 200rpx; }
&.n3 .user-image { width: 145rpx; height: 145rpx; }
&.n4 .user-image { width: 108rpx; height: 108rpx; }
}
/* PR-VOICE-OVERLAY (2026-09-28): 中央语音播放面板
半透明背景 + 中央卡片 (大播放按钮 + 时长 + 进度条)
与现有弹窗 (emoji/avatar/nickname) 风格一致 */
.voice-overlay {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
.voice-overlay-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
/* 入场淡入 */
animation: voiceOverlayFadeIn 0.18s ease-out;
}
.voice-overlay-panel {
position: relative;
z-index: 1;
/* 卡片弹入 */
animation: voiceOverlayPopIn 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.voice-overlay-card {
position: relative;
width: 560rpx;
background: #ffffff;
border-radius: 32rpx;
padding: 60rpx 48rpx 44rpx;
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.voice-overlay-close {
position: absolute;
top: 18rpx;
right: 18rpx;
width: 56rpx;
height: 56rpx;
line-height: 50rpx;
text-align: center;
font-size: 40rpx;
color: #b0b8c4;
cursor: pointer;
user-select: none;
}
.voice-overlay-icon {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: rgba(108, 142, 191, 0.12);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
.icondiy {
font-size: 84rpx;
color: $color-primary;
line-height: 1;
}
}
.voice-overlay-title {
font-size: 30rpx;
color: #1f1f1f;
font-weight: 500;
margin-bottom: 16rpx;
}
.voice-overlay-time {
font-size: 28rpx;
color: #6b7c93;
margin-bottom: 24rpx;
font-variant-numeric: tabular-nums;
.time-current {
color: $color-primary;
font-weight: 500;
}
.time-sep {
color: #b0b8c4;
margin: 0 4rpx;
}
}
.voice-overlay-progress-track {
width: 100%;
height: 10rpx;
background: #e8edf3;
border-radius: 5rpx;
overflow: hidden;
margin-bottom: 36rpx;
.voice-overlay-progress-bar {
height: 100%;
background: $color-primary;
border-radius: 5rpx;
transition: width 0.15s linear;
}
}
.voice-overlay-play-btn {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: $color-primary;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(108, 142, 191, 0.35);
transition: transform 0.15s ease, background 0.2s ease;
.icondiy {
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
line-height: 1;
}
&.playing {
background: #4f73a8;
/* 播放中轻微脉动 */
animation: voiceOverlayPulse 1.6s ease-in-out infinite;
}
&:active {
transform: scale(0.94);
}
}
.voice-overlay-hint {
margin-top: 24rpx;
font-size: 22rpx;
color: #b0b8c4;
}
}
}
@keyframes voiceOverlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes voiceOverlayPopIn {
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes voiceOverlayPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
/* 用户图片气泡 (提高选择器权重, 覆盖 H5/移动端下 .user-message .message-content .message-bubble 的大内边距) */
.user-message .message-content .message-bubble.user-image-bubble {
padding: 6rpx !important;
overflow: hidden;
position: relative;
margin-left: 8rpx;
.user-image { 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; }
}
}
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
复用 AI 侧 .file-item 样式 (line 2943), 仅包一层 user-file-bubble 容器 */
.user-file-bubble,
.user-audio-bubble {
padding: 12rpx 16rpx !important;
overflow: hidden;
cursor: pointer;
.file-item {
/* 复用 AI 侧定义; 复制过来以避免修改原有 AI 侧样式 */
display: flex;
align-items: center;
min-width: 240rpx;
max-width: 360rpx;
.file-icon {
width: 56rpx;
height: 56rpx;
margin-right: 12rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
.file-icon-emoji {
font-size: 44rpx;
line-height: 1;
}
.iconfont,
.icondiy { font-size: 48rpx; color: #6b7c93; }
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-size: 26rpx;
color: #2c3e50;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240rpx;
display: block;
}
.file-size {
font-size: 22rpx;
color: #8a9fb8;
margin-top: 4rpx;
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; }
}
}
/* PR-USER-VOICE-BUBBLE (2026-09-29): 用户语音消息气泡 —— 微信原生风格
- 左侧显示时长 X", 右侧显示扬声器 + 声波动画
- 气泡右侧带小三角尾巴
- 宽度按时长比例伸缩 (短语音窄、长语音宽)
与 .user-file-bubble 复用基础结构但视觉完全不同, 这里覆盖其样式
PR-USER-MULTI-VOICE (2026-09-28): 改为 flex-column 让多条 voice-msg 垂直堆叠 */
.user-audio-bubble {
/* 覆盖 .user-file-bubble 的 padding/file-item */
padding: 14rpx 22rpx !important;
min-width: 180rpx;
max-width: 460rpx;
background: #f5f5f5 !important;
border-radius: 16rpx !important;
color: #1f1f1f !important;
cursor: pointer;
position: relative;
overflow: visible;
display: inline-flex !important;
flex-direction: column;
gap: 12rpx;
width: auto !important;
/* 微信气泡右侧小三角尾巴 */
&::after {
content: '';
position: absolute;
right: -12rpx;
top: 20rpx;
width: 0;
height: 0;
border: 12rpx solid transparent;
border-left-color: $color-primary;
border-right: 0;
}
/* 上传中半透明 */
&.is-uploading {
opacity: 0.7;
}
&.is-error {
background: #c0c4cc !important;
&::after {
border-left-color: #c0c4cc !important;
}
}
.voice-msg {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
width: 100%;
min-height: 44rpx;
.voice-duration {
flex-shrink: 0;
font-size: 30rpx;
color: #1f1f1f;
font-variant-numeric: tabular-nums;
line-height: 1;
}
/* 微信风格语音图标: 扬声器 + 右侧声波 */
.voice-speaker {
flex-shrink: 0;
display: flex;
align-items: center;
height: 36rpx;
position: relative;
/* 扬声器后身 */
.voice-speaker-body {
width: 8rpx;
height: 14rpx;
background: #1f1f1f;
border-radius: 2rpx 0 0 2rpx;
}
/* 扬声器喇叭口(指向右侧) */
.voice-speaker-cone {
width: 0;
height: 0;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-right: 14rpx solid #1f1f1f;
margin-right: 4rpx;
}
/* 右侧两条声波弧线 */
.voice-speaker-waves {
display: flex;
align-items: center;
margin-left: 2rpx;
.voice-speaker-wave {
border-top: 3rpx solid transparent;
border-right: 3rpx solid #1f1f1f;
border-bottom: 3rpx solid transparent;
border-left: 3rpx solid transparent;
border-radius: 0 50% 50% 0;
&:nth-child(1) {
width: 8rpx;
height: 14rpx;
}
&:nth-child(2) {
width: 14rpx;
height: 22rpx;
margin-left: -8rpx;
}
}
}
}
}
/* PR-USER-MULTI-VOICE (2026-09-28): 语音 + 文字混合时, 文字写在语音列表下方
与语音行共用主色背景, 顶部分割线划开两块内容 */
.voice-bubble-text {
padding-top: 12rpx;
border-top: 1rpx solid rgba(0, 0, 0, 0.12);
.message-text {
font-size: 28rpx;
color: #1f1f1f;
line-height: 1.5;
word-break: break-word;
}
}
.upload-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
.upload-tip { color: #fff; font-size: 24rpx; }
&.upload-error .upload-tip { color: #ffb3b3; }
}
}
/* 用户视频气泡 (视频上传): 内嵌播放器, 与图片气泡一样紧凑 */
.user-video-bubble {
padding: 6rpx !important;
overflow: hidden;
.video-item {
position: relative;
}
.user-video {
width: 420rpx;
height: 300rpx;
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; }
}
}
/* PR-LOCATION-PENDING (2026-09-28): 用户位置气泡
复用主色背景 + 📍 emoji + 名称/地址, 跨平台一致 */
.user-location-bubble {
padding: 18rpx 24rpx !important;
min-width: 280rpx;
max-width: 480rpx;
background: $color-primary !important;
border-radius: 24rpx !important;
color: #fff !important;
.location-item {
display: flex;
align-items: center;
gap: 14rpx;
}
.location-icon {
font-size: 44rpx;
line-height: 1;
flex-shrink: 0;
}
.location-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.location-name {
font-size: 28rpx;
font-weight: 500;
color: #fff;
line-height: 1.2;
/* 跨平台防溢出 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 360rpx;
}
.location-address,
.location-coord {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.85);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 360rpx;
}
}
/* PR-LOCATION-PENDING (2026-09-28): 输入区待发送位置预览
与 pending-voice-thumb 风格一致, 紧凑卡片 + 📍 + 名称 + 地址 */
.pending-location-thumb {
display: flex;
align-items: center;
gap: 10rpx;
padding: 12rpx 16rpx;
background: #f5f7fa;
border-radius: 12rpx;
min-width: 200rpx;
max-width: 360rpx;
.pending-location-icon {
font-size: 32rpx;
flex-shrink: 0;
}
.pending-location-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.pending-location-name {
font-size: 24rpx;
color: #1f1f1f;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pending-location-addr {
font-size: 20rpx;
color: #8a9fb8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* 图片消息配文气泡 (图文一起发出时, 独立气泡显示在图片下方)
PR-FIX-CAPTION-BUBBLE-SIZE (2026-09-24): 这里原先单独定义了
background/border-radius/padding/max-width/字号, 使配文气泡比普通文字气泡小一圈
(H5 下又被 :not() 规则排除, 内边距只有 30rpx)。
现在只保留"与图片的间距 + 右对齐", 其余(内边距/圆角/背景/字号)全部复用普通文字气泡,
保证图文消息下方的问题气泡和纯文字消息的气泡一样大。 */
.image-caption-bubble {
margin-top: 10rpx;
align-self: flex-end;
width: auto;
}
/* 输入框内待发送图片缩略图: 位于文字输入行上方, 出现时输入框自动向上撑高 */
/* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 数组版 1-9 张, 横向排列 + 横向滚动 */
.pending-attachment {
display: flex;
flex-direction: row;
align-items: flex-start;
align-self: stretch;
margin: 6rpx 14rpx 10rpx;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
/* 内部每个 pending-item 独立 display:block, 由 item 自身定义尺寸 */
.pending-item {
position: relative;
width: 120rpx;
height: 120rpx;
border-radius: 14rpx;
overflow: hidden;
flex-shrink: 0;
margin-right: 10rpx;
}
.pending-image { width: 100%; height: 100%; display: block; }
/* PR-FRONTEND-MEDIA-PENDING-FILE: 非图片附件(文件/视频/音频)缩略占位 */
.pending-file-thumb {
width: 100%;
height: 100%;
background: #eef2f7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6rpx 8rpx;
box-sizing: border-box;
.pending-file-icon { font-size: 44rpx; line-height: 1; }
.pending-file-name {
margin-top: 6rpx;
max-width: 100%;
font-size: 18rpx;
color: #55657a;
line-height: 1.2;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
/* PR-PENDING-VOICE-PREVIEW (2026-09-28): 待发送语音预览, 复用发送后气泡的语音消息样式
容器改为横向矩形, 让 play + 进度 + 时长排成一行更紧凑 */
&.pending-item-voice {
width: 360rpx;
height: 80rpx;
border-radius: 16rpx;
}
.pending-voice-thumb {
width: 100%;
height: 100%;
background: $color-primary;
display: flex;
align-items: center;
padding: 0 14rpx;
gap: 12rpx;
color: #fff;
cursor: pointer;
box-sizing: border-box;
.pending-voice-play {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.22);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
.icondiy {
color: #fff;
font-size: 28rpx;
font-weight: 600;
line-height: 1;
}
&.playing {
background: rgba(255, 255, 255, 0.4);
}
&.disabled {
opacity: 0.5;
}
}
.pending-voice-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
.pending-voice-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.92);
line-height: 1;
}
.pending-voice-progress-track {
height: 6rpx;
background: rgba(255, 255, 255, 0.28);
border-radius: 3rpx;
overflow: hidden;
.pending-voice-progress-bar {
height: 100%;
background: #fff;
border-radius: 3rpx;
transition: width 0.15s linear;
}
}
}
.pending-voice-duration {
flex-shrink: 0;
font-size: 22rpx;
color: #fff;
font-variant-numeric: tabular-nums;
line-height: 1;
}
}
/* 用户消息里的文件附件卡片 */
.user-attachments { margin-top: 8rpx; }
.pending-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text { color: #fff; font-size: 20rpx; }
&.error text { color: #ffb3b3; }
}
/* PR-FRONTEND-MEDIA-UPLOAD-FIX: 失败时给删除按钮, 用户不再卡死 */
.pending-retry-delete {
margin-top: 6rpx;
padding: 4rpx 14rpx;
background: rgba(255, 80, 80, 0.85);
color: #fff;
font-size: 20rpx;
border-radius: 6rpx;
line-height: 1.2;
}
.pending-remove {
position: absolute;
top: 0; right: 0;
width: 36rpx; height: 36rpx;
line-height: 32rpx;
text-align: center;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 26rpx;
border-radius: 0 0 0 14rpx;
}
}
</style>