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

204 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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