|
|
@@ -16,7 +16,7 @@
|
|
|
</span>
|
|
|
<template #dropdown>
|
|
|
<el-dropdown-menu>
|
|
|
- <el-dropdown-item v-for="model in modelOptions" :command="model">{{ model }}</el-dropdown-item>
|
|
|
+ <el-dropdown-item v-for="model in modelOptions" :key="model" :command="model">{{ model }}</el-dropdown-item>
|
|
|
</el-dropdown-menu>
|
|
|
</template>
|
|
|
</el-dropdown>
|
|
|
@@ -28,7 +28,7 @@
|
|
|
<div :class="$style['message-box']" ref="containerTopRef">
|
|
|
<div
|
|
|
v-for="(item, index) in messageList"
|
|
|
- :key="index"
|
|
|
+ :key="`${item?.role}-${index}`"
|
|
|
:class="$style['message-group']"
|
|
|
>
|
|
|
<!-- 用户消息 -->
|
|
|
@@ -51,8 +51,10 @@
|
|
|
</div>
|
|
|
<div :class="$style['message-info']">
|
|
|
<div :class="$style['ai-name']">{{ data.model }}</div>
|
|
|
- <div :class="[$style['message-bubble'], $style['ai-bubble']]">
|
|
|
- {{ item?.content }}
|
|
|
+ <div
|
|
|
+ :class="[$style['message-bubble'], $style['ai-bubble']]"
|
|
|
+ v-html="renderMarkdown(item?.content || '')"
|
|
|
+ >
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -106,14 +108,16 @@
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
import "./AIChatter.module.scss";
|
|
|
- import type {IDialogue} from "@/types/dialogue.ts";
|
|
|
+ import type {IDialogue} from "@/types/dialogue";
|
|
|
import {useStore} from "@/store";
|
|
|
import useApis from "@/apis";
|
|
|
- import {onMounted, reactive, ref, defineProps, watch, onUnmounted, nextTick} from "vue";
|
|
|
+ import {onMounted, reactive, ref, watch, onUnmounted, nextTick} from "vue";
|
|
|
import {ArrowDown, Top} from '@element-plus/icons-vue'
|
|
|
import {ElMessage} from "element-plus";
|
|
|
import emitter from "@/utils/emitter";
|
|
|
- import dayjs from "dayjs";
|
|
|
+ import * as dayjsModule from "dayjs";
|
|
|
+ import { marked } from "marked";
|
|
|
+ import * as DOMPurifyModule from "dompurify";
|
|
|
|
|
|
// const svg = `
|
|
|
// <path class="path" d="
|
|
|
@@ -146,20 +150,33 @@
|
|
|
</svg>
|
|
|
`;
|
|
|
//update: 加入isTeacher属性,用于判断是否是教师批改界面
|
|
|
- interface IProps {
|
|
|
- dialogueId: string;
|
|
|
- messages: IDialogue[]
|
|
|
- isTeacher: boolean
|
|
|
- }
|
|
|
-
|
|
|
- const props = defineProps<{
|
|
|
+ const props = withDefaults(defineProps<{
|
|
|
dialogueId: string,
|
|
|
messages: IDialogue[]
|
|
|
isTeacher: boolean
|
|
|
- }>()
|
|
|
+ }>(), {
|
|
|
+ isTeacher: false
|
|
|
+ })
|
|
|
|
|
|
const apis = useApis()
|
|
|
const store = useStore()
|
|
|
+ const dayjs = (
|
|
|
+ (dayjsModule as unknown as { default?: (date?: Date) => { format: (template: string) => string } }).default
|
|
|
+ ?? dayjsModule
|
|
|
+ ) as (date?: Date) => { format: (template: string) => string }
|
|
|
+ const sanitizeHtml = (dirty: string) => {
|
|
|
+ const purifierModule = DOMPurifyModule as unknown as {
|
|
|
+ default?: { sanitize: (input: string) => string },
|
|
|
+ sanitize?: (input: string) => string
|
|
|
+ }
|
|
|
+ const purifier = purifierModule.default ?? purifierModule
|
|
|
+ return purifier.sanitize(dirty)
|
|
|
+ }
|
|
|
+
|
|
|
+ marked.setOptions({
|
|
|
+ gfm: true,
|
|
|
+ breaks: true
|
|
|
+ })
|
|
|
//UPDATE: 目前只有ChatGLM4
|
|
|
const modelOptions = ["ChatGLM4"]
|
|
|
const questionTemplates = [
|
|
|
@@ -175,7 +192,7 @@
|
|
|
const containerTopRef = ref(null);
|
|
|
|
|
|
// 用于处理ai对话展示异常问题
|
|
|
- let messageList:IDialogue[] = []
|
|
|
+ const messageList = ref<IDialogue[]>([])
|
|
|
|
|
|
let data = reactive<{
|
|
|
model: string;
|
|
|
@@ -196,15 +213,14 @@
|
|
|
// 加载聊天记录,并滚动至最后
|
|
|
onMounted(() => {
|
|
|
if (props.messages) data.messages = props.messages;
|
|
|
- // console.log(props.messages)
|
|
|
+ handleMessageList()
|
|
|
nextTick(scrollToEnd);
|
|
|
});
|
|
|
|
|
|
const scrollToEnd = () => {
|
|
|
- // messagesEndRef.value?.scrollIntoView({ behavior: 'smooth' });
|
|
|
- let container = containerTopRef.value
|
|
|
- container.scrollTo({ top: container.scrollHeight-1, behavior: 'smooth' });
|
|
|
-
|
|
|
+ const container = containerTopRef.value as HTMLDivElement | null
|
|
|
+ if (!container) return
|
|
|
+ container.scrollTo({ top: container.scrollHeight - 1, behavior: 'smooth' });
|
|
|
};
|
|
|
|
|
|
watch(() => props.messages, (newMessages:any) => {
|
|
|
@@ -240,7 +256,9 @@
|
|
|
});
|
|
|
|
|
|
// 发送问题
|
|
|
- const handleSendQuestion = () => {
|
|
|
+ const handleSendQuestion = async () => {
|
|
|
+ if (data.loading || !data.questionContent.trim()) return
|
|
|
+
|
|
|
// 触发行为记录
|
|
|
emitter.emit("click-ai-send-button", {
|
|
|
insert: data.questionContent,
|
|
|
@@ -258,32 +276,60 @@
|
|
|
}
|
|
|
data.loading = true;
|
|
|
|
|
|
- let requestBody:IDialogue[] = data.messages
|
|
|
- requestBody.push({
|
|
|
+ const questionContent = data.questionContent.trim()
|
|
|
+ const userMessage: IDialogue = {
|
|
|
role: 'user',
|
|
|
- content: data.questionContent
|
|
|
- })
|
|
|
+ content: questionContent
|
|
|
+ }
|
|
|
+ const assistantMessage: IDialogue = {
|
|
|
+ role: 'assistant',
|
|
|
+ content: ''
|
|
|
+ }
|
|
|
+ const requestBody: IDialogue[] = [...data.messages, userMessage]
|
|
|
+
|
|
|
+ data.messages.push(userMessage)
|
|
|
+ data.messages.push(assistantMessage)
|
|
|
+ data.questionContent = ''
|
|
|
+ handleMessageList()
|
|
|
+ nextTick(scrollToEnd)
|
|
|
|
|
|
- apis.requestAI(props.dialogueId, data.model, requestBody)
|
|
|
- .then((_res:any) => {
|
|
|
- const resDialogue = _res.data.data;
|
|
|
- data.messages.push(resDialogue);
|
|
|
+ try {
|
|
|
+ await apis.requestAIStream(props.dialogueId, data.model, requestBody, {
|
|
|
+ onChunk: (chunk: string) => {
|
|
|
+ assistantMessage.content += chunk
|
|
|
handleMessageList()
|
|
|
- })
|
|
|
- .catch((_err:any) => {
|
|
|
- console.log(_err);
|
|
|
- })
|
|
|
- .finally(() => {
|
|
|
- data.questionContent = '';
|
|
|
- data.loading = false;
|
|
|
- nextTick(scrollToEnd);
|
|
|
- });
|
|
|
+ nextTick(scrollToEnd)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } catch (_err:any) {
|
|
|
+ console.log(_err);
|
|
|
+ if (!assistantMessage.content) {
|
|
|
+ data.messages.pop()
|
|
|
+ } else {
|
|
|
+ assistantMessage.content += '\n\n[请求中断]'
|
|
|
+ }
|
|
|
+ ElMessage.error('AI 请求失败,请稍后重试')
|
|
|
+ handleMessageList()
|
|
|
+ } finally {
|
|
|
+ data.loading = false;
|
|
|
+ nextTick(scrollToEnd);
|
|
|
+ }
|
|
|
};
|
|
|
|
|
|
const handleCommand = (command:string) => {
|
|
|
data.model = command
|
|
|
}
|
|
|
|
|
|
+ const normalizeMarkdown = (content: string) => {
|
|
|
+ return content.replace(/^(#{1,6})(\S)/gm, '$1 $2')
|
|
|
+ }
|
|
|
+
|
|
|
+ const renderMarkdown = (content: string) => {
|
|
|
+ const normalizedContent = normalizeMarkdown(content ?? '')
|
|
|
+ const html = marked.parse(normalizedContent)
|
|
|
+ return sanitizeHtml(typeof html === 'string' ? html : '')
|
|
|
+ }
|
|
|
+
|
|
|
// 选择模板问题
|
|
|
const handleMenuClick = (key:any) => {
|
|
|
data.questionContent = questionTemplates[key];
|
|
|
@@ -292,6 +338,7 @@
|
|
|
|
|
|
// 处理ai对话显示异常问题
|
|
|
const handleMessageList = () => {
|
|
|
+ const nextMessageList: IDialogue[] = []
|
|
|
// console.log(data.messages)
|
|
|
let count1 = 0
|
|
|
let count2 = 1
|
|
|
@@ -312,7 +359,7 @@
|
|
|
if(userNum > AssistantNum) count2 = count2 + 2 * (userNum - AssistantNum)
|
|
|
for(let message of data.messages){
|
|
|
if(message.role == "user"){
|
|
|
- messageList[count1] = {
|
|
|
+ nextMessageList[count1] = {
|
|
|
role: "user",
|
|
|
content: message.content
|
|
|
}
|
|
|
@@ -320,13 +367,13 @@
|
|
|
}
|
|
|
if(message.role == "assistant"){
|
|
|
if(userNum == AssistantNum){
|
|
|
- messageList[count2] = {
|
|
|
+ nextMessageList[count2] = {
|
|
|
role: "assistant",
|
|
|
content: message.content
|
|
|
}
|
|
|
count2+=2
|
|
|
} else {
|
|
|
- messageList[count2] = {
|
|
|
+ nextMessageList[count2] = {
|
|
|
role: "assistant",
|
|
|
content: message.content
|
|
|
}
|
|
|
@@ -334,6 +381,7 @@
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ messageList.value = nextMessageList
|
|
|
// console.log(messageList)
|
|
|
}
|
|
|
|