277 lines
8.7 KiB
TypeScript
277 lines
8.7 KiB
TypeScript
|
|
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];
|
|||
|
|
}
|