import type { Persona, PersonaDraftFields, PersonaGuard, PersonaStyle, StyleDimKey, StyleDimension, } from "../domain/types"; import { nowUnixNano } from "./time"; export const DIM_ORDER: StyleDimKey[] = [ "d1Tone", "d2Structure", "d3Interaction", "d4Topics", "d5Rhythm", "d6Visual", "d7Conversion", "d8Risk", ]; export const DIM_LABELS: Record = { d1Tone: "D1 語氣人格", d2Structure: "D2 結構模板", d3Interaction: "D3 互動方式", d4Topics: "D4 主題分布", d5Rhythm: "D5 發文節奏", d6Visual: "D6 視覺語法", d7Conversion: "D7 轉換方式", d8Risk: "D8 風險紅線", }; export type PersonaPromptMode = "post" | "reply" | "outreach" | "inspire"; export function emptyDraftFields(): PersonaDraftFields { return { identity: "", tone: "", audience: "", hooks: "", languageFingerprint: "", rhythm: "", punctuation: "", contentPatterns: "", knowledgeTranslation: "", ctaStyle: "", examples: "", avoid: "", }; } export function emptyGuard(): PersonaGuard { return { avoid: ["硬廣", "客服腔", "條列說教"], maxChars: 280, banAiTone: true, }; } export function emptyStyle(): PersonaStyle { return { dimensions: {}, draft: emptyDraftFields(), draftText: "", source: "manual", sampleCount: 0, }; } export function createEmptyPersona(partial?: Partial & { id: string; name: string }): Persona { return { id: partial?.id || "", name: partial?.name || "", brief: partial?.brief || "", status: partial?.status || "empty", style: partial?.style || emptyStyle(), guard: partial?.guard || emptyGuard(), voice: partial?.voice, notes: partial?.notes, }; } /** 把舊 name/voice/notes 人設升級成三層結構 */ export function normalizePersona(raw: Persona | Record): Persona { const r = raw as Partial & { voice?: string; notes?: string }; const draft = { ...emptyDraftFields(), ...(r.style?.draft || {}), }; if (!draft.tone && r.voice) draft.tone = r.voice; if (!draft.identity && r.name) draft.identity = r.name; if (!draft.avoid && r.notes) draft.avoid = r.notes; const style: PersonaStyle = { dimensions: r.style?.dimensions || {}, draft, draftText: r.style?.draftText || (r.style ? serializeDraftText(draft) : ""), source: r.style?.source || "manual", sampleCount: r.style?.sampleCount || 0, analyzedAt: r.style?.analyzedAt, }; if (!style.draftText && (draft.tone || draft.identity)) { style.draftText = serializeDraftText(draft); } const hasFingerprint = Boolean(style.draftText.trim()) || Object.keys(style.dimensions).length > 0; const status = r.status || (hasFingerprint ? "ready" : "empty"); return { id: String(r.id || ""), name: String(r.name || "未命名人設"), brief: String(r.brief || r.notes || ""), status, style, guard: { ...emptyGuard(), ...(r.guard || {}), avoid: r.guard?.avoid?.length ? r.guard.avoid : emptyGuard().avoid, }, voice: r.voice || draft.tone, notes: r.notes || r.brief, }; } export function serializeDraftText(d: PersonaDraftFields): string { const lines: string[] = []; const push = (label: string, value: string) => { const v = value.trim(); if (v) lines.push(`【${label}】\n${v}`); }; push("我是誰", d.identity); push("語氣", d.tone); push("對誰說", d.audience); push("開場鉤子", d.hooks); push("語言指紋", d.languageFingerprint); push("節奏", d.rhythm); push("標點", d.punctuation); push("內容套路", d.contentPatterns); push("知識轉譯", d.knowledgeTranslation); push("CTA", d.ctaStyle); push("像他會說的話", d.examples); push("絕不怎麼說", d.avoid); return lines.join("\n\n"); } export function toneOf(persona?: Persona | null): string { if (!persona) return "自然口語"; return persona.style?.draft?.tone || persona.voice || "自然口語"; } export function isPersonaReady(persona?: Persona | null): boolean { return persona?.status === "ready" && Boolean(persona.style?.draftText?.trim() || persona.style?.draft?.tone); } export function personaOptionLabel(persona: Persona): string { const tone = toneOf(persona); const badge = persona.status === "ready" ? "ready" : persona.status === "analyzing" ? "分析中" : "待分析"; return `${persona.name} · ${tone}(${badge})`; } /** * 依任務切片組裝人設 block。 * mock / live 產文都應只吃這個字串。 */ export function buildPersonaPromptBlock(persona: Persona | null | undefined, mode: PersonaPromptMode): string { if (!persona) { return "【人設】尚未選擇。請用台灣繁體、口語、像真人在 Threads 打字。"; } const p = normalizePersona(persona); const parts: string[] = []; // Layer A — who if (mode === "post" || mode === "inspire" || mode === "outreach") { const who = [p.name && `名稱:${p.name}`, p.brief && `定位:${p.brief}`, p.style.draft.identity && `角色:${p.style.draft.identity}`] .filter(Boolean) .join("\n"); if (who) parts.push(`【人設定位】\n${who}`); } else { // reply: light identity const light = p.style.draft.identity || p.name; if (light) parts.push(`【人設】你是「${light}」,用自己的語氣回覆。`); } // Layer B — how const draftText = p.style.draftText.trim() || serializeDraftText(p.style.draft); if (mode === "post") { if (draftText) parts.push(`【語言指紋與寫作規則】\n${draftText}`); const d2 = p.style.dimensions.d2Structure?.summary; const d4 = p.style.dimensions.d4Topics?.summary; if (d2 || d4) { parts.push( `【結構與主題重點】\n${[d2 && `結構:${d2}`, d4 && `主題:${d4}`].filter(Boolean).join("\n")}`, ); } } else if (mode === "reply") { if (draftText) parts.push(`【語言指紋】\n${draftText}`); const d3 = p.style.dimensions.d3Interaction?.summary; if (d3) parts.push(`【互動方式】\n${d3}`); } else if (mode === "outreach") { const slim = [ p.style.draft.tone && `語氣:${p.style.draft.tone}`, p.style.draft.avoid && `避免:${p.style.draft.avoid}`, p.style.draft.languageFingerprint && `用字:${p.style.draft.languageFingerprint}`, p.style.draft.ctaStyle && `收尾:${p.style.draft.ctaStyle}`, ] .filter(Boolean) .join("\n"); if (slim) parts.push(`【外展語氣】\n${slim}`); else if (draftText) parts.push(`【語言指紋】\n${draftText}`); } else { // inspire const d4 = p.style.dimensions.d4Topics?.summary; const hooks = p.style.draft.hooks; const audience = p.style.draft.audience; parts.push( `【靈感方向】\n${[ audience && `對誰:${audience}`, hooks && `鉤子:${hooks}`, d4 && `主題:${d4}`, p.style.draft.tone && `語氣:${p.style.draft.tone}`, ] .filter(Boolean) .join("\n") || draftText || p.brief}`, ); } // Layer C — guard const avoid = [ ...p.guard.avoid, ...(p.style.draft.avoid ? p.style.draft.avoid.split(/[、,,]/).map((s) => s.trim()).filter(Boolean) : []), ]; const uniqAvoid = [...new Set(avoid)]; const guardLines = [ uniqAvoid.length ? `禁止:${uniqAvoid.join("、")}` : "", p.guard.banAiTone ? "禁止 AI 腔/客服腔/「總而言之」「希望這對您有幫助」" : "", p.guard.maxChars ? `長度約不超過 ${p.guard.maxChars} 字` : "", mode === "reply" ? "你是貼文作者本人在回,不要裝粉絲或官方帳號。" : "", mode === "outreach" ? "不硬廣;像過來人分享經驗,可輕輕帶觀點。" : "", ].filter(Boolean); if (guardLines.length) parts.push(`【護欄】\n${guardLines.join("\n")}`); // Light D1 + D8 always when available for post/reply if (mode === "post" || mode === "reply") { const d1 = p.style.dimensions.d1Tone?.summary; const d8 = p.style.dimensions.d8Risk?.summary; if (d1 || d8) { parts.push( `【語氣與禁忌參考】\n${[d1 && `語氣:${d1}`, d8 && `紅線:${d8}`].filter(Boolean).join("\n")}`, ); } } return parts.join("\n\n") || `【人設】${p.name}:${toneOf(p)}`; } export function markReady(persona: Persona): Persona { const draftText = persona.style.draftText.trim() || serializeDraftText(persona.style.draft); return { ...persona, status: draftText ? "ready" : "empty", style: { ...persona.style, draftText, analyzedAt: persona.style.analyzedAt || nowUnixNano(), }, voice: persona.style.draft.tone || persona.voice, }; } export function dimSummary(persona: Persona, key: StyleDimKey): StyleDimension | undefined { return persona.style.dimensions[key]; }