AiQuestionCreateDialog.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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. :show-close="false"
  10. @opened="handleOpened"
  11. @close="handleClose"
  12. >
  13. <div slot="title" class="dialog-title-container box-justify">
  14. <div class="title-left">
  15. <span class="title-text">创建试题 - AI 命题</span>
  16. <span v-if="courseInfo.name && courseInfo.code" class="course-info">
  17. {{ courseInfo.name }} ({{ courseInfo.code }})
  18. </span>
  19. </div>
  20. <div>
  21. <el-button icon="el-icon-back" @click="close">返回</el-button>
  22. </div>
  23. </div>
  24. <div class="dialog-content-container">
  25. <div class="left-panel">
  26. <el-form
  27. ref="form"
  28. :model="formModel"
  29. :rules="rules"
  30. label-width="90px"
  31. class="form-container"
  32. :disabled="loading"
  33. >
  34. <el-form-item prop="questionType" label="选择题型">
  35. <el-button
  36. v-for="item in questionTypes"
  37. :key="item.id"
  38. :type="
  39. formModel.questionType === item.questionType &&
  40. formModel.questionTypeName === item.name
  41. ? 'primary'
  42. : 'default'
  43. "
  44. size="small"
  45. style="margin: 0 5px 6px"
  46. @click="switchType(item)"
  47. >{{ item.name }}</el-button
  48. >
  49. </el-form-item>
  50. <el-form-item prop="questionCount" label="出题数量">
  51. <el-input-number
  52. v-model="formModel.questionCount"
  53. :min="1"
  54. :max="10"
  55. :step="1"
  56. step-strictly
  57. :controls="false"
  58. disabled
  59. ></el-input-number>
  60. </el-form-item>
  61. <el-form-item
  62. v-if="isChoiceQuestion"
  63. prop="optionCount"
  64. label="选项个数"
  65. >
  66. <el-input-number
  67. v-model="formModel.optionCount"
  68. :min="2"
  69. :max="8"
  70. :step="1"
  71. step-strictly
  72. :controls="false"
  73. ></el-input-number>
  74. </el-form-item>
  75. <el-form-item
  76. v-if="courseInfo.outlineFilePath && courseOutlineParsed"
  77. label="教学大纲"
  78. >
  79. <div class="box-justify">
  80. <el-checkbox v-model="formModel.syllabus"
  81. >教学大纲pdf</el-checkbox
  82. >
  83. </div>
  84. <el-input
  85. v-if="formModel.syllabus"
  86. type="textarea"
  87. :rows="3"
  88. v-model="formModel.syllabusNotes"
  89. placeholder="请输入教学大纲补充说明"
  90. ></el-input>
  91. </el-form-item>
  92. <el-form-item label="选择知识点">
  93. <property-tree-select
  94. v-model="formModel.propertyIdList"
  95. :course-id="courseInfo.id"
  96. :disabled="loading"
  97. :style="{ width: '100%' }"
  98. ></property-tree-select>
  99. <el-input
  100. v-if="
  101. formModel.propertyIdList && formModel.propertyIdList.length > 0
  102. "
  103. v-model="formModel.knowledgeNotes"
  104. type="textarea"
  105. :rows="3"
  106. placeholder="请输入知识点补充说明"
  107. style="margin-top: 10px"
  108. ></el-input>
  109. </el-form-item>
  110. <el-form-item>
  111. <el-button
  112. type="primary"
  113. :loading="loading"
  114. @click="toBuildQuestion"
  115. >生成试题</el-button
  116. >
  117. </el-form-item>
  118. </el-form>
  119. </div>
  120. <div class="right-panel">
  121. <div class="sse-output-title">试题预览</div>
  122. <div class="sse-output-container" ref="sseOuiputContainerRef">
  123. <template v-if="aiThinkingResult">
  124. <div
  125. class="sse-thinking-title"
  126. @click="thinkVisible = !thinkVisible"
  127. >
  128. <template v-if="thinking">
  129. <i class="el-icon-loading" style="margin-right: 6px"></i>
  130. <span>深度思考中...</span>
  131. </template>
  132. <template v-else>
  133. <svg-btn name="think"></svg-btn>
  134. <span>已深度思考(用时:{{ thinkDuration }}秒)</span>
  135. <i v-if="thinkVisible" class="el-icon el-icon-arrow-down"></i>
  136. <i v-else class="el-icon el-icon-arrow-down"></i>
  137. </template>
  138. </div>
  139. <div v-show="thinkVisible" class="sse-thinking-container">
  140. <sse-result-view :output="aiThinkingResult"></sse-result-view>
  141. </div>
  142. </template>
  143. <sse-result-view :output="aiResult"></sse-result-view>
  144. </div>
  145. <div class="action-buttons">
  146. <el-button type="primary" :disabled="loading" @click="saveQuestions"
  147. >保存试题</el-button
  148. >
  149. <el-button :disabled="loading" @click="clearPreview"
  150. >清空预览</el-button
  151. >
  152. </div>
  153. </div>
  154. </div>
  155. </el-dialog>
  156. </template>
  157. <script>
  158. import { BASE_QUESTION_TYPES } from "@/constants/constants";
  159. import { createParser } from "eventsource-parser";
  160. import PropertyTreeSelect from "../PropertyTreeSelect.vue";
  161. import SseResultView from "./SseResultView.vue";
  162. import {
  163. sourceDetailPageListApi,
  164. aiBuildQuestionApi,
  165. aiBuildQuestionSaveApi,
  166. } from "../../api";
  167. import { courseOutlineParsedCheckApi } from "@/modules/questions/api";
  168. import { fetchTime } from "@/plugins/syncServerTime";
  169. import { getAuthorization } from "@/plugins/crypto";
  170. export default {
  171. name: "AiQuestionCreateDialog",
  172. components: {
  173. PropertyTreeSelect,
  174. SseResultView,
  175. },
  176. props: {
  177. courseInfo: {
  178. type: Object,
  179. default: () => ({ id: "", name: "", code: "" }),
  180. },
  181. },
  182. data() {
  183. return {
  184. modalIsShow: false,
  185. formModel: this.getInitForm(),
  186. questionTypes: [],
  187. rules: {
  188. courseId: [
  189. {
  190. required: true,
  191. message: "请选择课程",
  192. trigger: "change",
  193. },
  194. ],
  195. questionCount: [
  196. {
  197. required: true,
  198. message: "请输入出题数量",
  199. trigger: "change",
  200. },
  201. ],
  202. optionCount: [
  203. {
  204. required: true,
  205. message: "请输入选项个数",
  206. trigger: "change",
  207. },
  208. ],
  209. questionType: [
  210. {
  211. required: true,
  212. message: "请选择题型",
  213. trigger: "change",
  214. },
  215. ],
  216. },
  217. courseOutlineParsed: false,
  218. // ai question result
  219. taskId: "",
  220. aiResult: "",
  221. aiThinkingResult: "",
  222. thinking: false,
  223. thinkVisible: true,
  224. thinkDuration: "",
  225. loading: false,
  226. controller: null,
  227. parser: null,
  228. };
  229. },
  230. computed: {
  231. isChoiceQuestion() {
  232. return ["SINGLE_ANSWER_QUESTION", "MULTIPLE_ANSWER_QUESTION"].includes(
  233. this.formModel.questionType
  234. );
  235. },
  236. },
  237. methods: {
  238. scrollToBottom() {
  239. const container = this.$refs.sseOuiputContainerRef;
  240. if (container) {
  241. container.scrollTop = container.scrollHeight;
  242. }
  243. },
  244. close() {
  245. this.modalIsShow = false;
  246. },
  247. open() {
  248. this.modalIsShow = true;
  249. },
  250. async checkCourseOutlineParsed() {
  251. if (!this.courseInfo.id || !this.courseInfo.outlineFilePath) return;
  252. const res = await courseOutlineParsedCheckApi(this.courseInfo.id).catch(
  253. () => {}
  254. );
  255. if (!res) return;
  256. this.courseOutlineParsed = res.data;
  257. },
  258. async getQuestionTypes() {
  259. if (!this.courseInfo.id) return;
  260. const res = await sourceDetailPageListApi({
  261. courseId: this.courseInfo.id,
  262. rootOrgId: this.$store.state.user.rootOrgId,
  263. pageSize: 100,
  264. pageNum: 1,
  265. }).catch(() => {});
  266. if (!res) return;
  267. const baseQuestionCodes = BASE_QUESTION_TYPES.map((item) => item.code);
  268. this.questionTypes = (res.data.content || []).filter((item) =>
  269. baseQuestionCodes.includes(item.questionType)
  270. );
  271. if (this.questionTypes.length > 0) {
  272. this.switchType(this.questionTypes[0]);
  273. }
  274. },
  275. switchType(item) {
  276. this.formModel.questionType = item.questionType;
  277. this.formModel.questionTypeName = item.name;
  278. this.$refs.form.validateField("questionType");
  279. },
  280. getInitForm() {
  281. return {
  282. questionType: "SINGLE_CHOICE",
  283. questionTypeName: "",
  284. questionCount: 1,
  285. optionCount: 4,
  286. syllabus: false,
  287. syllabusNotes: "",
  288. propertyIdList: [],
  289. knowledgeNotes: "",
  290. };
  291. },
  292. handleOpened() {
  293. this.formModel = this.getInitForm();
  294. this.getQuestionTypes();
  295. this.getInitForm();
  296. this.checkCourseOutlineParsed();
  297. },
  298. handleClose() {
  299. this.aiResult = "";
  300. this.aiThinkingResult = "";
  301. this.taskId = "";
  302. this.thinking = false;
  303. this.thinkDuration = "";
  304. this.courseOutlineParsed = false;
  305. this.stopStream();
  306. },
  307. setAuth(config) {
  308. const headers = {};
  309. let userSession = sessionStorage.getItem("user");
  310. if (userSession) {
  311. let user = JSON.parse(userSession);
  312. const timestamp = fetchTime();
  313. const authorization = getAuthorization(
  314. {
  315. method: config.method,
  316. uri: config.url.split("?")[0].trim(),
  317. timestamp,
  318. sessionId: user.sessionId,
  319. token: user.accessToken,
  320. },
  321. "token"
  322. );
  323. headers["Authorization"] = authorization;
  324. headers["time"] = timestamp;
  325. }
  326. return headers;
  327. },
  328. async toBuildQuestion() {
  329. const valid = await this.$refs.form.validate().catch(() => false);
  330. if (!valid) return;
  331. if (this.loading) return;
  332. this.stopStream(); // 清理前一个流
  333. this.aiResult = "";
  334. this.aiThinkingResult = "";
  335. this.thinkDuration = "";
  336. this.taskId = "";
  337. this.loading = true;
  338. // 设置60秒超时
  339. const timeoutId = setTimeout(() => {
  340. this.controller.abort();
  341. this.$message.error("请求超时!");
  342. this.loading = false;
  343. }, 60000);
  344. try {
  345. this.controller = new AbortController();
  346. const res = await aiBuildQuestionApi(
  347. {
  348. ...this.formModel,
  349. courseId: this.courseInfo.id,
  350. rootOrgId: this.$store.state.user.rootOrgId,
  351. },
  352. {
  353. headers: {
  354. "Content-Type": "application/json",
  355. Accept: "text/event-stream",
  356. "Cache-Control": "no-cache",
  357. Connection: "keep-alive",
  358. ...this.setAuth({
  359. method: "post",
  360. url: "/api/uq_basic/ai/question/stream/build",
  361. }),
  362. },
  363. signal: this.controller.signal, // 使用AbortController的signal
  364. }
  365. );
  366. // 请求成功后清除超时定时器
  367. clearTimeout(timeoutId);
  368. const startThinkingTime = Date.now();
  369. this.thinking = true;
  370. const onEvent = (event) => {
  371. if (event.data === "[DONE]") {
  372. // console.log(this.aiResult);
  373. return;
  374. }
  375. try {
  376. const parsed = JSON.parse(event.data);
  377. if (!this.taskId && parsed.taskId) {
  378. this.taskId = parsed.taskId;
  379. return;
  380. }
  381. if (
  382. parsed.choices &&
  383. parsed.choices[0] &&
  384. parsed.choices[0].delta
  385. ) {
  386. const { content, reasoning_content } = parsed.choices[0].delta;
  387. // 只要content不为null,则表示思考结束,开始处理生成结果内容
  388. if (content !== null) {
  389. this.thinking = false;
  390. if (!this.thinkDuration) {
  391. this.thinkDuration = Math.round(
  392. (Date.now() - startThinkingTime) / 1000
  393. );
  394. }
  395. requestAnimationFrame(() => {
  396. this.aiResult += content || "";
  397. this.scrollToBottom();
  398. });
  399. return;
  400. }
  401. // 处理思考过程内容
  402. requestAnimationFrame(() => {
  403. this.aiThinkingResult += reasoning_content || "";
  404. this.scrollToBottom();
  405. });
  406. }
  407. } catch (e) {
  408. console.error(
  409. "Error parsing SSE JSON:",
  410. e,
  411. "Event data:",
  412. event.data
  413. );
  414. }
  415. };
  416. const onError = (error) => {
  417. this.thinking = false;
  418. console.error("Error parsing event:", error);
  419. };
  420. const parser = createParser({ onEvent, onError });
  421. this.parser = parser;
  422. // Pipe the response stream through TextDecoderStream and feed to parser
  423. const reader = res.body
  424. .pipeThrough(new TextDecoderStream())
  425. .getReader();
  426. // eslint-disable-next-line no-constant-condition
  427. while (true) {
  428. const { done, value } = await reader.read();
  429. if (done) {
  430. break;
  431. }
  432. this.parser.feed(value);
  433. }
  434. } catch (err) {
  435. console.error("流式处理错误:", err);
  436. this.thinking = false;
  437. // 请求出错时清除超时定时器
  438. clearTimeout(timeoutId);
  439. } finally {
  440. this.loading = false;
  441. this.controller = null; // Ensure controller is reset
  442. }
  443. },
  444. stopStream() {
  445. this.controller?.abort();
  446. this.controller = null;
  447. this.parser = null;
  448. this.loading = false;
  449. },
  450. async saveQuestions() {
  451. if (this.loading) return;
  452. this.loading = true;
  453. const res = await aiBuildQuestionSaveApi({
  454. aiResult: this.aiResult,
  455. taskId: this.taskId,
  456. }).catch(() => {});
  457. this.loading = false;
  458. if (!res) return;
  459. this.$message.success("保存成功");
  460. this.$emit("modified");
  461. this.close();
  462. },
  463. clearPreview() {
  464. this.aiResult = "";
  465. this.aiThinkingResult = "";
  466. this.taskId = "";
  467. },
  468. },
  469. beforeDestroy() {
  470. this.stopStream(); // 在组件销毁前清理流
  471. },
  472. };
  473. </script>
  474. <style>
  475. .thinking-container {
  476. background-color: #f8f9fa;
  477. border-left: 3px solid #409eff;
  478. margin-bottom: 20px;
  479. }
  480. </style>