Files
zhuiguang-ai/scripts/lib/bot-avatar-generator.mjs
T

310 lines
13 KiB
JavaScript
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.
// 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) {
return PALETTE[pickFromKey(key, 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)
* - 4 个装饰几何形(位置/大小/旋转由 key 决定)
* - 中央大字 initial(displayName 首字符)
* - 右下角 🤖 标识(用 SVG path 画,不依赖 emoji 字体)
*/
export function generateSvgAvatar({ key, displayName, persona }) {
const palette = pickPalette(key);
const [b1, b2, b3, b4] = keyToBytes(key);
const initial = (displayName || key).charAt(0).toUpperCase();
const isPasserby = persona?.role === "passerby";
// 4 个装饰形:圆/三角/方/菱形,位置基于 key 哈希
const shapes = [
{ type: "circle", cx: 40 + b1 * 0.7, cy: 60 + b2 * 0.4, r: 30 + (b3 % 40), opacity: 0.18 },
{ type: "rect", x: 320 + b2 * 0.3, y: 30 + b3 * 0.2, w: 60 + (b4 % 50), h: 60 + (b1 % 50), rot: b1 % 90, opacity: 0.14 },
{ type: "circle", cx: 380 + b3 * 0.2, cy: 320 + b4 * 0.4, r: 40 + (b2 % 60), opacity: 0.12 },
{ type: "polygon", points: `60,${380 + b1 * 0.2} ${120 + b2 * 0.2},${440 + b3 * 0.15} ${20 + b4 * 0.3},${460}`, opacity: 0.16 },
];
const shapeSvg = shapes
.map((s) => {
if (s.type === "circle") {
return `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="white" opacity="${s.opacity}"/>`;
}
if (s.type === "rect") {
return `<rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" transform="rotate(${s.rot} ${s.x + s.w / 2} ${s.y + s.h / 2})" fill="white" opacity="${s.opacity}"/>`;
}
return `<polygon points="${s.points}" fill="white" opacity="${s.opacity}"/>`;
})
.join("\n ");
// 角色副标识
const roleBadge = isPasserby
? `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">社区观察者</text>`
: `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">行业专家</text>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${palette.from}"/>
<stop offset="100%" stop-color="${palette.to}"/>
</linearGradient>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
<feOffset dx="0" dy="4" result="offsetblur"/>
<feComponentTransfer><feFuncA type="linear" slope="0.35"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- 背景渐变 -->
<rect width="512" height="512" fill="url(#bg)"/>
<!-- 装饰图形 -->
${shapeSvg}
<!-- 角色徽章 -->
${roleBadge}
<!-- 中央大字母 -->
<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif" filter="url(#shadow)">${escapeXml(initial)}</text>
<!-- 右下角 🤖 标识(用 path 画,不依赖 emoji 字体) -->
<g transform="translate(440 440)">
<circle cx="0" cy="0" r="32" fill="white" opacity="0.95"/>
<path d="M-12 -4 L-12 8 Q-12 14 -6 14 L6 14 Q12 14 12 8 L12 -4 Q12 -10 6 -10 L-6 -10 Q-12 -10 -12 -4 Z" fill="${palette.from}"/>
<circle cx="-4" cy="0" r="2" fill="white"/>
<circle cx="4" cy="0" r="2" fill="white"/>
<line x1="-6" y1="6" x2="6" y2="6" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
<line x1="-8" y1="-12" x2="-12" y2="-16" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
<line x1="0" y1="-14" x2="0" y2="-20" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="-12" x2="12" y2="-16" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
<circle cx="-12" cy="-16" r="2" fill="${palette.from}"/>
<circle cx="0" cy="-20" r="2" fill="${palette.from}"/>
<circle cx="12" cy="-16" r="2" fill="${palette.from}"/>
</g>
</svg>`;
}
function escapeXml(s) {
return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[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();
}