Pārlūkot izejas kodu

feat: ai命题多题

zhangjie 2 nedēļas atpakaļ
vecāks
revīzija
e418a583c3

+ 215 - 111
src/modules/question/components/ai-question/AiQuestionCreateDialog.vue

@@ -57,7 +57,6 @@
               :step="1"
               step-strictly
               :controls="false"
-              disabled
             ></el-input-number>
           </el-form-item>
 
@@ -227,10 +226,20 @@ export default {
         ],
       },
       courseOutlineParsed: false,
-      // ai question result
-      taskId: "",
-      aiResult: "",
-      aiThinkingResult: "",
+      // Data for multiple question generation
+      taskIds: [], // Array to store task IDs from each call
+      aiResults: [], // Array to store complete AI result string from each call
+      aiThinkingResults: [], // Array to store complete AI thinking result string from each call
+      // Temporary holders for the currently streaming question's data
+      currentStreamingAiResult: "",
+      currentStreamingAiThinkingResult: "",
+      currentStreamingTaskId: null,
+      // These are for the SseResultView components, showing concatenated results
+      aiResult: "", // Bound to SseResultView for AI results (cumulative)
+      // aiThinkingResult is already defined below
+      // ai question result (aiResult and aiThinkingResult are now cumulative display strings)
+      // taskIds and aiResults are now arrays defined above
+      aiThinkingResult: "", // For SseResultView for thinking (cumulative)
       thinking: false,
       thinkVisible: true,
       thinkDuration: "",
