355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
// 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) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[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();
|
||
}
|