feat(ai-chat): 添加聊天记录搜索与远程欢迎语功能

This commit is contained in:
2026-05-16 17:34:24 +08:00
parent 76afe5f433
commit 7580d8b763
3 changed files with 403 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
import Config from './config.js'
import http from './http.js'
import store from '@/store/index.js'
import lang from './lang.js'
let currentConversationId = null;
export const CONVERSATION_KEY = 'ai_conversation_id';
@@ -524,6 +525,34 @@ export default {
}
},
/**
* 获取服务配置信息
*/
async getServiceConfig() {
try {
const params = {
url: '/api/kefu/info',
data: {
uniacid: this.getUniacid(),
token: store.state.token || 'test_token',
user_id: getUserId(),
member_id: getUserId(),
locale: lang.getCurrentLocale()
},
header: {
'Content-Type': 'application/json'
}
};
const response = await http.sendRequest({
...params,
async: false,
});
return response.data;
} catch (error) {
return null;
}
},
/**
* 清除会话历史(调用后端接口)
*/

View File

@@ -1,5 +1,10 @@
<template>
<view class="ai-chat-container" :style="aiChatContainerStyle">
<!-- 搜索入口按钮(浮动在消息区右上角) -->
<view class="search-entrance" v-if="!showSearchBar" @click="toggleSearch">
<text class="search-entrance-icon">🔍</text>
</view>
<!-- 聊天消息列表 - 统一使用 scroll-into-view -->
<scroll-view
class="chat-messages"
@@ -45,7 +50,8 @@
@touchmove="onBubbleTouchMove"
@touchend="onBubbleTouchEnd"
@touchcancel="onBubbleTouchEnd">
<text class="message-text" selectable>{{ message.content }}</text>
<rich-text v-if="searchKeyword" :nodes="highlightContent(message.content)" class="message-rich-text" />
<text v-else class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
</view>
@@ -76,17 +82,17 @@
@touchcancel="onBubbleTouchEnd">
<!-- 文本消息 -->
<view v-if="message.type === 'text'" class="text-content">
<rich-text :nodes="parseMarkdown(message.content)" />
<rich-text :nodes="getAIDisplayContent(message.content)" />
</view>
<!-- Markdown文档 -->
<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>
<text class="markdown-title"></text>
</view>
<view class="markdown-body">
<rich-text :nodes="parseMarkdown(message.content)" />
<rich-text :nodes="getAIDisplayContent(message.content)" />
</view>
</view>
@@ -206,6 +212,27 @@
</view>
</scroll-view>
<!-- 搜索浮条(消息区和输入框之间,靠近操作位置) -->
<view class="search-bar" v-if="showSearchBar">
<text class="search-bar-close" @click="closeSearch">✕</text>
<view class="search-bar-input-wrap">
<text class="search-bar-icon">🔍</text>
<input
class="search-bar-input"
v-model="searchKeyword"
placeholder="搜索聊天记录"
:focus="true"
@input="onSearchInput"
@confirm="doSearch" />
</view>
<view class="search-bar-actions" v-if="searchResults.length > 0">
<text class="search-bar-count">{{ searchResultIndex + 1 }}/{{ searchResults.length }}</text>
<text class="search-bar-nav" @click="scrollToPrevResult">↑</text>
<text class="search-bar-nav" @click="scrollToNextResult">↓</text>
</view>
<text class="search-bar-clear" v-if="searchKeyword" @click.stop="clearSearch">清空</text>
</view>
<!-- 输入区域 -->
<view class="input-area" :style="inputAreaStyle">
<view class="input-tools">
@@ -476,6 +503,11 @@ export default {
streamSpeed: {
type: Number,
default: 20
},
// API 获取欢迎语失败时的降级欢迎语
fallbackWelcome: {
type: String,
default: ''
}
},
data(){
@@ -526,7 +558,13 @@ export default {
// 手动长按检测相关(touch 计时器)
_longPressTimer: null,
_longPressMessage: null
_longPressMessage: null,
// 聊天记录搜索
showSearchBar: false,
searchKeyword: '',
searchResults: [],
searchResultIndex: -1
}
},
computed: {
@@ -540,7 +578,7 @@ export default {
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
chatMessagesStyle() {
// 键盘弹出时增加底部padding,避免消息内容被键盘遮挡
// 键盘弹出时增加底部padding
return {
'padding-bottom': this.keyboardHeight > 0 ? this.keyboardHeight + 'px' : '0px'
};
@@ -579,8 +617,11 @@ export default {
this.initUserNicknameData()
this.currentConversationId = aiService.getConversationId();
console.log('[AI Chat] created, currentConversationId:', this.currentConversationId, 'messages.length:', this.messages.length);
// 异步获取服务配置(含欢迎语)
this._loadWelcomeMessage()
},
mounted() {
mounted() {
// 初始加载时滚底(仅一次)
this.$nextTick(() => {
this.scrollToBottom()
@@ -627,6 +668,65 @@ export default {
console.log('[AI Chat] → 分支4: 跳过加载,保留当前消息, messages.length:', this.messages.length);
}
},
// 加载欢迎语:优先 API,API 失败或禁用时用父页面传入的 fallbackWelcome
async _loadWelcomeMessage() {
// 仅全新会话才需要欢迎语
if (this.currentConversationId) {
console.log('[AI Chat] 已有会话,跳过欢迎语')
return
}
let welcomeText = ''
let isMarkdown = false
let welcomeLoaded = false
// 1. 尝试从 API 获取远程欢迎语
try {
const config = await aiService.getServiceConfig()
if (config) {
const welcomeInfo = config.welcome
const remoteText = welcomeInfo?.welcome || ''
const isEnabled = welcomeInfo?.is_enabled
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] 无可用欢迎语')
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: []
}
this.messages.push(welcomeMsg)
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', welcomeMsg)
}
},
// 生成消息的唯一 key(兼容非 H5 平台)
getMessageKey(message, index) {
const id = message.id || message.timestamp || index;
@@ -1889,6 +1989,154 @@ _handleStreamError(msg) {
}
},
// ========================================
// 聊天记录搜索
// ========================================
// 切换搜索条
toggleSearch() {
this.showSearchBar = !this.showSearchBar
if (!this.showSearchBar) {
this.clearSearch()
}
},
// 关闭搜索
closeSearch() {
this.showSearchBar = false
this.clearSearch()
},
// 清空搜索状态
clearSearch() {
this.searchKeyword = ''
this.searchResults = []
this.searchResultIndex = -1
},
// 输入时实时搜索
onSearchInput(e) {
const val = e.detail ? e.detail.value : e
if (val) {
this.searchKeyword = val
this.doSearch()
} else {
this.clearSearch()
}
},
// 执行搜索
doSearch() {
const keyword = this.searchKeyword.trim().toLowerCase()
if (!keyword) {
this.searchResults = []
this.searchResultIndex = -1
return
}
const results = []
this.messages.forEach((msg, index) => {
if (msg.type === 'loading') return
const content = (msg.content || '').toLowerCase()
if (content.includes(keyword)) {
results.push(index)
}
})
this.searchResults = results
this.searchResultIndex = results.length > 0 ? 0 : -1
if (results.length > 0) {
this.$nextTick(() => {
this.scrollToSearchResult(0)
})
} else {
uni.showToast({ title: '未找到匹配结果', icon: 'none' })
}
},
// 滚动到指定搜索结果
scrollToSearchResult(index) {
if (index < 0 || index >= this.searchResults.length) return
const msgIdx = this.searchResults[index]
const message = this.messages[msgIdx]
if (message) {
this.scrollIntoViewId = this.getMessageKey(message, msgIdx)
this.searchResultIndex = index
}
},
// 上一个结果
scrollToPrevResult() {
const idx = this.searchResultIndex - 1
if (idx >= 0) {
this.scrollToSearchResult(idx)
} else {
uni.showToast({ title: '已到顶部', icon: 'none' })
}
},
// 下一个结果
scrollToNextResult() {
const idx = this.searchResultIndex + 1
if (idx < this.searchResults.length) {
this.scrollToSearchResult(idx)
} else {
uni.showToast({ title: '已到底部', icon: 'none' })
}
},
// 用户消息关键词高亮
highlightContent(content) {
if (!this.searchKeyword || !content) {
return content
}
return this._wrapHighlight(content, this.searchKeyword)
},
// AI 消息渲染(含关键词高亮)
getAIDisplayContent(content) {
if (!this.searchKeyword || !content) {
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) {
// 找到对应的用户消息
@@ -4206,5 +4454,121 @@ $radius-lg: 36rpx;
user-select: text;
-webkit-user-select: text;
}
// 搜索入口浮动按钮
.search-entrance {
position: absolute;
top: 12rpx;
right: 16rpx;
z-index: 10;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
.search-entrance-icon {
font-size: 28rpx;
}
&:active {
background: rgba(0, 0, 0, 0.12);
}
}
// 搜索浮条(位于消息区和输入框之间)
.search-bar {
display: flex;
align-items: center;
padding: 10rpx 24rpx;
background: #f5f5f5;
border-top: 1rpx solid #e8e8e8;
gap: 10rpx;
.search-bar-close {
font-size: 32rpx;
color: #888;
flex-shrink: 0;
line-height: 1;
&:active {
color: #333;
}
}
.search-bar-input-wrap {
flex: 1;
display: flex;
align-items: center;
background: #fff;
border-radius: 8rpx;
padding: 0 14rpx;
height: 58rpx;
border: 1rpx solid #ddd;
.search-bar-icon {
font-size: 24rpx;
color: #999;
margin-right: 8rpx;
flex-shrink: 0;
line-height: 1;
}
.search-bar-input {
flex: 1;
height: 58rpx;
font-size: 28rpx;
background: transparent;
border: none;
outline: none;
color: #333;
}
}
.search-bar-actions {
display: flex;
align-items: center;
gap: 4rpx;
flex-shrink: 0;
.search-bar-count {
font-size: 24rpx;
color: #999;
min-width: 44rpx;
text-align: center;
}
.search-bar-nav {
font-size: 32rpx;
color: #666;
padding: 6rpx 10rpx;
line-height: 1;
&:active {
color: #333;
}
}
}
.search-bar-clear {
font-size: 26rpx;
color: #07c160;
flex-shrink: 0;
white-space: nowrap;
&:active {
opacity: 0.6;
}
}
}
// 用户消息 rich-text 高亮(搜索时替代 <text>)
.message-rich-text {
font-size: 28rpx;
line-height: 1.7;
letter-spacing: 0.8rpx;
}
}
</style>

View File

@@ -17,6 +17,7 @@
<ai-chat-message
ref="chat"
:initial-messages="initialMessages"
:fallback-welcome="fallbackWelcome"
:user-avatar="userAvatar"
:ai-avatar="aiAvatar"
@message-sent="onMessageSent"
@@ -84,15 +85,8 @@ export default {
},
data() {
return {
initialMessages: [
{
id: 1,
role: 'ai',
type: 'text',
content: '您好!我是AI智能客服,很高兴为您服务!\n\n我可以帮您:\n• 解答产品相关问题\n• 处理订单问题\n• 提供技术支持\n• 推荐相关商品\n\n请告诉我您需要什么帮助?',
timestamp: Date.now() - 60000,
}
],
initialMessages: [],
fallbackWelcome: '您好!我是AI智能客服,很高兴为您服务!\n\n我可以帮您:\n• 解答产品相关问题\n• 处理订单问题\n• 提供技术支持\n• 推荐相关商品\n\n请告诉我您需要什么帮助?',
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px