fix(ai): 模型解析 - 兼容多种 JSON 顶层形态
This commit is contained in:
@@ -108,6 +108,30 @@ function findJsonValueEnd(source: string, start: number) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 角色卡数组的最小特征:至少有一个元素带 name 字段。用于把真正的角色数组和模型正文里的
|
||||
// 旁枝数组(角色名列表、aliases 片段等)区分开,避免提取阶段命中错误片段。
|
||||
function isCharacterCardArray(value: unknown): value is unknown[] {
|
||||
if (!Array.isArray(value) || !value.length) return false;
|
||||
return value.some((item) => Boolean(item) && typeof item === "object" && typeof (item as Record<string, unknown>).name === "string");
|
||||
}
|
||||
|
||||
// 模型偶尔把角色数组再包一层,或把 characters 写成以角色名为键的对象,这里统一摊平成候选列表。
|
||||
function flattenCharacterCandidates(value: unknown): unknown[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const result: unknown[] = [];
|
||||
value.forEach((item) => {
|
||||
if (Array.isArray(item)) {
|
||||
result.push(...(flattenCharacterCandidates(item) ?? []));
|
||||
return;
|
||||
}
|
||||
result.push(item);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (value && typeof value === "object") return Object.values(value as Record<string, unknown>);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractCharacterBreakdownJson(raw: string) {
|
||||
for (let start = 0; start < raw.length; start += 1) {
|
||||
if (raw[start] !== "{" && raw[start] !== "[") continue;
|
||||
@@ -115,9 +139,11 @@ function extractCharacterBreakdownJson(raw: string) {
|
||||
if (end < start) continue;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw.slice(start, end + 1));
|
||||
// 顶层角色数组是常见的契约偏离,先判数组再判对象,两者都视为合法载荷。
|
||||
if (isCharacterCardArray(parsed)) return parsed;
|
||||
// 模型的推理文字可能包含 aliases: [] 等合法 JSON 片段;角色契约只接受带 characters 字段的对象。
|
||||
const candidates = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as { characters?: unknown }).characters : undefined;
|
||||
if (Array.isArray(candidates)) return parsed;
|
||||
const candidates = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? flattenCharacterCandidates((parsed as { characters?: unknown }).characters) : undefined;
|
||||
if (Array.isArray(candidates) && candidates.length) return parsed;
|
||||
} catch {
|
||||
// Ignore unrelated braces in model prose and continue to the next complete JSON value.
|
||||
}
|
||||
@@ -128,7 +154,7 @@ function extractCharacterBreakdownJson(raw: string) {
|
||||
export function parseCharacterBreakdown(raw: string): CharacterBreakdown[] {
|
||||
const unfenced = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
||||
const parsed = extractCharacterBreakdownJson(unfenced);
|
||||
const candidates = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" ? (parsed as { characters?: unknown }).characters : undefined;
|
||||
const candidates = Array.isArray(parsed) ? flattenCharacterCandidates(parsed) : flattenCharacterCandidates((parsed as { characters?: unknown }).characters);
|
||||
if (!Array.isArray(candidates)) throw new Error("角色拆解结果缺少 characters 数组");
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -43,3 +43,64 @@ describe("parseCharacterBreakdown", () => {
|
||||
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏", "吴奶奶"]);
|
||||
});
|
||||
});
|
||||
|
||||
const characterCard = {
|
||||
name: "林夏",
|
||||
aliases: ["夏夏"],
|
||||
role: "女主角,加班晚归的都市上班族",
|
||||
appearance: "26岁东亚女性,短发",
|
||||
clothing: "米色风衣",
|
||||
physique: "年轻女性常规体态",
|
||||
personality: "善良内敛",
|
||||
props: "磁吸小夜灯",
|
||||
consistencyPrompt: "短发,米色风衣",
|
||||
multiViewPrompt: "正面、侧面、背面保持一致",
|
||||
voiceLanguage: "中文普通话",
|
||||
voiceAge: "26岁青年女性",
|
||||
voiceTimbre: "温和",
|
||||
};
|
||||
|
||||
describe("parseCharacterBreakdown 顶层形态兼容", () => {
|
||||
test("接受顶层角色数组", () => {
|
||||
expect(parseCharacterBreakdown(JSON.stringify([characterCard])).map((character) => character.name)).toEqual(["林夏"]);
|
||||
});
|
||||
|
||||
test("接受数组外再包一层", () => {
|
||||
expect(parseCharacterBreakdown(JSON.stringify([[characterCard]])).map((character) => character.name)).toEqual(["林夏"]);
|
||||
});
|
||||
|
||||
test("接受每个角色单独包一层 characters", () => {
|
||||
const payload = [{ characters: [characterCard] }];
|
||||
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
|
||||
});
|
||||
|
||||
test("接受 characters 写成以角色名为键的对象", () => {
|
||||
const payload = { characters: { 林夏: characterCard } };
|
||||
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
|
||||
});
|
||||
|
||||
test("跳过数组里的非角色元素", () => {
|
||||
const payload = [{ index: 1 }, characterCard];
|
||||
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
|
||||
});
|
||||
|
||||
test("正文先列角色名数组时仍取到最终契约对象", () => {
|
||||
const result = `本章角色:["林夏","陈默"]\n\n${JSON.stringify({ characters: [characterCard] })}`;
|
||||
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏"]);
|
||||
});
|
||||
|
||||
test("顶层数组被 markdown 代码块包裹", () => {
|
||||
const result = "```json\n" + JSON.stringify([characterCard]) + "\n```";
|
||||
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏"]);
|
||||
});
|
||||
|
||||
test("角色名单数组不是角色卡,继续向后寻找契约对象", () => {
|
||||
const result = `旁白提到了名字:["林夏"]`;
|
||||
expect(() => parseCharacterBreakdown(result)).toThrow(/符合契约的 JSON/);
|
||||
});
|
||||
|
||||
test("角色缺少设定字段时报出角色名", () => {
|
||||
const payload = [{ name: "林夏", role: "女主角" }];
|
||||
expect(() => parseCharacterBreakdown(JSON.stringify(payload))).toThrow(/林夏/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user