AiQuestionCreateDialog.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. <template>
  2. <el-dialog
  3. custom-class="ai-question-create-dialog"
  4. :visible.sync="modalIsShow"
  5. fullscreen
  6. :close-on-click-modal="false"
  7. :close-on-press-escape="false"
  8. append-to-body
  9. @opened="handleOpened"
  10. @close="handleClose"
  11. >
  12. <div slot="title" class="dialog-title-container">
  13. <div class="title-left">
  14. <span class="title-text">创建试题 - AI 命题</span>
  15. <span v-if="courseInfo.name && courseInfo.code" class="course-info">
  16. {{ courseInfo.name }} ({{ courseInfo.code }})
  17. </span>
  18. </div>
  19. </div>
  20. <div class="dialog-content-container">
  21. <div class="left-panel">
  22. <el-form
  23. ref="form"
  24. :model="formModel"
  25. :rules="rules"
  26. label-width="90px"
  27. class="form-container"
  28. >
  29. <el-form-item prop="questionType" label="选择题型">
  30. <el-button
  31. v-for="item in questionTypes"
  32. :key="item.id"
  33. :type="
  34. formModel.questionType === item.questionType
  35. ? 'primary'
  36. : 'default'
  37. "
  38. size="small"
  39. @click="switchType(item)"
  40. >{{ item.questionTypeName }}</el-button
  41. >
  42. </el-form-item>
  43. <el-form-item prop="questionCount" label="出题数量">
  44. <el-input-number
  45. v-model="formModel.questionCount"
  46. :min="1"
  47. :max="10"
  48. :step="1"
  49. step-strictly
  50. :controls="false"
  51. ></el-input-number>
  52. </el-form-item>
  53. <el-form-item
  54. v-if="isChoiceQuestion"
  55. prop="optionCount"
  56. label="选项个数"
  57. >
  58. <el-input-number
  59. v-model="formModel.optionCount"
  60. :min="2"
  61. :max="8"
  62. :step="1"
  63. step-strictly
  64. :controls="false"
  65. ></el-input-number>
  66. </el-form-item>
  67. <el-form-item label="教学大纲">
  68. <div class="box-justify">
  69. <el-checkbox v-model="formModel.syllabus"
  70. >教学大纲pdf</el-checkbox
  71. >
  72. <upload-button
  73. btn-content="上传文件"
  74. btn-icon="icon icon-import"
  75. :disabled="uploading || !courseInfo.id"
  76. :upload-data="{ id: courseInfo.id }"
  77. :upload-url="uploadUrl"
  78. :format="importFileTypes"
  79. @valid-error="validError"
  80. @upload-success="uploadSuccess"
  81. ></upload-button>
  82. </div>
  83. <el-input
  84. v-if="formModel.syllabus"
  85. type="textarea"
  86. :rows="3"
  87. v-model="formModel.syllabusNotes"
  88. placeholder="请输入教学大纲补充说明"
  89. ></el-input>
  90. </el-form-item>
  91. <el-form-item label="选择知识点">
  92. <property-tree-select
  93. v-model="formModel.propertyIdList"
  94. :course-id="courseInfo.id"
  95. :style="{ width: '100%' }"
  96. ></property-tree-select>
  97. <el-input
  98. v-if="
  99. formModel.propertyIdList && formModel.propertyIdList.length > 0
  100. "
  101. v-model="formModel.knowledgeNotes"
  102. type="textarea"
  103. :rows="3"
  104. placeholder="请输入知识点补充说明"
  105. style="margin-top: 10px"
  106. ></el-input>
  107. </el-form-item>
  108. <el-form-item>
  109. <el-button type="primary" @click="toBuildQuestion"
  110. >生成试题</el-button
  111. >
  112. </el-form-item>
  113. </el-form>
  114. </div>
  115. <div class="right-panel">
  116. <div class="sse-output-title">试题预览</div>
  117. <div class="sse-output-container">
  118. <pre>{{ output }}</pre>
  119. </div>
  120. <div class="action-buttons">
  121. <el-button type="primary" @click="saveQuestions">保存试题</el-button>
  122. <el-button @click="clearPreview">清空预览</el-button>
  123. </div>
  124. </div>
  125. </div>
  126. </el-dialog>
  127. </template>
  128. <script>
  129. import { BASE_QUESTION_TYPES } from "@/constants/constants";
  130. import { createParser } from "eventsource-parser";
  131. import PropertyTreeSelect from "../components/PropertyTreeSelect.vue";
  132. import UploadButton from "@/components/UploadButton.vue";
  133. import { QUESTION_API } from "@/constants/constants";
  134. import {
  135. sourceDetailPageListApi,
  136. aiBuildQuestionApi,
  137. aiBuildQuestionSaveApi,
  138. } from "../api";
  139. import { fetchTime } from "@/plugins/syncServerTime";
  140. import { getAuthorization } from "@/plugins/crypto";
  141. export default {
  142. name: "AiQuestionCreateDialog",
  143. components: {
  144. PropertyTreeSelect,
  145. UploadButton,
  146. },
  147. props: {
  148. courseInfo: {
  149. type: Object,
  150. default: () => ({ id: "", name: "", code: "" }),
  151. },
  152. },
  153. data() {
  154. return {
  155. modalIsShow: false,
  156. formModel: this.getInitForm(),
  157. questionTypes: [],
  158. sseData: "", // 用于存储SSE流式数据
  159. rules: {
  160. courseId: [
  161. {
  162. required: true,
  163. message: "请选择课程",
  164. trigger: "change",
  165. },
  166. ],
  167. questionCount: [
  168. {
  169. required: true,
  170. message: "请输入出题数量",
  171. trigger: "change",
  172. },
  173. ],
  174. optionCount: [
  175. {
  176. required: true,
  177. message: "请输入选项个数",
  178. trigger: "change",
  179. },
  180. ],
  181. questionType: [
  182. {
  183. required: true,
  184. message: "请选择题型",
  185. trigger: "change",
  186. },
  187. ],
  188. },
  189. // upload
  190. uploading: false,
  191. importFileTypes: ["pdf"],
  192. uploadUrl: `${QUESTION_API}/course/outline/upload`,
  193. showIframeDialog: false,
  194. // ai question result
  195. taskId: "",
  196. aiResult: "",
  197. // output
  198. output: "",
  199. loading: false,
  200. controller: null,
  201. parser: null,
  202. };
  203. },
  204. computed: {
  205. isChoiceQuestion() {
  206. return ["SINGLE_ANSWER_QUESTION", "MULTIPLE_ANSWER_QUESTION"].includes(
  207. this.formModel.questionType
  208. );
  209. },
  210. },
  211. watch: {
  212. cancel() {
  213. this.modalIsShow = false;
  214. },
  215. open() {
  216. this.modalIsShow = true;
  217. },
  218. },
  219. methods: {
  220. close() {
  221. this.modalIsShow = false;
  222. },
  223. open() {
  224. this.modalIsShow = true;
  225. },
  226. async getQuestionTypes() {
  227. if (!this.courseInfo.id) return;
  228. const res = await sourceDetailPageListApi({
  229. courseId: this.courseInfo.id,
  230. rootOrgId: this.$store.state.user.rootOrgId,
  231. pageSize: 100,
  232. pageNum: 1,
  233. }).catch(() => {});
  234. if (!res) return;
  235. const baseQuestionCodes = BASE_QUESTION_TYPES.map((item) => item.code);
  236. this.questionTypes = (res.data.content || []).filter((item) =>
  237. baseQuestionCodes.includes(item.questionType)
  238. );
  239. if (this.questionTypes.length > 0) {
  240. this.formModel.questionType = this.questionTypes[0].questionType;
  241. }
  242. },
  243. switchType(item) {
  244. this.formModel.questionType = item.questionType;
  245. this.$refs.form.validateField("questionType");
  246. },
  247. getInitForm() {
  248. return {
  249. questionType: "SINGLE_CHOICE",
  250. questionCount: 1,
  251. optionCount: 4,
  252. syllabus: false,
  253. syllabusNotes: "",
  254. propertyIdList: [],
  255. knowledgeNotes: "",
  256. };
  257. },
  258. handleOpened() {
  259. this.formModel = this.getInitForm();
  260. this.getQuestionTypes();
  261. this.getInitForm();
  262. },
  263. handleClose() {
  264. this.stopStream();
  265. },
  266. validError(error) {
  267. this.$message.error(error.message);
  268. },
  269. uploadSuccess(response) {
  270. console.log(response);
  271. // TODO:
  272. this.$message.success("上传成功!");
  273. },
  274. setAuth(config) {
  275. const headers = {};
  276. let userSession = sessionStorage.getItem("user");
  277. if (userSession) {
  278. let user = JSON.parse(userSession);
  279. const timestamp = fetchTime();
  280. const authorization = getAuthorization(
  281. {
  282. method: config.method,
  283. uri: config.url.split("?")[0].trim(),
  284. timestamp,
  285. sessionId: user.sessionId,
  286. token: user.accessToken,
  287. },
  288. "token"
  289. );
  290. headers["Authorization"] = authorization;
  291. headers["time"] = timestamp;
  292. }
  293. return headers;
  294. },
  295. async toBuildQuestion() {
  296. const valid = await this.$refs.form.validate().catch(() => false);
  297. if (!valid) return;
  298. if (this.loading) return;
  299. this.stopStream(); // 清理前一个流
  300. this.output = "";
  301. this.aiResult = ""; // Reset aiResult for the new stream
  302. this.taskId = ""; // Reset taskId for the new stream
  303. this.loading = true;
  304. try {
  305. const res = await aiBuildQuestionApi(
  306. {
  307. ...this.formModel,
  308. courseId: this.courseInfo.id,
  309. rootOrgId: this.$store.state.user.rootOrgId,
  310. },
  311. {
  312. headers: {
  313. "Content-Type": "application/json",
  314. ...this.setAuth({
  315. method: "post",
  316. url: "/api/uq_basic/ai/question/stream/build",
  317. }),
  318. },
  319. signal: (this.controller = new AbortController()).signal, // Ensures a new controller for each call
  320. }
  321. );
  322. const onEvent = (event) => {
  323. console.log(event);
  324. console.log(Date.now());
  325. if (event.data === "[DONE]") {
  326. this.controller.abort(); // End the stream
  327. return;
  328. }
  329. try {
  330. const parsed = JSON.parse(event.data);
  331. if (!this.taskId && parsed.id) {
  332. this.taskId = parsed.id;
  333. }
  334. if (
  335. parsed.choices &&
  336. parsed.choices[0] &&
  337. parsed.choices[0].delta
  338. ) {
  339. const content = parsed.choices[0].delta.content;
  340. console.log(content);
  341. if (typeof content === "string") {
  342. requestAnimationFrame(() => {
  343. this.output += content;
  344. this.aiResult += content;
  345. });
  346. }
  347. }
  348. } catch (e) {
  349. console.error(
  350. "Error parsing SSE JSON:",
  351. e,
  352. "Event data:",
  353. event.data
  354. );
  355. }
  356. };
  357. const onError = (error) => {
  358. console.error("Error parsing event:", error);
  359. if (error.type === "invalid-field") {
  360. console.error("Field name:", error.field);
  361. console.error("Field value:", error.value);
  362. console.error("Line:", error.line);
  363. } else if (error.type === "invalid-retry") {
  364. console.error("Invalid retry interval:", error.value);
  365. }
  366. };
  367. const parser = createParser({ onEvent, onError });
  368. this.parser = parser;
  369. // Pipe the response stream through TextDecoderStream and feed to parser
  370. const reader = res.body
  371. .pipeThrough(new TextDecoderStream())
  372. .getReader();
  373. // eslint-disable-next-line no-constant-condition
  374. while (true) {
  375. const { done, value } = await reader.read();
  376. if (done) {
  377. break;
  378. }
  379. this.parser.feed(value);
  380. }
  381. } catch (err) {
  382. if (err.name !== "AbortError") {
  383. console.error("流式处理错误:", err);
  384. }
  385. } finally {
  386. this.loading = false;
  387. this.controller = null; // Ensure controller is reset
  388. }
  389. },
  390. stopStream() {
  391. this.controller?.abort();
  392. this.controller = null;
  393. this.parser = null;
  394. this.loading = false;
  395. },
  396. async saveQuestions() {
  397. if (this.loading) return;
  398. this.loading = true;
  399. const res = await aiBuildQuestionSaveApi({
  400. aiResult: this.aiResult,
  401. taskId: this.taskId,
  402. }).catch(() => {});
  403. this.loading = false;
  404. if (!res) return;
  405. this.$message.success("保存成功");
  406. this.$emit("modified");
  407. this.close();
  408. },
  409. clearPreview() {
  410. this.output = "";
  411. },
  412. },
  413. beforeDestroy() {
  414. this.stopStream(); // 在组件销毁前清理流
  415. },
  416. };
  417. </script>