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,
|
|||
|
|
};
|
|||
|
|
}
|