244 lines
8.0 KiB
TypeScript
244 lines
8.0 KiB
TypeScript
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<typeof getModel>,
|
||
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<typeof getModel>,
|
||
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<MatrixRow[]> {
|
||
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<MatrixRow[]>> = 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);
|
||
} |