153 lines
6.6 KiB
JavaScript
153 lines
6.6 KiB
JavaScript
/**
|
||
* 画个标 · 核心:表情设计师 AI 编排(Emoji AI)
|
||
* AI 输出结构化设计决策 JSON(布局/风格/动画/分层)→ 系统负责渲染与导出;
|
||
* AI 失败/无 Key 时返回 null,由 emojiGen 降级为内置随机池(页面不空白)。
|
||
*
|
||
* 设计要点:
|
||
* - 与 aiDesigner 同模式:buildPrompt → HGB_AI.chat(json) → extractJson → validate → 决策
|
||
* - validate 把所有输出修正到合法白名单(布局/风格/动画类型/参数),非法即回退
|
||
* - 纯函数 + 环境分级容错(Node 无头无 HGB_AI 时返回 null,不算失败)
|
||
*/
|
||
const HGB_EmojiAI = (function () {
|
||
'use strict';
|
||
|
||
/* ============ Prompt 编排 ============ */
|
||
|
||
function layoutList() {
|
||
if (typeof HGB_EmojiGen !== 'undefined') return HGB_EmojiGen.EMOJI_LAYOUTS.join('/');
|
||
return 'orb/badge/seal/burst/duo/emblem/hexagon/diamond/arch/geo/techcorner/fullbleed/concentric/centroid';
|
||
}
|
||
|
||
function buildPrompt(opt) {
|
||
const ind = opt.industry || {};
|
||
const iconDesc = (opt.icons && opt.icons.length)
|
||
? opt.icons.map(i => i.name || i.id).join('、')
|
||
: '(未指定,由 AI 建议行业意象)';
|
||
const mood = opt.mood ? '用户期望情绪:' + opt.mood + ';' : '';
|
||
// 情绪-动画映射(生成前置,先审后生成:用户给出期望情绪时优先匹配,避免动效与情绪割裂)
|
||
const moodMap = opt.mood
|
||
? '情绪-动画映射参考(用户给出期望情绪时优先匹配,可适度发挥但勿选明显冲突类型):' +
|
||
'开心→bounce、生气→shake、惊讶→heartbeat、难过→float、兴奋→hopspin、平静→breath。'
|
||
: '';
|
||
const sys = [
|
||
'你是「画个标」的动态表情设计师。用户给出行业与图标素材,你输出「一张静态底图 + 一个主动画」的结构化设计决策。',
|
||
'铁律:只输出 JSON,不要任何解释文字。',
|
||
'字段与取值:',
|
||
'- overallConcept:一句话设计概念(中文,10-20 字)',
|
||
'- base.layoutId:从白名单选一个:' + layoutList(),
|
||
'- base.styleId:从系统风格库选一个(如 minimal/neon/flat/artdeco/corporate 等,按行业气质匹配)',
|
||
'- anim.type:从动画库选一个:bounce/pulse/wiggle/float/spin/fade/twirl/heartbeat/shake/breath/hopspin',
|
||
'- anim.speed:0.6-1.5(周期数,生气/兴奋偏快,平静偏慢)',
|
||
'- anim.amplitude:0.7-1.4(强度,活泼可大,克制宜小)',
|
||
'- anim.layered:true/false(是否元素分层错峰动画,让表情更活;多元素图形建议 true)',
|
||
'- anim.concept:动效概念一句话(中文)',
|
||
moodMap,
|
||
'示例:{"overallConcept":"咖啡的松弛感","base":{"layoutId":"seal","styleId":"neon"},"anim":{"type":"float","speed":0.8,"amplitude":0.9,"layered":true,"concept":"蒸汽缓缓漂浮"}}'
|
||
].join('\n');
|
||
|
||
const user = [
|
||
'行业:' + (ind.name || '通用') + '(' + (ind.emoji || '') + ',' + (ind.desc || '') + ')',
|
||
'品牌/主题:' + (opt.brand || '动态表情'),
|
||
'用户图标素材:' + iconDesc,
|
||
mood,
|
||
'请输出严格 JSON(不要 markdown 代码块)。'
|
||
].filter(Boolean).join('\n');
|
||
|
||
return [
|
||
{ role: 'system', content: sys },
|
||
{ role: 'user', content: user }
|
||
];
|
||
}
|
||
|
||
/* ============ 解析与校验 ============ */
|
||
|
||
/** 从聊天文本提取 JSON 对象(取第一个完整 {} 块,容忍前后缀文字) */
|
||
function extractJson(text) {
|
||
if (!text || typeof text !== 'string') return null;
|
||
const start = text.indexOf('{');
|
||
if (start < 0) return null;
|
||
let depth = 0, inStr = false, esc = false;
|
||
for (let i = start; i < text.length; i++) {
|
||
const c = text[i];
|
||
if (inStr) {
|
||
if (esc) esc = false;
|
||
else if (c === '\\') esc = true;
|
||
else if (c === '"') inStr = false;
|
||
} else if (c === '"') inStr = true;
|
||
else if (c === '{') depth++;
|
||
else if (c === '}') {
|
||
depth--;
|
||
if (depth === 0) {
|
||
try { return JSON.parse(text.slice(start, i + 1)); } catch (e) { return null; }
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function inList(v, list) { return list.indexOf(v) >= 0; }
|
||
|
||
/** 修正 AI 输出到合法白名单(非法项回退合理默认,返回完整决策对象) */
|
||
function validate(d, opt) {
|
||
const ind = opt.industry || {};
|
||
const dec = d || {};
|
||
const base = dec.base || {};
|
||
const anim = dec.anim || {};
|
||
const layouts = (typeof HGB_EmojiGen !== 'undefined') ? HGB_EmojiGen.EMOJI_LAYOUTS : [];
|
||
const animTypes = (typeof HGB_Motion !== 'undefined') ? HGB_Motion.animTypes() : [];
|
||
const styleIds = (typeof HGB_Styles !== 'undefined') ? HGB_Styles.map(s => s.id) : [];
|
||
|
||
let layoutId = inList(base.layoutId, layouts) ? base.layoutId : null;
|
||
if (!layoutId && ind.favLayout && inList(ind.favLayout, layouts)) layoutId = ind.favLayout;
|
||
if (!layoutId) layoutId = layouts[0] || 'orb';
|
||
|
||
const styleId = inList(base.styleId, styleIds) ? base.styleId : (opt.styleIds && opt.styleIds[0]);
|
||
|
||
const animType = inList(anim.type, animTypes.map(t => t.id)) ? anim.type : null;
|
||
let aType = animType || 'bounce';
|
||
|
||
const clamp = (v, lo, hi, dft) => {
|
||
const n = Number(v);
|
||
return (isFinite(n) && n >= lo && n <= hi) ? n : dft;
|
||
};
|
||
|
||
return {
|
||
overallConcept: String(dec.overallConcept || '').trim().slice(0, 30) || '动态表情包',
|
||
base: { layoutId, styleId },
|
||
anim: {
|
||
type: aType,
|
||
speed: clamp(anim.speed, 0.6, 1.5, 1),
|
||
amplitude: clamp(anim.amplitude, 0.7, 1.4, 1),
|
||
layered: !!anim.layered,
|
||
concept: String(anim.concept || '').trim().slice(0, 30)
|
||
}
|
||
};
|
||
}
|
||
|
||
/* ============ 主入口 ============ */
|
||
|
||
/**
|
||
* AI 表情设计决策(失败/无 Key/超时 → 返回 null,供生成器降级)
|
||
* @param {object} opt { industry, brand, icons, mood }
|
||
* @returns {Promise<{overallConcept, base:{layoutId,styleId}, anim:{...}}|null>}
|
||
*/
|
||
async function suggest(opt) {
|
||
if (typeof HGB_AI === 'undefined') return null;
|
||
try {
|
||
if (typeof HGB_AI.hasKey === 'function' && !HGB_AI.hasKey()) return null;
|
||
const messages = buildPrompt(opt);
|
||
const content = await HGB_AI.chat(messages, { json: true, maxTokens: 2048 });
|
||
const d = extractJson(content);
|
||
if (!d) return null;
|
||
return validate(d, opt);
|
||
} catch (e) {
|
||
return null; // AI 失败 → 生成器降级内置池
|
||
}
|
||
}
|
||
|
||
return { buildPrompt, extractJson, validate, suggest };
|
||
})();
|
||
|
||
// Node 无头冒烟自检(浏览器同样执行,幂等)
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_EmojiAI;
|