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

6703 lines
250 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 / attachments 字段检查
原过滤只识别顶层 fileName / audioUrl / imageUrl / videoUrl,
多条语音消息顶层无这些字段 (在 voiceList 里嵌套), 会被误过滤掉不渲染
修复: 显式判断 voiceList 非空 + attachments 非空 -->
<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.attachments && msg.attachments.length)
|| (msg.content && msg.content.trim() !== '')
)"
:key="getMessageKey(message, index)"
:id="getMessageKey(message, index)"
class="message-item"
:class="[message.role, { 'first-message': index === 0, 'search-current': showSearchBar && searchResultFilteredIndex === index, 'search-flash': showSearchBar && searchFlashAnimatingIndex === index }]">
<!-- 用户消息 (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 -->
<view v-else-if="message.type === 'file'" class="message-bubble user-file-bubble" @click="previewUserFile(message)">
<view class="file-item">
<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 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-else-if="message.type === 'video'" class="message-bubble user-video-bubble">
<view class="video-item">
<video
class="user-video"
:src="message.url || message.imageUrl"
:controls="true"
:autoplay="false"
object-fit="contain"></video>
<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>
<!-- PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
模仿微信/Telegram 的语音消息样式:
[▶播放] [语音 + 进度条] [时长]
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" @click.stop="openVoiceOverlay(v)">
<view class="voice-play-btn" :class="{ playing: v.playing }">
<text class="icondiy" :class="v.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
</view>
<view class="voice-info">
<text class="voice-label">{{ v.fileName || $lang('ai-chat.index.audioMessage') || '语音' }}</text>
<view class="voice-progress-track">
<view class="voice-progress-bar" :style="{ width: getVoiceProgress(v) + '%' }"></view>
</view>
</view>
<text class="voice-duration">{{ formatDuration((v.duration || 0) / 1000) }}</text>
</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">
<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)" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="ai-message-text selectable-md" v-html="getAIDisplayContent(message.content)"></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)" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="selectable-md" v-html="getAIDisplayContent(message.content)"></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" 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)">
<text class="quick-question-icon">💬</text>
<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">
<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>
</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 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-FRONTEND-VOICE-INPUT 2026-09-24) -->
<view
v-if="voiceMode"
class="voice-hold-btn"
:class="{ 'is-holding': voiceHolding, 'is-cancelling': voiceHoldCancelling }"
@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>
<!-- 按住时的「上滑取消」提示 (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 v-if="!voiceMode" class="input-row-top">
<!-- 默认态左侧: 点击进入语音(按住说话)模式 -->
<!-- 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="voiceMode || showTextMode" class="input-toolbar-row">
<!-- 语音模式: 键盘图标 (点击回到文字输入) -->
<view v-if="voiceMode" class="toolbar-round-btn" @click="exitVoiceMode">
<view class="emoji-icon-bg"></view>
</view>
<!-- 文字输入: 语音图标 (切到按住说话) -->
<view v-else 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,
// 失焦定时器: 用于延迟收起文字输入态, 避免 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-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)
},
onLocationSelected(locationMessage) {
this.messages.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
// 仅全新会话才需要欢迎语
if (this.currentConversationId) {
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. 插入欢迎语(再次检查是否仍是全新会话)
if (!this.currentConversationId && this.messages.length === 0) {
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
quickQuestions: this.normalizeQuickQuestions(welcomeInfo && welcomeInfo.quick_questions)
}
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='audio' (语音气泡为主, 复用 voice-msg 样式)
// - 其他 → type='text' (文字/普通附件)
// PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
// 把 pendingFilesList 里的音频拆出来 → voiceList 数组, 每条独立可播放.
// 优先级: 图片 > 语音 > 文字/附件 (图片永远独占气泡)
const voiceFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'audio')
const hasVoice = voiceFiles.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()
}
: 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'),
timestamp: Date.now()
}
})()
: {
id: ++this.messageId,
role: 'user',
type: 'text',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 用户气泡只显示用户自己输入的文字
// 给 AI 的指令 hint 在 queryText 里, 这里不要污染用户气泡
content: userText,
// 纯文件(无图)时把文件挂到 attachments, 用户消息气泡下方展示文件卡片
attachments: displayAttachments,
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)
if (this.enableStreaming) {
await this.sendStreamMessage(enhancedMessage, readyAttachments)
} else {
await this.sendNormalMessage(enhancedMessage, readyAttachments)
}
} 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 = []) {
const response = await aiService.sendMessage(userMessage, {
conversationId: this.currentConversationId,
stream: false,
attachments
})
// 移除加载状态
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 = []) {
// 标记进入流式接收, 禁止重复发送, 同时让思考面板处于"思考中"展开状态
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 })
// #endif
// #ifdef MP-WEIXIN
await aiService.sendStreamMessage(userMessage, onEvent, onComplete, { attachments })
// #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()
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'
// 辅助:内联格式处理
// 思路: 先把"链接/图片/裸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) 行内代码 / 加粗 / 斜体 / 删除线 (此时链接已抽离, 不会被误改)
t = t
.replace(/`([^`]+)`/g, '<code style="background:#f2f3f5;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:#d63384;">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong style="font-weight:700;color:' + TEXT + ';">$1</strong>')
.replace(/\*(.+?)\*/g, '<em style="font-style:italic;">$1</em>')
.replace(/~~(.+?)~~/g, '<del style="text-decoration:line-through;color:#999;">$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:#333;font-size:24rpx;">${safe}</code></pre>`
)
return `%%CODEBLOCK_${idx}%%`
})
// 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:600;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 = ['', '700', '700', '600', '600', '500', '500'][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:#57606a;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))
},
// 格式化 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 */ }
},
// 去掉 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 消息渲染(含关键词高亮)
getAIDisplayContent(content) {
if (!this.searchKeyword || !content) {
return this.parseMarkdown(content)
}
// 仅当内容包含关键词时才执行高亮,避免非匹配消息的额外处理
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
return this.parseMarkdown(content)
}
// 先做 markdown 渲染,再做关键词高亮
const mdHtml = this.parseMarkdown(content)
return this._wrapHighlightInHtml(mdHtml, this.searchKeyword)
},
// 在纯文本中高亮关键词(用于用户消息)
_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()
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)
} else {
// 普通响应
await this.sendNormalMessage(userMessage.content, attachments)
}
} 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
},
// 退出语音模式, 回到文字输入态
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) {
if (this._voiceRecorder) return
this.voiceHolding = true
this.voiceHoldCancelling = false
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)
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() {
if (!this.voiceHolding) {
// 非按住触发的 click (H5 鼠标等), 不做任何事, 避免双触发
return
}
const wasCancelling = this.voiceHoldCancelling
this.voiceHolding = false
this.voiceHoldCancelling = false
if (!this._voiceRecorder) {
// 启动录音器失败 (无 touch 触发的 click) → 退回弹窗路径, 保留功能不丢
this.showVoicePanel()
return
}
if (wasCancelling) {
// 取消手势: 主动作废, 不发 audio-ready
this._discardVoiceRecorder()
return
}
try {
const result = await this._voiceRecorder.stop()
this._teardownVoiceRecorder()
if (!result || !result.filePath) {
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
return
}
await this._handleRecordedAudio(result)
} catch (e) {
this._teardownVoiceRecorder()
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
}
},
// 按住说话: 手势被系统打断 (来电/滑动取消)
onVoiceHoldCancel() {
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() {
if (!this._voiceRecorder) return
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
this._teardownVoiceRecorder()
},
_teardownVoiceRecorder() {
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 ''
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':
return this.$lang('ai-chat.index.voiceStopFailed')
default:
return err.message || ''
}
},
// 输入框聚焦 / 失焦: 驱动"文字输入态"布局
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;
// 搜索定位 - 当前结果: 用柔和的浅绿底色提示 (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;
color: $color-text;
strong {
font-weight: 600;
color: $color-text;
}
em {
font-style: italic;
color: $color-text-light;
}
code {
background-color: #f0f4f8;
padding: 4rpx 8rpx;
border-radius: $radius-sm;
font-family: monospace;
font-size: 24rpx;
color: $color-primary;
}
a {
color: $color-primary;
text-decoration: underline;
text-underline-offset: 4rpx;
transition: color 0.2s ease;
&:hover {
color: #5a7cb0;
}
}
}
}
.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: $color-primary;
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-wrap: wrap;
justify-content: flex-start;
max-width: 100%;
.quick-question-chip {
display: inline-flex;
align-items: center;
margin-right: 16rpx;
margin-bottom: 16rpx;
padding: 12rpx 24rpx;
background: #ffffff;
border: 1rpx solid #1a1a1a;
border-radius: 32rpx;
font-size: 26rpx;
color: #1a1a1a;
cursor: pointer;
transition: all 0.2s ease;
max-width: 100%;
line-height: 1.4;
.quick-question-icon {
font-size: 24rpx;
flex-shrink: 0;
margin-right: 8rpx;
}
.quick-question-text {
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
}
.quick-question-chip--hover {
background: #1a1a1a;
color: #ffffff;
transform: translateY(-1rpx);
box-shadow: 0 4rpx 12rpx rgba(26, 26, 26, 0.2);
}
}
.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: 20rpx !important; // 还原为用户消息原字号
}
&::before {
display: none !important;
content: none !important;
}
}
}
}
/* #ifdef H5 */
/* H5 用户【纯文字】消息气泡: 气泡要明显比文字大(与小程序一致)
- 内边距加大 -> 气泡比文字大, 文字与气泡边缘留出间距
- 宽度 auto + inline-block -> 气泡宽度贴着内容, 不拉满整行
- 只调整气泡自身, 气泡内文字字号不变
- 用 :not() 排除图片/配文/文件/视频/音频气泡, 避免影响它们原有的紧凑样式 */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.image-caption-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 富文本(豆包风格)容器: 行高/字号/字色统一兜底
.ai-message-rich {
display: block;
font-size: 30rpx;
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: $color-primary;
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: $color-primary;
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=");
}
}
&:active {
background: #ebebeb;
}
}
}
}
}
}
}
/* 键盘垫高层:高度变化时平滑过渡 */
.keyboard-spacer {
flex-shrink: 0;
transition: height 0.25s ease;
}
/* 输入区域 (新版: 顶部胶囊形输入条 + 底部 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;
padding: 18rpx 14rpx 18rpx 32rpx;
min-height: 108rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
/* 无文字时左侧语音图标,点击打开语音面板
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,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTAgMTIgaDYgbDYgLTQgdjE2IGwtNiAtNCBoLTYgYTIgMiAwIDAgMSAtMiAtMiB2LTQgYTIgMiAwIDAgMSAyIC0yIHoiLz48cGF0aCBkPSJNMjQgMTAgYzIgMyAyIDkgMCAxMiIvPjwvc3ZnPg==");
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;
}
/* 展开态: 卡片变白底圆角容器, 内部输入行不再单独成胶囊 */
.input-card {
&.is-voice-mode,
&.is-text-mode {
background: #ffffff;
border-radius: 28rpx;
padding: 14rpx 16rpx 6rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
&.is-voice-mode .input-row-top,
&.is-text-mode .input-row-top {
background: transparent;
border-radius: 0;
box-shadow: none;
min-height: 76rpx;
padding: 6rpx 0;
}
}
/* 语音模式: 按住说话 */
.voice-hold-btn {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
margin: 4rpx 0 8rpx;
border-radius: 16rpx;
background: #f2f2f3;
position: relative; // PR-FRONTEND-VOICE-INPUT: 让 .voice-cancel-hint 锚定到这里
transition: background-color 0.15s ease-out;
&.is-holding {
background: #e2e2e4;
}
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上滑到 80px+ 视为取消手势
&.is-cancelling {
background: #fdecec;
.voice-hold-text {
color: #d23f3f;
}
}
.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() 排除图片/配文/文件/视频/音频气泡
- 小程序: 保持原有浅灰气泡 + 加大内边距(不动) */
/* #ifdef H5 */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.image-caption-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;
.input-card {
padding: 12rpx 14rpx 10rpx;
border-radius: 18rpx;
}
.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 回答: 无气泡 ==========
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: #1f6cff;
font-size: 24rpx;
/* 新对话图标: 圆角气泡 + 中间加号 + 左下角小尾巴的线性图标
微信小程序不支持内联 SVG, 沿用 base64 背景图方案 (与 svg-icon / voice-icon-bg 一致) */
.new-chat-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNOC41IDIuNUgxNkE1LjUgNS41IDAgMCAxIDIxLjUgOFYxMi41QTUuNSA1LjUgMCAwIDEgMTYgMThIOC42TDMgMjJWOEE1LjUgNS41IDAgMCAxIDguNSAyLjVaIi8+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;
.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-28): 用户语音消息气泡
模仿微信/Telegram 语音消息样式: 主色实心圆角胶囊 + 播放按钮 + 进度条 + 时长
与 .user-file-bubble 复用基础结构但视觉完全不同, 这里覆盖其样式
PR-USER-MULTI-VOICE (2026-09-28): 改为 flex-column 让多条 voice-msg 垂直堆叠 */
.user-audio-bubble {
/* 覆盖 .user-file-bubble 的 padding/file-item */
padding: 18rpx 24rpx !important;
min-width: 320rpx;
max-width: 480rpx;
background: $color-primary !important;
border-radius: 24rpx !important;
color: #fff !important;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex !important;
flex-direction: column;
gap: 16rpx;
/* 上传中半透明 */
&.is-uploading {
opacity: 0.7;
}
&.is-error {
background: #c0c4cc !important;
}
.voice-msg {
display: flex;
align-items: center;
gap: 16rpx;
.voice-play-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.22);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, transform 0.15s ease;
.icondiy {
color: #fff;
font-size: 32rpx;
font-weight: 600;
line-height: 1;
}
/* 播放中按钮背景加深 */
&.playing {
background: rgba(255, 255, 255, 0.4);
}
&.disabled {
opacity: 0.5;
}
}
.voice-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
.voice-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.92);
line-height: 1;
}
.voice-progress-track {
height: 8rpx;
background: rgba(255, 255, 255, 0.28);
border-radius: 4rpx;
overflow: hidden;
.voice-progress-bar {
height: 100%;
background: #fff;
border-radius: 4rpx;
/* 进度条用 transition 平滑过渡, 配合 innerAudioCtx.onTimeUpdate 的秒级刷新 */
transition: width 0.15s linear;
}
}
}
.voice-duration {
flex-shrink: 0;
font-size: 26rpx;
color: #fff;
font-variant-numeric: tabular-nums;
line-height: 1;
}
}
/* PR-USER-MULTI-VOICE (2026-09-28): 语音 + 文字混合时, 文字写在语音列表下方
与语音行共用主色背景, 顶部分割线划开两块内容 */
.voice-bubble-text {
padding-top: 12rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.2);
.message-text {
font-size: 28rpx;
color: #fff;
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; }
}
}
/* 图片消息配文气泡 (图文一起发出时, 独立气泡显示在图片下方) */
.image-caption-bubble {
margin-top: 10rpx;
background: #f5f5f5;
border-radius: 20rpx;
padding: 14rpx 22rpx;
max-width: 460rpx;
.message-text {
font-size: 28rpx;
color: #111827;
line-height: 1.5;
word-break: break-all;
}
}
/* 输入框内待发送图片缩略图: 位于文字输入行上方, 出现时输入框自动向上撑高 */
/* 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>