204 lines
6.0 KiB
TypeScript
204 lines
6.0 KiB
TypeScript
import type {
|
||
Brand,
|
||
InspireChatMessage,
|
||
InspireElement,
|
||
Persona,
|
||
} from "../domain/types";
|
||
import { newId } from "./id";
|
||
import { mockDelay } from "./mockAi";
|
||
import { isPersonaReady } from "./personaPrompt";
|
||
import { nowUnixNano } from "./time";
|
||
|
||
export type ResolvedInspireContext = {
|
||
roles: string[];
|
||
snippets: string[];
|
||
trends: string[];
|
||
persona?: Persona | null;
|
||
brand?: Brand | null;
|
||
labels: string[];
|
||
};
|
||
|
||
export function resolveInspireContext(
|
||
elements: InspireElement[],
|
||
opts?: {
|
||
personas?: Persona[];
|
||
brands?: Brand[];
|
||
},
|
||
): ResolvedInspireContext {
|
||
const personas = opts?.personas || [];
|
||
const brands = opts?.brands || [];
|
||
const roles: string[] = [];
|
||
const snippets: string[] = [];
|
||
const trends: string[] = [];
|
||
const labels: string[] = [];
|
||
let persona: Persona | null = null;
|
||
let brand: Brand | null = null;
|
||
|
||
for (const el of elements) {
|
||
labels.push(el.title);
|
||
if (el.kind === "role") {
|
||
roles.push(el.body || el.title);
|
||
} else if (el.kind === "snippet") {
|
||
snippets.push(el.body || el.title);
|
||
} else if (el.kind === "trend") {
|
||
trends.push(el.body || el.title);
|
||
} else if (el.kind === "persona" && el.ref_id) {
|
||
const p = personas.find((x) => x.id === el.ref_id);
|
||
if (p && isPersonaReady(p)) persona = p;
|
||
else if (el.body) snippets.push(el.body);
|
||
} else if (el.kind === "brand" && el.ref_id) {
|
||
const b = brands.find((x) => x.id === el.ref_id);
|
||
if (b) brand = b;
|
||
else if (el.body) snippets.push(el.body);
|
||
} else if (el.body) {
|
||
snippets.push(el.body);
|
||
}
|
||
}
|
||
|
||
return { roles, snippets, trends, persona, brand, labels };
|
||
}
|
||
|
||
function toneLead(ctx: ResolvedInspireContext): string {
|
||
if (ctx.roles.some((r) => /朋友|私訊/.test(r))) return "懂你…";
|
||
if (ctx.roles.some((r) => /鉤子|短句/.test(r))) return "先講結論:";
|
||
if (ctx.roles.some((r) => /真實|反業配/.test(r))) return "講實話——";
|
||
if (ctx.persona?.style?.draft?.hooks) {
|
||
return ctx.persona.style.draft.hooks.slice(0, 24);
|
||
}
|
||
if (ctx.persona?.name) return `以「${ctx.persona.name}」的口吻:`;
|
||
return "";
|
||
}
|
||
|
||
function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string {
|
||
const topic =
|
||
userText.trim() ||
|
||
ctx.trends[0] ||
|
||
ctx.brand?.display_name ||
|
||
"日常小事";
|
||
const lead = toneLead(ctx);
|
||
const lines: string[] = [];
|
||
|
||
if (lead) lines.push(lead);
|
||
|
||
if (ctx.trends.length) {
|
||
lines.push(`最近大家在聊 ${ctx.trends[0]!.replace(/^主題:/, "").slice(0, 40)},`);
|
||
}
|
||
|
||
const personaBit =
|
||
ctx.persona?.brief ||
|
||
ctx.persona?.style?.draft?.tone ||
|
||
ctx.persona?.voice ||
|
||
"";
|
||
const brandBit = ctx.brand
|
||
? `(想到 ${ctx.brand.display_name}${ctx.brand.brief ? `:${ctx.brand.brief.slice(0, 40)}` : ""})`
|
||
: "";
|
||
|
||
if (/改短|短一點|精簡/.test(userText)) {
|
||
lines.push(`${topic.slice(0, 60)}——有人也卡在這嗎?`);
|
||
} else if (/更口語|隨便|碎念/.test(userText)) {
|
||
lines.push(`欸所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`);
|
||
} else {
|
||
lines.push(
|
||
`${topic.slice(0, 80)}${personaBit ? `。${personaBit.slice(0, 48)}` : ""}${brandBit}`,
|
||
);
|
||
lines.push("我自己目前比較在意「實際用起來」而不是包裝怎麼寫。");
|
||
}
|
||
|
||
const wantAsk =
|
||
ctx.snippets.some((s) => /問句|留言/.test(s)) ||
|
||
ctx.roles.some((r) => /鉤子|問句/.test(r));
|
||
if (wantAsk) {
|
||
lines.push("你們最近有類似經驗嗎?");
|
||
}
|
||
|
||
if (ctx.snippets.some((s) => /誇大|療效|禁止/.test(s))) {
|
||
// 語氣收斂:不另加誇大句
|
||
}
|
||
|
||
// 短貼感
|
||
let body = lines.filter(Boolean).join("\n");
|
||
if (ctx.snippets.some((s) => /短貼|180|280/.test(s)) && body.length > 200) {
|
||
body = body.slice(0, 180) + "…";
|
||
}
|
||
return body.trim();
|
||
}
|
||
|
||
function assistantNote(ctx: ResolvedInspireContext, mode: "chat" | "generate"): string {
|
||
if (mode === "chat") {
|
||
if (ctx.labels.length) {
|
||
return `好。這輪會帶上:${ctx.labels.slice(0, 4).join("、")}${ctx.labels.length > 4 ? "…" : ""}。你可以直接說想寫什麼,或按「產文」。`;
|
||
}
|
||
return "可以。想寫什麼主題?也可以先從右側套用角色/人設/片段,或點下方夯什麼。";
|
||
}
|
||
if (ctx.labels.length) {
|
||
return `已依 ${ctx.labels.slice(0, 3).join("、")} 寫一則草稿(可再改):`;
|
||
}
|
||
return "這是一則草稿(尚未套用特別元素,可在右側 pin 後再產):";
|
||
}
|
||
|
||
export async function mockInspireChat(opts: {
|
||
userMessage: string;
|
||
mode: "chat" | "generate";
|
||
elements: InspireElement[];
|
||
personas?: Persona[];
|
||
brands?: Brand[];
|
||
}): Promise<InspireChatMessage[]> {
|
||
await mockDelay(opts.mode === "generate" ? 650 : 380);
|
||
const now = nowUnixNano();
|
||
const ctx = resolveInspireContext(opts.elements, {
|
||
personas: opts.personas,
|
||
brands: opts.brands,
|
||
});
|
||
const userText = opts.userMessage.trim();
|
||
|
||
const out: InspireChatMessage[] = [];
|
||
if (userText) {
|
||
out.push({
|
||
id: newId("im"),
|
||
role: "user",
|
||
text: userText,
|
||
created_at: now,
|
||
});
|
||
}
|
||
|
||
if (opts.mode === "generate") {
|
||
const body = buildDraftBody(userText || "寫一則可發的 Threads", ctx);
|
||
out.push({
|
||
id: newId("im"),
|
||
role: "assistant",
|
||
text: assistantNote(ctx, "generate"),
|
||
draft: {
|
||
title: (ctx.trends[0] || userText || "靈感草稿").slice(0, 32),
|
||
body,
|
||
},
|
||
created_at: now + 1,
|
||
});
|
||
} else {
|
||
out.push({
|
||
id: newId("im"),
|
||
role: "assistant",
|
||
text: assistantNote(ctx, "chat"),
|
||
created_at: now + 1,
|
||
});
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
export function emptyInspireSession(): import("../domain/types").InspireSession {
|
||
const now = nowUnixNano();
|
||
return {
|
||
id: newId("isess"),
|
||
messages: [
|
||
{
|
||
id: newId("im"),
|
||
role: "assistant",
|
||
text: "說你想寫什麼。右側可套用角色、人設、品牌、片段;下方是最近 Threads 夯什麼。",
|
||
created_at: now,
|
||
},
|
||
],
|
||
pinned_element_ids: [],
|
||
updated_at: now,
|
||
};
|
||
}
|