Files
lucky_shop/pages_tool/ai-chat/ai-chat-message.vue
2026-09-23 15:22:19 +08:00

5595 lines
201 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="ai-chat-container" :style="aiChatContainerStyle">
<!-- 搜索组件 -->
<ai-chat-search
ref="searchRef"
:messages="messages"
:show-label="false"
@scroll-to="onSearchScrollTo"
@active-change="onSearchActiveChange"
@result-change="onSearchResultChange"
@keyword-change="onSearchKeywordChange"
/>
<!-- 顶部栏: 标题 + 新对话 -->
<view class="chat-topbar">
<text class="chat-title">{{ $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>
<!-- 消息列表 -->
<view
v-for="(message, index) in messages.filter(msg =>
msg.type === 'loading'
|| msg.imageUrl
|| msg.fileName
|| msg.audioUrl
|| msg.videoUrl
|| (msg.content && msg.content.trim() !== '')
)"
:key="getMessageKey(message, index)"
:id="getMessageKey(message, index)"
class="message-item"
:class="[message.role, { 'first-message': index === 0, 'search-current': showSearchBar && searchResultFilteredIndex === index, 'search-flash': showSearchBar && searchFlashAnimatingIndex === index }]">
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
<view v-if="message.role === 'user'" class="user-message">
<view class="message-content">
<view v-if="message.type === 'image'" class="user-media-group">
<!-- 图片气泡 (在上, 与下方文字气泡同列右对齐)
PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 上传几张就显示几张, 横向排列 -->
<view class="user-image-row" :class="'n' + Math.min(messageImages(message).length || 1, 4)">
<view
v-for="(img, imgIndex) in messageImages(message)"
:key="imgIndex"
class="message-bubble user-image-bubble">
<image
:src="img.url"
mode="aspectFill"
class="user-image"
@click="previewUserImage(message, imgIndex)" />
<view v-if="imgIndex === 0 && message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
<view v-if="imgIndex === 0 && message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
</view>
</view>
<!-- 图片配文: 独立气泡, 显示在图片下方 -->
<view v-if="message.content" class="message-bubble image-caption-bubble">
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
tools-panel _uploadOneFile 选 kind='file'/'video'/'audio' 时,
m.type + m.fileName/size + m.imageUrl=本地预览(若有) / m.uploaded.url 上传后 URL -->
<view v-else-if="message.type === 'file'" class="message-bubble user-file-bubble" @click="previewUserFile(message)">
<view class="file-item">
<view class="file-icon"><text class="iconfont icon-file"></text></view>
<view class="file-info">
<text class="file-name">{{ message.fileName || '文件' }}</text>
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
</view>
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
</view>
</view>
<!-- 用户视频消息气泡 (视频上传): 内嵌播放器 -->
<view v-else-if="message.type === 'video'" class="message-bubble user-video-bubble">
<view class="video-item">
<video
class="user-video"
:src="message.url || message.imageUrl"
:controls="true"
:autoplay="false"
object-fit="contain"></video>
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
</view>
</view>
<!-- 用户音频消息气泡 (音频上传): 点击播放/暂停 -->
<view v-else-if="message.type === 'audio'" class="message-bubble user-audio-bubble" @click="playUserAudio(message)">
<view class="file-item">
<view class="file-icon"><text class="icondiy" :class="message.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text></view>
<view class="file-info">
<text class="file-name">{{ message.fileName || $lang('ai-chat.index.audioMessage') }}</text>
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
</view>
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
</view>
</view>
<view v-else 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">
<view class="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">
<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>
<!-- 文件链接 -->
<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">
<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="toggleAudio(message)">
<text class="iconfont" :class="message.playing ? 'icon-pause' : 'icon-play'"></text>
</button>
<slider
class="audio-slider"
:value="message.currentTime || 0"
:max="message.duration || 0"
@changing="onAudioSliderChange"
@change="onAudioSliderChangeEnd"
activeColor="#8a9fb8"
backgroundColor="#e8edf3"
block-size="12" />
</view>
</view>
</view>
<!-- 视频消息 -->
<view v-else-if="message.type === 'video'" class="video-content">
<view class="video-player">
<video
ref="videoRef"
:src="message.url"
:poster="message.cover"
:controls="true"
:autoplay="false"
class="video-element"
@loadedmetadata="onVideoLoaded(message, $event)"
@play="onVideoPlay(message)"
@pause="onVideoPause(message)">
</video>
<view class="video-info">
<text class="video-title">{{ message.title || $lang('ai-chat.index.videoMessage') }}</text>
<text class="video-duration">{{ formatDuration(message.duration) }}</text>
</view>
</view>
</view>
<!-- 跳转链接 -->
<view v-else-if="message.type === 'link'" class="link-content">
<view class="link-card" @click="openLink(message)">
<view class="link-image" v-if="message.image">
<image :src="message.image" mode="aspectFill" show-menu-by-longpress/>
</view>
<view class="link-info">
<text class="link-title">{{ message.title }}</text>
<text class="link-desc">{{ message.description }}</text>
<text class="link-url">{{ message.url }}</text>
</view>
</view>
</view>
<!-- 加载/处理中状态 -->
<view v-else-if="message.type === 'loading'" class="loading-content">
<view class="loading-typing">
<view class="typing-dots">
<text class="dot"></text>
<text class="dot"></text>
<text class="dot"></text>
</view>
<text class="loading-text">
<text class="loading-status">{{ message.loadingText || $lang('ai-chat.index.aiThinking') }}</text>
<text class="loading-ellipsis">
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
</text>
</text>
</view>
</view>
<!-- PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题
欢迎语气泡下方显示快捷问题 chips, 点击直接作为用户消息发送
一次性,点了之后从 message 上摘除 (避免重复点) -->
<view v-if="message.quickQuestions && message.quickQuestions.length > 0" class="quick-questions-row">
<view
v-for="(q, qi) in message.quickQuestions"
:key="qi"
class="quick-question-chip"
hover-class="quick-question-chip--hover"
@click="onQuickQuestionClick(message, q)">
<text class="quick-question-icon">💬</text>
<text class="quick-question-text">{{ getQuickQuestionText(q) }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="message-actions" v-if="message.actions && message.actions.length > 0">
<button
v-for="action in message.actions"
:key="action.id"
class="action-btn"
:class="[`iconfont`, action.icon, action.type]"
@click="handleAction(action, message)">
{{ action.text }}
</button>
</view>
</view>
<!-- 工具调用 (调试可见性) -->
<!-- PR-CHAT-UI-CONFIG-TOOL-CALLS (2026-09-20): 受 ui_show_thinking_process 控制
修复前: v-if 只判断数据存在, 关掉"显示 AI 思考过程"后 tool-calls 仍显示
修复后: 与 thought-panel 共用同一开关, "思考过程可见性"统一管理
后端不动, 仅前端加条件渲染 -->
<view v-if="message.toolCalls && message.toolCalls.length && chatUiConfig.ui_show_thinking_process" class="tool-calls">
<view v-for="tc in message.toolCalls" :key="tc.tool_call_id || tc.id" class="tool-call-item" :class="['status-' + (tc.status || 'pending')]">
<text>🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? (' (' + tc.duration_ms + 'ms)') : '' }}</text>
</view>
</view>
<!-- 附件 (图片/文件) -->
<view v-if="message.attachments && message.attachments.length" class="attachments">
<view v-for="att in message.attachments" :key="att.url" class="attachment" @click="previewAttachment(att)">
<image v-if="att.type === 'image'" :src="att.url" mode="aspectFill" class="att-image" />
<view v-else class="att-file">
<text class="file-icon">📎</text>
<text class="file-name">{{ att.name || '文件' }}</text>
</view>
</view>
</view>
<!-- Question 选项卡片 -->
<view v-if="message.pendingQuestion && !message.questionAnswered" class="question-card">
<view v-for="(q, qi) in message.pendingQuestion.questions" :key="qi" class="question-block">
<view v-if="q.header" class="question-header-chip">{{ q.header }}</view>
<view class="question-text">{{ q.question }}</view>
<view v-if="message.questionTimeout > 0" class="question-timer">⏱ {{ message.questionTimeout }}s</view>
<view class="question-options">
<view v-for="opt in q.options" :key="opt.label" class="option-btn"
:class="{ selected: q.multiSelect ? ((message.selectedAnswers && message.selectedAnswers['q'+qi]) || []).includes(opt.label) : (message.selectedAnswers && message.selectedAnswers['q'+qi] === opt.label) }"
@click="toggleOption(message, qi, q, opt)">
<text class="option-label">{{ opt.label }}</text>
<text v-if="opt.description" class="option-desc">{{ opt.description }}</text>
</view>
</view>
<view v-if="q.multiSelect && (message.selectedAnswers && (message.selectedAnswers['q'+qi] || []).length)" class="confirm-row">
<button class="confirm-btn" @click="confirmMultiSelect(message, qi)">确认 ({{ (message.selectedAnswers['q'+qi] || []).length }})</button>
</view>
</view>
</view>
<!-- AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 更多 -->
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 后端可关闭 ui_enable_feedback (隐藏点赞/点踩/更多), 默认 true -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading' && chatUiConfig.ui_enable_feedback">
<view class="ai-action-btn" @click="onAiActionCopy(message)">
<text class="iconfont icon-copy">⎘</text>
</view>
<view class="ai-action-btn" :class="{ active: message._liked }" @click="onAiActionLike(message)">
<!-- PR-FRONTEND-ACTION-SVG-ICON (2026-09-18): 点赞/点踩改用线性 SVG 矢量图, 不再用彩色 emoji -->
<view class="svg-icon icon-like"></view>
</view>
<view class="ai-action-btn" :class="{ active: message._disliked }" @click="onAiActionDislike(message)">
<view class="svg-icon icon-dislike"></view>
</view>
<view class="ai-action-btn more-btn" @click="onAiActionMore(message)">
<text class="iconfont icon-more">···</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
<view class="input-area" :style="inputAreaStyle">
<view class="input-card" :class="{ 'is-voice-mode': voiceMode, 'is-text-mode': showTextMode }">
<!-- 待发送附件预览(图片 / 文件 / 视频 / 音频): 挂在输入条内部顶端(文字行上方),
出现时输入框自动向上撑高, 删除后恢复原高度。
图片与文件走同一套 pending 逻辑: 选完先在这里预览, 等用户点发送再随文字一起发出 -->
<view v-if="pendingImages.length || pendingFiles.length" class="pending-attachment">
<view v-for="p in pendingImages" :key="p.localId" class="pending-item">
<image :src="p.uploaded ? p.uploaded.url : p.tempPath" mode="aspectFill" class="pending-image" />
<view v-if="p.uploading" class="pending-mask"><text>上传中</text></view>
<view v-else-if="p.uploadError" class="pending-mask error"><text>失败</text>
<!-- PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败时给 "删除" 按钮, 解决用户卡死 -->
<view class="pending-retry-delete" @tap.stop="removePendingImage(p.localId)">删除</view>
</view>
<view class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
</view>
<view v-for="f in pendingFiles" :key="f.localId" class="pending-item">
<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" />
<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): 输入区三态
① 默认(未输入/未聚焦): 单行胶囊 —— 语音图标 + 输入框 + 加号
② 语音模式(点语音图标进入): 「按住说话」按钮 + 底部工具栏(表情/模型/加号)
③ 文字输入(聚焦或有文字): 输入框独占一行 + 底部工具栏(语音/模型/加号/发送) -->
<!-- 语音模式: 按住说话 -->
<view
v-if="voiceMode"
class="voice-hold-btn"
:class="{ 'is-holding': voiceHolding }"
@touchstart.stop.prevent="onVoiceHoldStart"
@touchend.stop.prevent="onVoiceHoldEnd"
@touchcancel.stop.prevent="onVoiceHoldCancel"
@click.stop="onVoiceHoldEnd">
<text class="voice-hold-text">{{ voiceHolding ? '松开发送' : '按住说话' }}</text>
</view>
<view v-else 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="发消息或按住说话"
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>
<view class="model-selector" @click="onToggleModelSelector">
<!-- 圆圈 @ 图标,H5/小程序通用 -->
<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"
/>
<!-- 头像选择弹窗 -->
<ai-chat-avatar-selector
:visible.sync="showAvatarSelector"
:avatar="localUserAvatar"
@avatar-change="onAvatarChange"
/>
<!-- 昵称编辑弹窗 -->
<ai-chat-nickname-editor
:visible.sync="showNicknameEditor"
:nickname="localUserNickname"
@nickname-change="onNicknameChange"
/>
</view>
</template>
<script>
import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js'
import { uploadImage, fileToBase64 } from '@/common/js/media-upload.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: () => []
},
// 初始用户头像(外部传入,可选)
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,
inputFocused: false,
// 失焦定时器: 用于延迟收起文字输入态, 避免 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,
// 扩大编辑器相关
showExpandEditor: false,
expandInputText: '',
inputLineCount: 0,
streamingMessage: null,
streamInterval: null,
streamContent: '',
isStreaming: 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
},
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: {
aiAvatar() {
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
defaultAvatar() {
return this.$util.img(IMAGE_CONFIG.userAvatar);
},
aiAvatarUrl() {
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
chatMessagesStyle() {
// 键盘弹出时增加底部padding
return {
'padding-bottom': this.keyboardHeight > 0 ? this.keyboardHeight + 'px' : '0px'
};
},
aiChatContainerStyle() {
// 键盘高度由末尾垫高 view 处理,容器无需额外样式
return {};
},
inputAreaStyle() {
// 输入框在 flex 流中自然定位,无需额外样式
return {};
},
canSendMessage() {
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
return hasContent || hasEmoji
},
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 数组版: 至少 1 张且所有都上传成功 (无 uploading + 无 uploadError)
canSendPendingImage() {
if (!this.pendingImages.length) return false
return this.pendingImages.every(p => !p.uploading && !p.uploadError && p.uploaded)
},
// PR-FRONTEND-MEDIA-PENDING-FILE (2026-09-17): 待发送文件附件是否均可发送
canSendPendingFile() {
if (!this.pendingFiles || !this.pendingFiles.length) return false
return this.pendingFiles.every(p => !p.uploading && !p.uploadError && p.uploaded)
},
hasInputText() {
return this.inputText && this.inputText.trim() !== ''
},
// PR-FRONTEND-INPUT-3STATES (2026-09-17): 文字输入态
// 聚焦 或 已有文字时展开为"输入框一行 + 工具栏一行", 否则回到单行胶囊。
// 用聚焦(而不是只判断文字)是为了: 删除最后一个字时布局不会突然塌回胶囊。
// 另外: 有"上传完成的待发送图片/文件"时也必须展开, 否则发送按钮(在工具栏行)不可见。
showTextMode() {
return !this.voiceMode && (this.inputFocused || this.hasInputText || this.canSendPendingImage || this.canSendPendingFile)
},
// 发送按钮可用态 (无内容时按钮保留但置灰)
canSend() {
return this.hasInputText || this.canSendPendingImage || this.canSendPendingFile
}
},
// 新增:组件销毁时清除所有定时器
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;
}
},
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);
// 异步获取服务配置(含欢迎语)
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.pendingFiles = []
return
}
this.pendingFiles = this.pendingFiles.filter(p => p.localId !== localId)
},
onLocationSelected(locationMessage) {
this.messages.push(locationMessage)
this.$emit('location-selected', locationMessage)
},
// 插入 Emoji 到输入框
insertEmoji(emoji) {
this.inputText += emoji
},
/**
* 供父页面调用的加载历史方法
* 在 page onShow 中通过 ref 调用
*/
loadHistoryIfNeeded() {
const localConvId = this.getConversationIdFromLocal();
const shouldReload = this.currentConversationId !== localConvId;
console.log('[AI Chat] loadHistoryIfNeeded | localConvId:', localConvId, '| currentConvId:', this.currentConversationId, '| shouldReload:', shouldReload, '| messages.length:', this.messages.length, '| initialMessages.length:', this.initialMessages.length);
if (localConvId && shouldReload) {
console.log('[AI Chat] → 分支1: ID变化,加载历史');
this.currentConversationId = localConvId;
aiService.setConversationId(localConvId);
this.loadChatHistoryIfExist();
} else if (localConvId && !shouldReload && this.messages.length <= this.initialMessages.length) {
console.log('[AI Chat] → 分支2: ID不变但只有初始消息,从服务端恢复');
this.currentConversationId = localConvId;
this.loadChatHistoryIfExist();
} else if (!localConvId && !this.currentConversationId) {
console.log('[AI Chat] → 分支3: 全新会话');
this.historyOffset = this.messages.length;
} else {
console.log('[AI Chat] → 分支4: 跳过加载,保留当前消息, messages.length:', this.messages.length);
}
},
// 加载欢迎语:优先 API,API 失败或禁用时用父页面传入的 fallbackWelcome
async _loadChatUiConfig() {
// PR-CHAT-UI-CONFIG (2026-09-20): 拉取前端 UI 行为配置 (5 个 P0 开关)
// ai-service.getChatUiConfig() 内部已带 60s TTL + 与 getServiceConfig 共享 /info 调用
// 这里失败不影响主流程, 失败时保留 data() 里的初始默认值
try {
const cfg = await aiService.getChatUiConfig()
if (cfg && typeof cfg === 'object') {
this.chatUiConfig = cfg
// ui_available_models 同步覆盖 availableModels (UI 模型选择器用)
if (Array.isArray(cfg.ui_available_models) && cfg.ui_available_models.length) {
this.availableModels = cfg.ui_available_models.slice()
// 当前 selectedModel 已不在新列表里 → 回落 'Auto' 或第一项
if (!this.availableModels.includes(this.selectedModel)) {
this.selectedModel = this.availableModels[0] || 'Auto'
}
}
this.chatUiConfigLoaded = true
}
} catch (e) {
console.warn('[AI Chat] 加载 chatUiConfig 失败, 使用前端默认:', (e && e.message) || e)
}
},
async _loadWelcomeMessage() {
// PR-FIX (2026-09-20): 防重入 (created 异步加载期间 mounted/loadHistoryIfNeeded
// 等路径再触发会造成欢迎气泡重复 push; 网络慢时尤其容易)
if (this._welcomeLoading) return
this._welcomeLoading = true
// 仅全新会话才需要欢迎语
if (this.currentConversationId) {
console.log('[AI Chat] 已有会话,跳过欢迎语')
this._welcomeLoading = false
return
}
let welcomeText = ''
let isMarkdown = false
let welcomeLoaded = false
// PR-FIX (2026-09-20): welcomeInfo 原为 if 块内 const,第 3 步拼 quickQuestions
// 时在块外引用 → ReferenceError, 提升到函数作用域
// PR-WELCOME-INFO-CONSOLIDATE (2026-09-20): 字段名统一为 welcome, 移除 welcome_info
// 后端 KefuGetInfo 只下发 welcome 一个字段 (结构不变)
let welcomeInfo = null
// 1. 尝试从 API 获取远程欢迎语
try {
const config = await aiService.getServiceConfig()
console.log('[AI Chat] API getServiceConfig 返回:', JSON.stringify(config))
if (config) {
welcomeInfo = config.welcome
console.log('[AI Chat] welcomeInfo:', JSON.stringify(welcomeInfo))
const remoteText = welcomeInfo?.welcome || ''
const isEnabled = welcomeInfo?.is_enabled
console.log('[AI Chat] remoteText:', remoteText, 'isEnabled:', isEnabled)
if (remoteText && isEnabled !== 0 && isEnabled !== false) {
welcomeText = remoteText
isMarkdown = true
welcomeLoaded = true
console.log('[AI Chat] 使用远程欢迎语:', welcomeText.substring(0, 50))
}
}
} catch (e) {
console.warn('[AI Chat] 获取服务配置失败:', e)
}
// 2. API 未返回有效欢迎语时,使用降级方案
if (!welcomeLoaded) {
welcomeText = this.fallbackWelcome
isMarkdown = false
if (welcomeText) {
console.log('[AI Chat] 使用降级欢迎语')
} else {
console.log('[AI Chat] 无可用欢迎语')
this._welcomeLoading = false
return
}
}
// 3. 插入欢迎语(再次检查是否仍是全新会话)
if (!this.currentConversationId && this.messages.length === 0) {
const welcomeMsg = {
id: ++this.messageId,
role: 'ai',
type: isMarkdown ? 'markdown' : 'text',
content: welcomeText,
timestamp: Date.now(),
actions: [],
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 (chips)
// 后端可存 string[] 或 {id,text}[] ,前端统一归一化为 string[]
// message.quickQuestions 数组非空 → 模板渲染 chips;点击调 onQuickQuestionClick
quickQuestions: this.normalizeQuickQuestions(welcomeInfo && welcomeInfo.quick_questions)
}
this.messages.push(welcomeMsg)
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', welcomeMsg)
}
this._welcomeLoading = false
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 归一化 quick_questions 数据格式
// 后端可能存 string[] ("如何下单?" / "退换货流程")
// 也可能存 {id, text}[] ({id:'q1',text:'如何下单?'})
// PR-WELCOME-QUICK-NORMALIZE (2026-09-20): 兼容 textarea 时代的 string 格式
// 老数据 PR-WELCOME-SAVE-SANITIZE 上线前存的是 newline-separated string
// 例如 "如何下单?\n退换货流程\n价格说明",前端按 \n 切分兼容
// 后端 KefuGetInfo 已做归一化 (2026-09-20),前端是双保险
// 前端统一抽出 text,空值过滤
normalizeQuickQuestions(raw) {
if (Array.isArray(raw)) {
return raw
.map(function (q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
})
.filter(function (s) { return s && s.trim() !== '' })
}
// 兼容老数据 string 格式 (PR-WELCOME-SAVE-SANITIZE 上线前)
if (typeof raw === 'string' && raw !== '') {
return raw.split(/\r\n|\r|\n/)
.map(function (s) { return s.trim() })
.filter(function (s) { return s !== '' })
}
return []
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 渲染文本提取
getQuickQuestionText(q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 点击 chip
// 1. 把文本塞进 inputText,模拟"用户已输入"
// 2. 清空 welcome bubble 的 chips (一次性)
// 3. 调 sendMessage() 走正常 chat 流 (建 user bubble + AI 回复)
async onQuickQuestionClick(message, q) {
const text = this.getQuickQuestionText(q)
if (!text || !text.trim()) return
try {
// 一次性:点了就摘掉 chips,避免重复点
message.quickQuestions = []
// 走现有 sendMessage 流程 (inputText → 校验 → sendStream/Normal)
this.inputText = text
// 让 DOM 先更新再发,避免用户视觉上"消息凭空出现"
await this.$nextTick()
await this.sendMessage()
} catch (e) {
console.warn('[AI Chat] onQuickQuestionClick failed:', e && e.message)
}
},
// 生成消息的唯一 key(兼容非 H5 平台)
getMessageKey(message, index) {
const rawId = message.id || message.timestamp || index;
// 标准化 ID:转为字符串并替换非法字符(. 和其他非 HTML-safe 字符)
const id = String(rawId).replace(/[^a-zA-Z0-9_-]/g, '-');
return 'msg-' + id + '-' + index;
},
// 更新键盘高度
updateKeyboardHeight(height) {
this.keyboardHeight = height;
if (height > 0) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
// #ifdef H5
// H5 软键盘高度检测: 监听 visualViewport(优先) / window.resize(降级)。
// 键盘弹起时可视区域高度变小, 差值即为键盘高度, 写入 keyboardHeight 由视图垫高层顶起输入框。
_setupH5KeyboardObserver() {
try {
const vv = window.visualViewport
if (vv && typeof vv.addEventListener === 'function') {
this._h5KbOnResize = () => {
const kb = Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0))
this.updateKeyboardHeight(kb)
}
vv.addEventListener('resize', this._h5KbOnResize)
vv.addEventListener('scroll', this._h5KbOnResize)
// 初始记录一次基准高度
this._h5KbOnResize()
} else {
this._h5WinH = window.innerHeight
this._h5KbOnResize = () => {
const kb = Math.max(0, this._h5WinH - window.innerHeight)
this.updateKeyboardHeight(kb)
}
window.addEventListener('resize', this._h5KbOnResize)
}
} catch (e) { /* H5 键盘监听失败不阻断输入 */ }
},
_teardownH5KeyboardObserver() {
try {
const vv = window.visualViewport
if (vv && this._h5KbOnResize) {
vv.removeEventListener('resize', this._h5KbOnResize)
vv.removeEventListener('scroll', this._h5KbOnResize)
}
if (this._h5KbOnResize) {
window.removeEventListener('resize', this._h5KbOnResize)
}
} catch (e) { /* noop */ }
this._h5KbOnResize = null
},
// #endif
// 【新增:会话 ID 本地缓存方法】
saveConversationIdToLocal(convId) {
if (convId) {
uni.setStorageSync(CONVERSATION_KEY, convId);
}
},
getConversationIdFromLocal() {
return uni.getStorageSync(CONVERSATION_KEY) || null;
},
clearConversationIdFromLocal() {
uni.removeStorageSync(CONVERSATION_KEY);
},
// 初始化音频上下文
initAudioContext() {
// #ifdef H5
if (window.AudioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
// #endif
},
onScroll(e) {
const currentScrollTop = e.detail.scrollTop;
this.lastScrollTop = currentScrollTop;
// 防抖处理:50ms内只执行一次,避免频繁触发
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = setTimeout(() => {
// 滚动到顶部附近(<20px)、不在加载中、还有更多历史时,触发加载
if (currentScrollTop < 20 && !this.isLoadingHistory && this.hasMoreHistory) {
this.loadMoreHistory();
}
}, 50);
},
// 初始化用户头像(支持缓存持久化)
initUserAvatarData() {
// 1. 优先读取本地缓存的头像
const cachedAvatar = uni.getStorageSync('user_avatar')
if (cachedAvatar) {
this.localUserAvatar = cachedAvatar
}
// 2. 无缓存时,使用外部传入的初始头像
else if (this.initUserAvatar) {
this.localUserAvatar = this.initUserAvatar
// 缓存初始头像
uni.setStorageSync('user_avatar', this.initUserAvatar)
}
// 3. 最后使用默认头像
else {
this.localUserAvatar = this.defaultAvatar
}
},
// 新增:加载历史记录
async loadChatHistoryIfExist() {
const convId = aiService.getConversationId();
console.log('[AI Chat] loadChatHistoryIfExist 被调用, convId:', convId);
if (convId) {
try {
// 调用正确的方法,并传入会话ID
console.log('[AI Chat] 请求历史 API...');
const response = await aiService.getConversationHistory({
conversation_id: convId
});
console.log('[AI Chat] 历史 API 返回:', JSON.stringify(response).substring(0, 200));
if (!response.success || !Array.isArray(response.messages)) {
console.warn('[AI Chat] 历史记录为空或格式无效, success:', response.success, 'messages:', Array.isArray(response.messages));
return;
}
console.log('[AI Chat] 原始消息数:', response.messages.length, 'total:', response.total);
// 需要根据实际返回的 response.data.messages 来映射
this.messages = response.messages.map(msg => {
let content = '';
if (msg.role === 'user') {
content = msg.query || msg.content || (typeof msg.inputs === 'string' ? msg.inputs : JSON.stringify(msg.inputs || '')) || '';
} else {
content = msg.answer || msg.content || '';
}
// 只使用后端返回的真实时间,不使用假时间
const realTimestamp = msg.created_at || msg.create_time || msg.time;
return {
id: msg.message_id || (Date.now() + Math.random() * 10000),
role: msg.role === 'user' ? 'user' : 'ai',
type: 'text',
content: content,
timestamp: realTimestamp,
actions: []
};
}).filter(msg => msg.content.trim() !== ''); // 过滤掉内容为空的消息
console.log('[AI Chat] 过滤后消息数:', this.messages.length, '内容摘要:', this.messages.map(m => ({role: m.role, content: m.content.substring(0, 30)})));
// 根据服务端 total 判断是否还有更多历史可加载
const msgCount = this.messages.length;
this.hasMoreHistory = response.total > msgCount;
this.historyOffset = msgCount;
} catch (error) {
console.error('[AI Chat] 加载历史记录失败:', error.message, error.stack);
// 只需调用服务层的清除方法,它会处理本地存储
aiService.clearConversationId();
this.currentConversationId = null;
}
}
},
// 新增:初始化用户昵称(支持缓存持久化)
initUserNicknameData() {
// 1. 优先读取本地缓存的昵称
const cachedNickname = uni.getStorageSync('user_nickname')
if (cachedNickname) {
this.localUserNickname = cachedNickname
}
// 2. 无缓存时,使用外部传入的初始昵称
else if (this.initUserNickname) {
this.localUserNickname = this.initUserNickname
// 缓存初始昵称
uni.setStorageSync('user_nickname', this.initUserNickname)
}
// 3. 最后使用默认昵称
else {
this.localUserNickname = this.defaultNickname
}
},
// 发送消息
// PR-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-FIX (2026-09-20): 手动发送消息后清掉欢迎气泡上的快捷问题 chips
// 原来只有点击 chip 才摘除, 用户手动打字发消息后 chips 一直挂着,
// 与"引导一次性"的语义不符
this.messages.forEach(msg => {
if (msg.quickQuestions && msg.quickQuestions.length) msg.quickQuestions = []
})
// 待发送文件/视频/音频附件 (与图片一并带出给后端)
const fileAttachments = pendingFilesList
.filter(p => p.uploaded)
.map(p => ({
// 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
}))
// 输入框待发送图片: 全部 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]
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now()
const queryText = hasContent
? this.inputText.trim()
: (readyAttachments.length ? '[附件]' : '')
// 带图片时消息类型为 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',
})),
]
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()
}
: {
id: ++this.messageId,
role: 'user',
type: 'text',
content: queryText,
// 纯文件(无图)时把文件挂到 attachments, 用户消息气泡下方展示文件卡片
attachments: displayAttachments,
timestamp: Date.now()
}
this.shouldScrollToBottom = true
this.messages.push(userMessage)
this.inputText = ''
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
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content || (readyAttachments.length ? '[图片]' : ''))
if (this.enableStreaming) {
await this.sendStreamMessage(enhancedMessage, readyAttachments)
} else {
await this.sendNormalMessage(enhancedMessage, readyAttachments)
}
} catch (error) {
console.error('AI服务调用失败:', error)
// 移除加载状态
this._removeLoadingMessages()
// 显示错误消息
const errorMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceUnavailable'),
timestamp: Date.now(),
actions: [
{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }
]
}
this.shouldScrollToBottom = true
this.messages.push(errorMessage)
this.$emit('ai-response', errorMessage)
}
// 触发消息发送事件
this.$emit('message-sent', userMessage)
},
// 发送普通消息
async sendNormalMessage(userMessage, attachments = []) {
const response = await aiService.sendMessage(userMessage, {
conversationId: this.currentConversationId,
stream: false,
attachments
})
// 移除加载状态
this._removeLoadingMessages()
// 更新会话ID
if (response.conversationId) {
this.currentConversationId = response.conversationId;
aiService.setConversationId(response.conversationId);
this.saveConversationIdToLocal(response.conversationId);
}
// 普通响应也可能在 result.thoughts 里带思考过程 (如 aikefu 阻塞接口)
const fallbackThoughts = response.thoughts || ''
const aiMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: response.content,
timestamp: Date.now(),
conversationId: response.conversationId,
messageId: response.messageId,
thoughtsText: fallbackThoughts,
thoughtSteps: fallbackThoughts ? (fallbackThoughts.split(/\n+/).filter(s => s.trim()).length || 1) : 0,
tokens: response.tokens || null,
// 耗时(秒): 用户发消息到 AI 回复完成的耗时
elapsed: this._aiRequestStart > 0 ? (Date.now() - this._aiRequestStart) / 1000 : 0,
_finished: true,
actions: []
}
this.shouldScrollToBottom = true
this.messages.push(aiMessage)
this.$emit('ai-response', aiMessage)
},
// 发送流式消息(自动适配 H5 / 微信小程序), 支持思考流 / 选项 / 工具调用 / 附件
async sendStreamMessage(userMessage, attachments = []) {
// 标记进入流式接收, 禁止重复发送, 同时让思考面板处于"思考中"展开状态
this.isStreaming = true
// 找到已有的 loading 消息,复用它在 DOM 中的位置,实现无缝过渡
const loadingIdx = this.messages.findIndex(msg => msg.type === 'loading')
let targetMsg
if (loadingIdx === -1) {
// 安全回退:没有 loading 消息时创建新消息
targetMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: '',
timestamp: Date.now(),
isStreaming: true,
thoughtsText: '',
toolCalls: [],
pendingQuestion: null,
questionAnswered: false,
tokens: null,
thoughtExpanded: true,
selectedAnswers: {},
questionTimeout: 0
}
this.messages.push(targetMsg)
this.scrollToBottom()
} else {
targetMsg = this.messages[loadingIdx]
this._stopLoadingTextRotation(targetMsg)
// 固定文案, 不走 $lang: 远端语言覆写会覆盖本地 lang 文件, 导致改了 lang/zh-cn 也不生效
targetMsg.loadingText = '思考中'
// 进入流式接收状态: 让思考面板在收到 thought 事件时自动展开并显示"思考中..."
this.$set(targetMsg, 'isStreaming', true)
if (!targetMsg.thoughtsText) this.$set(targetMsg, 'thoughtsText', '')
if (!targetMsg.toolCalls) this.$set(targetMsg, 'toolCalls', [])
if (!targetMsg.selectedAnswers) this.$set(targetMsg, 'selectedAnswers', {})
}
const findMsg = () => this.messages.find(m => m.id === targetMsg.id)
const onEvent = (event, data) => {
const msg = findMsg()
if (!msg) return
switch (event) {
case 'message': {
// 首次收到正文: 把 loading 转成正式文本消息
if (msg.type === 'loading') {
msg.type = 'text'
msg.content = ''
this.$set(msg, 'isStreaming', true)
this.$delete(msg, 'loadingText')
this.$delete(msg, '_loadingIndex')
}
msg.content = (msg.content || '') + (data.content || '')
this.scrollToBottom()
break
}
case 'thought':
this.$set(msg, 'thoughtsText', (msg.thoughtsText || '') + (data.content || ''))
// 文档 §1.4: "思考过程 (N 步)" 折叠标签需要的步数计数
this.$set(msg, 'thoughtSteps', (msg.thoughtSteps || 0) + 1)
this.scrollToBottom()
break
case 'tool_call': {
if (!msg.toolCalls) this.$set(msg, 'toolCalls', [])
const list = msg.toolCalls
const tcId = data.tool_call_id || data.id
const idx = list.findIndex(t => t.tool_call_id === tcId)
if (idx >= 0) this.$set(list, idx, Object.assign({}, list[idx], data))
else list.push(data)
break
}
case 'question':
this.$set(msg, 'pendingQuestion', data.pending_question)
this.$set(msg, 'questionAnswered', false)
this.$set(msg, 'rpc_id', data.pending_question && data.pending_question.rpc_id)
this.$set(msg, 'questionMessageId', data.message_id)
this.$set(msg, 'questionConversationId', data.conversation_id)
this.$set(msg, 'selectedAnswers', {})
this.startQuestionTimer(msg)
break
case 'message_end': {
msg.isStreaming = false
this.isStreaming = false
if (data.content) msg.content = data.content
if (data.thoughts) {
this.$set(msg, 'thoughtsText', data.thoughts)
// 如果后端只在 message_end 一次返回完整 thoughts(如 HTTP 降级/aiagent HTTP),
// 按段落数反推步数, 让"思考过程 (N 步)"标签更准确
if (!msg.thoughtSteps || msg.thoughtSteps <= 1) {
const steps = data.thoughts.split(/\n+/).filter(s => s.trim()).length || 1
this.$set(msg, 'thoughtSteps', steps)
}
}
if (data.tool_calls && data.tool_calls.length) this.$set(msg, 'toolCalls', data.tool_calls)
if (data.tokens) this.$set(msg, 'tokens', data.tokens)
if (data.phase) this.$set(msg, 'phase', data.phase)
if (data.stop_reason === 'pending_stale') {
this.$set(msg, 'questionAnswered', true)
this.$set(msg, 'questionTimedOut', true)
}
if (data.conversation_id) {
this.currentConversationId = data.conversation_id
aiService.setConversationId(data.conversation_id)
this.saveConversationIdToLocal(data.conversation_id)
}
// 纯思考/工具无正文时, 保留思考面板; 完全空则移除
if (!msg.content || !msg.content.trim()) {
if (!msg.thoughtsText) {
const i = this.messages.findIndex(m => m.id === msg.id)
if (i >= 0) this.messages.splice(i, 1)
}
}
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
break
}
case 'error':
msg.isStreaming = false
this.isStreaming = false
msg.content = (msg.content || '') + '\n[错误] ' + (data.message || '未知错误')
break
case 'attachments':
if (!msg.attachments) this.$set(msg, 'attachments', [])
msg.attachments.push(...(data || []))
break
}
}
const onComplete = (result) => {
const msg = findMsg()
if (!msg) return
if (msg.isStreaming === false) return // 已由 message_end 处理
if (result && result.conversation_id) {
this.currentConversationId = result.conversation_id
aiService.setConversationId(result.conversation_id)
this.saveConversationIdToLocal(result.conversation_id)
}
if (!msg.content) msg.content = (result && result.content) || ''
msg.isStreaming = false
this.isStreaming = false
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
}
try {
// #ifdef H5
await aiService.sendHttpStream(userMessage, onEvent, onComplete, { attachments })
// #endif
// #ifdef MP-WEIXIN
await aiService.sendStreamMessage(userMessage, onEvent, onComplete, { attachments })
// #endif
} catch (error) {
console.error('流式请求失败:', error)
this.isStreaming = false
const msg = findMsg()
if (msg && msg.type === 'loading') {
this._removeLoadingMessages()
} else if (msg && (!msg.content || !msg.content.trim()) && !msg.thoughtsText) {
const i = this.messages.findIndex(m => m.id === msg.id)
if (i >= 0) this.messages.splice(i, 1)
}
const errorMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceTemporaryUnavailable'),
timestamp: Date.now(),
actions: [{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
}
},
// 思考流面板: 点击折叠/展开 (流式进行中不可收起)
toggleThought(msg) {
if (!msg.isStreaming) this.$set(msg, 'thoughtExpanded', !msg.thoughtExpanded)
},
// Question 选项: 单选立即提交, 多选切换
toggleOption(msg, qi, q, opt) {
if (!msg.selectedAnswers) this.$set(msg, 'selectedAnswers', {})
const key = 'q' + qi
if (q.multiSelect) {
const arr = msg.selectedAnswers[key] || []
const idx = arr.indexOf(opt.label)
if (idx >= 0) arr.splice(idx, 1)
else arr.push(opt.label)
this.$set(msg.selectedAnswers, key, arr)
} else {
this.$set(msg.selectedAnswers, key, opt.label)
this.submitQuestionAnswer(msg)
}
},
confirmMultiSelect(msg) {
this.submitQuestionAnswer(msg)
},
async submitQuestionAnswer(msg) {
if (!msg.rpc_id) {
uni.showToast({ title: 'rpc_id 缺失, 无法回传', icon: 'none' })
return
}
// PR-FA-PRE (2026-09-18): 仅在 elicitResponse 真返回 true 时才置 questionAnswered=true
// 老逻辑先置 true 再 await,后端返回 false (H5 后端未就绪) 时也已被 UI 标记为已答,
// 用户看不到正确答案的继续流 → 体感"假性中断"。修:成功后才标记;失败提示并保留未答状态
// 由 30s pending_stale 兜底超时。
this.$set(msg, 'questionTimedOut', false)
if (msg.id && this.questionTimers[msg.id]) {
clearInterval(this.questionTimers[msg.id])
this.$delete(this.questionTimers, msg.id)
}
let ok = false
try {
ok = await aiService.elicitResponse({
conversation_id: msg.questionConversationId || aiService.getConversationId(),
message_id: msg.questionMessageId,
tool_call_id: msg.pendingQuestion && msg.pendingQuestion.tool_call_id,
rpc_id: msg.rpc_id,
answers: msg.selectedAnswers
})
} catch (e) {
ok = false
uni.showToast({ title: '选项提交失败: ' + (e.message || ''), icon: 'none' })
}
if (ok) {
this.$set(msg, 'questionAnswered', true)
// 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理
} else {
// H5 后端暂未就绪 或 网络失败:保留 questionAnswered=false,30s 由 pending_stale 兜底
uni.showToast({ title: '选项已展示,等待后端确认…', icon: 'none' })
}
},
startQuestionTimer(msg) {
this.$set(msg, 'questionTimeout', 30)
if (!this.questionTimers) this.questionTimers = {}
if (this.questionTimers[msg.id]) clearInterval(this.questionTimers[msg.id])
this.questionTimers[msg.id] = setInterval(() => {
if (!msg.pendingQuestion || msg.questionAnswered) {
if (this.questionTimers[msg.id]) {
clearInterval(this.questionTimers[msg.id])
this.$delete(this.questionTimers, msg.id)
}
return
}
msg.questionTimeout -= 1
if (msg.questionTimeout <= 0) {
if (this.questionTimers[msg.id]) {
clearInterval(this.questionTimers[msg.id])
this.$delete(this.questionTimers, msg.id)
}
// 超时: 自动选第一个选项, 触发后端 pending_stale
const firstQ = msg.pendingQuestion.questions[0]
if (firstQ && firstQ.options[0]) {
this.$set(msg, 'selectedAnswers', { q0: firstQ.options[0].label })
this.$set(msg, 'questionTimedOut', true)
this.submitQuestionAnswer(msg)
}
}
}, 1000)
},
// PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 取出用户消息里所有图片 (按发送顺序)
// 优先用 attachments (多图批量发送), 只有单张时退回 imageUrl
messageImages(message) {
const list = (message.attachments || []).filter(a => !a.kind || a.kind === 'image' || a.type === 'image')
if (list.length) return list
return message.imageUrl ? [{ url: message.imageUrl }] : []
},
// 点击用户图片: 在全部图片中预览 (左右可滑动切换)
previewUserImage(message, index) {
const urls = this.messageImages(message).map(img => img.url).filter(Boolean)
if (!urls.length) return
uni.previewImage({ urls, current: urls[index] || urls[0] })
},
previewAttachment(att) {
if (att.type === 'image') {
uni.previewImage({ urls: [att.url], current: att.url })
} else if (att.type === 'video') {
uni.navigateTo({ url: '/pages_tool/file-preview/file-preview?url=' + encodeURIComponent(att.url) + '&fileType=video' })
} else if (att.type === 'audio') {
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): AI 侧音频附件 — 跳到 file-preview 复用音频播放器
// file-preview.vue 加了 audio fileType 分支, 用 createInnerAudioContext 播放
uni.navigateTo({
url: '/pages_tool/file-preview/file-preview?url=' + encodeURIComponent(att.url)
+ '&fileType=audio'
+ (att.name ? '&fileName=' + encodeURIComponent(att.name) : '')
})
} else {
// #ifdef H5
window.open(att.url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.downloadFile({
url: att.url,
success: (res) => {
if (res.statusCode === 200) {
uni.openDocument({ filePath: res.tempFilePath, fail: () => uni.showToast({ title: '暂不支持预览该文件', icon: 'none' }) })
}
},
fail: () => uni.showToast({ title: '文件打开失败', icon: 'none' })
})
// #endif
}
},
// 新对话: 清后端会话 + 清前端
async onNewChat() {
const cid = aiService.getConversationId()
if (cid) {
try {
await aiService.clearConversation(cid)
} catch (e) {
console.warn('[AI Chat] clearConversation 后端失败, 继续清前端:', e)
}
}
this.clearMessages()
uni.showToast({ title: '已开始新对话', icon: 'success' })
},
// 完成流式消息处理(共用逻辑)
_finishStream(msg, result) {
msg.isStreaming = false
this.isStreaming = false
if (!msg.content || msg.content.trim() === '') {
this._removeLoadingMessages()
return false
}
if (result) {
const convId = typeof result === 'string' ? '' : (result.conversation_id || '')
if (convId) {
this.currentConversationId = convId
aiService.setConversationId(convId)
this.saveConversationIdToLocal(convId)
}
}
msg.actions = []
// 计算并写入耗时 (秒), 用于状态行 "已完成 XmXXs"
if (this._aiRequestStart > 0) {
msg.elapsed = (Date.now() - this._aiRequestStart) / 1000
msg._finished = true
}
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
return true
},
// 流式请求出错处理
_handleStreamError(msg) {
console.error('流式请求失败')
this.isStreaming = false
this.messages = this.messages.filter(m => m.id !== msg.id)
const errorMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceTemporaryUnavailable'),
timestamp: Date.now(),
actions: [{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
},
// 加载状态文字轮换 - 让AI看起来在处理不同阶段
_startLoadingTextRotation(loadingMsg) {
const statusTexts = [
this.$lang('ai-chat.index.aiThinkingStatus1'),
this.$lang('ai-chat.index.aiThinkingStatus2'),
this.$lang('ai-chat.index.aiThinkingStatus3'),
this.$lang('ai-chat.index.aiThinkingStatus4')
]
loadingMsg._rotationTimer = setInterval(() => {
loadingMsg._loadingIndex = (loadingMsg._loadingIndex + 1) % statusTexts.length
loadingMsg.loadingText = statusTexts[loadingMsg._loadingIndex]
}, 2000)
},
// 停止加载状态文字轮换
_stopLoadingTextRotation(loadingMsg) {
if (loadingMsg && loadingMsg._rotationTimer) {
clearInterval(loadingMsg._rotationTimer)
loadingMsg._rotationTimer = null
}
},
// 移除加载消息并清理定时器
_removeLoadingMessages() {
const loadingMsgs = this.messages.filter(msg => msg.type === 'loading');
loadingMsgs.forEach(msg => this._stopLoadingTextRotation(msg));
this.messages = this.messages.filter(msg => msg.type !== 'loading');
},
generateAIResponse(userMessage) {
const responses = {
'你好': this.$lang('ai-chat.index.autoReplyGreeting'),
'价格': this.$lang('ai-chat.index.autoReplyPrice'),
'发货': this.$lang('ai-chat.index.autoReplyShipping'),
'退货': this.$lang('ai-chat.index.autoReplyReturn'),
'客服': this.$lang('ai-chat.index.autoReplyService')
};
for (let key in responses) {
if (userMessage.includes(key)) {
return responses[key];
}
}
return this.$lang('ai-chat.index.autoReplyDefault', { question: userMessage });
},
// 增强版Markdown解析(无第三方依赖)
parseMarkdown(content) {
if (!content) return ''
// 兜底: 后端流式响应有时会把每个字用 \n 拼接 ("你\n好\n!"), 导致最终渲染一字一行.
// 检测"每行 1 个字符"的比例, 若超过 70% 则认为是退化格式, 先合并成连续字符串再走 markdown.
try {
const rawLines = String(content).split('\n')
if (rawLines.length >= 5) {
const singleCharLines = rawLines.filter(l => l.trim().length === 1).length
if (singleCharLines / rawLines.length > 0.7) {
content = rawLines.join('').replace(/\s+/g, '')
}
}
} catch (e) { /* 忽略, 走原始 content */ }
// 主文字色 / 链接色 (偏豆包风格的近黑 + 蓝)
const TEXT = '#1a1a1a'
const LINK = '#3a7afe'
// 微信小程序端: 回答更紧凑 (行高/段距收紧, 不改变颜色与字号)
let COMPACT = false
/* #ifdef MP-WEIXIN */
COMPACT = true
/* #endif */
const LH = COMPACT ? '1.6' : '1.75'
const LI_LH = COMPACT ? '1.55' : '1.75'
const P_MARGIN = COMPACT ? '1rpx 0' : '2rpx 0'
const HR_MARGIN = COMPACT ? '10rpx 0' : '16rpx 0'
const BQ_MARGIN = COMPACT ? '6rpx 0' : '8rpx 0'
const BQ_LH = COMPACT ? '1.6' : '1.7'
// 辅助:内联格式处理
// 思路: 先把"链接/图片/裸URL/电话"整体抽成占位符保护起来, 再做加粗/斜体等,
// 最后还原, 避免各规则互相串改对方的 href / 数字。
const parseInline = (text) => {
const stash = []
const hold = (html) => { stash.push(html); return '\u0000' + (stash.length - 1) + '\u0000' }
let t = text
// 1) 已有的 markdown 链接 / 图片先抽出
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (m) => hold(m))
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m) => hold(m))
// 2) 裸 URL(含 www.) -> 蓝色链接, 并抽离保护
.replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi, (m) => {
let url = m
// 去掉结尾可能被一并匹配的中英文标点
const trail = url.match(/[,。、!?;:,!?;:'")\]】》」』]+$/)
if (trail) url = url.slice(0, url.length - trail[0].length)
const href = /^https?:\/\//i.test(url) ? url : 'https://' + url
return hold('<a href="' + href + '" style="color:' + LINK + ';text-decoration:none;">' + url + '</a>')
})
// 3) 手机号 / 400 电话 -> 蓝色链接, 抽离保护
.replace(/\b(1[3-9]\d{9})\b/g, (m) => hold('<a href="tel:' + m + '" style="color:' + LINK + ';text-decoration:none;">' + m + '</a>'))
.replace(/\b(400[\d-]{7,10})\b/g, (m) => hold('<a href="tel:' + m + '" style="color:' + LINK + ';text-decoration:none;">' + m + '</a>'))
// 4) 还原抽出的 markdown, 转成图片 / 蓝色锚点
t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => {
const orig = stash[Number(i)]
if (orig.charAt(0) === '!') {
return orig.replace(/!\[([^\]]*)\]\(([^)]+)\)/, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8rpx;margin:8rpx 0;display:block;" />')
}
return orig.replace(/\[([^\]]+)\]\(([^)]+)\)/, '<a href="$2" style="color:' + LINK + ';text-decoration:none;">$1</a>')
})
// 5) 行内代码 / 加粗 / 斜体 / 删除线 (此时链接已抽离, 不会被误改)
t = t
.replace(/`([^`]+)`/g, '<code style="background:#f2f3f5;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:#d63384;">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong style="font-weight:700;color:' + TEXT + ';">$1</strong>')
.replace(/\*(.+?)\*/g, '<em style="font-style:italic;">$1</em>')
.replace(/~~(.+?)~~/g, '<del style="text-decoration:line-through;color:#999;">$1</del>')
return t
}
// 1. 转义HTML特殊字符
let html = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 2. 提取代码块(保护其内容不被后续处理污染)
const codeBlocks = []
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, lang, code) => {
const idx = codeBlocks.length
const safe = code.replace(/</g, '&lt;').replace(/>/g, '&gt;')
codeBlocks.push(
`<pre style="background:#f7f8fa;padding:20rpx;border-radius:8rpx;overflow-x:auto;font-size:24rpx;line-height:1.6;margin:12rpx 0;white-space:pre-wrap;">` +
`<code style="background:transparent;padding:0;font-family:monospace;color:#333;font-size:24rpx;">${safe}</code></pre>`
)
return `%%CODEBLOCK_${idx}%%`
})
// 3. 按行处理,用 <br> 连接单换行,双换行分割段落块
const lines = html.split('\n')
const out = []
let inList = false
let buf = [] // 普通文本行缓冲区
let inTable = false
let tableRows = [] // 表格行缓冲区
const closeList = () => {
if (inList === 'ul') { inList = false; return '</ul>' }
if (inList === 'ol') { inList = false; return '</ol>' }
return ''
}
const flushBuf = () => {
if (buf.length === 0) return
// 去掉首尾的空串,避免产生尾随 <br>
while (buf.length > 0 && buf[0] === '') buf.shift()
while (buf.length > 0 && buf[buf.length - 1] === '') buf.pop()
if (buf.length === 0) return
const content = buf.map(l => parseInline(l)).join('<br>')
out.push(`<div style="line-height:${LH};font-size:30rpx;color:${TEXT};margin:${P_MARGIN};">${content}</div>`)
buf = []
}
const flushTable = () => {
if (!inTable || tableRows.length === 0) return
const thead = tableRows[0].map(c =>
`<th style="padding:8rpx 12rpx;border:1rpx solid #eaecef;font-weight:600;background:#f6f8fa;text-align:left;font-size:24rpx;color:${TEXT};">${parseInline(c)}</th>`
).join('')
const tbody = tableRows.slice(1).map(row =>
'<tr>' + row.map(c =>
`<td style="padding:8rpx 12rpx;border:1rpx solid #eaecef;line-height:1.6;font-size:24rpx;color:${TEXT};">${parseInline(c)}</td>`
).join('') + '</tr>'
).join('')
out.push(
`<table style="border-collapse:collapse;margin:12rpx 0;width:100%;font-size:24rpx;color:${TEXT};">` +
`<thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`
)
tableRows = []
inTable = false
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmed = line.trim()
// 代码块占位符
if (/^%%CODEBLOCK_\d+%%$/.test(trimmed)) {
flushBuf(); flushTable(); out.push(closeList())
out.push(line)
continue
}
// 表格行(以 | 开头)
if (trimmed.charAt(0) === '|') {
flushBuf(); out.push(closeList())
// 去除首尾空 cell
const rawCells = trimmed.split('|').filter((c, j, a) => j > 0 && j < a.length - 1)
if (rawCells.length === 0) continue
// 跳过分隔行 | --- | :--- | :---: | ---: |
const isSep = rawCells.every(c => /^[\s:\-|]+$/.test(c.trim()))
if (isSep) continue
if (!inTable) { inTable = true; tableRows = [] }
tableRows.push(rawCells.map(c => c.trim()))
continue
}
// 非表格行 → 刷新已积累的表格
if (inTable) flushTable()
// 标题
const hMatch = line.match(/^(#{1,6})\s+(.+)$/)
if (hMatch) {
flushBuf(); out.push(closeList())
const lv = hMatch[1].length
const size = ['', '36rpx', '32rpx', '30rpx', '28rpx', '26rpx', '24rpx'][lv]
const wgt = ['', '700', '700', '600', '600', '500', '500'][lv]
const mg = COMPACT ? ['', '14rpx 0 7rpx', '12rpx 0 6rpx', '10rpx 0 5rpx', '8rpx 0 5rpx', '7rpx 0 3rpx', '6rpx 0 3rpx'][lv] : ['', '20rpx 0 10rpx', '16rpx 0 8rpx', '14rpx 0 6rpx', '12rpx 0 6rpx', '10rpx 0 4rpx', '8rpx 0 4rpx'][lv]
out.push(`<h${lv} style="font-size:${size};font-weight:${wgt};margin:${mg};line-height:1.35;color:${TEXT};">${parseInline(hMatch[2])}</h${lv}>`)
continue
}
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
flushBuf(); out.push(closeList())
out.push('<hr style="border:none;border-top:2rpx solid #eaecef;margin:${HR_MARGIN};">')
continue
}
// 引用
const bqMatch = line.match(/^>\s?(.+)$/)
if (bqMatch) {
flushBuf(); out.push(closeList())
out.push(`<blockquote style="border-left:4rpx solid #d0d7de;padding:8rpx 16rpx;margin:${BQ_MARGIN};background:#f6f8fa;color:#57606a;line-height:${BQ_LH};font-size:28rpx;border-radius:0 6rpx 6rpx 0;">${parseInline(bqMatch[1])}</blockquote>`)
continue
}
// 无序列表
const ulMatch = line.match(/^[-*+]\s+(.+)$/)
if (ulMatch) {
flushBuf()
if (!inList) { out.push('<ul style="padding-left:30rpx;margin:8rpx 0;color:' + TEXT + ';">'); inList = 'ul' }
out.push(`<li style="margin:5rpx 0;line-height:${LI_LH};font-size:30rpx;">${parseInline(ulMatch[1])}</li>`)
continue
}
// 有序列表
const olMatch = line.match(/^\d+\.\s+(.+)$/)
if (olMatch) {
flushBuf()
if (!inList) { out.push('<ol style="padding-left:30rpx;margin:8rpx 0;color:' + TEXT + ';">'); inList = 'ol' }
out.push(`<li style="margin:5rpx 0;line-height:${LI_LH};font-size:30rpx;">${parseInline(olMatch[1])}</li>`)
continue
}
// 空行 → 加入缓冲区作段落分隔(<br><br>),同时关闭列表
if (trimmed === '') {
// 关闭列表(标准Markdown行为)
if (inList) {
flushBuf()
out.push(closeList())
}
// 避免连续空行推入多个空串
if (buf.length > 0 && buf[buf.length - 1] !== '') {
buf.push('')
}
continue
}
// 普通文本行 => 加入缓冲区
buf.push(line)
}
// 扫尾
flushBuf(); flushTable(); out.push(closeList())
html = out.join('')
// 4. 还原代码块
html = html.replace(/%%CODEBLOCK_(\d+)%%/g, (m, idx) => codeBlocks[parseInt(idx)])
return html
},
// 格式化时间(人性化显示)
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}`;
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}`;
},
// 格式化文件大小
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')
},
// 格式化 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
scrollToBottom() {
if (this.messages.length === 0) return
// 取真实最后一条消息(包括空内容的流式消息)
const lastMsg = this.messages[this.messages.length - 1]
const lastIndex = this.messages.length - 1
const key = lastMsg.id || lastMsg.timestamp || lastIndex
// 先清空再设置,确保 scroll-into-view 重新触发
this.scrollIntoViewId = ''
this.$nextTick(() => {
this.scrollIntoViewId = 'msg-' + key + '-' + lastIndex
})
},
// 加载更多历史消息
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
}
this.toggleAudio(message)
},
// 切换音频播放
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
if (this.innerAudioCtx.duration && !isNaN(this.innerAudioCtx.duration)) {
message.duration = this.innerAudioCtx.duration
}
})
this.innerAudioCtx.onEnded(() => {
message.playing = false
message.currentTime = 0
this.currentAudio = null
})
this.innerAudioCtx.onError((e) => {
message.playing = false
uni.showToast({ title: '音频播放失败', icon: 'none' })
console.error('[AI audio] play error:', e)
})
} catch (e) {
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' })
console.error('[AI audio] createInnerAudioContext failed:', e)
return
}
}
// seek 到上次位置 (暂停后继续播放)
if (message.currentTime && message.currentTime > 0 && this.innerAudioCtx) {
try { this.innerAudioCtx.seek(message.currentTime) } catch (e) { /* ignore */ }
}
message.playing = true
try {
this.innerAudioCtx.play()
} catch (e) {
message.playing = false
uni.showToast({ title: '音频播放失败', icon: 'none' })
return
}
this.currentAudio = message
this.$emit('audio-play', message)
},
// 暂停音频
pauseAudio(message) {
if (this.innerAudioCtx) {
try { this.innerAudioCtx.pause(); } catch (e) { /* ignore */ }
}
if (message) message.playing = false
this.$emit('audio-pause', message)
},
// 音频滑块变化
onAudioSliderChange(e) {
if (this.currentAudio) {
this.currentAudio.currentTime = e.detail.value
}
},
onAudioSliderChangeEnd(e) {
if (this.currentAudio) {
this.currentAudio.currentTime = e.detail.value
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 同步 seek 到 innerAudioCtx
if (this.innerAudioCtx) {
try { this.innerAudioCtx.seek(e.detail.value) } catch (e) { /* ignore */ }
}
this.$emit('audio-seek', this.currentAudio)
}
},
// 视频加载完成事件
onVideoLoaded(message, event) {
this.$nextTick(() => {
let width = 0;
let height = 0;
// H5端:通过video元素获取真实视频宽高
// #ifdef H5
const video = this.$refs.videoRef;
if (video) {
width = video.videoWidth || 0;
height = video.videoHeight || 0;
}
// #endif
// 微信小程序端:通过事件对象获取视频宽高
// #ifdef MP-WEIXIN
if (event && event.detail) {
width = event.detail.width || 0;
height = event.detail.height || 0;
}
// #endif
if (width && height) {
// 判断视频方向:宽度 >= 高度为横屏,否则为竖屏
const videoRatio = width >= height ? 'landscape' : 'portrait';
// 动态设置视频样式
// 这里可以添加逻辑来动态调整视频播放器的样式
// 例如:message.videoRatio = videoRatio;
// 然后在模板中根据message.videoRatio调整样式
}
});
},
// 视频播放
onVideoPlay(message) {
this.$emit('video-play', message)
},
// 视频暂停
onVideoPause(message) {
this.$emit('video-pause', message)
},
// 打开链接
openLink(message) {
// #ifdef H5
window.open(message.url, '_blank')
// #endif
// #ifdef APP-PLUS
plus.runtime.openURL(message.url)
// #endif
this.$emit('link-open', message)
},
// 处理操作
handleAction(action, message) {
// 处理重试操作
if (action.type === 'retry') {
this.retryMessage(message)
return
}
this.$emit('action-click', { action, message })
},
// ====== AI 消息下方操作栏 (复制 / 点赞 / 不喜欢 / 朗读 / 更多) ======
// 复制 AI 回复
onAiActionCopy(message) {
this.copyMessage(message)
},
// 点赞 (toggle, 与不喜欢互斥)
onAiActionLike(message) {
try {
this.$set(message, '_liked', !message._liked)
if (message._liked) this.$set(message, '_disliked', false)
uni.showToast({ title: message._liked ? '已点赞' : '已取消点赞', icon: 'none', duration: 1200 })
} catch (e) { /* noop */ }
},
// 不喜欢 (toggle, 与点赞互斥)
onAiActionDislike(message) {
try {
this.$set(message, '_disliked', !message._disliked)
if (message._disliked) this.$set(message, '_liked', false)
uni.showToast({ title: message._disliked ? '已反馈' : '已取消反馈', icon: 'none', duration: 1200 })
} catch (e) { /* noop */ }
},
// 朗读 / 停止朗读 (用 uni.createInnerAudioContext 简单播放, 占位实现)
onAiActionSpeak(message) {
try {
const text = this._stripMarkdown(message && (message.content || ''))
if (!text) {
uni.showToast({ title: '暂无可朗读内容', icon: 'none' })
return
}
if (this._ttsPlaying && this._ttsCtx) {
// 暂停/停止
try { this._ttsCtx.stop() } catch (e) {}
this._ttsPlaying = false
if (this._ttsMsg === message) this.$set(message, '_playing', false)
return
}
// #ifdef MP-WEIXIN
// 微信小程序优先用 wx.createInnerAudioContext + 走 TTS, 这里给个最小占位
try {
this._ttsCtx = uni.createInnerAudioContext()
this._ttsCtx.onError(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
uni.showToast({ title: '朗读失败', icon: 'none' })
})
// 真实项目应接入 TTS 服务 (微信同声传译插件 / 第三方 API)
// 此处仅占位, 让 _playing 状态可切换
this._ttsPlaying = true
this._ttsMsg = message
this.$set(message, '_playing', true)
setTimeout(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
}, 1500)
} catch (e) { /* noop */ }
// #endif
// 非微信环境: 仅切换状态, 不实际朗读
// #ifndef MP-WEIXIN
this._ttsPlaying = true
this._ttsMsg = message
this.$set(message, '_playing', true)
setTimeout(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
}, 1500)
// #endif
} catch (e) { /* noop */ }
},
// 更多菜单: 只保留"复制" (uni.showActionSheet 自带底部"取消"按钮, 无需额外添加)
onAiActionMore(message) {
try {
uni.showActionSheet({
itemList: ['复制'],
success: (res) => {
if (res.tapIndex === 0) {
this.copyMessage(message)
}
},
fail: () => { /* 用户取消 */ }
})
} catch (e) { /* noop */ }
},
// 去掉 markdown 标记, 用于 TTS 朗读
_stripMarkdown(text) {
if (!text) return ''
return String(text)
.replace(/```[\s\S]*?```/g, '')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/[ \t]+/g, ' ')
.trim()
},
// 复制消息内容到剪贴板(兼容 H5 / 微信小程序)
copyMessage(message) {
let copyText = message.content || ''
if (!copyText && message.type === 'text') {
copyText = message.content || ''
}
if (!copyText) {
uni.showToast({ title: this.$lang('ai-chat.index.noContentToCopy'), icon: 'none' })
return
}
// 由"选取文字复制"弹窗传入的已选范围,直接按传入内容复制,不再读取浏览器选区
const isSelected = !!message._selected
// #ifdef H5
// H5:检测是否有局部文本被选中,有则复制选区文本,无则复制全文
let finalText = copyText
let selectedText = ''
if (!isSelected) {
try {
const selection = window.getSelection()
selectedText = selection && selection.toString().trim()
finalText = selectedText || copyText
} catch (e) { /* noop */ }
} else {
selectedText = copyText
}
// H5 优先使用 Clipboard API,降级使用 document.execCommand
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(finalText).then(() => {
uni.showToast({ title: selectedText ? this.$lang('ai-chat.index.copySelected', '已复制选中内容') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
}).catch(() => {
this._fallbackCopyH5(finalText)
})
} else {
this._fallbackCopyH5(finalText)
}
} catch (e) {
this._fallbackCopyH5(finalText)
}
// #endif
// #ifdef MP-WEIXIN
// 微信小程序专用(含小米等 Android 兼容)
console.log('[AI Chat] 开始复制, 内容长度:', copyText.length, '内容前50字:', copyText.substring(0, 50))
uni.setClipboardData({
data: copyText,
success: () => {
console.log('[AI Chat] 复制成功')
uni.showToast({ title: isSelected ? this.$lang('ai-chat.index.copySelected', '已复制选中内容') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
},
fail: (err) => {
console.error('[AI Chat] 复制失败, 详细错误:', JSON.stringify(err))
// 剪贴板隐私声明缺失(errno 112)属平台配置问题, 不把微信原始英文错误暴露给用户
const reason = isClipboardPrivacyError(err)
? this.$lang('ai-chat.index.copyFailPrivacyReason')
: (err.errMsg || this.$lang('ai-chat.index.serviceUnavailable'))
uni.showToast({ title: this.$lang('ai-chat.index.copyFailWithReason', { reason }), icon: 'none', duration: 3000 })
}
})
// #endif
},
// H5 降级复制方案(document.execCommand + textarea)
_fallbackCopyH5(text) {
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
const success = document.execCommand('copy')
document.body.removeChild(textarea)
if (success) {
uni.showToast({ title: this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
} else {
uni.showToast({ title: this.$lang('ai-chat.index.copyFail'), icon: 'none' })
}
} catch (e) {
uni.showToast({ title: this.$lang('ai-chat.index.copyFail'), icon: 'none' })
}
},
// ========================================
// 聊天记录搜索
// ========================================
// 用户消息关键词高亮(仅当消息内容包含关键词时)
highlightContent(content) {
if (!this.searchKeyword || !content) {
return content
}
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
return content
}
return this._wrapHighlight(content, this.searchKeyword)
},
// AI 消息渲染(含关键词高亮)
getAIDisplayContent(content) {
if (!this.searchKeyword || !content) {
return this.parseMarkdown(content)
}
// 仅当内容包含关键词时才执行高亮,避免非匹配消息的额外处理
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
return this.parseMarkdown(content)
}
// 先做 markdown 渲染,再做关键词高亮
const mdHtml = this.parseMarkdown(content)
return this._wrapHighlightInHtml(mdHtml, this.searchKeyword)
},
// 在纯文本中高亮关键词(用于用户消息)
_wrapHighlight(text, keyword) {
if (!keyword || !text) return text
const parts = text.split(new RegExp('(' + this._escapeRegExp(keyword) + ')', 'gi'))
return parts.map(part => {
if (part.toLowerCase() === keyword.toLowerCase()) {
return `<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">${part}</span>`
}
return part
}).join('')
},
// 在 HTML 中高亮关键词(用于 AI 消息的 markdown 结果)
_wrapHighlightInHtml(html, keyword) {
if (!keyword || !html) return html
// 只高亮文本节点,不破坏 HTML 标签
const escaped = this._escapeRegExp(keyword)
// 匹配 <xxx> 之外的文本
return html.replace(/(>|^)([^<]+)(?=<|$)/g, (match, prefix, text) => {
if (!text) return match
const highlighted = text.replace(
new RegExp('(' + escaped + ')', 'gi'),
'<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">$1</span>'
)
return prefix + highlighted
})
},
// 转义正则特殊字符
_escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
},
// 重试消息
async retryMessage(message) {
// 找到对应的用户消息
const userMessageIndex = this.messages.findIndex(msg =>
msg.role === 'user' && msg.id < message.id
)
if (userMessageIndex !== -1) {
const userMessage = this.messages[userMessageIndex]
// 移除错误消息
this.messages = this.messages.filter(msg => msg.id !== message.id)
// 重新发送消息
await this.sendMessageWithContent(userMessage.content)
}
},
// 使用指定内容发送消息
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 增加 attachments 参数
// 之前 sendStreamMessage/sendNormalMessage 已经接收 attachments 但这里没传,
// 导致商品页 / 重发 路径丢附件
async sendMessageWithContent(content, attachments = []) {
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now()
const userMessage = {
id: ++this.messageId,
role: 'user',
type: 'text',
content: content,
timestamp: Date.now()
}
this.messages.push(userMessage)
// 显示AI正在输入
const loadingMessage = {
id: ++this.messageId,
role: 'ai',
type: 'loading',
content: '',
timestamp: Date.now(),
loadingText: this.$lang('ai-chat.index.aiThinking'),
_loadingIndex: 0
}
this.messages.push(loadingMessage)
this._startLoadingTextRotation(loadingMessage)
// 等待 Vue 渲染 loading 动画
await this.$nextTick()
try {
// 使用AI服务获取回复
if (this.enableStreaming) {
// 流式响应
await this.sendStreamMessage(userMessage.content, attachments)
} else {
// 普通响应
await this.sendNormalMessage(userMessage.content, attachments)
}
} catch (error) {
console.error('AI服务调用失败:', error)
// 移除加载状态
this._removeLoadingMessages()
// 显示错误消息
const errorMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceUnavailable'),
timestamp: Date.now(),
actions: [
{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }
]
}
this.messages.push(errorMessage)
this.$emit('ai-response', errorMessage)
}
// 触发消息发送事件
this.$emit('message-sent', userMessage)
},
// 输入事件
onInput(e) {
this.$emit('input-change', e.detail.value)
},
// 输入框行数变化事件
onLineChange(e) {
this.inputLineCount = e.detail.lineCount || 0
},
// 切换Emoji面板
toggleEmojiPanel() {
this.showEmojiPanel = !this.showEmojiPanel
this.showToolsPanel = false
},
// 快捷指令占位提示点击: 在输入框插入 @ 或 / 触发后续指令面板 (此处仅展示交互, 后续按需扩展)
onQuickTipClick(type) {
try {
if (type === 'quote') {
this.inputText = (this.inputText || '') + '@'
} else if (type === 'skill') {
this.inputText = (this.inputText || '') + '/'
}
} catch (e) { /* noop */ }
},
// 底部 5 图标工具栏点击: 任务 / 专家 / 资料库 / 自动化 / 更多
// - task / expert / library / automation: 触发 toolbar-click 事件(占位)
// - more: 复用 showMoreTools 触发更多功能弹窗
onToolbarItem(type) {
try {
switch (type) {
case 'task':
case 'expert':
case 'library':
case 'automation':
this.$emit('toolbar-click', { type })
break
case 'more':
this.showMoreTools()
break
}
} catch (e) { /* noop */ }
},
// 默认权限下拉点击: 占位交互, 后续接入权限弹窗
onTogglePermission() {
try {
uni.showToast({ title: '权限设置待接入', icon: 'none' })
} catch (e) { /* noop */ }
},
// ==================== PR-FRONTEND-INPUT-3STATES (2026-09-17) ====================
// 进入语音模式(按住说话): 收起键盘, 输入区从"单行胶囊"切成"按住说话 + 工具栏"
enterVoiceMode() {
if (this.voiceMode) return
try { uni.hideKeyboard() } catch (e) { /* noop */ }
this.voiceMode = true
this.voiceHolding = false
this.inputFocused = false
},
// 退出语音模式, 回到文字输入态
exitVoiceMode() {
this.voiceMode = false
this.voiceHolding = false
},
// 按住说话: 按下 (文案切"松开发送" + 高亮)
onVoiceHoldStart() {
this.voiceHolding = true
},
// 按住说话: 松开
// 项目当前没有录音 + 语音识别实现, 松开后复用原有「语音输入」面板入口, 保证功能不丢。
// H5 鼠标(桌面预览)不会触发 touch 事件, 所以 click 也绑到同一个方法; showVoicePanel 幂等, 重复调用无副作用。
onVoiceHoldEnd() {
this.voiceHolding = false
this.showVoicePanel()
},
// 按住说话: 手势被系统打断 (来电/滑动取消)
onVoiceHoldCancel() {
this.voiceHolding = false
},
// 输入框聚焦 / 失焦: 驱动"文字输入态"布局
onInputFocus() {
// 聚焦时取消尚未触发的失焦收起定时器(处理 H5 布局变化导致的瞬时 blur->focus)
if (this._blurTimer) {
clearTimeout(this._blurTimer)
this._blurTimer = null
}
this._focusTs = Date.now()
// 幻影失焦恢复触发的 focus 不重置计数, 避免失去防循环保护
if (!this._refocusing) this._refocusCount = 0
this.inputFocused = true
if (this.voiceMode) this.voiceMode = false
},
// PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 按下阶段预展开
// H5 上聚焦会触发布局从中途展开(语音图标移除/工具栏行插入), 该变动会导致 textarea 被动失焦,
// 表现为"短暂展开后立刻塌回胶囊, 无法输入"。在 touchstart/mousedown 阶段先置 inputFocused=true,
// 让布局在原生 focus 到来之前就完成展开, 聚焦过程中不再有布局变动。
onInputPointerDown() {
if (this._blurTimer) {
clearTimeout(this._blurTimer)
this._blurTimer = null
}
this._lastInputPointerTs = Date.now()
if (!this.voiceMode && !this.inputFocused) this.inputFocused = true
},
onInputBlur() {
// 延迟收起: 先让工具栏按钮的 click 派发完, 避免点"发送/加号"时布局先塌回胶囊。
// 同时防止 H5 因布局变化(出现/隐藏工具栏行)触发的瞬时 blur 误将输入态收起:
// 到时若输入框仍是真实焦点(document.activeElement 仍是本 textarea), 则不收起。
// #ifdef H5
if (this._blurTimer) clearTimeout(this._blurTimer)
this._blurTimer = setTimeout(() => {
this._blurTimer = null
const el = this.$refs.messageInput && this.$refs.messageInput.$el
const ta = el && el.querySelector ? el.querySelector('textarea') : null
const stillFocused = ta
? (document.activeElement === ta || el.contains(document.activeElement))
: false
if (stillFocused) return
// 幻影失焦恢复: 聚焦后极短时间内(键盘弹起/布局重排导致)失焦, 且用户刚在输入框上按下,
// 说明用户意图是输入而非点别处 —— 主动把焦点拉回, 不塌回胶囊。最多恢复 2 次防死循环。
const withinBounceWindow = this._focusTs && (Date.now() - this._focusTs < 800)
const tappedInside = this._lastInputPointerTs && (Date.now() - this._lastInputPointerTs < 800)
if (withinBounceWindow && tappedInside && (this._refocusCount || 0) < 2) {
this._refocusCount = (this._refocusCount || 0) + 1
this._refocusing = true
this._focusTs = Date.now()
try { ta && ta.focus({ preventScroll: true }) } catch (e) { try { ta && ta.focus() } catch (e2) { /* noop */ } }
setTimeout(() => { this._refocusing = false }, 0)
return
}
this.inputFocused = false
}, 200)
// #endif
// #ifndef H5
if (this._blurTimer) clearTimeout(this._blurTimer)
this._blurTimer = setTimeout(() => {
this._blurTimer = null
this.inputFocused = false
}, 200)
// #endif
},
// 语音按钮点击: 兼容旧入口 (打开语音输入面板)
onVoiceClick() {
try {
this.showVoicePanel && this.showVoicePanel()
} catch (e) {
try { this.showToolsPanel = true } catch (e2) { /* noop */ }
}
},
// 切换模型选择器: 在 availableModels 列表中循环选择
onToggleModelSelector() {
const list = this.availableModels || ['Auto']
const idx = list.indexOf(this.selectedModel)
const nextIdx = (idx + 1) % list.length
this.selectedModel = list[nextIdx]
},
// 打开扩大编辑器
openExpandEditor() {
this.expandInputText = this.inputText
this.showExpandEditor = true
this.showEmojiPanel = false
this.showToolsPanel = false
},
// 关闭扩大编辑器
closeExpandEditor() {
this.showExpandEditor = false
this.expandInputText = ''
},
// 从扩大编辑器发送消息
async sendFromExpandEditor() {
if (!this.expandInputText.trim()) {
return
}
this.inputText = this.expandInputText
this.closeExpandEditor()
await this.sendMessage()
},
// 处理头像加载失败
handleAvatarError() {
this.avatarError = true
},
// 清空聊天记录
clearMessages() {
// 清理所有加载状态定时器
this.messages.forEach(msg => {
if (msg._rotationTimer) {
clearInterval(msg._rotationTimer)
msg._rotationTimer = null
}
})
// 清空消息数组
this.messages = []
// 重置消息 ID
this.messageId = 0
// 清除会话 ID
this.currentConversationId = null
aiService.clearConversationId()
this.clearConversationIdFromLocal()
// 重置历史偏移量和加载状态
this.historyOffset = 0
this.hasMoreHistory = false
// 触发清空事件
this.$emit('messages-cleared')
},
// 检测字符串中是否包含表情
containsEmoji(str) {
const emojiReg = /[\u{1f300}-\u{1f9ff}\u{1f1e0}-\u{1f1ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{200d}\u{2640}-\u{2642}\u{2300}-\u{23ff}\u{2b50}-\u{2bff}]/gu
return emojiReg.test(str)
},
// 根据表情生成适当的提示文本,帮助 AI 更好地理解
enhanceEmojiMessage(content) {
// 常见表情的含义映射
const emojiResponses = {
'😀': '[开心]',
'😂': '[大笑]',
'😊': '[微笑]',
'❤️': '[爱心]',
'👍': '[点赞]',
'😭': '[哭泣]',
'🙏': '[感谢/拜托]',
'😍': '[喜欢]',
'🎉': '[庆祝]',
'🤔': '[思考]',
'😅': '[尴尬]',
'😁': '[开心]',
'🥰': '[喜爱]',
'😘': '[飞吻]',
'😏': '[坏笑]',
'🥳': '[庆祝]',
'😔': '[难过]',
'😢': '[哭泣]',
'😤': '[生气]',
'😴': '[困]',
'🤗': '[拥抱]',
'🙂': '[微笑]',
'😃': '[开心]',
'😄': '[大笑]',
'😆': '[开心]',
'😇': '[天使]',
'🤩': '[惊喜]',
'😋': '[好吃]',
'😛': '[调皮]',
'😜': '[调皮]',
'🤪': '[搞怪]',
'😝': '[开心]',
'🤑': '[发财]',
'🤭': '[害羞]',
'🤫': '[嘘]',
'🤐': '[闭嘴]',
'🤨': '[疑惑]',
'😐': '[无语]',
'😑': '[无语]',
'😶': '[沉默]',
'😒': '[不屑]',
'🙄': '[白眼]',
'😬': '[尴尬]',
'🤥': '[说谎]',
'😌': '[安心]',
'😪': '[困]',
'🤤': '[流口水]',
'😷': '[生病]',
'🤒': '[发烧]',
'🤕': '[受伤]',
'🤢': '[恶心]',
'🤮': '[呕吐]',
'🤧': '[感冒]',
'🥵': '[热]',
'🥶': '[冷]',
'🥴': '[头晕]',
'😵': '[晕]',
'🤯': '[爆炸]',
'🤠': '[酷]',
'😎': '[酷]',
'🤓': '[书呆子]',
'🧐': '[疑惑]',
'😕': '[困惑]',
'😟': '[担心]',
'🙁': '[难过]',
'😮': '[惊讶]',
'😯': '[惊讶]',
'😲': '[震惊]',
'😳': '[脸红]',
'🥺': '[恳求]',
'😦': '[惊讶]',
'😧': '[惊讶]',
'😨': '[害怕]',
'😰': '[紧张]',
'😥': '[失望]',
'😱': '[恐惧]',
'😖': '[困扰]',
'😣': '[痛苦]',
'😞': '[难过]',
'😓': '[汗]',
'😩': '[累]',
'😫': '[累]',
'🥱': '[困]',
'😡': '[愤怒]',
'😠': '[生气]',
'🤬': '[咒骂]',
'😈': '[恶魔]',
'👿': '[生气]',
'💀': '[骷髅]',
'💩': '[便便]',
'👻': '[鬼]',
'👽': '[外星人]',
'🤖': '[机器人]',
'🎃': '[万圣节]',
'😺': '[开心猫]',
'😸': '[微笑猫]',
'😹': '[大笑猫]',
'😻': '[花痴猫]',
'😼': '[得意猫]',
'😽': '[亲亲猫]',
'🙀': '[惊讶猫]',
'😿': '[哭泣猫]',
'😾': '[生气猫]',
'👋': '[挥手]',
'🤚': '[举手]',
'🖐': '[手掌]',
'✋': '[手掌]',
'🖖': '[V]',
'👌': '[OK]',
'🤏': '[捏]',
'✌️': '[耶]',
'🤞': '[好运]',
'🤟': '[爱你]',
'🤘': '[摇滚]',
'🤙': '[打电话]',
'👈': '[左边]',
'👉': '[右边]',
'👆': '[上边]',
'🖕': '[竖中指]',
'👇': '[下边]',
'☝': '[向上]',
'👎': '[鄙视]',
'✊': '[拳头]',
'👊': '[拳头]',
'🤛': '[左拳]',
'🤜': '[右拳]',
'👏': '[鼓掌]',
'🙌': '[举手庆祝]',
'👐': '[张开手]',
'🤲': '[摊手]',
'🤝': '[握手]',
'✍️': '[写字]',
'💅': '[美甲]',
'🤳': '[自拍]',
'💪': '[肌肉]',
'💗': '[爱心]',
'💓': '[心跳]',
'💕': '[两颗心]',
'💖': '[闪亮心]',
'💝': '[礼物心]',
'💞': '[旋转心]',
'💘': '[丘比特]',
'💌': '[情书]',
'💟': '[心形]',
'💛': '[黄心]',
'💙': '[蓝心]',
'💜': '[紫心]',
'🖤': '[黑心]',
'💔': '[心碎]',
'💢': '[愤怒]',
'💣': '[炸弹]',
'💥': '[爆炸]',
'💫': '[头晕]',
'💦': '[汗]',
'💨': '[风]',
'👁️': '[眼睛]',
'👀': '[看]',
'👅': '[舌头]',
'👄': '[嘴巴]',
'👂': '[耳朵]',
'👃': '[鼻子]'
}
// 如果是纯表情消息,添加一些提示
if (this.containsEmoji(content) && !content.match(/[\u4e00-\u9fa5a-zA-Z]/)) {
let enhancedMessage = content
// 为表情添加描述
for (let emoji in emojiResponses) {
if (content.includes(emoji)) {
enhancedMessage += ` (${emojiResponses[emoji]})`
}
}
return enhancedMessage
}
return content
}
}
}
</script>
<style lang="scss" scoped>
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
@import './styles/ai-chat-global.scss';
/* 页面样式 */
.ai-chat-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
/* WorkBuddy IDE 风格: 纯白底, 无气泡, 文字直接渲染其上 */
background: #ffffff;
color: #1f1f1f;
/* 禁止浏览器强制暗色模式(夜间模式)把聊天页反色成深色 */
color-scheme: light only;
overflow: hidden;
box-sizing: border-box;
.chat-messages {
flex: 1;
padding: 16rpx 24rpx;
overflow-y: auto;
box-sizing: border-box;
min-height: 0; /* 重要:防止flex元素溢出 */
background: transparent;
/* 微信小程序专属:聊天消息区域增加底部内边距,确保键盘弹出时最后一条消息可见 */
/* 由 chatMessagesStyle 动态控制 */
.load-more {
text-align: center;
padding: 16rpx 0;
color: #999;
}
.message-item {
margin-bottom: 20rpx;
scroll-margin-top: 300rpx;
scroll-margin-bottom: 200rpx;
// 搜索定位 - 当前结果: 用柔和的浅绿底色提示 (WorkBuddy 风格, 无气泡 box-shadow)
&.search-current {
position: relative;
z-index: 1;
border-radius: 12rpx;
background: rgba(7, 193, 96, 0.08);
.user-message .message-bubble,
.ai-message .message-bubble {
background: transparent !important;
transition: background 0.3s ease;
}
}
// 搜索定位 - Flash 脉冲动画(导航到新结果时触发)
&.search-flash {
animation: searchPulse 1.2s ease-out;
}
&.first-message {
margin-top: 16rpx;
}
.user-message, .ai-message {
display: flex;
align-items: flex-start;
.avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
/* 白底风格: 取消外圈与阴影 */
box-shadow: none;
background-color: transparent;
border: none;
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&:hover {
transform: scale(1.03);
}
image {
width: 100%;
height: 100%;
object-fit: cover;
}
text {
font-size: 32rpx;
line-height: 1;
}
.avatar-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
background-color: transparent;
}
}
.message-content {
flex: 1;
margin-left: 16rpx;
max-width: calc(100% - 56rpx);
min-width: 0;
width: 0; /* 添加这个属性防止flex元素溢出 */
.message-bubble {
/* 去掉所有气泡视觉: 背景透明, 无边框, 无阴影, 无内边距, 无小三角 */
padding: 0 !important;
max-width: 100%;
word-break: break-word;
box-shadow: none !important;
position: relative;
border: none !important;
background: transparent !important;
&::before {
display: none !important;
content: none !important;
}
.message-text {
font-size: 30rpx;
line-height: 1.5;
position: relative;
z-index: 1;
letter-spacing: 0.3rpx;
color: #1f1f1f;
}
// 微信小程序: AI 文本消息(H5 端走 v-html 时的外层容器)
.ai-message-text {
font-size: 30rpx;
line-height: 1.75;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
word-break: break-word;
}
}
.message-time {
font-size: 22rpx;
/* 白底风格: 浅灰色时间戳 */
color: #999;
margin-top: 8rpx;
opacity: 1;
letter-spacing: 0.5rpx;
}
/* 各种消息类型样式 */
.markdown-content {
.markdown-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
.icon-markdown {
color: $color-primary;
margin-right: 12rpx;
font-size: 28rpx;
}
.markdown-title {
font-weight: 600;
color: $color-text;
font-size: 28rpx;
}
}
.markdown-body {
line-height: 1.8;
font-size: 26rpx;
color: $color-text;
strong {
font-weight: 600;
color: $color-text;
}
em {
font-style: italic;
color: $color-text-light;
}
code {
background-color: #f0f4f8;
padding: 4rpx 8rpx;
border-radius: $radius-sm;
font-family: monospace;
font-size: 24rpx;
color: $color-primary;
}
a {
color: $color-primary;
text-decoration: underline;
text-underline-offset: 4rpx;
transition: color 0.2s ease;
&:hover {
color: #5a7cb0;
}
}
}
}
.file-content {
.file-item {
display: flex;
align-items: center;
padding: 24rpx;
background-color: $bg-card;
border-radius: $radius-md;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
background-color: #fafbfc;
box-shadow: $shadow-md;
}
.file-icon {
margin-right: 24rpx;
.icon-file {
font-size: 52rpx;
color: $color-primary;
}
}
.file-info {
flex: 1;
.file-name {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.file-size {
font-size: 24rpx;
color: $color-text-light;
}
}
.file-action {
.icon-download {
font-size: 38rpx;
color: $color-primary;
transition: transform 0.2s ease;
}
&:hover .icon-download {
transform: scale(1.1);
}
}
}
}
.audio-content {
.audio-player {
.audio-info {
margin-bottom: 16rpx;
.audio-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.audio-duration {
font-size: 24rpx;
color: $color-text-light;
}
}
.audio-controls {
display: flex;
align-items: center;
.play-btn {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
background-color: $color-primary;
color: white;
margin-right: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.3);
transition: all 0.2s ease;
&.playing {
background-color: $color-primary-light;
box-shadow: 0 2rpx 8rpx rgba(138, 159, 184, 0.2);
}
&:hover {
transform: scale(1.05);
}
}
.audio-slider {
flex: 1;
}
}
}
}
.video-content {
.video-player {
.video-element {
width: 100%;
height: 420rpx;
border-radius: $radius-md;
overflow: hidden;
box-shadow: $shadow-md;
}
.video-info {
margin-top: 16rpx;
.video-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.video-duration {
font-size: 24rpx;
color: $color-text-light;
}
}
}
}
.link-content {
.link-card {
display: flex;
background-color: $bg-card;
border-radius: $radius-md;
overflow: hidden;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2rpx);
}
.link-image {
width: 130rpx;
height: 130rpx;
flex-shrink: 0;
border-radius: $radius-md 0 0 $radius-md;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.link-info {
flex: 1;
padding: 24rpx;
.link-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.link-desc {
display: block;
font-size: 24rpx;
color: $color-text-light;
margin-bottom: 6rpx;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.link-url {
display: block;
font-size: 22rpx;
color: $color-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.product-content {
.product-card {
display: flex;
background-color: $bg-card;
border-radius: $radius-md;
overflow: hidden;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2rpx);
}
.product-image {
width: 130rpx;
height: 130rpx;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.product-info {
flex: 1;
padding: 24rpx;
.product-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-price {
display: block;
font-size: 28rpx;
font-weight: 600;
margin-bottom: 6rpx;
color: $color-user;
}
.product-desc {
display: block;
font-size: 24rpx;
color: $color-text-light;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
}
.loading-content {
display: flex;
align-items: center;
padding: 32rpx 0;
.loading-typing {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12rpx;
.typing-dots {
/* 隐藏三个圆点动画 */
display: none !important;
align-items: center;
.dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background-color: $color-primary;
margin: 0 6rpx;
animation: dotPulse 1.5s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.loading-text {
color: $color-text-light;
font-size: 24rpx;
display: flex;
align-items: center;
.loading-status {
color: $color-primary;
font-weight: 500;
transition: opacity 0.3s ease;
}
.loading-ellipsis {
display: inline-flex;
margin-left: 4rpx;
.ellipsis-dot {
animation: ellipsisBlink 1.4s infinite ease-in-out;
opacity: 0;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
}
}
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 chip 样式
// PR-FIX (2026-09-20): flex gap 在低版本微信基础库/部分安卓 WebView(小米) 不生效,
// 间距全部改用 margin 实现, 不依赖 gap
.quick-questions-row {
margin-top: 16rpx;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
max-width: 100%;
.quick-question-chip {
display: inline-flex;
align-items: center;
margin-right: 16rpx;
margin-bottom: 16rpx;
padding: 12rpx 24rpx;
background: #ffffff;
border: 1rpx solid #1a1a1a;
border-radius: 32rpx;
font-size: 26rpx;
color: #1a1a1a;
cursor: pointer;
transition: all 0.2s ease;
max-width: 100%;
line-height: 1.4;
.quick-question-icon {
font-size: 24rpx;
flex-shrink: 0;
margin-right: 8rpx;
}
.quick-question-text {
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
}
.quick-question-chip--hover {
background: #1a1a1a;
color: #ffffff;
transform: translateY(-1rpx);
box-shadow: 0 4rpx 12rpx rgba(26, 26, 26, 0.2);
}
}
.message-actions {
margin-top: 24rpx;
display: flex;
gap: 16rpx;
justify-content: flex-start;
flex-wrap: nowrap;
width: 100%;
.action-btn {
border-radius: $radius-lg;
font-size: 22rpx;
background-color: $bg-card;
padding: 10rpx 20rpx;
display: flex;
align-items: center;
gap: 6rpx;
transition: all 0.2s ease;
border: 1rpx solid #f0f4f8;
color: $color-text;
white-space: nowrap;
&.like {
color: $color-user;
border-color: #e8e0f2;
&:hover {
background-color: #f9f6fc;
transform: translateY(-2rpx);
}
}
&.dislike {
color: $color-text-light;
border-color: #e9edf2;
&:hover {
background-color: #f8fafc;
transform: translateY(-2rpx);
}
}
}
}
}
}
/* ========== 关键修复:拆分 .message-nickname 样式 ========== */
.message-nickname {
font-size: 24rpx;
color: $color-text-light;
margin-bottom: 8rpx;
letter-spacing: 0.5rpx;
display: flex;
align-items: baseline;
gap: 12rpx;
line-height: 1;
.message-time {
font-size: 24rpx;
color: #999;
line-height: 1;
top:-15rpx;
}
}
/* 用户昵称:右对齐 */
.user-message .message-nickname {
text-align: right;
justify-content: flex-end;
display: flex;
align-items: baseline;
}
/* AI 昵称:使用 .ai-nickname 类控制 */
.ai-nickname {
text-align: left;
margin-left: 0;
padding-left: 0;
align-self: flex-start;
margin-bottom: 4rpx;
justify-content: flex-start;
display: flex;
align-items: center;
width: 100%;
position: relative;
gap: 12rpx;
.message-time {
/* AI 回答上方只保留时间, 左对齐显示 */
position: static;
margin-top: 0;
margin-left: 0;
}
}
/* 用户消息特有样式 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右)
整体不再贴左, 头像显示且位于气泡右侧 */
.user-message {
flex-direction: row;
justify-content: flex-end;
.avatar {
/* 隐藏用户头像: 消息保持右对齐气泡, 但不再显示头像 */
display: none !important;
}
.message-content {
margin-left: 0;
margin-right: 0;
text-align: right;
max-width: calc(100% - 64rpx);
display: flex;
flex-direction: column;
align-items: flex-end;
/* 昵称 + 时间戳行, 右对齐 */
.message-nickname {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 6rpx;
font-size: 22rpx;
color: #999;
.message-time {
margin-right: 8rpx;
margin-top: 0;
}
.nickname-text {
color: #666;
}
}
/* 气泡: 浅灰背景 + 轻微圆角, 内容在气泡内左对齐(中文阅读体验更好) */
.message-bubble {
background: #f5f5f5 !important;
color: #1f1f1f !important;
border: none !important;
border-radius: 16rpx !important;
box-shadow: none !important;
padding: 20rpx 30rpx !important; // 加大内边距,让气泡更"大"
display: inline-block;
word-break: break-word;
white-space: pre-wrap;
line-height: 1.7;
text-align: left;
max-width: 100%;
.message-text {
font-size: 20rpx !important; // 还原为用户消息原字号
}
&::before {
display: none !important;
content: none !important;
}
}
}
}
/* #ifdef H5 */
/* H5 用户【纯文字】消息气泡: 气泡要明显比文字大(与小程序一致)
- 内边距加大 -> 气泡比文字大, 文字与气泡边缘留出间距
- 宽度 auto + inline-block -> 气泡宽度贴着内容, 不拉满整行
- 只调整气泡自身, 气泡内文字字号不变
- 用 :not() 排除图片/配文/文件/视频/音频气泡, 避免影响它们原有的紧凑样式 */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.image-caption-bubble):not(.user-file-bubble):not(.user-video-bubble):not(.user-audio-bubble) {
padding: 10rpx 30rpx !important;
border-radius: 16rpx !important;
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
text-align: left !important;
}
/* #endif */
/* AI消息特有样式 (微信风格: 头像 + 名称 + 时间同行, 下方内容)
与你截图样式一致 */
.ai-message {
.avatar {
/* 隐藏 AI 头像 */
display: none !important;
}
.message-content {
/* 确保昵称在内容上方的布局 */
display: flex;
flex-direction: column;
align-items: flex-start;
.message-nickname.ai-nickname {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 4rpx;
.message-time {
margin-top: 0;
font-size: 22rpx;
color: #999;
line-height: 1;
}
}
.message-bubble {
background: transparent !important;
color: #1f1f1f !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 0 !important;
display: block;
/* 容器不够宽时正常换行, 不主动按字断开 */
overflow-wrap: break-word;
word-wrap: break-word;
/* pre-line: 保留后端 \n 的段落分隔, 但合并多个连续空白, 配合 parseMarkdown normalize 避免每字一行 */
white-space: pre-line;
line-height: 1.5;
&::before {
display: none !important;
content: none !important;
}
}
// 微信端 AI 富文本(豆包风格)容器: 行高/字号/字色统一兜底
.ai-message-rich {
display: block;
font-size: 30rpx;
line-height: 1.75;
color: #1a1a1a;
letter-spacing: 0.2rpx;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
word-break: break-word;
overflow-wrap: break-word;
margin: 2rpx 0;
}
// 富文本模式下隐藏 markdown 角标,更接近豆包观感
.markdown-header {
display: none;
}
/* AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 朗读 / 更多 */
.ai-actions {
margin-top: 12rpx;
display: flex;
align-items: center;
gap: 8rpx;
flex-wrap: nowrap;
.ai-action-btn {
min-width: 56rpx;
height: 56rpx;
padding: 0 14rpx;
border-radius: 28rpx;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease, color 0.2s ease;
.iconfont {
font-size: 30rpx;
color: #666;
line-height: 1;
}
/* 隐藏"更多(···)"按钮: 只保留 复制 / 点赞 / 不喜欢 */
&.more-btn {
display: none !important;
}
/* PR-FRONTEND-ACTION-SVG-ICON (2026-09-18): 点赞/点踩线性 SVG 矢量图标
微信小程序不支持内联 SVG, 沿用 voice-icon-bg 的 base64 背景图方案;
默认灰色描边, active 态切换为蓝色描边版本 */
.svg-icon {
width: 32rpx;
height: 32rpx;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
&.icon-like {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiLz48L3N2Zz4=");
/* 屏蔽全局 iconfont.css 的 .icon-like:before { content:"\e041" },
避免点赞图标额外渲染出一个心形/方框伪元素;此处不设置 content 值,
只让 ::before 不显示 */
&::before {
display: none !important;
}
}
&.icon-dislike {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiIHRyYW5zZm9ybT0icm90YXRlKDE4MCAxMiAxMikiLz48L3N2Zz4=");
}
}
&.active {
background: #e6f0ff;
.iconfont {
color: #1f6cff;
}
.svg-icon.icon-like {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiLz48L3N2Zz4=");
}
.svg-icon.icon-dislike {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiIHRyYW5zZm9ybT0icm90YXRlKDE4MCAxMiAxMikiLz48L3N2Zz4=");
}
}
&:active {
background: #ebebeb;
}
}
}
}
}
}
}
/* 键盘垫高层:高度变化时平滑过渡 */
.keyboard-spacer {
flex-shrink: 0;
transition: height 0.25s ease;
}
/* 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标均匀分布) */
.input-area {
background: transparent;
padding: 12rpx 20rpx 16rpx;
flex-shrink: 0;
box-sizing: border-box;
.input-card {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
box-shadow: none;
box-sizing: border-box;
}
/* 顶部输入横条: 胶囊形,左侧输入区 + 右侧操作按钮 */
.input-row-top {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
border-radius: 999rpx;
padding: 18rpx 14rpx 18rpx 32rpx;
min-height: 108rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
/* 无文字时左侧语音图标,点击打开语音面板 */
.input-row-icon {
flex-shrink: 0;
width: 52rpx;
height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
.voice-icon-bg {
width: 36rpx;
height: 36rpx;
}
}
/* 输入区: 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) */
.voice-icon-bg {
width: 40rpx;
height: 40rpx;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTAgMTIgaDYgbDYgLTQgdjE2IGwtNiAtNCBoLTYgYTIgMiAwIDAgMSAtMiAtMiB2LTQgYTIgMiAwIDAgMSAyIC0yIHoiLz48cGF0aCBkPSJNMjQgMTAgYzIgMyAyIDkgMCAxMiIvPjwvc3ZnPg==");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
/* 键盘图标: 语音模式下点击切回文字输入 */
.emoji-icon-bg {
width: 40rpx;
height: 40rpx;
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;
&.is-holding {
background: #e2e2e4;
}
.voice-hold-text {
font-size: 30rpx;
color: #1f1f1f;
font-weight: 500;
}
}
/* 底部工具栏行: 语音模式 / 文字输入态 */
.input-toolbar-row {
display: flex;
align-items: center;
min-height: 76rpx;
padding: 0 2rpx 4rpx;
.toolbar-round-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
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;
.send-plane-text {
color: #ffffff;
font-size: 34rpx;
line-height: 1;
transform: rotate(-90deg); // 箭头向上,呈向上纸飞机
display: inline-block;
margin-left: -2rpx; // 视觉居中
margin-top: -2rpx;
}
/* 无可发送内容时置灰 (仍有 sendMessage 内部兜底拦截) */
&.is-disabled {
background: #e6e6e8;
}
&::after {
border: none;
}
}
/* 底部工具栏: 5 个图标按钮均匀分布 */
.input-toolbar {
display: flex;
align-items: center;
justify-content: space-around;
margin-top: 24rpx;
padding: 4rpx 0;
.toolbar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 4rpx 0;
&.toolbar-item--hover,
&:active {
opacity: 0.55;
}
.toolbar-icon {
width: 44rpx;
height: 44rpx;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
line-height: 1;
&.icon-task {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3QgeD0iMiIgeT0iNiIgd2lkdGg9IjI2IiBoZWlnaHQ9IjE4IiByeD0iNiIgZmlsbD0iIzFmMWYxZiIvPjxjaXJjbGUgY3g9IjEwIiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==");
}
&.icon-automation {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTEiLz48cGF0aCBkPSJNMTEgMTYgbDQgNCBsNyAtOCIvPjwvc3ZnPg==");
}
&.icon-library {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgNyBjNSAtMiAxMCAtMiAxMyAxIGMzIC0zIDggLTMgMTMgLTEgdjE4IGMtNSAtMiAtMTAgLTIgLTEzIDEgYy0zIC0zIC04IC0zIC0xMyAtMSB6Ii8+PHBhdGggZD0iTTE2IDggdjE4Ii8+PC9zdmc+");
}
&.icon-expert {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTEiLz48cGF0aCBkPSJNMTYgOSB2NyBsNSAzIi8+PC9zdmc+");
}
&.icon-more {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjkiIGhlaWdodD0iOSIgcng9IjEuNSIvPjxyZWN0IHg9IjE5IiB5PSI0IiB3aWR0aD0iOSIgaGVpZ2h0PSI5IiByeD0iMS41Ii8+PHJlY3QgeD0iNCIgeT0iMTkiIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHJ4PSIxLjUiLz48cmVjdCB4PSIxOSIgeT0iMTkiIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHJ4PSIxLjUiLz48L3N2Zz4=");
}
}
.toolbar-text {
font-size: 22rpx;
color: #1f1f1f;
line-height: 1;
}
}
}
}
}
/* 动画定义 */
@keyframes dotPulse {
0%, 100% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
@keyframes ellipsisBlink {
0%, 100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes wave {
0%, 100% {
transform: scaleY(0.5);
opacity: 0.7;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
/* 搜索定位脉冲动画 */
@keyframes searchPulse {
0% {
transform: translateY(0);
}
15% {
transform: translateY(-12rpx);
}
30% {
transform: translateY(4rpx);
}
45% {
transform: translateY(-4rpx);
}
60% {
transform: translateY(0);
}
0%, 60%, 100% {
filter: brightness(1);
}
15%, 45% {
filter: brightness(1.15);
}
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 200%;
}
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 滚动条样式优化 */
::-webkit-scrollbar {
width: 8rpx;
height: 8rpx;
}
::-webkit-scrollbar-track {
background: $bg-main;
border-radius: 4rpx;
}
::-webkit-scrollbar-thumb {
background: $color-primary-light;
border-radius: 4rpx;
opacity: 0.7;
&:hover {
background: $color-primary;
opacity: 1;
}
}
/* 商品选择弹窗样式 */
.product-selector-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
.product-selector-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.product-selector-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 800rpx;
max-height: 80vh;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
.product-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
border-bottom: 1rpx solid #e8edf3;
text {
font-size: 32rpx;
font-weight: bold;
color: #4e5969;
}
.close-btn {
padding: 0;
background: none;
.iconfont {
font-size: 32rpx;
color: #8492a6;
}
}
}
.product-selector-content {
display: flex;
height: 60vh;
.category-list {
width: 200rpx;
border-right: 1rpx solid #e8edf3;
.category-item {
padding: 20rpx;
text-align: center;
border-bottom: 1rpx solid #e8edf3;
&.active {
background-color: #f5f7fa;
font-weight: bold;
color: #6c8ebf;
}
}
}
.product-list {
flex: 1;
padding: 20rpx;
.loading {
text-align: center;
padding: 40rpx;
color: #8492a6;
}
.empty {
text-align: center;
padding: 40rpx;
color: #8492a6;
}
.product-item {
display: flex;
padding: 15rpx;
border-bottom: 1rpx solid #e8edf3;
.product-image {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
margin-right: 15rpx;
}
.product-info {
flex: 1;
.product-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 10rpx;
color: #4e5969;
}
.product-price {
color: #f56c6c;
font-weight: bold;
}
}
}
}
}
}
}
/* 响应式适配 */
@media (max-width: 750rpx) {
.ai-chat-container {
.chat-messages {
padding: 16rpx 24rpx;
.message-item {
margin-bottom: 16rpx;
.user-message, .ai-message {
.avatar {
width: 40rpx;
height: 40rpx;
}
.message-content {
margin-left: 16rpx;
max-width: calc(100% - 56rpx);
.message-bubble {
/* 移动端也保持无气泡样式, 文字直接渲染在深色背景上 */
padding: 0 !important;
.message-text {
font-size: 28rpx;
}
}
}
/* 用户消息气泡: 覆盖上面的 padding:0
- H5: 气泡要明显比文字大(与小程序一致), 只改气泡, 不改文字字号;
用 :not() 排除图片/配文/文件/视频/音频气泡
- 小程序: 保持原有浅灰气泡 + 加大内边距(不动) */
/* #ifdef H5 */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.image-caption-bubble):not(.user-file-bubble):not(.user-video-bubble):not(.user-audio-bubble) {
padding: 20rpx 40rpx !important;
border-radius: 16rpx !important;
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
text-align: left !important;
}
/* #endif */
/* #ifndef H5 */
.user-message .message-content .message-bubble {
padding: 20rpx 40rpx !important;
}
/* #endif */
}
}
}
.input-area {
padding: 10rpx 16rpx 12rpx;
.input-card {
padding: 12rpx 14rpx 10rpx;
border-radius: 18rpx;
}
.input-row-top {
min-height: 64rpx;
.message-input {
min-height: 52rpx;
font-size: 28rpx;
}
.message-input-placeholder {
font-size: 28rpx !important;
}
}
.input-toolbar {
min-height: 56rpx;
.send-btn {
width: 56rpx;
height: 56rpx;
.send-icon {
font-size: 26rpx;
}
}
}
}
}
}
/* 扩大按钮 */
.expand-btn {
width: 72rpx;
height: 72rpx;
border-radius: 12rpx;
margin-left: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: #f2f2f2;
padding: 0;
left:60rpx;
.iconfont {
font-size: 36rpx;
line-height: 1;
}
}
/* 扩大编辑器弹窗 */
.expand-editor-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
flex-direction: column;
.expand-editor-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.expand-editor-panel {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 0;
margin-top: 100rpx;
.expand-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #e5e5e5;
background-color: #f9f9f9;
text {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.close-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
border: none;
padding: 0;
text {
font-size: 48rpx;
color: #999;
line-height: 1;
}
}
}
.expand-editor-input {
flex: 1;
min-height: 300rpx;
max-height: none;
padding: 32rpx;
font-size: 30rpx;
line-height: 1.6;
background-color: #fff;
border: none;
color: #333;
}
.expand-editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-top: 1rpx solid #e5e5e5;
background-color: #f9f9f9;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
.expand-cancel-btn {
width: 160rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #f2f2f2;
color: #666;
font-size: 28rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.expand-send-btn {
width: 160rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #07c160;
color: #fff;
font-size: 28rpx;
font-weight: 500;
border: none;
display: flex;
align-items: center;
justify-content: center;
&[disabled] {
background-color: #ccc;
opacity: 0.7;
}
}
}
}
// H5 下允许 rich-text / v-html 内容文本选择(鼠标/长按拖选, 复制、全选等由浏览器原生菜单提供)
.text-content,
.markdown-content,
.markdown-body,
.ai-message-text,
.selectable-md {
user-select: text;
-webkit-user-select: text;
}
.selectable-md {
font-size: 30rpx;
line-height: 1.75;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
// 保证 v-html 内部段落、列表等不影响选择
p, ul, ol, li, h1, h2, h3, h4, h5, h6, code, pre {
user-select: text;
-webkit-user-select: text;
}
}
// 用户消息 rich-text 高亮(搜索时替代 <text>)
.message-rich-text {
font-size: 28rpx;
line-height: 1.7;
letter-spacing: 0.8rpx;
}
}
/* ========== AI 回答: 无气泡 ==========
AI 回答不做任何卡片/气泡, 背景透明、无边框、无阴影、无内边距, 文字直接渲染在白色页面底上 */
/* ========== 思考流 / 工具调用 / 选项卡片 / 附件 样式 ========== */
/* 顶部栏: 标题 + 新对话 */
.chat-topbar {
display: flex;
align-items: center;
justify-content: space-between;
/* 右侧预留搜索入口(56rpx 圆形按钮, right:16rpx)的宽度,
把"新对话"按钮左移, 避免被搜索按钮遮挡 */
padding: 16rpx 88rpx 16rpx 24rpx;
border-bottom: 1rpx solid rgba(17, 24, 39, 0.06);
background: #ffffff;
.chat-title {
font-size: 30rpx;
font-weight: 600;
color: #1f1f1f;
}
.new-chat-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: #f4f5f7;
color: #1f6cff;
font-size: 24rpx;
/* 新对话图标: 圆角气泡 + 中间加号 + 左下角小尾巴的线性图标
微信小程序不支持内联 SVG, 沿用 base64 背景图方案 (与 svg-icon / voice-icon-bg 一致) */
.new-chat-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNOC41IDIuNUgxNkE1LjUgNS41IDAgMCAxIDIxLjUgOFYxMi41QTUuNSA1LjUgMCAwIDEgMTYgMThIOC42TDMgMjJWOEE1LjUgNS41IDAgMCAxIDguNSAyLjVaIi8+PHBhdGggZD0iTTEyIDcuNVYxMy41TTkuMiAxMC41SDE0LjgiLz48L3N2Zz4=");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
}
}
/* 思考流折叠面板 */
.thought-panel {
background: #f6f8fa;
border-left: 4rpx solid #8b8b8b;
padding: 12rpx 16rpx;
margin-bottom: 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
color: #666;
// 流式思考过程保持和完成后一致的灰色背景, 不出现黄色
&.streaming {
background: #f6f8fa;
border-left-color: #8b8b8b;
.thought-label { color: #666; }
}
.thought-header { display: flex; align-items: center; }
.thought-icon { margin-right: 8rpx; }
.thought-label { flex: 1; }
.thought-toggle { font-size: 20rpx; color: #999; margin-left: 8rpx; }
.thought-content {
margin-top: 8rpx;
white-space: pre-wrap;
line-height: 1.6;
font-size: 22rpx;
max-height: 400rpx;
overflow-y: auto;
}
}
/* 工具调用 */
.tool-calls {
margin-top: 8rpx;
padding: 8rpx 12rpx;
background: #f0f4f8;
border-radius: 4rpx;
font-size: 22rpx;
.tool-call-item { padding: 4rpx 0; color: #666; }
.status-completed { color: #2e7d32; }
.status-failed { color: #c62828; }
.status-pending, .status-in_progress, .status-pending { color: #f57c00; }
}
/* 附件 */
.attachments {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 12rpx;
.attachment {
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
overflow: hidden;
background: #f0f4f8;
display: flex;
align-items: center;
justify-content: center;
.att-image { width: 100%; height: 100%; }
.att-file { display: flex; align-items: center; gap: 8rpx; padding: 12rpx; font-size: 22rpx; color: #666;
.file-icon { font-size: 32rpx; }
}
}
}
/* Question 选项卡片 */
.question-card {
background: #ffffff;
border: 1rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 16rpx;
margin-top: 12rpx;
.question-header-chip {
display: inline-block;
background: #f0f4f8;
color: #666;
padding: 4rpx 12rpx;
border-radius: 16rpx;
font-size: 22rpx;
margin-bottom: 8rpx;
}
.question-text { font-size: 28rpx; color: #333; margin-bottom: 12rpx; }
.question-timer { font-size: 22rpx; color: #f57c00; float: right; }
.question-options {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
.option-btn {
background: #f6f8fa;
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
padding: 12rpx 20rpx;
font-size: 26rpx;
&.selected {
background: #e3f2fd;
border-color: #1976d2;
color: #1976d2;
}
.option-label { display: block; }
.option-desc { display: block; font-size: 22rpx; color: #999; margin-top: 4rpx; }
}
}
.confirm-row {
margin-top: 12rpx;
text-align: right;
.confirm-btn {
background: #1976d2;
color: #fff;
border-radius: 8rpx;
padding: 8rpx 24rpx;
font-size: 26rpx;
}
}
}
/* 用户图文消息容器: 让图片气泡与文字气泡在同一列内右对齐,
图片自然落在文字气泡的正上方 (而不是贴在左上角) */
.user-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; }
}
/* 用户图片气泡 (提高选择器权重, 覆盖 H5/移动端下 .user-message .message-content .message-bubble 的大内边距) */
.user-message .message-content .message-bubble.user-image-bubble {
padding: 6rpx !important;
overflow: hidden;
position: relative;
margin-left: 8rpx;
.user-image { border-radius: 12rpx; display: block; }
.upload-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
.upload-tip { color: #fff; font-size: 24rpx; }
&.upload-error .upload-tip { color: #ffb3b3; }
}
}
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
复用 AI 侧 .file-item 样式 (line 2943), 仅包一层 user-file-bubble 容器 */
.user-file-bubble,
.user-audio-bubble {
padding: 12rpx 16rpx !important;
overflow: hidden;
cursor: pointer;
.file-item {
/* 复用 AI 侧定义; 复制过来以避免修改原有 AI 侧样式 */
display: flex;
align-items: center;
min-width: 240rpx;
max-width: 360rpx;
.file-icon {
width: 56rpx;
height: 56rpx;
margin-right: 12rpx;
flex-shrink: 0;
.iconfont,
.icondiy { font-size: 48rpx; color: #6b7c93; }
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-size: 26rpx;
color: #2c3e50;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240rpx;
display: block;
}
.file-size {
font-size: 22rpx;
color: #8a9fb8;
margin-top: 4rpx;
display: block;
}
}
}
.upload-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
.upload-tip { color: #fff; font-size: 24rpx; }
&.upload-error .upload-tip { color: #ffb3b3; }
}
}
/* 用户视频气泡 (视频上传): 内嵌播放器, 与图片气泡一样紧凑 */
.user-video-bubble {
padding: 6rpx !important;
overflow: hidden;
.video-item {
position: relative;
}
.user-video {
width: 420rpx;
height: 300rpx;
border-radius: 12rpx;
display: block;
}
.upload-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
.upload-tip { color: #fff; font-size: 24rpx; }
&.upload-error .upload-tip { color: #ffb3b3; }
}
}
/* 图片消息配文气泡 (图文一起发出时, 独立气泡显示在图片下方) */
.image-caption-bubble {
margin-top: 10rpx;
background: #f5f5f5;
border-radius: 20rpx;
padding: 14rpx 22rpx;
max-width: 460rpx;
.message-text {
font-size: 28rpx;
color: #111827;
line-height: 1.5;
word-break: break-all;
}
}
/* 输入框内待发送图片缩略图: 位于文字输入行上方, 出现时输入框自动向上撑高 */
/* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 数组版 1-9 张, 横向排列 + 横向滚动 */
.pending-attachment {
display: flex;
flex-direction: row;
align-items: flex-start;
align-self: stretch;
margin: 6rpx 14rpx 10rpx;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
/* 内部每个 pending-item 独立 display:block, 由 item 自身定义尺寸 */
.pending-item {
position: relative;
width: 120rpx;
height: 120rpx;
border-radius: 14rpx;
overflow: hidden;
flex-shrink: 0;
margin-right: 10rpx;
}
.pending-image { width: 100%; height: 100%; display: block; }
/* PR-FRONTEND-MEDIA-PENDING-FILE: 非图片附件(文件/视频/音频)缩略占位 */
.pending-file-thumb {
width: 100%;
height: 100%;
background: #eef2f7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6rpx 8rpx;
box-sizing: border-box;
.pending-file-icon { font-size: 44rpx; line-height: 1; }
.pending-file-name {
margin-top: 6rpx;
max-width: 100%;
font-size: 18rpx;
color: #55657a;
line-height: 1.2;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
/* 用户消息里的文件附件卡片 */
.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>