|
|
@@ -0,0 +1,541 @@
|
|
|
+<template>
|
|
|
+ <div class="ai_main">
|
|
|
+ <div class="main_ai_content" @scroll="handleUserScroll">
|
|
|
+ <div class="main_ai_weclome" >
|
|
|
+ <h1>HI~我是慧教研大数据精准分析AI助教</h1>
|
|
|
+ <p>您身边的智能助教,专注于成绩数据分析与教学改进建议,为您提供精准解读与决策支持,快来体验吧~</p>
|
|
|
+ </div>
|
|
|
+ <div class="main_ai_list" v-if="isShowQuickList">
|
|
|
+ <div class="main_ai_list_title">
|
|
|
+ 您可以这样问:
|
|
|
+ </div>
|
|
|
+ <div class="main_ai_list_content">
|
|
|
+ <span class="list_item_tag" v-for="(value,index) in quickList" :key="'list_quick_'+index" @click="quickAsk(value.title)">
|
|
|
+ {{value.title}}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <!-- 显示对话历史 -->
|
|
|
+ <div class="chat_history" v-if="chatHistory.length > 0 ">
|
|
|
+ <div v-for="(message, index) in chatHistory"
|
|
|
+ :key="index"
|
|
|
+ :class="['message_item', message.role]"
|
|
|
+ >
|
|
|
+ <div class="message_user" v-if="message.role === 'user'">
|
|
|
+
|
|
|
+ </div>
|
|
|
+ <div v-else class="message_avatar">
|
|
|
+ <div class="avatar_icon">
|
|
|
+ </div>
|
|
|
+ <div class="avatar_name">
|
|
|
+ <div class="name_text">
|
|
|
+ deepseek-v4-pro
|
|
|
+ <span v-if="loading">正在思考中,请稍后<i class="el-icon-loading"></i></span>
|
|
|
+ <span v-else>回答完成</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="user_message_content" v-if="message.role === 'user'">
|
|
|
+ <div class="content_user" >
|
|
|
+ {{message.content}}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="assistant_message_content" v-else>
|
|
|
+ <div class="content_text" v-html="FormatContent(message.content, message.role)"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_20"></div>
|
|
|
+ <div class="main_ai_input">
|
|
|
+ <el-input
|
|
|
+ type="textarea"
|
|
|
+ :rows="4"
|
|
|
+ :maxlength="1000"
|
|
|
+ placeholder="解读交给我,有问必答"
|
|
|
+ v-model="inputContent"
|
|
|
+ @keydown.native.enter="handleEnter"
|
|
|
+ >
|
|
|
+ </el-input>
|
|
|
+ <div class="chat_model" >
|
|
|
+ <i class="iconfont icon_deepseek"></i>
|
|
|
+ deepseek-v4-pro
|
|
|
+ </div>
|
|
|
+ <div class="input_button" @click="StopMessage" v-if="loading" title="点击可停止思考">
|
|
|
+ <i class="el-icon-loading"></i>
|
|
|
+ </div>
|
|
|
+ <div class="input_button" @click="SendMessage" v-else>
|
|
|
+ <i class="iconfont icon_shangjiantou"></i>
|
|
|
+ </div>
|
|
|
+ <!-- <div class="file_button" @click="OpenMessage()">
|
|
|
+ <i class="iconfont icon_folder_upload"></i>
|
|
|
+ </div> -->
|
|
|
+ </div>
|
|
|
+ <div class="main_ai_message">
|
|
|
+ 内容由ai生成,仅供参考
|
|
|
+ </div>
|
|
|
+ <div class="page_jg_10"></div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+<script>
|
|
|
+import MarkdownIt from 'markdown-it';
|
|
|
+export default {
|
|
|
+ name: "AiRobot",
|
|
|
+ components: {
|
|
|
+
|
|
|
+ },
|
|
|
+ props: {
|
|
|
+
|
|
|
+
|
|
|
+ isShowQuickList:{
|
|
|
+ type:Boolean,
|
|
|
+ default:true
|
|
|
+ },//默认显示快捷列表
|
|
|
+
|
|
|
+
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ inputContent:'',//输入框
|
|
|
+ chatHistory:[],//聊天历史记录
|
|
|
+ loading:false,//加载动画
|
|
|
+ apiKey:process.env.VUE_APP_DEEPSEEK_API_KEY || 'sk-85322fa189f24dd7aae324c5ff6a236a',// 从环境变量获取API密钥
|
|
|
+ quickList:[
|
|
|
+ {
|
|
|
+ title:'分数段如何助力教学问题诊断?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'平均分波动背后,教学调整方向在哪?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'标准差在成绩分析里怎么用?能看出啥教学问题?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'怎样通过名次段洞察学生学习走势?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'等级分布在教学分层决策中的应用?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'借助得分率,怎样精准定位学生知识薄弱点?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'超均分指标,如何指导教学资源分配?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'运用难度指标,怎样科学把控教学深度?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'用n%名段,如何精准规划教学?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'信度指标能帮助我们排查教学中的哪些隐患?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'在教学质量评估中,标准差能为教师教学的精准度提供哪些数据?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'借助标准分,如何横向比较学生不同学科的学习表现?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'优秀率的波动能反映出教学在培养尖子生方面的哪些问题?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'历次平均分变化,能为教学调整提供哪些线索?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'历次标准分变化,对教学评价体系有何调整意义?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'区分度指标,怎样助力实现个性化教学?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'通过对比不同学科的 n% 名段,能发现学生学科学习的哪些不均衡问题?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'如何借助四分位盒须图分析成绩离散问题?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'通过分析历次标准分,怎样判断教学是否有效提升了学生在整体中的竞争力?',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title:'从赋分等级分布能看出哪些教学问题?'
|
|
|
+ }
|
|
|
+ ],//快捷提问列表
|
|
|
+ currentStreamingMessageIndex: 0,//当前正在流式传输的消息序号
|
|
|
+ abortController: null,//用于取消请求的控制器
|
|
|
+ isUserScrolling: false,//是否正在用户滚动页面
|
|
|
+
|
|
|
+ };
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+
|
|
|
+ },
|
|
|
+ mounted()
|
|
|
+ {
|
|
|
+
|
|
|
+ },
|
|
|
+ methods:
|
|
|
+ {
|
|
|
+ //停止消息
|
|
|
+ StopMessage()
|
|
|
+ {
|
|
|
+ // 取消当前正在进行的请求
|
|
|
+ if (this.abortController) {
|
|
|
+ this.abortController.abort();
|
|
|
+ this.abortController = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 结束流式传输消息
|
|
|
+ this.finishStreamingMessage();
|
|
|
+
|
|
|
+ // 停止加载状态
|
|
|
+ this.loading = false;
|
|
|
+ this.$emit('StopMessage');
|
|
|
+ // 如果当前有正在流式传输的消息,将其标记为完成
|
|
|
+ if (this.currentStreamingMessageIndex >= 0 &&
|
|
|
+ this.currentStreamingMessageIndex < this.chatHistory.length) {
|
|
|
+ this.$set(this.chatHistory[this.currentStreamingMessageIndex], 'isStreaming', false);
|
|
|
+ this.currentStreamingMessageIndex = -1;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ //打开消息提醒
|
|
|
+ OpenMessage()
|
|
|
+ {
|
|
|
+ this.$message({
|
|
|
+ message: '上传文件功能暂未开放,敬请期待!',
|
|
|
+ type: 'warning'
|
|
|
+ });
|
|
|
+ },
|
|
|
+ // 发送消息
|
|
|
+ async SendMessage()
|
|
|
+ {
|
|
|
+ // 确保 inputContent 是字符串类型
|
|
|
+ const content = this.inputContent !== null && this.inputContent !== undefined ?
|
|
|
+ String(this.inputContent) : '';
|
|
|
+
|
|
|
+ if (!content.trim())
|
|
|
+ {
|
|
|
+ this.$message({
|
|
|
+ message: '您还未输入内容哦!',
|
|
|
+ type: 'warning'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ const userMessage = this.inputContent.trim();
|
|
|
+ this.addMessage('user', userMessage);
|
|
|
+ this.inputContent = '';
|
|
|
+ this.loading = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await this.callDeepSeekAPI(userMessage);
|
|
|
+ const aiResponse = response.data.choices[0].message.content;
|
|
|
+
|
|
|
+ console.log('AI 返回的消息:', aiResponse);
|
|
|
+ this.addMessage('assistant', aiResponse);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('API调用失败:', error);
|
|
|
+ let errorMessage = '抱歉,AI助手暂时无法回答您的问题。';
|
|
|
+ if (error.response) {
|
|
|
+ switch (error.response.status) {
|
|
|
+ case 401:
|
|
|
+ errorMessage = 'API密钥无效,请检查配置。';
|
|
|
+ break;
|
|
|
+ case 429:
|
|
|
+ errorMessage = '请求过于频繁,请稍后再试。';
|
|
|
+ break;
|
|
|
+ case 500:
|
|
|
+ errorMessage = '服务器内部错误,请稍后再试。';
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ errorMessage = `请求失败: ${error.response.data.message || '未知错误'}`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // this.addMessage('assistant', errorMessage);
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 设置输入框内容
|
|
|
+ SetInputContent(content)
|
|
|
+ {
|
|
|
+ if(content)
|
|
|
+ {
|
|
|
+ this.inputContent = content;
|
|
|
+ // 如果需要自动发送,可以调用发送方法
|
|
|
+ this.SendMessage();
|
|
|
+ this.inputContent = '';
|
|
|
+ }
|
|
|
+
|
|
|
+ },
|
|
|
+
|
|
|
+ // 快捷提问
|
|
|
+ quickAsk(question) {
|
|
|
+ this.inputContent = question;
|
|
|
+ this.SendMessage();
|
|
|
+ },
|
|
|
+
|
|
|
+ // 调用DeepSeek API(流式)
|
|
|
+ async callDeepSeekAPI(message) {
|
|
|
+ const apiUrl = 'https://api.deepseek.com/chat/completions';
|
|
|
+ // 创建AbortController用于取消请求
|
|
|
+ this.abortController = new AbortController();
|
|
|
+
|
|
|
+ // 构建消息历史(只保留最近几条以控制token使用)
|
|
|
+ const recentMessages = this.chatHistory.slice(-6); // 保留最近6轮对话
|
|
|
+ const messages = [
|
|
|
+ {
|
|
|
+ role: "system", //用户角色信息
|
|
|
+ content: "你是一位精通分析的教育专家,请帮我分析这份考试数据,要求保持原文核心逻辑和数据画像框架,语言更简洁精准、通俗易懂,贴合教育数据分析诊断的专业视角,同时确保上下文流畅,突出问题定位和教学改进相关的核心导向。"//角色提示词信息
|
|
|
+ },
|
|
|
+ ...recentMessages
|
|
|
+ .filter(msg => !msg.isStreaming) // 过滤掉仍在流式传输的消息
|
|
|
+ .map(msg => ({
|
|
|
+ role: msg.role,
|
|
|
+ content: msg.content
|
|
|
+ })),
|
|
|
+ {
|
|
|
+ role: "user",
|
|
|
+ content: message
|
|
|
+ }
|
|
|
+ ];
|
|
|
+
|
|
|
+ const payload = {
|
|
|
+ model: "deepseek-v4-pro",//模型名称 deepseek-v4-pro 思考版 DeepSeek-V4-Flash-0731 不带思索版
|
|
|
+ thinking: {
|
|
|
+ type: "disabled"//如果设为 enabled,则使用思考模式。如果设为 disabled,则使用非思考模式
|
|
|
+ },
|
|
|
+ messages: messages,
|
|
|
+ stream: true,// 是否为流式消息
|
|
|
+ };
|
|
|
+
|
|
|
+ // 添加AI助手的初始消息
|
|
|
+ this.currentStreamingMessageIndex = this.chatHistory.length;
|
|
|
+ this.addStreamingMessage('assistant', '');
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await fetch(apiUrl, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Authorization': `Bearer ${this.apiKey}`
|
|
|
+ },
|
|
|
+ body: JSON.stringify(payload),
|
|
|
+ signal: this.abortController.signal // 添加信号用于取消请求
|
|
|
+ });
|
|
|
+ console.log('打印请求结果response', response);
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`HTTP error! status: ${response.status}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const reader = response.body.getReader();
|
|
|
+ const decoder = new TextDecoder('utf-8');
|
|
|
+ let accumulatedContent = '';
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ // 检查是否已取消请求
|
|
|
+ if (this.abortController.signal.aborted) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ const { done, value } = await reader.read();
|
|
|
+ if (done) break;
|
|
|
+
|
|
|
+ const chunk = decoder.decode(value, { stream: true });
|
|
|
+ const lines = chunk.split('\n');
|
|
|
+
|
|
|
+ for (const line of lines) {
|
|
|
+ if (this.abortController.signal.aborted) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (line.startsWith('data: ')) {
|
|
|
+ const data = line.slice(6);
|
|
|
+ if (data === '[DONE]') {
|
|
|
+ // 流式传输完成
|
|
|
+ this.finishStreamingMessage();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(data);
|
|
|
+ const content = parsed.choices[0]?.delta?.content || '';
|
|
|
+ if (content) {
|
|
|
+ accumulatedContent += content;
|
|
|
+ this.updateStreamingMessage(accumulatedContent);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 忽略解析错误
|
|
|
+ console.warn('Failed to parse stream data:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.finishStreamingMessage();
|
|
|
+ } catch (error) {
|
|
|
+ // 检查是否是因为取消请求导致的错误
|
|
|
+ if (error.name === 'AbortError') {
|
|
|
+ console.log('请求已被用户取消');
|
|
|
+ this.finishStreamingMessage();
|
|
|
+ return; // 不抛出错误,因为这是用户主动取消的
|
|
|
+ }
|
|
|
+ console.error('Stream error:', error);
|
|
|
+ this.finishStreamingMessage();
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+
|
|
|
+ // 添加流式消息
|
|
|
+ addStreamingMessage(role, content) {
|
|
|
+ const now = new Date();
|
|
|
+ const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
|
|
+
|
|
|
+ this.chatHistory.push({
|
|
|
+ role: role,
|
|
|
+ content: content,
|
|
|
+ time: timeString,
|
|
|
+ isStreaming: true
|
|
|
+ });
|
|
|
+
|
|
|
+ this.scrollToBottom();
|
|
|
+ },
|
|
|
+
|
|
|
+ // 更新流式消息内容
|
|
|
+ updateStreamingMessage(content) {
|
|
|
+ if (this.currentStreamingMessageIndex >= 0 &&
|
|
|
+ this.currentStreamingMessageIndex < this.chatHistory.length) {
|
|
|
+ this.$set(this.chatHistory[this.currentStreamingMessageIndex], 'content', content);
|
|
|
+ this.scrollToBottom();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 完成流式消息
|
|
|
+ finishStreamingMessage() {
|
|
|
+ if (this.currentStreamingMessageIndex >= 0 &&
|
|
|
+ this.currentStreamingMessageIndex < this.chatHistory.length) {
|
|
|
+ this.$set(this.chatHistory[this.currentStreamingMessageIndex], 'isStreaming', false);
|
|
|
+ this.currentStreamingMessageIndex = -1;
|
|
|
+ }
|
|
|
+ // 完成后重置用户滚动状态
|
|
|
+ this.isUserScrolling = false;
|
|
|
+ this.scrollToBottom();
|
|
|
+ },
|
|
|
+
|
|
|
+ // 添加消息到历史记录
|
|
|
+ addMessage(role, content) {
|
|
|
+ const now = new Date();
|
|
|
+ const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
|
|
+
|
|
|
+ this.chatHistory.push({
|
|
|
+ role: role,
|
|
|
+ content: content,
|
|
|
+ time: timeString
|
|
|
+ });
|
|
|
+
|
|
|
+ // 滚动到底部
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const contentElement = document.querySelector('.main_ai_content');
|
|
|
+ if (contentElement) {
|
|
|
+ contentElement.scrollTop = contentElement.scrollHeight;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ // 滚动到底部
|
|
|
+ scrollToBottom() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const contentElement = document.querySelector('.main_ai_content');
|
|
|
+ if (contentElement) {
|
|
|
+ // 只有在用户未手动滚动或已接近底部时才自动滚动
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = contentElement;
|
|
|
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; // 距离底部50px以内
|
|
|
+
|
|
|
+ // 如果用户未手动滚动或者已经接近底部,则自动滚动
|
|
|
+ if (!this.isUserScrolling || isNearBottom) {
|
|
|
+ contentElement.scrollTop = scrollHeight; // 修正:使用scrollHeight而不是contentHeight
|
|
|
+ this.isUserScrolling = false; // 重置滚动状态
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ // 监听用户滚动事件
|
|
|
+ handleUserScroll() {
|
|
|
+ const contentElement = document.querySelector('.main_ai_content');
|
|
|
+ if (contentElement) {
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = contentElement;
|
|
|
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
|
|
|
+
|
|
|
+ // 如果用户滚动到顶部附近,标记为用户正在滚动
|
|
|
+ if (!isNearBottom) {
|
|
|
+ this.isUserScrolling = true;
|
|
|
+ } else {
|
|
|
+ // 如果滚动到底部附近,取消用户滚动标记
|
|
|
+ this.isUserScrolling = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 格式化消息内容
|
|
|
+ FormatContent(content, user)
|
|
|
+ {
|
|
|
+ console.log("打印用户的消息",user);
|
|
|
+ if (user === 'user') {
|
|
|
+ // 用户消息只显示前100个字,超出部分用省略号替代
|
|
|
+ console.log("打印用户的消息",content.length);
|
|
|
+ if(content.length>100)
|
|
|
+ {
|
|
|
+ return this.escapeHtml(content.substring(0, 100) + '...');
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ return this.escapeHtml(content);
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+ const md = new MarkdownIt({
|
|
|
+ html: true,// 允许解析HTML标签,可以直接渲染HTML代码
|
|
|
+ linkify: true,// 自动识别URL并将其转换为可点击的链接
|
|
|
+ typographer: true,//启用语言替换和引号美化功能,如将引号转换为更美观的样式
|
|
|
+ });
|
|
|
+ return md.render(content);
|
|
|
+ },
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 转义HTML特殊字符
|
|
|
+ escapeHtml(text) {
|
|
|
+ const map = {
|
|
|
+ '&': '&',
|
|
|
+ '<': '<',
|
|
|
+ '>': '>',
|
|
|
+ '"': '"',
|
|
|
+ "'": '''
|
|
|
+ };
|
|
|
+
|
|
|
+ return text.replace(/[&<>"']/g, function(m) { return map[m]; });
|
|
|
+ },
|
|
|
+
|
|
|
+
|
|
|
+ // 处理回车键发送(任何情况下的回车都发送消息)
|
|
|
+ handleEnter(event) {
|
|
|
+ // 如果按住 Shift 键,则允许换行(不阻止默认行为)
|
|
|
+ if (event.shiftKey) {
|
|
|
+ return; // 允许默认的换行行为
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果没有按住 Shift 键,则发送消息
|
|
|
+ event.preventDefault();
|
|
|
+ this.SendMessage();
|
|
|
+ }
|
|
|
+ },
|
|
|
+};
|
|
|
+</script>
|
|
|
+<style lang="scss" scoped>
|
|
|
+
|
|
|
+</style>
|