// 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 ` ${shapeSvg} ${roleBadge} ${textShadow} ${textMain} `; } 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(); }