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