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