import { generateObject, generateText } from "ai"; import { z } from "zod"; import type { ProviderApiKeys } from "./keys"; import { withAgentSystem } from "./agent"; import { buildPersonaPromptBlock } from "./persona"; import { getModel } from "./provider"; import { extractJsonFromText } from "./coerce-research-map"; import { getOpenCodeGenerationSettings, prefersOpenCodeTextFirst, } from "./opencode-go-settings"; import { coerceMatrixRaw, formatMatrixError, matrixLooseSchema, } from "./coerce-matrix"; import { HASHTAG_USER_REMINDER, HASHTAG_WRITING_RULES } from "./hashtag-rules"; import { sanitizePromptText, THREADS_MAX_CHARS } from "@/lib/utils"; import type { MatrixRow, ResearchMap } from "@/lib/types/research"; const matrixSchema = z.object({ rows: z.array( z.object({ sortOrder: z.number().int().min(1), searchTag: z.string(), angle: z.string(), hook: z.string(), text: z.string().max(THREADS_MAX_CHARS), referenceNotes: z.string(), sourcePermalinks: z.array(z.string()), rationale: z.string(), }) ), }); /** 非 OpenCode Go 但 structured output 不穩的模型 */ const TEXT_FIRST_MODELS = new Set(["grok-3", "grok-3-fast"]); export interface GenerateMatrixInput { topicLabel: string; query: string; brief?: string | null; persona?: string | null; researchMap?: ResearchMap | null; aiProvider: string; aiModel: string; apiKeys?: ProviderApiKeys; count: number; posts: Array<{ text: string; authorName?: string | null; permalink?: string | null; searchTag?: string | null; likeCount?: number | null; replyCount?: number | null; qualityReason?: string | null; replies?: Array<{ text: string; authorName?: string | null; likeCount?: number | null }>; }>; } function buildSystemPrompt(persona?: string | null) { return withAgentSystem(`你是 Threads 內容企劃師。根據篩選後的優質素材,產出「內容矩陣」——一週可發的貼文企劃表。 ${buildPersonaPromptBlock(persona)} 規則: - 每篇必須原創,不可抄襲參考貼文 - hook 是一句吸引人的開頭(可獨立於正文),風格符合人設開場習慣 - text 是完整 Threads 貼文(≤500字,含 #標籤),語感要像創作者本人親筆 ${HASHTAG_WRITING_RULES} - referenceNotes 是從參考素材摘出的 2-3 個重點(簡短條列感,用換行分隔) - 每篇對應一個 searchTag(主題標籤) - sortOrder 從 1 開始遞增 - 繁體中文台灣用語 - 知識型貼文:不寫未查證的醫療/數據斷言,rationale 標註需網路查證的關鍵句`); } function buildUserPrompt(input: GenerateMatrixInput, researchBlock: string, materials: string) { return `主題:${sanitizePromptText(input.topicLabel)} 種子關鍵字:${sanitizePromptText(input.query)} ${input.brief ? `Brief:${sanitizePromptText(input.brief)}` : ""} ${researchBlock} 優質參考素材: ${materials} 請產出 ${input.count} 篇內容矩陣。每篇涵蓋不同切角,避免重複。 寫作提醒:矩陣裡的 text 必須像創作者親筆,有代表句範例時語感要向範例靠攏。${HASHTAG_USER_REMINDER}`; } function buildJsonPromptSuffix(count: number) { return ` 請只回傳 JSON(不要 markdown),格式: { "rows": [ { "sortOrder": 1, "searchTag": "標籤", "angle": "切角說明", "hook": "開頭一句", "text": "完整貼文(結尾含 1~3 個 #話題標籤)", "referenceNotes": "參考重點(多行以換行分隔)", "sourcePermalinks": ["https://..."], "rationale": "為什麼這篇有效" } ] } 請產出 ${count} 篇,sortOrder 從 1 遞增,每篇 text 不超過 ${THREADS_MAX_CHARS} 字。`; } function normalizeRows(rows: MatrixRow[]): MatrixRow[] { return rows .sort((a, b) => a.sortOrder - b.sortOrder) .map((row) => ({ ...row, text: row.text.slice(0, THREADS_MAX_CHARS), })); } async function generateWithText( model: ReturnType, system: string, prompt: string, fallback: { query: string; count: number }, provider: string, modelId: string ) { const settings = getOpenCodeGenerationSettings(provider, modelId); const { text } = await generateText({ model, system: sanitizePromptText(`${system}${buildJsonPromptSuffix(fallback.count)}`), prompt: sanitizePromptText(prompt), ...settings, }); const parsed = extractJsonFromText(text); matrixLooseSchema.parse(parsed); return coerceMatrixRaw(parsed, fallback); } async function generateWithObject( model: ReturnType, system: string, prompt: string, fallback: { query: string; count: number }, provider: string, modelId: string ) { const settings = getOpenCodeGenerationSettings(provider, modelId); const { object } = await generateObject({ model, schema: matrixSchema, system: sanitizePromptText(system), prompt: sanitizePromptText(`${prompt}${buildJsonPromptSuffix(fallback.count)}`), ...settings, }); return normalizeRows(object.rows); } export async function generateContentMatrix(input: GenerateMatrixInput): Promise { const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {}); const researchBlock = input.researchMap ? ` 受眾:${sanitizePromptText(input.researchMap.audienceSummary)} 內容目標:${sanitizePromptText(input.researchMap.contentGoal)} 想回答的問題:${input.researchMap.questions.map(sanitizePromptText).join("、")} 內容支柱:${input.researchMap.pillars.map(sanitizePromptText).join("、")} ` : ""; const materials = input.posts .map((post, i) => { const repliesBlock = post.replies && post.replies.length > 0 ? `\n 留言:${post.replies .slice(0, 3) .map( (r) => `@${sanitizePromptText(r.authorName) || "匿名"}:${sanitizePromptText(r.text).slice(0, 80)}` ) .join(" | ")}` : ""; return `${i + 1}. [${sanitizePromptText(post.searchTag) || sanitizePromptText(input.query)}] @${sanitizePromptText(post.authorName) || "匿名"}(${post.likeCount ?? 0}讚) ${sanitizePromptText(post.text).slice(0, 300)} 連結:${sanitizePromptText(post.permalink) || "無"}${repliesBlock}${post.qualityReason ? `\n 品質說明:${sanitizePromptText(post.qualityReason)}` : ""}`; }) .join("\n\n"); const system = buildSystemPrompt(input.persona); const prompt = buildUserPrompt(input, researchBlock, materials); const fallback = { query: input.query, count: input.count }; const preferText = prefersOpenCodeTextFirst(input.aiProvider, input.aiModel) || TEXT_FIRST_MODELS.has(input.aiModel); let rows: MatrixRow[] | null = null; let lastError: unknown; const attempts: Array<() => Promise> = preferText ? [ () => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel), () => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel), () => generateWithText( model, system, `${prompt}\n\n上次格式不完整,請務必補齊 ${input.count} 篇 rows。`, fallback, input.aiProvider, input.aiModel ), ] : [ () => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel), () => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel), () => generateWithText( model, system, `${prompt}\n\n上次格式不完整,請務必補齊 ${input.count} 篇 rows。`, fallback, input.aiProvider, input.aiModel ), ]; for (const attempt of attempts) { try { rows = await attempt(); break; } catch (error) { lastError = error; } } if (!rows) { throw new Error(formatMatrixError(lastError)); } return normalizeRows(rows); }