thread-master/apps/web/src/lib/personaPrompt.ts

277 lines
8.7 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
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<StyleDimKey, string> = {
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<Persona> & { 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<string, unknown>): Persona {
const r = raw as Partial<Persona> & { 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];
}