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) : {}; 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; 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 "生成內容矩陣失敗"; }