// scripts/lib/bot-avatar-generator.mjs
// 数字人头像生成器
//
// 策略:
// 1. 先调用 Lunaris (trae-api text_to_image) 拿一张"AI 生成"图
// 2. 检测是否是占位 default(目前 trae-api 任何 prompt 都返回同一张 default.jpeg)
// 3. 若是占位 → 用程序化 SVG 生成唯一头像(基于 key 哈希 + 调色板 + 角色 initial)
// 4. 落盘到 public/bot-avatars/{key}.{ext} + 更新 User.avatarUrl
//
// 这样每个 bot 都有视觉上独特的头像;当 Lunaris 真正支持按 prompt 生成时,
// 重跑 --force --use-lunaris-only 即可切回 AI 生成的版本。
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import "dotenv/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
export const prisma = new PrismaClient({ adapter });
export const BOT_DATA_PATH = resolve(__dirname, "..", "..", "data", "bot-characters.json");
export const AVATAR_DIR = resolve(__dirname, "..", "..", "public", "bot-avatars");
export const LUNARIS_DIR = resolve(AVATAR_DIR, "lunaris");
export const LUNARIS_ENDPOINT = "https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image";
// trae-api 当前所有 prompt 都返回同一张 default.jpeg(实测 176626 bytes)
// 用这个 hash 前缀识别占位图,命中则改用 SVG 兜底
export const LUNARIS_DEFAULT_HASH_PREFIX = "e330cd0232";
// ---------- 工具函数 ----------
/**
* 把 key 字符串做稳定 hash(32-bit FNV-1a 变体)
*/
export function hashKey(key) {
let h = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
/**
* 从 0..MAX 区间里按 key 取一个稳定随机数
*/
export function pickFromKey(key, max) {
return hashKey(key) % max;
}
/**
* 把 key 拆成 4 个 8-bit 数字(0-255)用于 SVG 几何定位
*/
export function keyToBytes(key) {
const h1 = pickFromKey(key + "a", 256);
const h2 = pickFromKey(key + "b", 256);
const h3 = pickFromKey(key + "c", 256);
const h4 = pickFromKey(key + "d", 256);
return [h1, h2, h3, h4];
}
// 调色板:与 src/lib/bot-utils.ts 的 getBotColorScheme 保持视觉一致
// 但用实际 hex(Tailwind 调色板)方便 SVG 渲染
export const PALETTE = [
{ from: "#fb7185", to: "#fb923c", accent: "#fff1f2", name: "rose-orange" },
{ from: "#fbbf24", to: "#f43f5e", accent: "#fffbeb", name: "amber-rose" },
{ from: "#34d399", to: "#06b6d4", accent: "#ecfdf5", name: "emerald-cyan" },
{ from: "#38bdf8", to: "#6366f1", accent: "#f0f9ff", name: "sky-indigo" },
{ from: "#a78bfa", to: "#d946ef", accent: "#f5f3ff", name: "violet-fuchsia" },
{ from: "#e879f9", to: "#ec4899", accent: "#fdf4ff", name: "fuchsia-pink" },
{ from: "#2dd4bf", to: "#10b981", accent: "#f0fdfa", name: "teal-emerald" },
{ from: "#818cf8", to: "#a855f7", accent: "#eef2ff", name: "indigo-purple" },
{ from: "#fb923c", to: "#ef4444", accent: "#fff7ed", name: "orange-red" },
{ from: "#22d3ee", to: "#3b82f6", accent: "#ecfeff", name: "cyan-blue" },
];
export function pickPalette(key) {
// 改为真正的随机,而不是基于key的哈希
return PALETTE[Math.floor(Math.random() * PALETTE.length)];
}
// ---------- Lunaris 调用 ----------
export async function fetchLunarisImage(avatarPrompt, { size = "square_hd", timeout = 30000 } = {}) {
const enhancedPrompt = `人物头像, ${avatarPrompt}, 高质量肖像照, 自然光, 浅色背景, 半身像, 微笑, 现代感`;
const url = `${LUNARIS_ENDPOINT}?prompt=${encodeURIComponent(enhancedPrompt)}&image_size=${size}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const r = await fetch(url, {
headers: { Accept: "image/*,*/*", "User-Agent": "Mozilla/5.0" },
signal: controller.signal,
redirect: "follow",
});
if (!r.ok) throw new Error(`Lunaris HTTP ${r.status}`);
const ct = r.headers.get("content-type") || "";
if (!ct.includes("image")) throw new Error(`Lunaris 响应非图片: ${ct}`);
const buf = Buffer.from(await r.arrayBuffer());
return { buffer: buf, contentType: ct, size: buf.byteLength, url: r.url };
} finally {
clearTimeout(timer);
}
}
export async function isLunarisDefault(buffer) {
const { createHash } = await import("crypto");
const hash = createHash("sha256").update(buffer).digest("hex");
return hash.startsWith(LUNARIS_DEFAULT_HASH_PREFIX);
}
// ---------- SVG 程序化生成 ----------
/**
* 根据 bot persona 生成 512x512 SVG 头像
* 设计要素:
* - 渐变背景(palette.from → palette.to)
* - 独特的几何图案(基于 key 哈希生成)
* - 中央标识(使用 displayName 的完整首字或独特符号)
* - 装饰元素多样化(圆形、方形、三角形、线条等)
*/
export function generateSvgAvatar({ key, displayName, persona }) {
const palette = pickPalette(key);
const hash = hashKey(key);
const initial = (displayName || key).charAt(0).toUpperCase();
const isPasserby = persona?.role === "passerby";
// 基于 hash 生成多样化的设计元素
const designType = hash % 5; // 5种不同的设计风格
const rotation = (hash % 360);
const scale = 0.8 + (hash % 40) / 100; // 0.8-1.2
let shapeSvg = "";
if (designType === 0) {
// 风格0:放射状圆形
const circles = [];
for (let i = 0; i < 8; i++) {
const angle = (i * 45) * Math.PI / 180;
const cx = 256 + Math.cos(angle) * 150;
const cy = 256 + Math.sin(angle) * 150;
const r = 30 + (hash % 30);
circles.push(``);
}
shapeSvg = circles.join("\n ");
} else if (designType === 1) {
// 风格1:网格方块
const rects = [];
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 6; j++) {
if ((i + j + hash) % 3 === 0) {
const x = 50 + i * 70;
const y = 50 + j * 70;
rects.push(``);
}
}
}
shapeSvg = rects.join("\n ");
} else if (designType === 2) {
// 风格2:波浪线条
const paths = [];
for (let i = 0; i < 5; i++) {
const y = 80 + i * 80;
const amplitude = 40 + (hash % 30);
paths.push(``);
}
shapeSvg = paths.join("\n ");
} else if (designType === 3) {
// 风格3:三角形阵列
const triangles = [];
for (let i = 0; i < 12; i++) {
const cx = (hash * (i + 1)) % 450 + 30;
const cy = (hash * (i + 2)) % 450 + 30;
const size = 20 + (hash % 25);
const rot = (hash * i) % 360;
triangles.push(``);
}
shapeSvg = triangles.join("\n ");
} else {
// 风格4:螺旋圆点
const dots = [];
for (let i = 0; i < 20; i++) {
const angle = i * 0.5;
const radius = 50 + i * 10;
const cx = 256 + Math.cos(angle) * radius;
const cy = 256 + Math.sin(angle) * radius;
const r = 8 + (i % 5) * 2;
dots.push(``);
}
shapeSvg = dots.join("\n ");
}
// 角色副标识
const roleBadge = isPasserby
? `
社区观察者`
: `
行业专家`;
// 中央文字:使用完整的首字,并添加阴影效果
const textShadow = `${escapeXml(initial)}`;
const textMain = `${escapeXml(initial)}`;
return `
`;
}
function escapeXml(s) {
return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
}
// ---------- 主流程 ----------
/**
* 给单个 bot 生成头像
* @returns {avatarUrl, source, key, bytes}
*/
export async function generateForBot(character, { force = false, useLunarisOnly = false, saveLunaris = true } = {}) {
if (!existsSync(AVATAR_DIR)) mkdirSync(AVATAR_DIR, { recursive: true });
if (saveLunaris && !existsSync(LUNARIS_DIR)) mkdirSync(LUNARIS_DIR, { recursive: true });
const svgPath = resolve(AVATAR_DIR, `${character.key}.svg`);
const lunarisPath = resolve(LUNARIS_DIR, `${character.key}.jpg`);
const publicSvgUrl = `/bot-avatars/${character.key}.svg`;
// 已有 SVG 且不强制 → 跳过
if (!force && existsSync(svgPath)) {
return { key: character.key, source: "existing-svg", avatarUrl: publicSvgUrl, bytes: readFileSync(svgPath).byteLength };
}
// 步骤 1: 调 Lunaris(顺便存档 lunaris/{key}.jpg)
let lunarisBuf = null;
let lunarisIsDefault = true;
try {
lunarisBuf = await fetchLunarisImage(character.avatarPrompt || "");
lunarisIsDefault = await isLunarisDefault(lunarisBuf.buffer);
if (saveLunaris) writeFileSync(lunarisPath, lunarisBuf.buffer);
} catch (err) {
console.warn(`[${character.key}] Lunaris 拉取失败: ${err.message}`);
}
// 步骤 2: 决定最终使用
if (useLunarisOnly && lunarisBuf && !lunarisIsDefault) {
// 未来 Lunaris 真支持按 prompt 生成时启用
const jpgPath = resolve(AVATAR_DIR, `${character.key}.jpg`);
writeFileSync(jpgPath, lunarisBuf.buffer);
return { key: character.key, source: "lunaris", avatarUrl: `/bot-avatars/${character.key}.jpg`, bytes: lunarisBuf.buffer.byteLength };
}
// 步骤 3: 用 SVG 生成(覆盖 Lunaris 行为或作为兜底)
const svg = generateSvgAvatar({
key: character.key,
displayName: character.displayName,
persona: character.personality || {},
});
writeFileSync(svgPath, svg, "utf-8");
return {
key: character.key,
source: lunarisIsDefault ? "svg-fallback" : "svg-preferred",
avatarUrl: publicSvgUrl,
bytes: Buffer.byteLength(svg, "utf-8"),
lunarisIsDefault,
};
}
/**
* 给所有 bot 批量生成
*/
export async function generateForAll({ force = false, botKey = null, useLunarisOnly = false, delayMs = 200 } = {}) {
const all = JSON.parse(readFileSync(BOT_DATA_PATH, "utf-8")).characters || [];
const targets = botKey ? all.filter((c) => c.key === botKey) : all;
if (botKey && targets.length === 0) throw new Error(`Bot "${botKey}" 不在 bot-characters.json 中`);
const results = [];
for (const ch of targets) {
try {
const r = await generateForBot(ch, { force, useLunarisOnly });
// 写库
const user = await prisma.user.findFirst({ where: { email: ch.email } });
if (user) {
await prisma.user.update({ where: { id: user.id }, data: { avatarUrl: r.avatarUrl } });
r.userId = user.id;
r.dbUpdated = true;
} else {
r.dbUpdated = false;
r.warn = `User ${ch.email} 不存在`;
}
results.push(r);
console.log(`✅ ${ch.key} (${ch.displayName}) → ${r.avatarUrl} [${r.source}, ${r.bytes}B]`);
} catch (err) {
results.push({ key: ch.key, error: err.message });
console.error(`❌ ${ch.key}: ${err.message}`);
}
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
}
// 写 manifest
const manifest = {
generatedAt: new Date().toISOString(),
total: results.length,
succeeded: results.filter((r) => !r.error).length,
failed: results.filter((r) => r.error).length,
avatars: results.map((r) => ({
key: r.key,
avatarUrl: r.avatarUrl,
source: r.source,
bytes: r.bytes,
})),
};
writeFileSync(resolve(AVATAR_DIR, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
return { summary: { total: results.length, ok: manifest.succeeded, failed: manifest.failed }, results };
}
export async function disconnect() {
await prisma.$disconnect();
}