/**
* characters.js — 🧑 角色库(P3 重做版)
*
* 后端:/api/ave/assets/characters(列表/搜索/筛选/详情/新建/更新/删除/上传文件)
* 数据:agent-local/tools/ave/assets/characters/{id}/(meta.json + 参考图 + 音色)
*
* 功能:
* · 搜索(名字/标签/描述)+ 筛选(类型/用途/项目/标签/有音色)+ 排序
* · 卡片 / 表格 两种视图
* · 新建(名字/类型/用途/描述)
* · 详情抽屉:编辑字段 + 上传参考图(正/侧/背/全身)+ 删除
*/
const API = '/api/ave/assets/characters';
// 模块状态
let _items = [];
let _stats = {};
let _view = localStorage.getItem('ave_char_view') || 'card'; // card | table
let _filters = { q: '', type: '', role: '', tag: '', hasVoice: false, sort: 'updated_desc' };
let _current = null; // 当前打开详情的角色
let _uid = '';
const TYPES = { real: '真人', anime: '动漫', '3d': '3D' };
const ROLES = { lead: '主角', support: '配角', extra: '群演' };
const REF_KEYS = [
['front', '正面'], ['side', '侧面'], ['back', '背面'], ['full', '全身'],
];
export async function loadView(container) {
_uid = 'ch';
container.innerHTML = shell();
bind();
await refresh();
}
function shell() {
return `
`;
}
function bind() {
const q = document.getElementById('chQ');
let t = null;
q.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => { _filters.q = q.value.trim(); refresh(); }, 300);
});
document.getElementById('chType').addEventListener('change', e => { _filters.type = e.target.value; refresh(); });
document.getElementById('chRole').addEventListener('change', e => { _filters.role = e.target.value; refresh(); });
document.getElementById('chTag').addEventListener('input', e => {
clearTimeout(t);
t = setTimeout(() => { _filters.tag = e.target.value.trim(); refresh(); }, 300);
});
document.getElementById('chVoice').addEventListener('change', e => { _filters.hasVoice = e.target.checked; refresh(); });
document.getElementById('chSort').addEventListener('change', e => { _filters.sort = e.target.value; refresh(); });
updateViewBtn();
}
function updateViewBtn() {
const b = document.getElementById('chViewBtn');
if (b) b.textContent = _view === 'card' ? '📋 表格视图' : '🔳 卡片视图';
}
window._chToggleView = () => {
_view = _view === 'card' ? 'table' : 'card';
localStorage.setItem('ave_char_view', _view);
updateViewBtn();
renderList();
};
window._chReset = () => {
_filters = { q: '', type: '', role: '', tag: '', hasVoice: false, sort: 'updated_desc' };
['chQ', 'chTag'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; });
['chType', 'chRole', 'chSort'].forEach(id => { const e = document.getElementById(id); if (e) e.value = id === 'chSort' ? 'updated_desc' : ''; });
const v = document.getElementById('chVoice'); if (v) v.checked = false;
refresh();
};
async function refresh() {
const p = new URLSearchParams();
if (_filters.q) p.set('q', _filters.q);
if (_filters.type) p.set('type', _filters.type);
if (_filters.role) p.set('role', _filters.role);
if (_filters.tag) p.set('tag', _filters.tag);
if (_filters.hasVoice) p.set('has_voice', '1');
p.set('sort', _filters.sort);
try {
const r = await fetch(`${API}?${p}`);
const d = await r.json();
_items = d.items || [];
renderList();
const sr = await fetch(`${API}/stats`);
_stats = (await sr.json()).stats || {};
const c = document.getElementById('chCount');
if (c) c.textContent = `共 ${_stats.total || 0} 个(真人 ${(_stats.by_type || {}).real || 0} · 动漫 ${(_stats.by_type || {}).anime || 0} · 有音色 ${_stats.with_voice || 0})`;
} catch (e) {
const el = document.getElementById('chList');
if (el) el.innerHTML = `❌ 加载失败:${e.message}
`;
}
}
function renderList() {
const el = document.getElementById('chList');
if (!el) return;
if (!_items.length) {
const hasFilter = _filters.q || _filters.type || _filters.role || _filters.tag || _filters.hasVoice;
el.innerHTML = `
${hasFilter ? '🔍 没有匹配的角色(试试重置筛选)' : '还没有角色,点右上角「➕ 新建角色」开始'}
`;
return;
}
el.innerHTML = _view === 'card' ? _cardHtml() : _tableHtml();
}
/** 📌 加载生成历史(版本 / 提示词 / seed / 模型 —— 供版本说明追溯) */
window._chLoadHistory = async () => {
const el = document.getElementById('chHistory');
if (!el || !_current) return;
el.innerHTML = '⏳ 加载生成历史…
';
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/history`);
const d = await r.json();
if (d.status !== 'ok') { el.innerHTML = ''; return; }
const hist = d.history || [];
if (!hist.length) {
el.innerHTML = `📌 暂无生成记录(用 AI 生成的图会自动记录:提示词 / seed / 模型 / 引擎)
`;
return;
}
el.innerHTML = `📌 生成历史(${hist.length} 版${d.active_version ? ` · 当前 v${d.active_version}` : ''})
${hist.slice().reverse().map(h => `
v${h.version}${esc(h.at || '')}
${h.engine ? `引擎 ${esc(h.engine)}` : ''}${h.model ? ` · 模型 ${esc(h.model)}` : ''}${h.seed != null ? ` · seed ${h.seed}` : ''}${h.slot ? ` · 槽位 ${esc(h.slot)}` : ''}
${h.prompt ? `
${esc(h.prompt)}
` : ''}
`).join('')}
`;
} catch (e) {
el.innerHTML = `❌ 历史加载失败:${esc(e.message)}
`;
}
};
window._chLock = async () => {
const reason = prompt('🔒 锁定原因(可留空):', '手动锁定');
if (reason === null) return;
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/lock`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || '手动锁定' }),
});
const d = await r.json();
if (d.detail) { alert('❌ ' + d.detail); return; }
alert('🔒 已锁定(参考图/seed/音色已冻结)'); window._chOpen(_current.id);
} catch (e) { alert('❌ ' + e.message); }
};
window._chUnlock = async () => {
const reason = prompt('🔓 解锁原因(会记入历史,便于追溯):', '');
if (reason === null) return;
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/unlock`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || '' }),
});
const d = await r.json();
if (d.detail) { alert('❌ ' + d.detail); return; }
alert('🔓 已解锁(改动已记入历史)'); window._chOpen(_current.id);
} catch (e) { alert('❌ ' + e.message); }
};
function thumb(m) {
const refs = m.reference_images || {};
// 优先标准槽位;否则取**任意第一个**有图的槽位(兼容 gen1 / portrait / front_face 等)
const preferred = ['front', 'portrait', 'front_face', 'side', 'full', 'back'];
const k = preferred.find(x => refs[x]) || Object.keys(refs).find(x => refs[x]);
if (!k || !refs[k]) {
return `🧑
`;
}
const url = `${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(refs[k])}`;
return `
`;
}
/** 点击缩略图 → 全屏看大图(含「保存到本地」) */
window._chViewImage = (url, title) => {
const ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;cursor:zoom-out';
ov.onclick = () => ov.remove();
ov.innerHTML = `
${title}
`;
document.body.appendChild(ov);
};
function _cardHtml() {
return `
${_items.map(m => `
${thumb(m)}
${esc(m.name || m.id)}
${TYPES[m.type] || '真人'} · ${ROLES[m.role] || '主角'}${(m.voice || {}).sample ? ' · 🔊' : ''}
用过 ${m.usage_count || 0} 次${(m.tags || []).length ? ' · ' + (m.tags || []).slice(0, 2).map(esc).join('/') : ''}
`).join('')}
`;
}
function _tableHtml() {
return `
| 名字 | 类型 | 用途 |
标签 | 音色 |
使用 | 更新时间 | 操作 |
${_items.map(m => `
| ${esc(m.name || m.id)} |
${TYPES[m.type] || '真人'} |
${ROLES[m.role] || '主角'} |
${(m.tags || []).slice(0, 3).map(esc).join(' / ') || '—'} |
${(m.voice || {}).sample ? '🔊' : '—'} |
${m.usage_count || 0} |
${esc((m.updated_at || '').slice(5, 16))} |
|
`).join('')}
`;
}
// ── 详情抽屉 ────────────────────────────────────────────
window._chOpen = async (aid) => {
try {
const r = await fetch(`${API}/${encodeURIComponent(aid)}`);
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '加载失败')); return; }
_current = d.item;
renderDrawer();
document.getElementById('chDrawer').style.display = 'block';
document.getElementById('chMask').style.display = 'block';
window._chLoadHistory(); // 📌 加载生成历史(版本/提示词/seed/模型)
} catch (e) { alert('❌ ' + e.message); }
};
window._chClose = () => {
document.getElementById('chDrawer').style.display = 'none';
document.getElementById('chMask').style.display = 'none';
_current = null;
};
function renderDrawer() {
const m = _current;
const refs = m.reference_images || {};
const files = m.files || {};
document.getElementById('chDrawerBody').innerHTML = `
🧑 ${esc(m.name || m.id)}
ID:${esc(m.id)} · 用过 ${m.usage_count || 0} 次 · 更新 ${esc((m.updated_at || '').slice(0, 16))}
${m.locked
? `
🔒 已锁定 · ${esc(m.locked_reason || '')}
锁定时间:${esc(m.locked_at || '')}${m.locked_by === 'auto' ? '(自动)' : '(手动)'} · 参考图/生成历史/seed/音色已冻结
`
: `
🔓 未锁定 · 参考图/seed/音色可修改
提示:角色一经流程使用会**自动锁定**(保证一致性);也可手动锁定
`}
📷 参考图
${(() => {
// 🔧 动态槽位:**已存在的全部槽位**(含 gen1 / portrait 等自定义名)+ 未填的标准槽位
const LABELS = { front: '正面', side: '侧面', back: '背面', full: '全身',
portrait: '半身', front_face: '正面脸部', grid: '九宫格',
scene: '场景照', expression: '表情特写' };
const out = [];
const seen = new Set();
Object.keys(refs).filter(x => refs[x]).forEach(x => {
out.push([x, LABELS[x] || x]); seen.add(x);
});
REF_KEYS.forEach(([x, lb]) => { if (!seen.has(x)) out.push([x, lb]); });
return out;
})().map(([k, label]) => {
const f = refs[k];
const url = f ? `${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(f)}` : '';
return `
${label}
${f ? `
`
: `
+
`}
`;
}).join('')}
🔊 音色
${(m.voice || {}).sample
? `
`
: '
还没有音色样本
'}
${Object.keys(files).length ? `目录文件:${Object.entries(files).map(([d, fs]) => `${d}/ (${fs.length})`).join(' · ')}
` : ''}`;
}
window._chSave = async () => {
if (!_current) return;
const patch = {
name: document.getElementById('dName').value.trim(),
type: document.getElementById('dType').value,
role: document.getElementById('dRole').value,
tags: document.getElementById('dTags').value.split(',').map(s => s.trim()).filter(Boolean),
description: document.getElementById('dDesc').value.trim(),
};
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '保存失败')); return; }
_current = d.item;
renderDrawer();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chUpRef = async (key, input) => {
const f = input.files && input.files[0];
if (!f || !_current) return;
const fd = new FormData();
fd.append('file', f);
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/file?name=${encodeURIComponent('ref_' + key + (f.name.match(/\.\w+$/) || ['.png'])[0])}`,
{ method: 'POST', body: fd });
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
// 更新 meta 里的 reference_images 映射
const refs = { ...(_current.reference_images || {}), [key]: d.path };
const r2 = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reference_images: refs }),
});
_current = (await r2.json()).item;
renderDrawer();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chDelRef = async (key) => {
if (!_current || !confirm(`删除「${key}」参考图?(仅解除引用,文件保留在目录)`)) return;
const refs = { ...(_current.reference_images || {}) };
delete refs[key];
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reference_images: refs }),
});
_current = (await r.json()).item;
renderDrawer();
await refresh();
};
window._chUpVoice = async (input) => {
const f = input.files && input.files[0];
if (!f || !_current) return;
const fd = new FormData();
fd.append('file', f);
try {
const ext = (f.name.match(/\.\w+$/) || ['.mp3'])[0];
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/file?name=${encodeURIComponent('voice_sample' + ext)}`,
{ method: 'POST', body: fd });
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
const r2 = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: { ...(_current.voice || {}), sample: d.path } }),
});
_current = (await r2.json()).item;
renderDrawer();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chUse = () => {
if (!_current) return;
// 存到 localStorage → 导演创作页自动预选(不用手抄 ID)
try { localStorage.setItem('dir_preset_character', _current.id); } catch (e) {}
if (confirm(`🔗 使用角色「${_current.name}」\n\n· 执行器会按 ID 从资源库取该角色的参考图 / 音色\n· 分镜级:可在每个镜头单独指定不同角色\n\n是否跳转到「🎬 导演创作」并自动选中它?`)) {
if (window.switchView) window.switchView('ave-director');
}
};
window._chDelete = async () => {
if (!_current || !confirm(`删除角色「${_current.name || _current.id}」?\n(默认移到 _trash/,可恢复)`)) return;
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, { method: 'DELETE' });
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '删除失败')); return; }
window._chClose();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chNew = async () => {
const name = prompt('角色名字:');
if (!name) return;
const type = prompt('类型(real 真人 / anime 动漫 / 3d):', 'real') || 'real';
const role = prompt('用途(lead 主角 / support 配角 / extra 群演):', 'lead') || 'lead';
const desc = prompt('一句话描述(可空):') || '';
try {
const r = await fetch(API, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type, role, description: desc }),
});
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '创建失败')); return; }
await refresh();
window._chOpen(d.item.id); // 直接打开详情,方便补图和音色
} catch (e) { alert('❌ ' + e.message); }
};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, m =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
}