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

355 lines
14 KiB
JavaScript
Raw Permalink 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) {
// 改为真正的随机,而不是基于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(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.15"/>`);
}
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(`<rect x="${x}" y="${y}" width="50" height="50" fill="white" opacity="0.12" transform="rotate(${rotation} ${x + 25} ${y + 25})"/>`);
}
}
}
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(`<path d="M 0 ${y} Q 128 ${y - amplitude} 256 ${y} T 512 ${y}" stroke="white" stroke-width="3" fill="none" opacity="0.2"/>`);
}
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(`<polygon points="${cx},${cy - size} ${cx - size * 0.866},${cy + size * 0.5} ${cx + size * 0.866},${cy + size * 0.5}" fill="white" opacity="0.15" transform="rotate(${rot} ${cx} ${cy})"/>`);
}
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(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="${0.3 - i * 0.01}"/>`);
}
shapeSvg = dots.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>`;
// 中央文字:使用完整的首字,并添加阴影效果
const textShadow = `<text x="258" y="322" text-anchor="middle" fill="rgba(0,0,0,0.3)" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif">${escapeXml(initial)}</text>`;
const textMain = `<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif">${escapeXml(initial)}</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>
</defs>
<!-- 背景渐变 -->
<rect width="512" height="512" fill="url(#bg)"/>
<!-- 装饰图形 -->
${shapeSvg}
<!-- 角色徽章 -->
${roleBadge}
<!-- 中央大字母(带阴影) -->
${textShadow}
${textMain}
<!-- 右下角 🤖 标识 -->
<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();
}