@@ -302,18 +311,29 @@ export default {
         syllabusNotes: "",
         propertyIdList: [],
         knowledgeNotes: "",
+        enableThinking: true,
       };
     },
     handleOpened() {
       this.formModel = this.getInitForm();
       this.getQuestionTypes();
-      this.getInitForm();
+      // this.getInitForm(); // Called by this.formModel assignment already
       this.checkCourseOutlineParsed();
+      // Clear previous results on open
+      this.aiResult = "";
+      this.aiThinkingResult = "";
+      this.taskIds = [];
+      this.aiResults = [];
+      this.aiThinkingResults = [];
+      this.thinkDuration = "";
+      this.thinking = false;
     },
     handleClose() {
       this.aiResult = "";
       this.aiThinkingResult = "";
-      this.taskId = "";
+      this.taskIds = [];
+      this.aiResults = [];
+      this.aiThinkingResults = [];
       this.thinking = false;
       this.thinkDuration = "";
       this.courseOutlineParsed = false;
@@ -346,29 +366,50 @@ export default {
 
       if (this.loading) return;
 
-      this.stopStream(); // 清理前一个流
-      this.aiResult = "";
-      this.aiThinkingResult = "";
-      this.thinkDuration = "";
-      this.taskId = "";
+      this.stopStream(); // Clear any previous stream
+
+      // Initialize/clear result containers
+      this.aiResult = ""; // For cumulative display
+      this.aiThinkingResult = ""; // For cumulative display
+      this.taskIds = [];
+      this.aiResults = [];
+      this.aiThinkingResults = [];
+      this.thinkDuration = ""; // Reset global duration
+      // this.thinking will be set based on the first call
+
       this.loading = true;
+      const totalQuestionsToGenerate = this.formModel.questionCount;
+      let overallStartThinkingTime = null;
 
-      // 设置60秒超时
-      const timeoutId = setTimeout(() => {
-        this.controller.abort();
-        this.$message.error("请求超时!");
-        this.loading = false;
-      }, 60000);
+      for (let i = 0; i < totalQuestionsToGenerate; i++) {
+        this.currentStreamingAiResult = "";
+        this.currentStreamingAiThinkingResult = "";
+        this.currentStreamingTaskId = null;
 
-      try {
-        this.controller = new AbortController();
-        const res = await aiBuildQuestionApi(
-          {
-            ...this.formModel,
-            courseId: this.courseInfo.id,
-            rootOrgId: this.$store.state.user.rootOrgId,
-          },
-          {
+        const paramsForThisCall = {
+          ...this.formModel,
+          questionCount: 1, // Generate one question per call
+          enableThinking: i === 0 && this.formModel.enableThinking, // Only true for the first call if initially enabled
+          courseId: this.courseInfo.id,
+          rootOrgId: this.$store.state.user.rootOrgId,
+        };
+
+        if (i === 0 && paramsForThisCall.enableThinking) {
+          this.thinking = true; // Global thinking status on for the first question
+          overallStartThinkingTime = Date.now();
+        }
+
+        let currentCallTimeoutId;
+        const timeoutPromise = new Promise((_, reject) => {
+          currentCallTimeoutId = setTimeout(() => {
+            this.controller?.abort(); // Abort the current controller
+            reject(new Error(`第 ${i + 1} 题请求超时!`));
+          }, 60000); // 60-second timeout per question
+        });
+
+        try {
+          this.controller = new AbortController(); // New controller for each stream
+          const apiCallConfig = {
             headers: {
               "Content-Type": "application/json",
               Accept: "text/event-stream",
@@ -379,94 +420,143 @@ export default {
                 url: "/api/uq_basic/ai/question/stream/build",
               }),
             },
-            signal: this.controller.signal, // 使用AbortController的signal
+            signal: this.controller.signal,
+          };
+
+          const fetchPromise = aiBuildQuestionApi(
+            paramsForThisCall,
+            apiCallConfig
+          );
+          const res = await Promise.race([fetchPromise, timeoutPromise]);
+          if (res.status !== 200) {
+            throw new Error(`第 ${i + 1} 题请求失败!`);
           }
-        );
 
-        // 请求成功后清除超时定时器
-        clearTimeout(timeoutId);
-        const startThinkingTime = Date.now();
-        this.thinking = true;
+          clearTimeout(currentCallTimeoutId); // Clear timeout if fetch succeeded/failed fast enough
 
-        const onEvent = (event) => {
-          if (event.data === "[DONE]") {
-            // console.log(this.aiResult);
-            return;
-          }
-          try {
-            const parsed = JSON.parse(event.data);
-            if (!this.taskId && parsed.taskId) {
-              this.taskId = parsed.taskId;
-              return;
-            }
-
-            if (
-              parsed.choices &&
-              parsed.choices[0] &&
-              parsed.choices[0].delta
-            ) {
-              const { content, reasoning_content } = parsed.choices[0].delta;
-
-              // 只要content不为null,则表示思考结束,开始处理生成结果内容
-              if (content !== null) {
-                this.thinking = false;
-
-                if (!this.thinkDuration) {
-                  this.thinkDuration = Math.round(
-                    (Date.now() - startThinkingTime) / 1000
-                  );
+          // Stream processing for the current question
+          await new Promise((resolveStream, rejectStream) => {
+            const onEvent = (event) => {
+              if (event.data === "[DONE]") {
+                this.aiResults.push(this.currentStreamingAiResult);
+                this.aiThinkingResults.push(
+                  this.currentStreamingAiThinkingResult
+                );
+                if (this.currentStreamingTaskId) {
+                  this.taskIds.push(this.currentStreamingTaskId);
+                }
+                // Append a separator for cumulative display if not the first question's result
+                if (i < totalQuestionsToGenerate - 1) {
+                  // this.aiThinkingResult +=
+                  //   "\n<hr style='margin: 10px 0; border-top: 1px solid #eee;'/>\n";
+                  this.aiResult +=
+                    "\n<hr style='margin: 10px 0; border-top: 1px solid #eee;'/>\n";
                 }
 
-                requestAnimationFrame(() => {
-                  this.aiResult += content || "";
-                  this.scrollToBottom();
-                });
+                resolveStream();
                 return;
               }
 
-              // 处理思考过程内容
-              requestAnimationFrame(() => {
-                this.aiThinkingResult += reasoning_content || "";
-                this.scrollToBottom();
-              });
-            }
-          } catch (e) {
-            console.error(
-              "Error parsing SSE JSON:",
-              e,
-              "Event data:",
-              event.data
-            );
-          }
-        };
-        const onError = (error) => {
-          this.thinking = false;
-          console.error("Error parsing event:", error);
-        };
+              try {
+                const parsed = JSON.parse(event.data);
+                if (!this.currentStreamingTaskId && parsed.taskId) {
+                  this.currentStreamingTaskId = parsed.taskId;
+                }
 
-        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);
+                if (
+                  parsed.choices &&
+                  parsed.choices[0] &&
+                  parsed.choices[0].delta
+                ) {
+                  const { content, reasoning_content } =
+                    parsed.choices[0].delta;
+
+                  if (content !== null) {
+                    if (i === 0 && this.thinking) {
+                      // First call was thinking
+                      this.thinking = false; // Global thinking UI flag off
+                      if (!this.thinkDuration && overallStartThinkingTime) {
+                        this.thinkDuration = Math.round(
+                          (Date.now() - overallStartThinkingTime) / 1000
+                        );
+                      }
+                    }
+                    this.currentStreamingAiResult += content || "";
+                    // Live update cumulative display (without extra HR, [DONE] handles that)
+                    this.aiResult += content || "";
+                    requestAnimationFrame(this.scrollToBottom);
+                  } else if (reasoning_content !== null) {
+                    this.currentStreamingAiThinkingResult +=
+                      reasoning_content || "";
+                    // Live update cumulative display
+                    this.aiThinkingResult += reasoning_content || "";
+                    requestAnimationFrame(this.scrollToBottom);
+                  }
+                }
+              } catch (e) {
+                console.error(
+                  "Error parsing SSE JSON:",
+                  e,
+                  "Event data:",
+                  event.data
+                );
+                // Do not reject stream here, parser's onError will handle it
+              }
+            };
+
+            const onError = (error) => {
+              if (i === 0 && this.thinking) this.thinking = false;
+              console.error("Error parsing event:", error);
+              rejectStream(error); // Reject the promise for this stream
+            };
+
+            this.parser = createParser({ onEvent, onError });
+
+            const reader = res.body
+              .pipeThrough(new TextDecoderStream())
+              .getReader();
+            // IIFE to handle async reader loop and resolve/reject stream promise
+            (async () => {
+              try {
+                // eslint-disable-next-line no-constant-condition
+                while (true) {
+                  const { done, value } = await reader.read();
+                  if (done) break;
+                  this.parser.feed(value);
+                }
+                // If stream ends without [DONE] but no error, resolveStream might not have been called.
+                // However, createParser's onEvent for [DONE] should handle normal completion.
+              } catch (readError) {
+                rejectStream(readError);
+              }
+            })();
+          });
+        } catch (err) {
+          console.error(`流式处理错误 (第 ${i + 1} 题):`, err);
+          if (i === 0 && this.thinking) this.thinking = false;
+          clearTimeout(currentCallTimeoutId); // Clear timeout if any for this iteration
+          this.$message.error(err.message || `生成第 ${i + 1} 题时出错`);
+          this.controller?.abort(); // Abort current controller before breaking
+          this.controller = null;
+          break; // Stop generating further questions if one fails
+        }
+      } // End for loop
+
+      this.loading = false;
+      this.controller = null; // Ensure controller is reset finally
+      // If loop completed fully and thinking was active for the first question but no content came to turn it off
+      if (
+        totalQuestionsToGenerate > 0 &&
+        this.formModel.enableThinking &&
+        this.thinking
+      ) {
+        this.thinking = false; // Ensure thinking is off if loop finishes
+        if (!this.thinkDuration && overallStartThinkingTime) {
+          // Calculate duration if not set
+          this.thinkDuration = Math.round(
+            (Date.now() - overallStartThinkingTime) / 1000
+          );
         }
-      } catch (err) {
-        console.error("流式处理错误:", err);
-        this.thinking = false;
-        // 请求出错时清除超时定时器
-        clearTimeout(timeoutId);
-      } finally {
-        this.loading = false;
-        this.controller = null; // Ensure controller is reset
       }
     },
     stopStream() {
@@ -476,22 +566,36 @@ export default {
       this.loading = false;
     },
     async saveQuestions() {
-      if (this.loading) return;
+      if (this.loading || this.aiResults.length === 0) {
+        if (this.aiResults.length === 0 && !this.loading) {
+          this.$message.info("没有可保存的试题");
+        }
+        return;
+      }
       this.loading = true;
+
       const res = await aiBuildQuestionSaveApi({
-        aiResult: this.aiResult,
-        taskId: this.taskId,
+        aiResults: this.aiResults,
+        taskIds: this.taskIds, // Assuming taskIds array matches aiResults
       }).catch(() => {});
       this.loading = false;
-      if (!res) return;
-      this.$message.success("保存成功");
+      if (!res) {
+        this.$message.error(`保存失败`);
+        return;
+      }
+
+      this.$message.success(`保存成功`);
       this.$emit("modified");
       this.close();
     },
     clearPreview() {
       this.aiResult = "";
       this.aiThinkingResult = "";
-      this.taskId = "";
+      this.taskIds = [];
+      this.aiResults = [];
+      this.aiThinkingResults = [];
+      this.thinkDuration = "";
+      this.thinking = false;
     },
   },
   beforeDestroy() {