diff --git a/app/api/suggestions/route.ts b/app/api/suggestions/route.ts index 2fa23003..17c9206a 100644 --- a/app/api/suggestions/route.ts +++ b/app/api/suggestions/route.ts @@ -3,6 +3,7 @@ import { unstable_cache } from "next/cache"; import { getModel, requiresApiKey, type AIProvider } from "@/lib/ai/models"; import { createGlmFlashModel } from "@/lib/ai/providers/glm"; import { limitChat, rateLimitResponse } from "@/lib/rate-limit"; +import { parseSuggestionJson } from "@/lib/ai/parse-suggestions"; // 允许流式响应最长30秒 export const maxDuration = 30; @@ -150,43 +151,7 @@ export async function POST(req: Request) { let questions: unknown[] = []; try { - // 尝试解析 JSON - // 清理可能存在的 Markdown 代码块标记 - let cleanedText = text - .replace(/```json/gi, "") - .replace(/```/g, "") - .trim(); - - // 修复大模型可能生成的中文引号 - cleanedText = cleanedText.replace(/“/g, '"').replace(/”/g, '"'); - // 修复 key 后的全角冒号 / 冒号被包进引号,如 "action:"xx" 或 "action:"xx" - cleanedText = cleanedText.replace( - /"(title|label|action)"?\s*[::]\s*"/g, - '"$1":"', - ); - - // 尝试仅提取数组部分,防止 AI 返回了前缀描述文本 - const arrayMatch = cleanedText.match(/\[[\s\S]*\]/); - if (arrayMatch) { - cleanedText = arrayMatch[0]; - } - - try { - questions = JSON.parse(cleanedText); - } catch (parseError) { - // 编号列表 / 多个数组拼接等非法外壳:逐个提取对象 - const objects = (cleanedText.match(/\{[^{}]*\}/g) ?? []).flatMap( - (chunk) => { - try { - return [JSON.parse(chunk)]; - } catch { - return []; - } - }, - ); - if (objects.length === 0) throw parseError; - questions = objects; - } + questions = parseSuggestionJson(text); } catch (e) { console.error("解析建议 JSON 失败:", e, "原始文本:", text); diff --git a/lib/ai/parse-suggestions.ts b/lib/ai/parse-suggestions.ts new file mode 100644 index 00000000..f9c8e302 --- /dev/null +++ b/lib/ai/parse-suggestions.ts @@ -0,0 +1,38 @@ +export function parseSuggestionJson(text: string): unknown[] { + let cleanedText = text + .replace(/```json/gi, "") + .replace(/```/g, "") + .trim(); + + cleanedText = cleanedText.replace(/“/g, '"').replace(/”/g, '"'); + cleanedText = cleanedText.replace( + /"(title|label|action)"?\s*[::]\s*"/g, + '"$1":"', + ); + cleanedText = cleanedText + .replace(/"\s*,\s*"/g, '","') + .replace(/}\s*,\s*{/g, "},{"); + + const arrayMatch = cleanedText.match(/\[[\s\S]*\]/); + if (arrayMatch) { + cleanedText = arrayMatch[0]; + } + + try { + const parsed: unknown = JSON.parse(cleanedText); + return Array.isArray(parsed) ? parsed : []; + } catch (parseError) { + // 编号列表 / 多个数组拼接等非法外壳:逐个提取对象 + const objects = (cleanedText.match(/\{[^{}]*\}/g) ?? []).flatMap( + (chunk) => { + try { + return [JSON.parse(chunk)]; + } catch { + return []; + } + }, + ); + if (objects.length === 0) throw parseError; + return objects; + } +} diff --git a/tests/parse-suggestions.test.ts b/tests/parse-suggestions.test.ts new file mode 100644 index 00000000..259ba4cd --- /dev/null +++ b/tests/parse-suggestions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { parseSuggestionJson } from "@/lib/ai/parse-suggestions"; + +describe("parseSuggestionJson", () => { + it("解析标准 JSON 数组", () => { + expect( + parseSuggestionJson('[{"title":"a","label":"b","action":"c"}]'), + ).toEqual([{ title: "a", label: "b", action: "c" }]); + }); + + it("剥掉 markdown 代码块和前缀文本", () => { + expect( + parseSuggestionJson('好的:\n```json\n[{"title":"a"}]\n```'), + ).toEqual([{ title: "a" }]); + }); + + it("中文引号 + 字段间全角逗号", () => { + const raw = `[ + {"title":"基础概念”,“label":"基础知识”,“action":"请解释一下计算机科学中的基本概念是什么?"}, + {"title":"算法分析”,“label":"算法复杂度”,“action":"如何分析算法的时间复杂度和空间复杂度?"} +]`; + expect(parseSuggestionJson(raw)).toEqual([ + { + title: "基础概念", + label: "基础知识", + action: "请解释一下计算机科学中的基本概念是什么?", + }, + { + title: "算法分析", + label: "算法复杂度", + action: "如何分析算法的时间复杂度和空间复杂度?", + }, + ]); + }); + + it("对象间全角逗号", () => { + expect(parseSuggestionJson('[{"title":"a"},{"title":"b"}]')).toEqual([ + { title: "a" }, + { title: "b" }, + ]); + }); + + it("值内部的全角逗号保持不变", () => { + expect(parseSuggestionJson('[{"action":"先读题,再写码"}]')).toEqual([ + { action: "先读题,再写码" }, + ]); + }); + + it("key 后全角冒号", () => { + expect(parseSuggestionJson('[{"title":"a"}]')).toEqual([{ title: "a" }]); + }); + + it("完全无法解析时抛错", () => { + expect(() => parseSuggestionJson("没有任何 JSON")).toThrow(); + }); +});