<script setup lang="ts">
import { nextTick, ref } from 'vue';
// @ts-ignore - These variables are used in the template
import { marked as originalMarked } from 'marked';
// @ts-ignore - These variables are used in the template
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import { useMessage, useToast } from 'wot-design-uni';
import send from '/static/icons/send.svg';
import { onLoad } from '@dcloudio/uni-app'; // 添加页面生命周期钩子
// @ts-ignore - These variables are used in the template
import { getModellist } from '@/api/user.js';
const marked: any = originalMarked;
const inputBottom = ref('15rpx'); // 输入框底部间距
const keyboardHeight = ref(0); // 键盘高度
const message = useMessage();
const toast = useToast();
const picker = ref<any>(null); // 添加选择器引用
// 监听键盘高度变化
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height;
// 转换为px单位(小程序环境使用px)
inputBottom.value = `${res.height}px`;
});
// 在组件卸载时取消监听
onUnmounted(() => {
uni.offKeyboardHeightChange();
});
// 配置marked选项
marked.setOptions({
highlight: (code: string, lang: string) => {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (err) {
console.warn('代码高亮失败:', err);
return code;
}
}
return code;
},
gfm: true,
breaks: true,
headerIds: false,
mangle: false
});
function renderMarkdown(content: string) {
try {
// 在小程序中,我们需要确保返回的是字符串
const html = marked(content);
// 处理代码块的样式类,确保返回字符串
return String(html).replace(/<pre><code/g, '<pre><code class="hljs"');
} catch (err) {
console.error('Markdown渲染失败:', err);
return content;
}
}
interface Message {
role: 'user' | 'assistant';
content: string;
typing?: boolean;
}
const messages = ref<Message[]>([
{
role: 'assistant',
content: '你好,我是你的智能助手。有什么问题我可以帮你解答吗?'
}
]);
const inputMessage = ref('');
const loading = ref(false);
const scrollTop = ref(0);
const messageListRef = ref<HTMLElement | null>(null);
// 防抖函数
function debounce(fn: Function, delay: number) {
let timer: number | null = null;
return function (this: any, ...args: any[]) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 节流函数
function throttle(fn: Function, delay: number) {
let lastTime = 0;
let timer: number | null = null;
return function (this: any, ...args: any[]) {
const now = Date.now();
const remaining = delay - (now - lastTime);
if (remaining <= 0) {
if (timer) {
clearTimeout(timer);
timer = null;
}
fn.apply(this, args);
lastTime = now;
} else if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args);
lastTime = Date.now();
timer = null;
}, remaining);
}
};
}
// 滚动到底部的函数
const scrollToBottom = throttle(async () => {
if (!messageListRef.value) return;
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage?.typing) return; // 打字过程中不触发滚动
await nextTick();
const query = uni.createSelectorQuery();
query
.select('.message-list')
.boundingClientRect((data: any) => {
if (data) {
scrollTop.value = data.height;
}
})
.exec();
}, 200); // 增加节流时间,减少滚动更新频率
// 监听消息列表变化,自动滚动到底部
watch(
() => messages.value.length,
() => {
if (messages.value[messages.value.length - 1]?.typing) return;
scrollToBottom();
}
);
// 监听最后一条消息的内容变化(用于打字效果)
watch(
() => messages.value[messages.value.length - 1]?.content,
async () => {
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage?.typing) {
// 在打字过程中,每次内容变化都尝试滚动
await nextTick();
const query = uni.createSelectorQuery();
query
.select('.message-list')
.boundingClientRect((data: any) => {
if (data && data.height > scrollTop.value) {
scrollTop.value = data.height;
}
})
.exec();
}
}
);
// 模拟打字效果的函数
async function typeMessage(fullContent: string) {
try {
// 获取最后一条消息的引用
const lastMessage = messages.value[messages.value.length - 1];
// 初始化内容并标记为正在输入
lastMessage.content = '';
lastMessage.typing = true;
// 确保滚动到底部
await nextTick();
await scrollToBottom();
// 逐步更新内容
const chars = Array.from(fullContent);
let currentContent = '';
const batchSize = 1;
for (let i = 0; i < chars.length; i += batchSize) {
currentContent += chars.slice(i, i + batchSize).join('');
lastMessage.content = currentContent; // 直接更新最后一条消息
await new Promise((resolve) => setTimeout(resolve, 10));
await scrollToBottom();
}
// 最终状态更新
lastMessage.content = fullContent;
lastMessage.typing = false;
await nextTick();
await scrollToBottom();
} catch (error) {
console.error('打字效果执行失败:', error);
const lastMessage = messages.value[messages.value.length - 1];
lastMessage.content = fullContent;
lastMessage.typing = false;
await scrollToBottom();
}
}
async function sendMessage() {
if (!inputMessage.value.trim()) return;
const userContent = inputMessage.value;
// 添加用户消息
messages.value.push({
role: 'user',
content: userContent
});
// 清空输入框
inputMessage.value = '';
loading.value = true;
try {
// 创建初始的助手消息
const assistantMessage: Message = {
role: 'assistant',
content: '',
typing: true
};
messages.value.push(assistantMessage);
await nextTick();
const token = uni.getStorageSync('token');
// 发送请求
const { data } = await uni.request({
url: 'https://qa.mini.xmaas.cn/chat/completions',
method: 'POST',
header: {
logic: 'check',
Authorization: `${token}`,
'Content-Type': 'application/json'
},
data: {
position: 0,
model: pickerValue.value,
stream: true,
messages: [{ role: 'user', content: userContent }]
}
});
// 处理流式数据
const streamData = data as string;
const chunks = streamData
.split('\n\n') // 分割事件
.filter((chunk) => chunk.trim().startsWith('data:')); // 过滤有效数据
let fullContent = '';
for (const chunk of chunks) {
try {
const jsonStr = chunk.replace(/^data:/, '').trim();
if (!jsonStr) continue;
const eventData = JSON.parse(jsonStr);
const contentChunk = eventData.choices[0]?.delta?.content || '';
// 处理Unicode转义字符
const decodedContent = unescape(contentChunk.replace(/\\u/g, '%u'));
fullContent += decodedContent;
} catch (err) {
console.error('解析数据块失败:', err);
}
}
// 更新最后一条消息内容,并触发打字效果
messages.value[messages.value.length - 1].content = fullContent;
await typeMessage(fullContent); // 使用逐字打印效果
// 结束打字状态
messages.value[messages.value.length - 1].typing = false;
} catch (error) {
console.error('请求失败:', error);
messages.value[messages.value.length - 1].content = '回答生成失败,请稍后重试';
} finally {
loading.value = false;
await scrollToBottom();
}
}
function loadMoreMessages() {
// TODO: 实现加载更多历史消息
console.log('加载更多消息');
}
// ... existing code ...
function handleBack() {
uni.navigateBack({
delta: 1,
fail: () => {
// 如果返回失败(比如没有上一页),则跳转到首页
uni.switchTab({
url: '/pages/index'
});
}
});
}
//清空对话
// 修改 handClear 函数
async function handClear() {
const token = uni.getStorageSync('token');
try {
// 1. 调用清空接口
await uni.request({
url: 'https://qa.mini.xmaas.cn/message/deleteByChatId',
method: 'DELETE',
header: {
'Content-Type': 'application/json',
Authorization: `${token}`
}
});
// 2. 重置本地消息状态为初始欢迎消息
messages.value = [
{
role: 'assistant',
content: '你好,我是你的智能助手。有什么问题我可以帮你解答吗?'
}
];
// 3. 重置滚动位置到顶部
scrollTop.value = 0;
// 4. 显示成功提示(已经在 beforeConfirm 中处理)
} catch (error) {
console.error('清空消息失败:', error);
toast.error('清空消息失败');
}
}
function beforeConfirm() {
message
.confirm({
msg: '是否删除',
title: '提示',
beforeConfirm: ({ resolve }) => {
toast.loading('删除中...');
setTimeout(() => {
toast.close();
handClear();
resolve(true);
toast.success('删除成功');
}, 2000);
}
})
.then(() => {})
.catch((error) => {
console.log(error);
});
}
const columns = ref<Record<string, any>>([]);
const pickerValue = ref<string>('');
// 添加获取模型列表的函数
async function fetchModelList() {
try {
const data = await getModellist();
if (data && data.models && data.models.length > 0) {
// 将接口数据映射为选择器需要的格式
columns.value = data.models.map((model: any) => ({
value: model.name,
label: model.name
}));
// 设置默认选中第一个模型
if (columns.value.length > 0) {
pickerValue.value = columns.value[0].value;
}
}
} catch (error) {}
}
function handleChange({ value }: any) {
pickerValue.value = value;
}
// 添加按钮点击处理函数
function handleButtonClick() {
picker.value?.open();
}
function pauseLoading() {
console.log(22222222222);
}
// 添加历史消息获取函数
async function fetchHistoryMessages() {
const token = uni.getStorageSync('token');
try {
loading.value = true;
const { data }: any = await uni.request({
url: 'https://qa.mini.xmaas.cn/message/findByChatId',
method: 'GET',
header: {
'Content-Type': 'application/json',
Authorization: `${token}`
}
});
// 处理接口返回的数据
if (data && data.messages && data.messages.length > 0) {
// 按时间排序(确保消息顺序正确)
const sortedMessages = data.messages.sort((a: any, b: any) => a.created_time - b.created_time);
messages.value = [];
// 转换为需要的格式
sortedMessages.forEach((msg: any) => {
messages.value.push({
role: msg.model_id === 0 ? 'user' : 'assistant',
content: msg.content,
typing: false // 历史消息不需要打字效果
});
});
// 滚动到底部
await nextTick();
scrollToBottom();
} else {
// 没有历史消息时显示欢迎语
setWelcomeMessage();
}
} catch (error) {
console.error('获取历史消息失败:', error);
toast.error('加载历史消息失败');
// 请求失败时也显示欢迎语
setWelcomeMessage();
} finally {
loading.value = false;
}
}
function setWelcomeMessage() {
messages.value = [
{
role: 'assistant',
content: '你好,我是你的智能助手。有什么问题我可以帮你解答吗?'
}
];
}
// 在页面加载时获取历史消息
onLoad(() => {
fetchHistoryMessages();
fetchModelList();
});
//长按复制
function handleCopy(e: any) {
// 确保是复制操作
if (e.detail.action === 'copy') {
// 获取消息内容
const content = e.currentTarget.dataset.content;
// 复制到剪贴板
uni.setClipboardData({
data: content,
success: () => {
// 使用您现有的 toast 组件
toast.success('复制成功');
},
fail: () => {
toast.error('复制失败');
}
});
}
}
// +++ 添加长按事件处理 +++
function handleLongPress(e: any, content: string) {
console.log('长按事件触发', content);
}
</script>
<template>
<view class="chat-container">
<wd-navbar safe-area-inset-top placeholder left-arrow fixed :bordered="false" @click-left="handleBack">
<template #right>
<view class="custom-right">
<wd-icon name="clear" size="22px" @click="beforeConfirm" />
</view>
</template>
</wd-navbar>
<scroll-view
ref="messageListRef"
scroll-y
class="chat-messages"
:scroll-top="scrollTop"
:scroll-with-animation="false"
:scroll-anchoring="true"
:enhanced="true"
:bounces="false"
@scrolltoupper="loadMoreMessages"
>
<view class="message-list">
<view
v-for="(message, index) in messages"
:key="index"
class="message-item"
:class="[message.role, { typing: message.typing }]"
>
<view v-if="message.role === 'assistant'" class="message-avatar">
<image src="/static/svg/Hara.svg" mode="aspectFill" style="width: 44rpx; height: 44rpx" />
<text>Hara</text>
</view>
<view class="message-content">
<!-- 统一使用rich-text渲染Markdown -->
<view
v-if="!message.typing"
@longpress="(e) => handleLongPress(e, message.content)"
>
<rich-text
:nodes="renderMarkdown(message.content)"
:data-content="message.content"
/>
</view>
<rich-text
v-else
:nodes="renderMarkdown(message.content)"
/>
<view v-if="message.typing" class="typing-indicator">
<view class="dot" />
<view class="dot" />
<view class="dot" />
</view>
</view>
</view>
</view>
</scroll-view>
<wd-select-picker
ref="picker"
v-model="pickerValue"
:columns="columns"
@change="handleChange"
custom-class="hidden-picker"
custom-style="z-index:120"
type="radio"
:show-confirm="false"
></wd-select-picker>
<view class="button_picker" style="margin-bottom: 45rpx; margin-left: 25rpx">
<wd-button type="success" @click="handleButtonClick" custom-class="handbtn">
<text style="font-family: PingFang SC">{{ pickerValue }}</text>
<wd-icon name="arrow-down" size="16px" custom-style="transform: translateY(3rpx)"></wd-icon>
</wd-button>
</view>
<view class="chat-input safe-area-bottom" :style="{ bottom: inputBottom }">
<input
v-model="inputMessage"
type="text"
placeholder="向你的专属知识库提问吧~"
:rows="2"
class="message-textarea"
:disabled="loading"
:adjust-position="false"
@keypress.enter.prevent="sendMessage"
/>
<!-- <wd-button type="primary" size="small" :loading="loading" :disabled="!inputMessage.trim()" @click="sendMessage">
发送
</wd-button> -->
<image
:src="loading ? '/static/icons/Pause.svg' : !inputMessage.trim() ? '/static/icons/ic_send.svg' : '/static/icons/arrow.svg'"
@click="loading ? pauseLoading() : inputMessage.trim() ? sendMessage() : null"
style="width: 56rpx; height: 56rpx"
/>
</view>
</view>
</template>
<style lang="scss" scoped>
:deep(.handbtn) {
background-color: #d8f2f3 !important;
color: #14c3c9 !important;
width: auto !important;
border-radius: 16rpx !important;
font-family: 'PingFang SC';
display: flex;
align-items: center;
}
.button_picker {
margin-bottom: 90rpx;
margin-left: 80rpx;
}
/* 隐藏原生选择器控件 */
:deep(.hidden-picker) {
.wd-select-picker__field {
display: none !important;
}
.data-v-d4a8410a.wd-icon.wd-icon-check {
color: #14c3c9 !important;
}
.data-v-aa3a6253.wd-button.is-primary.is-large.is-round.is-block {
background-color: #7f6ce0 !important;
}
}
.custom-right {
display: flex;
align-items: center;
/* 垂直居中 */
gap: 10rpx;
/* 元素间距 */
padding-right: 160rpx;
/* 右侧距离 */
margin-right: 20rpx;
}
:deep(.wd-navbar) {
background-color: #f4f4f5 !important; // 添加背景色
}
:deep(.wd-navbar__title) {
color: #000000 !important;
}
:deep(.wd-icon-arrow-left) {
color: #000000 !important;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f4f4f5;
position: relative;
}
.chat-messages {
flex: 0.9;
box-sizing: border-box;
padding: 20rpx 0 20rpx 20rpx;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.message-list {
padding-bottom: 20rpx;
}
.message-item {
display: flex;
flex-direction: column;
margin-bottom: 48rpx;
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s, transform 0.3s;
// padding: 0 20rpx;
&.typing {
.message-content {
min-width: 120rpx;
}
}
&.user {
flex-direction: row-reverse;
.message-content {
background-color: #272727;
border-radius: 24rpx 24rpx 24rpx 24rpx;
padding: 20rpx;
color: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
max-width: 80%;
:deep(pre),
:deep(code) {
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
}
&.assistant .message-content {
max-width: 100%;
// background-color: #fff;
padding: 0 !important;
// border-radius: 4rpx 20rpx 20rpx 20rpx;
}
}
.message-avatar {
width: 140rpx;
flex-shrink: 0;
margin: 0;
display: flex;
align-items: center;
image {
width: 100%;
height: 100%;
border-radius: 50%;
}
text {
margin-left: 8rpx;
color: 737373;
font-weight: 400;
font-size: 28rpx;
}
}
.message-content {
// max-width: 80%;
margin: 0 20rpx;
font-size: 28rpx;
word-break: break-word;
overflow-wrap: break-word;
:deep(pre) {
background-color: #f6f8fa;
padding: 16rpx;
border-radius: 6rpx;
overflow-x: auto;
margin: 16rpx 0;
white-space: pre-wrap;
word-wrap: break-word;
}
:deep(code) {
font-family: Consolas, Monaco, 'Andale Mono', monospace;
font-size: 24rpx;
padding: 4rpx 8rpx;
background-color: #f6f8fa;
border-radius: 4rpx;
white-space: pre-wrap;
word-wrap: break-word;
}
:deep(p) {
margin: 16rpx 0;
}
:deep(ul),
:deep(ol) {
padding-left: 32rpx;
margin: 16rpx 0;
}
:deep(table) {
border-collapse: collapse;
margin: 16rpx 0;
width: 100%;
}
:deep(th),
:deep(td) {
border: 2rpx solid #dfe2e5;
padding: 12rpx 16rpx;
}
:deep(th) {
background-color: #f6f8fa;
}
:deep(a) {
color: #0366d6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(blockquote) {
margin: 16rpx 0;
padding: 0 16rpx;
color: #6a737d;
border-left: 4rpx solid #dfe2e5;
}
}
.chat-input {
padding: 24rpx 40rpx;
position: fixed;
left: 20rpx;
right: 20rpx;
bottom: 15rpx;
display: flex;
align-items: center;
gap: 20rpx;
z-index: 100;
transition: bottom 0.3s ease; // 添加过渡动画
border-radius: 16rpx;
border: 2rpx solid var(--Extra-Shallow-Theme, #f4f3fc);
background: var(--White, #fff);
box-shadow: 0px 8rpx 20rpx 0px rgba(62, 46, 136, 0.17);
margin-bottom: 45rpx;
.message-textarea {
flex: 1;
}
}
.typing-indicator {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 0;
.dot {
width: 8rpx;
height: 8rpx;
background-color: #999;
border-radius: 50%;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.3;
}
30% {
transform: translateY(-4rpx);
opacity: 1;
}
}
</style>
markdown 输出的消息不能复制咋办
最新发布