Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 2 additions & 37 deletions app/api/suggestions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
38 changes: 38 additions & 0 deletions lib/ai/parse-suggestions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
57 changes: 57 additions & 0 deletions tests/parse-suggestions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading