/** * 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 => ``).join('')}
名字类型用途 标签音色 使用更新时间操作
${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))}
`; } // ── 详情抽屉 ──────────────────────────────────────────── 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])); }