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

204 lines
6.0 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
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,
};
}