AiQuestionCreateDialog.vue 14 KB

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