117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import { sanitizePromptText } from "@/lib/utils";
|
|
|
|
export interface PersonaFields {
|
|
identity: string;
|
|
tone: string;
|
|
audience: string;
|
|
hooks: string;
|
|
examples: string;
|
|
avoid: string;
|
|
styleStrategy: string;
|
|
}
|
|
|
|
const SECTION_KEYS: Array<{ key: keyof PersonaFields; label: string }> = [
|
|
{ key: "identity", label: "【我是誰】" },
|
|
{ key: "tone", label: "【語氣】" },
|
|
{ key: "audience", label: "【對誰說】" },
|
|
{ key: "hooks", label: "【開場習慣】" },
|
|
{ key: "examples", label: "【代表句範例】" },
|
|
{ key: "avoid", label: "【避免】" },
|
|
{ key: "styleStrategy", label: "【8D 風格策略】" },
|
|
];
|
|
|
|
const EMPTY_PERSONA: PersonaFields = {
|
|
identity: "",
|
|
tone: "",
|
|
audience: "",
|
|
hooks: "",
|
|
examples: "",
|
|
avoid: "",
|
|
styleStrategy: "",
|
|
};
|
|
|
|
export function emptyPersonaFields(): PersonaFields {
|
|
return { ...EMPTY_PERSONA };
|
|
}
|
|
|
|
export function hasPersonaConfigured(raw?: string | null): boolean {
|
|
const fields = parsePersona(raw);
|
|
return Object.values(fields).some((value) => value.trim().length > 0);
|
|
}
|
|
|
|
export function parsePersona(raw?: string | null): PersonaFields {
|
|
if (!raw?.trim()) return emptyPersonaFields();
|
|
|
|
const text = raw.trim();
|
|
if (!text.includes("【")) {
|
|
return { ...EMPTY_PERSONA, identity: text };
|
|
}
|
|
|
|
const fields = emptyPersonaFields();
|
|
const pattern = /【[^】]+】/g;
|
|
const markers = [...text.matchAll(pattern)];
|
|
|
|
if (markers.length === 0) {
|
|
return { ...EMPTY_PERSONA, identity: text };
|
|
}
|
|
|
|
for (let i = 0; i < markers.length; i++) {
|
|
const marker = markers[i];
|
|
const label = marker[0];
|
|
const start = (marker.index ?? 0) + label.length;
|
|
const end = i + 1 < markers.length ? (markers[i + 1].index ?? text.length) : text.length;
|
|
const content = text.slice(start, end).trim();
|
|
const section = SECTION_KEYS.find((item) => item.label === label);
|
|
if (section) {
|
|
fields[section.key] = content;
|
|
}
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
export function serializePersona(fields: PersonaFields): string {
|
|
return SECTION_KEYS.map(({ key, label }) => {
|
|
const value = fields[key].trim();
|
|
return value ? `${label}\n${value}` : "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
}
|
|
|
|
export function buildPersonaPromptBlock(raw?: string | null): string {
|
|
const fields = parsePersona(raw);
|
|
if (!hasPersonaConfigured(raw)) {
|
|
return `## 創作者人設
|
|
尚未設定人設。請用專業、有觀點、語氣自然的台灣 Threads 創作者口吻撰寫,像真人在分享見解,避免 AI 模板感。`;
|
|
}
|
|
|
|
const sections: string[] = [];
|
|
|
|
if (fields.identity) sections.push(`### 我是誰\n${sanitizePromptText(fields.identity)}`);
|
|
if (fields.tone) sections.push(`### 語氣特質\n${sanitizePromptText(fields.tone)}`);
|
|
if (fields.audience) sections.push(`### 對誰說\n${sanitizePromptText(fields.audience)}`);
|
|
if (fields.hooks) sections.push(`### 開場習慣\n${sanitizePromptText(fields.hooks)}`);
|
|
if (fields.examples) {
|
|
sections.push(
|
|
`### 代表句範例(最高優先)\n${sanitizePromptText(fields.examples)}\n` +
|
|
`寫作時語感、句長、用詞、停頓節奏要向以上句子靠攏,讀起來像同一個人在說話。`
|
|
);
|
|
}
|
|
if (fields.avoid) sections.push(`### 絕對避免\n${sanitizePromptText(fields.avoid)}`);
|
|
if (fields.styleStrategy) {
|
|
sections.push(`### 帳號 8D 風格策略(產文與回覆都必須執行)\n${sanitizePromptText(fields.styleStrategy)}`);
|
|
}
|
|
|
|
return `## 創作者人設(每篇都必須貫徹)
|
|
|
|
${sections.join("\n\n")}
|
|
|
|
## 人設執行規則
|
|
1. 人設優先於你的預設寫法;寧可少寫資訊,也不要寫成通用 AI 文
|
|
2. hook 要符合「開場習慣」,正文語氣要符合「語氣特質」
|
|
3. 有「代表句範例」時,把它當成聲音樣本,不是素材可抄的內容
|
|
4. 「絕對避免」裡的用語、語氣、套路一篇都不能出現
|
|
5. 寫完自檢:關掉作者名,讀起來還像這個人嗎?不像就改到像為止`;
|
|
}
|