118 lines
4.3 KiB
TypeScript
118 lines
4.3 KiB
TypeScript
import { z } from "zod";
|
||
import { THREADS_MAX_CHARS } from "@/lib/utils";
|
||
import type { MatrixRow } from "@/lib/types/research";
|
||
import { explainProviderApiError } from "./opencode-go-settings";
|
||
|
||
export const matrixLooseSchema = z.object({
|
||
rows: z
|
||
.array(
|
||
z.object({
|
||
sortOrder: z.union([z.number(), z.string()]).optional(),
|
||
searchTag: z.string().optional(),
|
||
angle: z.string().optional(),
|
||
hook: z.string().optional(),
|
||
text: z.string().optional(),
|
||
referenceNotes: z.string().optional(),
|
||
sourcePermalinks: z.union([z.array(z.string()), z.string()]).optional(),
|
||
rationale: z.string().optional(),
|
||
})
|
||
)
|
||
.min(1),
|
||
});
|
||
|
||
function asString(value: unknown): string {
|
||
if (typeof value === "string") return value.trim();
|
||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||
return "";
|
||
}
|
||
|
||
function asStringArray(value: unknown): string[] {
|
||
if (Array.isArray(value)) {
|
||
return value.map((item) => asString(item)).filter(Boolean);
|
||
}
|
||
const single = asString(value);
|
||
return single ? [single] : [];
|
||
}
|
||
|
||
function asSortOrder(value: unknown, fallback: number): number {
|
||
if (typeof value === "number" && Number.isFinite(value)) return Math.max(1, Math.floor(value));
|
||
const parsed = parseInt(asString(value), 10);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
export function coerceMatrixRaw(
|
||
parsed: unknown,
|
||
fallback: { query: string; count: number }
|
||
): MatrixRow[] {
|
||
const root =
|
||
parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
||
|
||
const rowsRaw =
|
||
root.rows ??
|
||
root.matrix ??
|
||
root.content ??
|
||
(Array.isArray(parsed) ? parsed : null);
|
||
|
||
if (!Array.isArray(rowsRaw) || rowsRaw.length === 0) {
|
||
throw new Error("AI 回傳的內容矩陣缺少 rows 陣列");
|
||
}
|
||
|
||
const rows: MatrixRow[] = [];
|
||
for (let i = 0; i < rowsRaw.length; i++) {
|
||
const item = rowsRaw[i];
|
||
if (!item || typeof item !== "object") continue;
|
||
const obj = item as Record<string, unknown>;
|
||
|
||
const text = asString(obj.text ?? obj.body ?? obj.post ?? obj.content);
|
||
if (!text) continue;
|
||
|
||
rows.push({
|
||
sortOrder: asSortOrder(obj.sortOrder ?? obj.order ?? obj.index, i + 1),
|
||
searchTag: asString(obj.searchTag ?? obj.tag ?? obj.topic) || fallback.query,
|
||
angle: asString(obj.angle ?? obj.perspective) || "待補切角",
|
||
hook: asString(obj.hook ?? obj.opening) || text.split("\n")[0]?.slice(0, 80) || text.slice(0, 80),
|
||
text: text.slice(0, THREADS_MAX_CHARS),
|
||
referenceNotes:
|
||
asString(obj.referenceNotes ?? obj.references ?? obj.notes) || "(待補參考重點)",
|
||
sourcePermalinks: asStringArray(obj.sourcePermalinks ?? obj.sources ?? obj.permalinks),
|
||
rationale: asString(obj.rationale ?? obj.reason) || "依優質素材衍生的企劃切角",
|
||
});
|
||
}
|
||
|
||
if (rows.length === 0) {
|
||
throw new Error("AI 回傳的內容矩陣沒有可用貼文");
|
||
}
|
||
|
||
return rows
|
||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||
.map((row, index) => ({ ...row, sortOrder: index + 1 }))
|
||
.slice(0, fallback.count);
|
||
}
|
||
|
||
export function formatMatrixError(error: unknown): string {
|
||
if (typeof error === "string") return error;
|
||
if (error instanceof z.ZodError) {
|
||
const detail = error.issues
|
||
.slice(0, 3)
|
||
.map((issue) => `${issue.path.join(".") || "回傳內容"}:${issue.message}`)
|
||
.join(";");
|
||
return `AI 回傳的內容矩陣格式不完整(${detail})。請再試一次。`;
|
||
}
|
||
if (error instanceof Error) {
|
||
const extra = error as Error & { responseBody?: string };
|
||
const body = typeof extra.responseBody === "string" ? extra.responseBody : "";
|
||
const explained = explainProviderApiError(error.message, body);
|
||
if (explained) return explained;
|
||
if (body.includes("hex escape") || body.includes("parse the request body")) {
|
||
return "參考素材含有特殊字元,導致 AI 請求格式錯誤。系統已自動清理字元,請再按一次「內容矩陣」。";
|
||
}
|
||
if (error.message === "Bad Request" && body) {
|
||
return `AI 請求失敗:${body.slice(0, 200)}`;
|
||
}
|
||
if (error.message === "Bad Request") {
|
||
return "AI 供應商拒絕請求(Bad Request)。請到設定頁確認 API key 或更換模型後重試。";
|
||
}
|
||
return error.message;
|
||
}
|
||
return "生成內容矩陣失敗";
|
||
} |