feat(ai-chat): 添加聊天记录搜索与远程欢迎语功能
This commit is contained in:
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除会话历史(调用后端接口)
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user