浏览代码

feat:公式展示优化

zhangjie 1 月之前
父节点
当前提交
4b54f23c0f

+ 2 - 1
src/assets/styles/pages.scss

@@ -1932,13 +1932,14 @@
   .sse-output-container {
     flex-grow: 1;
     border: 1px solid #dcdfe6;
-    padding: 10px;
+    padding: 15px;
     overflow-y: auto;
     background-color: #fff;
     white-space: pre-wrap;
     word-break: break-all;
     border-bottom-left-radius: 3px;
     border-bottom-right-radius: 3px;
+    color: #313444;
   }
 
   .action-buttons {

+ 7 - 6
src/modules/question/api.js

@@ -423,16 +423,17 @@ export const asyncTaskDownloadApi = (taskId) => {
 };
 // ai-question
 export const aiBuildQuestionApi = (datas, config = {}) => {
-  return fetch(`${QUESTION_API}/ai/question/stream/build`, {
+  // vue-server代理导致的SSE流式响应延迟,试过调整proxy配置,也不行,这里暂时这么处理
+  const prev =
+    process.env.NODE_ENV === "development"
+      ? process.env.VUE_APP_QUESTIONS_HOST_URL
+      : "";
+  return fetch(`${prev}${QUESTION_API}/ai/question/stream/build`, {
     ...config,
     method: "POST",
     body: JSON.stringify(datas),
   });
 };
 export const aiBuildQuestionSaveApi = (datas) => {
-  return $httpWithMsg.post(
-    `${QUESTION_API}/ai/question/stream/save`,
-    {},
-    { params: datas }
-  );
+  return $httpWithMsg.post(`${QUESTION_API}/ai/question/stream/save`, datas);
 };

+ 498 - 0
src/modules/question/components/ai-question/AiQuestionCreateDialog.throttle.vue

@@ -0,0 +1,498 @@
+<template>
+  <el-dialog
+    custom-class="ai-question-create-dialog"
+    :visible.sync="modalIsShow"
+    fullscreen
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @opened="handleOpened"
+    @close="handleClose"
+  >
+    <div slot="title" class="dialog-title-container">
+      <div class="title-left">
+        <span class="title-text">创建试题 - AI 命题</span>
+        <span v-if="courseInfo.name && courseInfo.code" class="course-info">
+          {{ courseInfo.name }} ({{ courseInfo.code }})
+        </span>
+      </div>
+    </div>
+
+    <div class="dialog-content-container">
+      <div class="left-panel">
+        <el-form
+          ref="form"
+          :model="formModel"
+          :rules="rules"
+          label-width="90px"
+          class="form-container"
+        >
+          <el-form-item prop="questionType" label="选择题型">
+            <el-button
+              v-for="item in questionTypes"
+              :key="item.id"
+              :type="
+                formModel.questionType === item.questionType
+                  ? 'primary'
+                  : 'default'
+              "
+              size="small"
+              @click="switchType(item)"
+              >{{ item.questionTypeName }}</el-button
+            >
+          </el-form-item>
+
+          <el-form-item prop="questionCount" label="出题数量">
+            <el-input-number
+              v-model="formModel.questionCount"
+              :min="1"
+              :max="10"
+              :step="1"
+              step-strictly
+              :controls="false"
+            ></el-input-number>
+          </el-form-item>
+
+          <el-form-item
+            v-if="isChoiceQuestion"
+            prop="optionCount"
+            label="选项个数"
+          >
+            <el-input-number
+              v-model="formModel.optionCount"
+              :min="2"
+              :max="8"
+              :step="1"
+              step-strictly
+              :controls="false"
+            ></el-input-number>
+          </el-form-item>
+
+          <el-form-item label="教学大纲">
+            <div class="box-justify">
+              <el-checkbox v-model="formModel.syllabus"
+                >教学大纲pdf</el-checkbox
+              >
+              <upload-button
+                btn-content="上传文件"
+                btn-icon="icon icon-import"
+                :disabled="uploading || !courseInfo.id"
+                :upload-data="{ id: courseInfo.id }"
+                :upload-url="uploadUrl"
+                :format="importFileTypes"
+                @valid-error="validError"
+                @upload-success="uploadSuccess"
+              ></upload-button>
+            </div>
+            <el-input
+              v-if="formModel.syllabus"
+              type="textarea"
+              :rows="3"
+              v-model="formModel.syllabusNotes"
+              placeholder="请输入教学大纲补充说明"
+            ></el-input>
+          </el-form-item>
+
+          <el-form-item label="选择知识点">
+            <property-tree-select
+              v-model="formModel.propertyIdList"
+              :course-id="courseInfo.id"
+              :style="{ width: '100%' }"
+            ></property-tree-select>
+            <el-input
+              v-if="
+                formModel.propertyIdList && formModel.propertyIdList.length > 0
+              "
+              v-model="formModel.knowledgeNotes"
+              type="textarea"
+              :rows="3"
+              placeholder="请输入知识点补充说明"
+              style="margin-top: 10px"
+            ></el-input>
+          </el-form-item>
+
+          <el-form-item>
+            <el-button type="primary" @click="toBuildQuestion"
+              >生成试题</el-button
+            >
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <div class="right-panel">
+        <div class="sse-output-title">试题预览</div>
+        <div class="sse-output-container">
+          <pre>{{ output }}</pre>
+        </div>
+        <div class="action-buttons">
+          <el-button type="primary" @click="saveQuestions">保存试题</el-button>
+          <el-button @click="clearPreview">清空预览</el-button>
+        </div>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { BASE_QUESTION_TYPES } from "@/constants/constants";
+import { createParser } from "eventsource-parser";
+import PropertyTreeSelect from "../components/PropertyTreeSelect.vue";
+import UploadButton from "@/components/UploadButton.vue";
+import { QUESTION_API } from "@/constants/constants";
+import {
+  sourceDetailPageListApi,
+  aiBuildQuestionApi,
+  aiBuildQuestionSaveApi,
+} from "../../api";
+
+import { fetchTime } from "@/plugins/syncServerTime";
+import { getAuthorization } from "@/plugins/crypto";
+
+export default {
+  name: "AiQuestionCreateDialog",
+  components: {
+    PropertyTreeSelect,
+    UploadButton,
+  },
+  props: {
+    courseInfo: {
+      type: Object,
+      default: () => ({ id: "", name: "", code: "" }),
+    },
+  },
+  data() {
+    return {
+      modalIsShow: false,
+      formModel: this.getInitForm(),
+      questionTypes: [],
+      sseData: "", // 用于存储SSE流式数据
+      lastEventTime: 0, // 用于记录上一次事件的时间
+      updateTimer: null, // 用于存储更新UI的定时器引用
+      rules: {
+        courseId: [
+          {
+            required: true,
+            message: "请选择课程",
+            trigger: "change",
+          },
+        ],
+        questionCount: [
+          {
+            required: true,
+            message: "请输入出题数量",
+            trigger: "change",
+          },
+        ],
+        optionCount: [
+          {
+            required: true,
+            message: "请输入选项个数",
+            trigger: "change",
+          },
+        ],
+        questionType: [
+          {
+            required: true,
+            message: "请选择题型",
+            trigger: "change",
+          },
+        ],
+      },
+      // upload
+      uploading: false,
+      importFileTypes: ["pdf"],
+      uploadUrl: `${QUESTION_API}/course/outline/upload`,
+      showIframeDialog: false,
+      // ai question result
+      taskId: "",
+      aiResult: "",
+      // output
+      output: "",
+      loading: false,
+      controller: null,
+      parser: null,
+    };
+  },
+  computed: {
+    isChoiceQuestion() {
+      return ["SINGLE_ANSWER_QUESTION", "MULTIPLE_ANSWER_QUESTION"].includes(
+        this.formModel.questionType
+      );
+    },
+  },
+  watch: {
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+  },
+  methods: {
+    close() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    async getQuestionTypes() {
+      if (!this.courseInfo.id) return;
+      const res = await sourceDetailPageListApi({
+        courseId: this.courseInfo.id,
+        rootOrgId: this.$store.state.user.rootOrgId,
+        pageSize: 100,
+        pageNum: 1,
+      }).catch(() => {});
+
+      if (!res) return;
+
+      const baseQuestionCodes = BASE_QUESTION_TYPES.map((item) => item.code);
+      this.questionTypes = (res.data.content || []).filter((item) =>
+        baseQuestionCodes.includes(item.questionType)
+      );
+
+      if (this.questionTypes.length > 0) {
+        this.formModel.questionType = this.questionTypes[0].questionType;
+      }
+    },
+    switchType(item) {
+      this.formModel.questionType = item.questionType;
+      this.$refs.form.validateField("questionType");
+    },
+    getInitForm() {
+      return {
+        questionType: "SINGLE_CHOICE",
+        questionCount: 1,
+        optionCount: 4,
+        syllabus: false,
+        syllabusNotes: "",
+        propertyIdList: [],
+        knowledgeNotes: "",
+      };
+    },
+    handleOpened() {
+      this.formModel = this.getInitForm();
+      this.getQuestionTypes();
+      this.getInitForm();
+    },
+    handleClose() {
+      this.stopStream();
+    },
+    validError(error) {
+      this.$message.error(error.message);
+    },
+    uploadSuccess(response) {
+      console.log(response);
+      // TODO:
+      this.$message.success("上传成功!");
+    },
+    setAuth(config) {
+      const headers = {};
+      let userSession = sessionStorage.getItem("user");
+      if (userSession) {
+        let user = JSON.parse(userSession);
+        const timestamp = fetchTime();
+        const authorization = getAuthorization(
+          {
+            method: config.method,
+            uri: config.url.split("?")[0].trim(),
+            timestamp,
+            sessionId: user.sessionId,
+            token: user.accessToken,
+          },
+          "token"
+        );
+        headers["Authorization"] = authorization;
+        headers["time"] = timestamp;
+      }
+      return headers;
+    },
+    async toBuildQuestion() {
+      const valid = await this.$refs.form.validate().catch(() => false);
+      if (!valid) return;
+
+      if (this.loading) return;
+
+      this.stopStream(); // 清理前一个流
+      this.output = "";
+      this.aiResult = ""; // Reset aiResult for the new stream
+      this.taskId = ""; // Reset taskId for the new stream
+      this.lastEventTime = Date.now(); // 初始化事件时间
+      this.loading = true;
+
+      try {
+        const res = await aiBuildQuestionApi(
+          {
+            ...this.formModel,
+            courseId: this.courseInfo.id,
+            rootOrgId: this.$store.state.user.rootOrgId,
+          },
+          {
+            headers: {
+              "Content-Type": "application/json",
+              ...this.setAuth({
+                method: "post",
+                url: "/api/uq_basic/ai/question/stream/build",
+              }),
+            },
+            signal: (this.controller = new AbortController()).signal, // Ensures a new controller for each call
+          }
+        );
+
+        // 使用节流函数来控制UI更新频率
+        let pendingContent = "";
+        let updateScheduled = false;
+        let lastUpdateTime = 0;
+        const UPDATE_INTERVAL = 30; // 30毫秒的节流间隔,更适合流式响应
+
+        const updateUI = () => {
+          if (pendingContent) {
+            this.output += pendingContent;
+            this.aiResult += pendingContent;
+            pendingContent = "";
+          }
+          updateScheduled = false;
+          lastUpdateTime = Date.now();
+        };
+
+        const scheduleUpdate = (content) => {
+          pendingContent += content;
+
+          if (!updateScheduled) {
+            const now = Date.now();
+            const timeUntilNextUpdate = Math.max(
+              0,
+              UPDATE_INTERVAL - (now - lastUpdateTime)
+            );
+
+            updateScheduled = true;
+            // 保存定时器引用以便清理
+            if (this.updateTimer) {
+              clearTimeout(this.updateTimer);
+            }
+            this.updateTimer = setTimeout(() => {
+              requestAnimationFrame(updateUI);
+            }, timeUntilNextUpdate);
+          }
+        };
+
+        const onEvent = (event) => {
+          // 计算事件间隔时间,帮助调试
+          const now = Date.now();
+          const timeDiff = now - this.lastEventTime;
+          this.lastEventTime = now;
+
+          // 仅在开发环境下输出调试信息
+          if (process.env.NODE_ENV === "development" && timeDiff > 0) {
+            console.log(`事件间隔: ${timeDiff}ms`);
+          }
+
+          if (event.data === "[DONE]") {
+            // 确保最后一次更新被应用
+            if (pendingContent) {
+              requestAnimationFrame(updateUI);
+            }
+            this.controller.abort(); // 结束流
+            return;
+          }
+
+          try {
+            const parsed = JSON.parse(event.data);
+            if (!this.taskId && parsed.id) {
+              this.taskId = parsed.id;
+            }
+
+            if (
+              parsed.choices &&
+              parsed.choices[0] &&
+              parsed.choices[0].delta
+            ) {
+              const content = parsed.choices[0].delta.content;
+              if (typeof content === "string") {
+                scheduleUpdate(content);
+              }
+            }
+          } catch (e) {
+            console.error(
+              "Error parsing SSE JSON:",
+              e,
+              "Event data:",
+              event.data
+            );
+          }
+        };
+        const onError = (error) => {
+          console.error("Error parsing event:", error);
+          if (error.type === "invalid-field") {
+            console.error("Field name:", error.field);
+            console.error("Field value:", error.value);
+            console.error("Line:", error.line);
+          } else if (error.type === "invalid-retry") {
+            console.error("Invalid retry interval:", error.value);
+          }
+        };
+
+        const parser = createParser({ onEvent, onError });
+        this.parser = parser;
+
+        // Pipe the response stream through TextDecoderStream and feed to parser
+        const reader = res.body
+          .pipeThrough(new TextDecoderStream())
+          .getReader();
+        // eslint-disable-next-line no-constant-condition
+        while (true) {
+          const { done, value } = await reader.read();
+          if (done) {
+            break;
+          }
+          this.parser.feed(value);
+        }
+      } catch (err) {
+        if (err.name !== "AbortError") {
+          console.error("流式处理错误:", err);
+        }
+      } finally {
+        this.loading = false;
+        this.controller = null; // Ensure controller is reset
+      }
+    },
+    stopStream() {
+      this.controller?.abort();
+      this.controller = null;
+      this.parser = null;
+      this.loading = false;
+
+      // 清理可能存在的定时器
+      if (this.updateTimer) {
+        clearTimeout(this.updateTimer);
+        this.updateTimer = null;
+      }
+    },
+    async saveQuestions() {
+      if (this.loading) return;
+      this.loading = true;
+      const res = await aiBuildQuestionSaveApi({
+        aiResult: this.aiResult,
+        taskId: this.taskId,
+      }).catch(() => {});
+      this.loading = false;
+      if (!res) return;
+      this.$message.success("保存成功");
+      this.$emit("modified");
+      this.close();
+    },
+    clearPreview() {
+      this.output = "";
+    },
+  },
+  beforeDestroy() {
+    this.stopStream(); // 在组件销毁前清理流和定时器
+
+    // 确保所有资源都被清理
+    if (this.updateTimer) {
+      clearTimeout(this.updateTimer);
+      this.updateTimer = null;
+    }
+  },
+};
+</script>

+ 30 - 30
src/modules/question/components/AiQuestionCreateDialog.vue → src/modules/question/components/ai-question/AiQuestionCreateDialog.vue

@@ -26,6 +26,7 @@
           :rules="rules"
           label-width="90px"
           class="form-container"
+          :disabled="loading"
         >
           <el-form-item prop="questionType" label="选择题型">
             <el-button
@@ -112,7 +113,10 @@
           </el-form-item>
 
           <el-form-item>
-            <el-button type="primary" @click="toBuildQuestion"
+            <el-button
+              type="primary"
+              :loading="loading"
+              @click="toBuildQuestion"
               >生成试题</el-button
             >
           </el-form-item>
@@ -122,11 +126,16 @@
       <div class="right-panel">
         <div class="sse-output-title">试题预览</div>
         <div class="sse-output-container">
-          <pre>{{ output }}</pre>
+          <!-- {{ output }} -->
+          <sse-result-view :output="aiResult"></sse-result-view>
         </div>
         <div class="action-buttons">
-          <el-button type="primary" @click="saveQuestions">保存试题</el-button>
-          <el-button @click="clearPreview">清空预览</el-button>
+          <el-button type="primary" :disabled="loading" @click="saveQuestions"
+            >保存试题</el-button
+          >
+          <el-button :disabled="loading" @click="clearPreview"
+            >清空预览</el-button
+          >
         </div>
       </div>
     </div>
@@ -136,14 +145,15 @@
 <script>
 import { BASE_QUESTION_TYPES } from "@/constants/constants";
 import { createParser } from "eventsource-parser";
-import PropertyTreeSelect from "../components/PropertyTreeSelect.vue";
+import PropertyTreeSelect from "../PropertyTreeSelect.vue";
+import SseResultView from "./SseResultView.vue";
 import UploadButton from "@/components/UploadButton.vue";
 import { QUESTION_API } from "@/constants/constants";
 import {
   sourceDetailPageListApi,
   aiBuildQuestionApi,
   aiBuildQuestionSaveApi,
-} from "../api";
+} from "../../api";
 
 import { fetchTime } from "@/plugins/syncServerTime";
 import { getAuthorization } from "@/plugins/crypto";
@@ -153,6 +163,7 @@ export default {
   components: {
     PropertyTreeSelect,
     UploadButton,
+    SseResultView,
   },
   props: {
     courseInfo: {
@@ -165,7 +176,6 @@ export default {
       modalIsShow: false,
       formModel: this.getInitForm(),
       questionTypes: [],
-      sseData: "", // 用于存储SSE流式数据
       rules: {
         courseId: [
           {
@@ -204,8 +214,6 @@ export default {
       // ai question result
       taskId: "",
       aiResult: "",
-      // output
-      output: "",
       loading: false,
       controller: null,
       parser: null,
@@ -274,6 +282,8 @@ export default {
       this.getInitForm();
     },
     handleClose() {
+      this.aiResult = "";
+      this.taskId = "";
       this.stopStream();
     },
     validError(error) {
@@ -312,9 +322,8 @@ export default {
       if (this.loading) return;
 
       this.stopStream(); // 清理前一个流
-      this.output = "";
-      this.aiResult = ""; // Reset aiResult for the new stream
-      this.taskId = ""; // Reset taskId for the new stream
+      this.aiResult = "";
+      this.taskId = "";
       this.loading = true;
 
       try {
@@ -327,6 +336,9 @@ export default {
           {
             headers: {
               "Content-Type": "application/json",
+              Accept: "text/event-stream",
+              "Cache-Control": "no-cache",
+              Connection: "keep-alive",
               ...this.setAuth({
                 method: "post",
                 url: "/api/uq_basic/ai/question/stream/build",
@@ -337,16 +349,14 @@ export default {
         );
 
         const onEvent = (event) => {
-          console.log(event);
-          console.log(Date.now());
           if (event.data === "[DONE]") {
-            this.controller.abort(); // End the stream
+            console.log(this.aiResult);
             return;
           }
           try {
             const parsed = JSON.parse(event.data);
-            if (!this.taskId && parsed.id) {
-              this.taskId = parsed.id;
+            if (!this.taskId && parsed.taskId) {
+              this.taskId = parsed.taskId;
             }
             if (
               parsed.choices &&
@@ -354,10 +364,9 @@ export default {
               parsed.choices[0].delta
             ) {
               const content = parsed.choices[0].delta.content;
-              console.log(content);
+              // console.log(content);
               if (typeof content === "string") {
                 requestAnimationFrame(() => {
-                  this.output += content;
                   this.aiResult += content;
                 });
               }
@@ -373,13 +382,6 @@ export default {
         };
         const onError = (error) => {
           console.error("Error parsing event:", error);
-          if (error.type === "invalid-field") {
-            console.error("Field name:", error.field);
-            console.error("Field value:", error.value);
-            console.error("Line:", error.line);
-          } else if (error.type === "invalid-retry") {
-            console.error("Invalid retry interval:", error.value);
-          }
         };
 
         const parser = createParser({ onEvent, onError });
@@ -398,9 +400,7 @@ export default {
           this.parser.feed(value);
         }
       } catch (err) {
-        if (err.name !== "AbortError") {
-          console.error("流式处理错误:", err);
-        }
+        console.error("流式处理错误:", err);
       } finally {
         this.loading = false;
         this.controller = null; // Ensure controller is reset
@@ -426,7 +426,7 @@ export default {
       this.close();
     },
     clearPreview() {
-      this.output = "";
+      this.aiResult = "";
     },
   },
   beforeDestroy() {

+ 95 - 0
src/modules/question/components/ai-question/SseResultView.vue

@@ -0,0 +1,95 @@
+<template>
+  <div class="sse-result-content" v-html="parsedContent"></div>
+</template>
+
+<script>
+import { renderLatexToSVG } from "@/components/mathjax-editor/mathjax.js";
+
+export default {
+  name: "SseResultView",
+  props: {
+    output: {
+      type: String,
+      default: "",
+    },
+  },
+  data() {
+    return {
+      parsedContent: "",
+      latexCache: new Map(), // 缓存已解析的LaTeX表达式
+      lastParsedText: "", // 记录上次解析的文本
+    };
+  },
+  watch: {
+    output: {
+      immediate: true,
+      handler(newValue) {
+        if (!newValue) {
+          this.parsedContent = "";
+          return;
+        }
+        this.parseLatex(newValue);
+      },
+    },
+  },
+  methods: {
+    parseLatex(text) {
+      if (!text) return "";
+
+      // 如果文本与上次解析的完全相同,直接返回已解析内容
+      if (text === this.lastParsedText) {
+        return;
+      }
+
+      // 使用正则表达式匹配所有被\(\)、\[\]、$或者$$包围的LaTeX表达式
+      const regex =
+        /(?:\\\(([\s\S]*?)\\\)|\\\[([\s\S]*?)\\\]|(?<!\\)\$((?!\$|\\\$)[\s\S]*?)(?<!\\)\$|(?<!\\)\$\$((?!\$\$|\\\$\$)[\s\S]*?)(?<!\\)\$\$)/g;
+
+      // 替换所有匹配到的LaTeX表达式为SVG
+      const parsedText = text.replace(
+        regex,
+        (match, latexContent1, latexContent2, latexContent3, latexContent4) => {
+          try {
+            // 获取实际的LaTeX内容(可能来自四个捕获组中的任意一个)
+            const latexContent =
+              latexContent1 || latexContent2 || latexContent3 || latexContent4;
+
+            // 检查缓存中是否已有该LaTeX表达式的解析结果
+            if (this.latexCache.has(latexContent)) {
+              return this.latexCache.get(latexContent);
+            }
+
+            // 使用renderLatexToSVG方法将LaTeX转换为SVG
+            const svgContent = renderLatexToSVG(latexContent);
+
+            // 将结果存入缓存
+            this.latexCache.set(latexContent, svgContent);
+
+            return svgContent;
+          } catch (error) {
+            console.error("LaTeX渲染错误:", error);
+            return match; // 如果渲染失败,保留原始文本
+          }
+        }
+      );
+
+      // 更新上次解析的文本记录
+      this.lastParsedText = text;
+      this.parsedContent = parsedText;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.sse-result-content {
+  white-space: pre-wrap;
+  word-break: break-word;
+  line-height: 1.6;
+
+  :deep(svg) {
+    vertical-align: middle;
+    display: inline-block;
+  }
+}
+</style>

+ 1 - 1
src/modules/question/views/QuestionManage.vue

@@ -435,7 +435,7 @@ import FolderQuestionManageDialog from "../components/FolderQuestionManageDialog
 import PropertyTreeSelect from "../components/PropertyTreeSelect.vue";
 import QuestionImportEdit from "../components/QuestionImportEdit.vue";
 import GptQuestionDialog from "../components/GptQuestionDialog.vue";
-import AiQuestionCreateDialog from "../components/AiQuestionCreateDialog.vue";
+import AiQuestionCreateDialog from "../components/ai-question/AiQuestionCreateDialog.vue";
 import TaskProgressDialog from "@/components/TaskProgressDialog.vue";
 import { mapActions, mapGetters, mapMutations } from "vuex";
 import { USER_SIGNIN } from "../../portal/store/user";