zhangjie 1 mēnesi atpakaļ
vecāks
revīzija
bbbafea34d

+ 2 - 1
package.json

@@ -23,6 +23,7 @@
     "docx-preview": "^0.3.2",
     "echarts": "^5.5.1",
     "element-ui": "2.15.6",
+    "eventsource-parser": "^3.0.1",
     "html2canvas": "^1.4.1",
     "html2pdf.js": "^0.10.2",
     "js-md5": "^0.7.3",
@@ -72,4 +73,4 @@
       "git add"
     ]
   }
-}
+}

+ 75 - 0
src/assets/styles/pages.scss

@@ -1872,3 +1872,78 @@
     height: 200px;
   }
 }
+
+// .ai-question-create-dialog
+.ai-question-create-dialog {
+  .el-dialog__body {
+    height: 100%;
+    overflow: hidden;
+  }
+  .dialog-content-container {
+    display: flex;
+    justify-content: space-between;
+    align-items: stretch;
+    height: 100%;
+    overflow: hidden;
+  }
+
+  .title-text {
+    font-size: 18px;
+    font-weight: bold;
+  }
+
+  .course-info {
+    font-size: 14px;
+    color: #606266;
+    margin-left: 15px;
+  }
+
+  .left-panel {
+    width: 500px;
+    padding: 20px;
+    border: 1px solid #dcdfe6;
+    overflow-y: auto;
+    flex-shrink: 0;
+    background-color: #fff;
+    border-radius: 3px;
+  }
+
+  .right-panel {
+    flex-grow: 1;
+    display: flex;
+    flex-direction: column;
+    padding: 0 20px;
+    overflow: hidden;
+  }
+
+  .sse-output-title {
+    background-color: #fff;
+    flex-shrink: 0;
+    padding: 10px;
+    text-align: center;
+    font-size: 16px;
+    font-weight: bold;
+    border: 1px solid #dcdfe6;
+    border-bottom: none;
+    border-top-left-radius: 3px;
+    border-top-right-radius: 3px;
+  }
+
+  .sse-output-container {
+    flex-grow: 1;
+    border: 1px solid #dcdfe6;
+    padding: 10px;
+    overflow-y: auto;
+    background-color: #fff;
+    white-space: pre-wrap;
+    word-break: break-all;
+    border-bottom-left-radius: 3px;
+    border-bottom-right-radius: 3px;
+  }
+
+  .action-buttons {
+    margin-top: 15px;
+    text-align: center;
+    flex-shrink: 0;
+  }
+}

+ 15 - 0
src/modules/question/api.js

@@ -421,3 +421,18 @@ export const asyncTaskDownloadApi = (taskId) => {
     }
   );
 };
+// ai-question
+export const aiBuildQuestionApi = (datas, config = {}) => {
+  return fetch(`${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 }
+  );
+};

+ 436 - 0
src/modules/question/components/AiQuestionCreateDialog.vue

@@ -0,0 +1,436 @@
+<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流式数据
+      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.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
+          }
+        );
+
+        const onEvent = (event) => {
+          console.log(event);
+          console.log(Date.now());
+          if (event.data === "[DONE]") {
+            this.controller.abort(); // End the stream
+            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;
+              console.log(content);
+              if (typeof content === "string") {
+                requestAnimationFrame(() => {
+                  this.output += content;
+                  this.aiResult += 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;
+    },
+    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(); // 在组件销毁前清理流
+  },
+};
+</script>

+ 0 - 13
src/modules/question/components/ai-question/intro.md

@@ -1,13 +0,0 @@
-使用 vue2 在当前目录下写一个组件 AiQuestionCreateDialog.vue,他的特点如下:
-
-- 它是一个全屏的对话框,顶部标题为“创建试题 - AI 命题”,标题后面显示当前课程的信息,格式为“课程名称(课程编号)”。
-- 它的内容部分包含左右两个部分,左侧部分是一个表单,用户可以在表单依次输入如下信息:
-
-  - 选择题型
-  - 出题数量
-  - 选项个数(只有题型为单选或者多选时则显示)
-  - 是否选择教学大纲。选择教学大纲后,显示教学大纲补充说明,使用 textarea 组件,用户可以在文本框中输入教学大纲补充说明。
-  - 选择知识点。选择知识点后,显示知识点补充说明,使用 textarea 组件,用户可以在文本框中输入知识点补充说明。
-
-- 表单下方是一个内容为“生成试题”按钮,用户可以点击按钮提交表单。
-- 右侧部分是一个流式数据展示框,

+ 11 - 0
src/modules/question/views/QuestionManage.vue

@@ -400,6 +400,13 @@
       :task="curTask"
       :download-handle="taskFinished"
     ></task-progress-dialog>
+    <!-- AiQuestionCreateDialog -->
+    <ai-question-create-dialog
+      ref="AiQuestionCreateDialog"
+      :course-info="curCourse"
+      @modified="getList"
+    ></ai-question-create-dialog>
+
     <router-view></router-view>
   </div>
 </template>
@@ -428,6 +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 TaskProgressDialog from "@/components/TaskProgressDialog.vue";
 import { mapActions, mapGetters, mapMutations } from "vuex";
 import { USER_SIGNIN } from "../../portal/store/user";
@@ -449,6 +457,7 @@ export default {
     GptQuestionDialog,
     QuestionFolder,
     TaskProgressDialog,
+    AiQuestionCreateDialog,
   },
   data() {
     return {
@@ -637,6 +646,8 @@ export default {
         this.$message.error("请先选择课程!");
         return;
       }
+
+      this.$refs.AiQuestionCreateDialog.open();
     },
     async toExportQuestion() {
       if (this.downloading) return;

+ 5 - 0
yarn.lock

@@ -3638,6 +3638,11 @@ events@^3.2.0:
   resolved "https://registry.npmmirror.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
   integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
 
+eventsource-parser@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.1.tgz#5e358dba9a55ba64ca90da883c4ca35bd82467bd"
+  integrity sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==
+
 execa@^0.8.0:
   version "0.8.0"
   resolved "https://registry.npmmirror.com/execa/-/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da"