// story-summary-ui.js // iframe 内 UI 逻辑 (function() { 'use strict'; // ═══════════════════════════════════════════════════════════════════════════ // DOM Helpers // ═══════════════════════════════════════════════════════════════════════════ const $ = id => document.getElementById(id); const $$ = sel => document.querySelectorAll(sel); const h = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ); const setHtml = (el, html) => { if (!el) return; const range = document.createRange(); range.selectNodeContents(el); // eslint-disable-next-line no-unsanitized/method const fragment = range.createContextualFragment(String(html ?? '')); el.replaceChildren(fragment); }; const setSelectOptions = (select, items, placeholderText) => { if (!select) return; select.replaceChildren(); if (placeholderText != null) { const option = document.createElement('option'); option.value = ''; option.textContent = placeholderText; select.appendChild(option); } (items || []).forEach(item => { const option = document.createElement('option'); option.value = item; option.textContent = item; select.appendChild(option); }); }; // ═══════════════════════════════════════════════════════════════════════════ // Constants // ═══════════════════════════════════════════════════════════════════════════ const PARENT_ORIGIN = (() => { try { return new URL(document.referrer).origin; } catch { return window.location.origin; } })(); const PROVIDER_DEFAULTS = { st: { url: '', needKey: false, canFetch: false, needManualModel: false }, openai: { url: 'https://api.openai.com', needKey: true, canFetch: true, needManualModel: false }, google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false, needManualModel: true }, claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false, needManualModel: true }, deepseek: { url: 'https://api.deepseek.com', needKey: true, canFetch: true, needManualModel: false }, cohere: { url: 'https://api.cohere.ai', needKey: true, canFetch: false, needManualModel: true }, custom: { url: '', needKey: true, canFetch: true, needManualModel: false } }; const SECTION_META = { keywords: { title: '编辑关键词', hint: '每行一个关键词,格式:关键词|权重(核心/重要/一般)' }, events: { title: '编辑事件时间线', hint: '编辑时,每个事件要素都应完整' }, characters: { title: '编辑人物关系', hint: '编辑时,每个要素都应完整' }, arcs: { title: '编辑角色弧光', hint: '编辑时,每个要素都应完整' }, world: { title: '编辑世界状态', hint: '每行一条:category|topic|content。清除用:category|topic|(留空)或 category|topic|cleared' } }; const TREND_COLORS = { '破裂': '#444444', '厌恶': '#8b0000', '反感': '#cd5c5c', '陌生': '#888888', '投缘': '#4a9a7e', '亲密': '#d87a7a', '交融': '#c71585' }; const TREND_CLASS = { '破裂': 'trend-broken', '厌恶': 'trend-hate', '反感': 'trend-dislike', '陌生': 'trend-stranger', '投缘': 'trend-click', '亲密': 'trend-close', '交融': 'trend-merge' }; const LOCAL_MODELS_INFO = { 'bge-small-zh': { desc: '手机/低配适用' }, 'bge-base-zh': { desc: 'PC 推荐,效果更好' }, 'e5-small': { desc: '非中文用户' } }; const ONLINE_PROVIDERS_INFO = { siliconflow: { url: 'https://api.siliconflow.cn', models: ['BAAI/bge-m3', 'BAAI/bge-large-zh-v1.5', 'BAAI/bge-small-zh-v1.5'], hint: '💡 硅基流动 注册即送额度,推荐 BAAI/bge-m3', canFetch: false, urlEditable: false }, cohere: { url: 'https://api.cohere.ai', models: ['embed-multilingual-v3.0', 'embed-english-v3.0'], hint: '💡 Cohere 提供免费试用额度', canFetch: false, urlEditable: false }, openai: { url: '', models: [], hint: '💡 可用 Hugging Face Space 免费自建
', canFetch: true, urlEditable: true } }; const DEFAULT_FILTER_RULES = [ { start: '', end: '' }, { start: '', end: '' }, ]; // ═══════════════════════════════════════════════════════════════════════════ // State // ═══════════════════════════════════════════════════════════════════════════ const config = { api: { provider: 'st', url: '', key: '', model: '', modelCache: [] }, gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null }, trigger: { enabled: false, interval: 20, timing: 'after_ai', useStream: true, maxPerRun: 100, wrapperHead: '', wrapperTail: '', forceInsertAtEnd: false }, vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } } }; let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] }; let localGenerating = false; let vectorGenerating = false; let relationChart = null; let relationChartFullscreen = null; let currentEditSection = null; let currentCharacterId = null; let allNodes = []; let allLinks = []; let activeRelationTooltip = null; let lastRecallLogText = ''; // ═══════════════════════════════════════════════════════════════════════════ // Messaging // ═══════════════════════════════════════════════════════════════════════════ function postMsg(type, data = {}) { window.parent.postMessage({ source: 'LittleWhiteBox-StoryFrame', type, ...data }, PARENT_ORIGIN); } // ═══════════════════════════════════════════════════════════════════════════ // Config Management // ═══════════════════════════════════════════════════════════════════════════ function loadConfig() { try { const s = localStorage.getItem('summary_panel_config'); if (s) { const p = JSON.parse(s); Object.assign(config.api, p.api || {}); Object.assign(config.gen, p.gen || {}); Object.assign(config.trigger, p.trigger || {}); if (p.vector) config.vector = p.vector; if (config.trigger.timing === 'manual' && config.trigger.enabled) { config.trigger.enabled = false; saveConfig(); } } } catch {} } function applyConfig(cfg) { if (!cfg) return; Object.assign(config.api, cfg.api || {}); Object.assign(config.gen, cfg.gen || {}); Object.assign(config.trigger, cfg.trigger || {}); if (cfg.vector) config.vector = cfg.vector; if (config.trigger.timing === 'manual') config.trigger.enabled = false; localStorage.setItem('summary_panel_config', JSON.stringify(config)); } function saveConfig() { try { const settingsOpen = $('settings-modal')?.classList.contains('active'); if (settingsOpen) config.vector = getVectorConfig(); if (!config.vector) { config.vector = { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } }; } localStorage.setItem('summary_panel_config', JSON.stringify(config)); postMsg('SAVE_PANEL_CONFIG', { config }); } catch (e) { console.error('saveConfig error:', e); } } // ═══════════════════════════════════════════════════════════════════════════ // Vector Config UI // ═══════════════════════════════════════════════════════════════════════════ function getVectorConfig() { const safeVal = (id, fallback) => { const el = $(id); if (!el) return fallback; return el.type === 'checkbox' ? el.checked : (el.value?.trim() || fallback); }; const safeRadio = (name, fallback) => { const el = document.querySelector(`input[name="${name}"]:checked`); return el?.value || fallback; }; const modelSelect = $('vector-model-select'); const modelCache = []; if (modelSelect) { for (const opt of modelSelect.options) { if (opt.value) modelCache.push(opt.value); } } const result = { enabled: safeVal('vector-enabled', false), engine: safeRadio('vector-engine', 'online'), local: { modelId: safeVal('local-model-select', 'bge-small-zh') }, online: { provider: safeVal('online-provider', 'siliconflow'), url: safeVal('vector-api-url', ''), key: safeVal('vector-api-key', ''), model: safeVal('vector-model-select', ''), modelCache } }; // 收集过滤规则 result.textFilterRules = collectFilterRules(); return result; } function loadVectorConfig(cfg) { if (!cfg) return; $('vector-enabled').checked = !!cfg.enabled; $('vector-config-area').classList.toggle('hidden', !cfg.enabled); const engine = cfg.engine || 'online'; const engineRadio = document.querySelector(`input[name="vector-engine"][value="${engine}"]`); if (engineRadio) engineRadio.checked = true; $('local-engine-area').classList.toggle('hidden', engine !== 'local'); $('online-engine-area').classList.toggle('hidden', engine !== 'online'); if (cfg.local?.modelId) { $('local-model-select').value = cfg.local.modelId; updateLocalModelDesc(cfg.local.modelId); } if (cfg.online) { const provider = cfg.online.provider || 'siliconflow'; $('online-provider').value = provider; updateOnlineProviderUI(provider); if (cfg.online.url) $('vector-api-url').value = cfg.online.url; if (cfg.online.key) $('vector-api-key').value = cfg.online.key; if (cfg.online.modelCache?.length) { setSelectOptions($('vector-model-select'), cfg.online.modelCache); } if (cfg.online.model) $('vector-model-select').value = cfg.online.model; } // 加载过滤规则 renderFilterRules(cfg?.textFilterRules || DEFAULT_FILTER_RULES); } function updateLocalModelDesc(modelId) { const info = LOCAL_MODELS_INFO[modelId]; $('local-model-desc').textContent = info?.desc || ''; } function updateOnlineProviderUI(provider) { const info = ONLINE_PROVIDERS_INFO[provider]; if (!info) return; const urlInput = $('vector-api-url'); const urlRow = $('online-url-row'); if (info.urlEditable) { urlInput.value = urlInput.value || ''; urlInput.disabled = false; urlRow.style.display = ''; } else { urlInput.value = info.url; urlInput.disabled = true; urlRow.style.display = 'none'; } const modelSelect = $('vector-model-select'); const fetchBtn = $('btn-fetch-models'); if (info.canFetch) { fetchBtn.style.display = ''; setHtml(modelSelect, ''); } else { fetchBtn.style.display = 'none'; setSelectOptions(modelSelect, info.models); } setHtml($('provider-hint'), info.hint); const guideBtn = $('btn-hf-guide'); if (guideBtn) guideBtn.onclick = e => { e.preventDefault(); openHfGuide(); }; } // ═══════════════════════════════════════════════════════════════════════════ // Filter Rules UI // ═══════════════════════════════════════════════════════════════════════════ function renderFilterRules(rules) { const list = $('filter-rules-list'); if (!list) return; const items = rules?.length ? rules : []; setHtml(list, items.map((r, i) => `
`).join('')); // 绑定删除 list.querySelectorAll('.filter-rule-del').forEach(btn => { btn.onclick = () => { btn.closest('.filter-rule-item')?.remove(); }; }); } function collectFilterRules() { const list = $('filter-rules-list'); if (!list) return []; const rules = []; list.querySelectorAll('.filter-rule-item').forEach(item => { const start = item.querySelector('.filter-rule-start')?.value?.trim() || ''; const end = item.querySelector('.filter-rule-end')?.value?.trim() || ''; if (start || end) { rules.push({ start, end }); } }); return rules; } function addFilterRule() { const list = $('filter-rules-list'); if (!list) return; const idx = list.querySelectorAll('.filter-rule-item').length; const div = document.createElement('div'); div.className = 'filter-rule-item'; div.dataset.idx = idx; div.style.cssText = 'display:flex;gap:6px;align-items:center'; setHtml(div, ` `); div.querySelector('.filter-rule-del').onclick = () => div.remove(); list.appendChild(div); } function updateLocalModelStatus(status, message) { const dot = $('local-model-status').querySelector('.status-dot'); const text = $('local-model-status').querySelector('.status-text'); dot.className = 'status-dot ' + status; text.textContent = message; const btnDownload = $('btn-download-model'); const btnCancel = $('btn-cancel-download'); const btnDelete = $('btn-delete-model'); const progress = $('local-model-progress'); btnDownload.style.display = (status === 'not_downloaded' || status === 'cached' || status === 'error') ? '' : 'none'; btnCancel.style.display = (status === 'downloading') ? '' : 'none'; btnDelete.style.display = (status === 'ready' || status === 'cached') ? '' : 'none'; progress.classList.toggle('hidden', status !== 'downloading'); btnDownload.textContent = status === 'cached' ? '加载模型' : status === 'error' ? '重试下载' : '下载模型'; } function updateLocalModelProgress(percent) { const progress = $('local-model-progress'); progress.classList.remove('hidden'); progress.querySelector('.progress-inner').style.width = percent + '%'; progress.querySelector('.progress-text').textContent = percent + '%'; } function updateOnlineStatus(status, message) { const dot = $('online-api-status').querySelector('.status-dot'); const text = $('online-api-status').querySelector('.status-text'); dot.className = 'status-dot ' + status; text.textContent = message; } function updateOnlineModels(models) { const select = $('vector-model-select'); const current = select.value; setSelectOptions(select, models); if (current && models.includes(current)) select.value = current; if (!config.vector) config.vector = { enabled: false, engine: 'online', local: {}, online: {} }; if (!config.vector.online) config.vector.online = {}; config.vector.online.modelCache = [...models]; } function updateVectorStats(stats) { $('vector-event-count').textContent = stats.eventVectors || 0; if ($('vector-event-total')) $('vector-event-total').textContent = stats.eventCount || 0; if ($('vector-chunk-count')) $('vector-chunk-count').textContent = stats.chunkCount || 0; if ($('vector-chunk-floors')) $('vector-chunk-floors').textContent = stats.builtFloors || 0; if ($('vector-chunk-total')) $('vector-chunk-total').textContent = stats.totalFloors || 0; if ($('vector-message-count')) $('vector-message-count').textContent = stats.totalMessages || 0; } function updateVectorGenProgress(phase, current, total) { const progressId = phase === 'L1' ? 'vector-gen-progress-l1' : 'vector-gen-progress-l2'; const progress = $(progressId); const btnGen = $('btn-gen-vectors'); const btnCancel = $('btn-cancel-vectors'); const btnClear = $('btn-clear-vectors'); if (current < 0) { progress.classList.add('hidden'); const l1Hidden = $('vector-gen-progress-l1').classList.contains('hidden'); const l2Hidden = $('vector-gen-progress-l2').classList.contains('hidden'); if (l1Hidden && l2Hidden) { btnGen.classList.remove('hidden'); btnCancel.classList.add('hidden'); btnClear.classList.remove('hidden'); vectorGenerating = false; } return; } vectorGenerating = true; progress.classList.remove('hidden'); btnGen.classList.add('hidden'); btnCancel.classList.remove('hidden'); btnClear.classList.add('hidden'); const percent = total > 0 ? Math.round(current / total * 100) : 0; progress.querySelector('.progress-inner').style.width = percent + '%'; progress.querySelector('.progress-text').textContent = `${current}/${total}`; } function showVectorMismatchWarning(show) { $('vector-mismatch-warning').classList.toggle('hidden', !show); } function initVectorUI() { $('vector-enabled').onchange = e => { $('vector-config-area').classList.toggle('hidden', !e.target.checked); }; document.querySelectorAll('input[name="vector-engine"]').forEach(radio => { radio.onchange = e => { const isLocal = e.target.value === 'local'; $('local-engine-area').classList.toggle('hidden', !isLocal); $('online-engine-area').classList.toggle('hidden', isLocal); }; }); $('local-model-select').onchange = e => { updateLocalModelDesc(e.target.value); postMsg('VECTOR_CHECK_LOCAL_MODEL', { modelId: e.target.value }); }; $('online-provider').onchange = e => updateOnlineProviderUI(e.target.value); $('btn-download-model').onclick = () => postMsg('VECTOR_DOWNLOAD_MODEL', { modelId: $('local-model-select').value }); $('btn-cancel-download').onclick = () => postMsg('VECTOR_CANCEL_DOWNLOAD'); $('btn-delete-model').onclick = () => { if (confirm('确定删除本地模型缓存?')) postMsg('VECTOR_DELETE_MODEL', { modelId: $('local-model-select').value }); }; $('btn-fetch-models').onclick = () => { postMsg('VECTOR_FETCH_MODELS', { config: { url: $('vector-api-url').value.trim(), key: $('vector-api-key').value.trim() } }); }; $('btn-test-vector-api').onclick = () => { postMsg('VECTOR_TEST_ONLINE', { provider: $('online-provider').value, config: { url: $('vector-api-url').value.trim(), key: $('vector-api-key').value.trim(), model: $('vector-model-select').value.trim() } }); }; // 过滤规则:添加按钮 $('btn-add-filter-rule').onclick = addFilterRule; $('btn-gen-vectors').onclick = () => { if (vectorGenerating) return; postMsg('VECTOR_GENERATE', { config: getVectorConfig() }); }; $('btn-clear-vectors').onclick = () => { if (confirm('确定清除当前聊天的向量数据?')) postMsg('VECTOR_CLEAR'); }; $('btn-cancel-vectors').onclick = () => postMsg('VECTOR_CANCEL_GENERATE'); // 导入导出 $('btn-export-vectors').onclick = () => { $('btn-export-vectors').disabled = true; $('vector-io-status').textContent = '导出中...'; postMsg('VECTOR_EXPORT'); }; $('btn-import-vectors').onclick = () => { // 让 parent 处理文件选择,避免 iframe 传大文件 $('btn-import-vectors').disabled = true; $('vector-io-status').textContent = '导入中...'; postMsg('VECTOR_IMPORT_PICK'); }; } // ═══════════════════════════════════════════════════════════════════════════ // Settings Modal // ═══════════════════════════════════════════════════════════════════════════ function updateProviderUI(provider) { const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom; const isSt = provider === 'st'; $('api-url-row').classList.toggle('hidden', isSt); $('api-key-row').classList.toggle('hidden', !pv.needKey); $('api-model-manual-row').classList.toggle('hidden', isSt || !pv.needManualModel); $('api-model-select-row').classList.toggle('hidden', isSt || pv.needManualModel || !config.api.modelCache.length); $('api-connect-row').classList.toggle('hidden', isSt || !pv.canFetch); const urlInput = $('api-url'); if (!urlInput.value && pv.url) urlInput.value = pv.url; } function openSettings() { $('api-provider').value = config.api.provider; $('api-url').value = config.api.url; $('api-key').value = config.api.key; $('api-model-text').value = config.api.model; $('gen-temp').value = config.gen.temperature ?? ''; $('gen-top-p').value = config.gen.top_p ?? ''; $('gen-top-k').value = config.gen.top_k ?? ''; $('gen-presence').value = config.gen.presence_penalty ?? ''; $('gen-frequency').value = config.gen.frequency_penalty ?? ''; $('trigger-enabled').checked = config.trigger.enabled; $('trigger-interval').value = config.trigger.interval; $('trigger-timing').value = config.trigger.timing; $('trigger-stream').checked = config.trigger.useStream !== false; $('trigger-max-per-run').value = config.trigger.maxPerRun || 100; $('trigger-wrapper-head').value = config.trigger.wrapperHead || ''; $('trigger-wrapper-tail').value = config.trigger.wrapperTail || ''; $('trigger-insert-at-end').checked = !!config.trigger.forceInsertAtEnd; const en = $('trigger-enabled'); if (config.trigger.timing === 'manual') { en.checked = false; en.disabled = true; en.parentElement.style.opacity = '.5'; } else { en.disabled = false; en.parentElement.style.opacity = '1'; } if (config.api.modelCache.length) { setHtml($('api-model-select'), config.api.modelCache.map(m => `` ).join('')); } updateProviderUI(config.api.provider); if (config.vector) loadVectorConfig(config.vector); $('settings-modal').classList.add('active'); postMsg('SETTINGS_OPENED'); } function closeSettings(save) { if (save) { const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v); }; const provider = $('api-provider').value; const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom; config.api.provider = provider; config.api.url = $('api-url').value; config.api.key = $('api-key').value; config.api.model = provider === 'st' ? '' : pv.needManualModel ? $('api-model-text').value : $('api-model-select').value; config.gen.temperature = pn('gen-temp'); config.gen.top_p = pn('gen-top-p'); config.gen.top_k = pn('gen-top-k'); config.gen.presence_penalty = pn('gen-presence'); config.gen.frequency_penalty = pn('gen-frequency'); const timing = $('trigger-timing').value; config.trigger.timing = timing; config.trigger.enabled = timing === 'manual' ? false : $('trigger-enabled').checked; config.trigger.interval = parseInt($('trigger-interval').value) || 20; config.trigger.useStream = $('trigger-stream').checked; config.trigger.maxPerRun = parseInt($('trigger-max-per-run').value) || 100; config.trigger.wrapperHead = $('trigger-wrapper-head').value; config.trigger.wrapperTail = $('trigger-wrapper-tail').value; config.trigger.forceInsertAtEnd = $('trigger-insert-at-end').checked; config.vector = getVectorConfig(); saveConfig(); } $('settings-modal').classList.remove('active'); postMsg('SETTINGS_CLOSED'); } async function fetchModels() { const btn = $('btn-connect'); const provider = $('api-provider').value; if (!PROVIDER_DEFAULTS[provider]?.canFetch) { alert('当前渠道不支持自动拉取模型'); return; } let baseUrl = $('api-url').value.trim().replace(/\/+$/, ''); const apiKey = $('api-key').value.trim(); if (!apiKey) { alert('请先填写 API KEY'); return; } btn.disabled = true; btn.textContent = '连接中...'; try { const tryFetch = async url => { const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' } }); return res.ok ? (await res.json())?.data?.map(m => m?.id).filter(Boolean) || null : null; }; if (baseUrl.endsWith('/v1')) baseUrl = baseUrl.slice(0, -3); let models = await tryFetch(`${baseUrl}/v1/models`); if (!models) models = await tryFetch(`${baseUrl}/models`); if (!models?.length) throw new Error('未获取到模型列表'); config.api.modelCache = [...new Set(models)]; const sel = $('api-model-select'); setSelectOptions(sel, config.api.modelCache); $('api-model-select-row').classList.remove('hidden'); if (!config.api.model && models.length) { config.api.model = models[0]; sel.value = models[0]; } else if (config.api.model) { sel.value = config.api.model; } saveConfig(); alert(`成功获取 ${models.length} 个模型`); } catch (e) { alert('连接失败:' + (e.message || '请检查 URL 和 KEY')); } finally { btn.disabled = false; btn.textContent = '连接 / 拉取模型列表'; } } // ═══════════════════════════════════════════════════════════════════════════ // Rendering Functions // ═══════════════════════════════════════════════════════════════════════════ function renderKeywords(kw) { summaryData.keywords = kw || []; const wc = { '核心': 'p', '重要': 's', high: 'p', medium: 's' }; setHtml($('keywords-cloud'), kw.length ? kw.map(k => `${h(k.text)}`).join('') : '
暂无关键词
'); } function renderTimeline(ev) { summaryData.events = ev || []; const c = $('timeline-list'); if (!ev?.length) { setHtml(c, '
暂无事件记录
'); return; } setHtml(c, ev.map(e => { const participants = (e.participants || e.characters || []).map(h).join('、'); return `
${h(e.title || '')}
${h(e.timeLabel || '')}
${h(e.summary || e.brief || '')}
人物:${participants || '—'} ${h(e.type || '')}${e.type && e.weight ? ' · ' : ''}${h(e.weight || '')}
`; }).join('')); } function getCharName(c) { return typeof c === 'string' ? c : c.name; } function hideRelationTooltip() { if (activeRelationTooltip) { activeRelationTooltip.remove(); activeRelationTooltip = null; } } function showRelationTooltip(from, to, fromLabel, toLabel, fromTrend, toTrend, x, y, container) { hideRelationTooltip(); const tip = document.createElement('div'); const mobile = innerWidth <= 768; const fc = TREND_COLORS[fromTrend] || '#888'; const tc = TREND_COLORS[toTrend] || '#888'; setHtml(tip, `
${fromLabel ? `
${h(from)}→${h(to)}: ${h(fromLabel)} [${h(fromTrend)}]
` : ''} ${toLabel ? `
${h(to)}→${h(from)}: ${h(toLabel)} [${h(toTrend)}]
` : ''}
`); tip.style.cssText = mobile ? 'position:absolute;left:8px;bottom:8px;background:#fff;color:#333;padding:10px 14px;border:1px solid #ddd;border-radius:6px;font-size:12px;z-index:100;box-shadow:0 2px 12px rgba(0,0,0,.15);max-width:calc(100% - 16px)' : `position:absolute;left:${Math.max(80, Math.min(x, container.clientWidth - 80))}px;top:${Math.max(60, y)}px;transform:translate(-50%,-100%);background:#fff;color:#333;padding:10px 16px;border:1px solid #ddd;border-radius:6px;font-size:12px;z-index:1000;box-shadow:0 4px 12px rgba(0,0,0,.15);max-width:280px`; container.style.position = 'relative'; container.appendChild(tip); activeRelationTooltip = tip; } function renderRelations(data) { summaryData.characters = data || { main: [], relationships: [] }; const dom = $('relation-chart'); if (!relationChart) relationChart = echarts.init(dom); const rels = data?.relationships || []; const allNames = new Set((data?.main || []).map(getCharName)); rels.forEach(r => { if (r.from) allNames.add(r.from); if (r.to) allNames.add(r.to); }); const degrees = {}; rels.forEach(r => { degrees[r.from] = (degrees[r.from] || 0) + 1; degrees[r.to] = (degrees[r.to] || 0) + 1; }); const nodeColors = { main: '#d87a7a', sec: '#f1c3c3', ter: '#888888', qua: '#b8b8b8' }; const sortedDegs = Object.values(degrees).sort((a, b) => b - a); const getPercentile = deg => { if (!sortedDegs.length || deg === 0) return 100; const rank = sortedDegs.filter(d => d > deg).length; return (rank / sortedDegs.length) * 100; }; allNodes = Array.from(allNames).map(name => { const deg = degrees[name] || 0; const pct = getPercentile(deg); let col, fontWeight; if (pct < 30) { col = nodeColors.main; fontWeight = '600'; } else if (pct < 60) { col = nodeColors.sec; fontWeight = '500'; } else if (pct < 90) { col = nodeColors.ter; fontWeight = '400'; } else { col = nodeColors.qua; fontWeight = '400'; } return { id: name, name, symbol: 'circle', symbolSize: Math.min(36, Math.max(16, deg * 3 + 12)), draggable: true, itemStyle: { color: col, borderColor: '#fff', borderWidth: 2, shadowColor: 'rgba(0,0,0,.1)', shadowBlur: 6, shadowOffsetY: 2 }, label: { show: true, position: 'right', distance: 5, color: '#333', fontSize: 11, fontWeight }, degree: deg }; }); const relMap = new Map(); rels.forEach(r => { const k = [r.from, r.to].sort().join('|||'); if (!relMap.has(k)) relMap.set(k, { from: r.from, to: r.to, fromLabel: '', toLabel: '', fromTrend: '', toTrend: '' }); const e = relMap.get(k); if (r.from === e.from) { e.fromLabel = r.label || r.type || ''; e.fromTrend = r.trend || ''; } else { e.toLabel = r.label || r.type || ''; e.toTrend = r.trend || ''; } }); allLinks = Array.from(relMap.values()).map(r => { const fc = TREND_COLORS[r.fromTrend] || '#b8b8b8'; const tc = TREND_COLORS[r.toTrend] || '#b8b8b8'; return { source: r.from, target: r.to, fromName: r.from, toName: r.to, fromLabel: r.fromLabel, toLabel: r.toLabel, fromTrend: r.fromTrend, toTrend: r.toTrend, lineStyle: { width: 1, color: '#d8d8d8', curveness: 0, opacity: 1 }, label: { show: true, position: 'middle', distance: 0, formatter: '{a|◀}{b|▶}', rich: { a: { color: fc, fontSize: 10 }, b: { color: tc, fontSize: 10 } }, align: 'center', verticalAlign: 'middle', offset: [0, -0.1] }, emphasis: { lineStyle: { width: 1.5, color: '#aaa' }, label: { fontSize: 11 } } }; }); if (!allNodes.length) { relationChart.clear(); return; } const updateChart = (nodes, links, focusId = null) => { const fadeOpacity = 0.2; const processedNodes = focusId ? nodes.map(n => { const rl = links.filter(l => l.source === focusId || l.target === focusId); const rn = new Set([focusId]); rl.forEach(l => { rn.add(l.source); rn.add(l.target); }); const isRelated = rn.has(n.id); return { ...n, itemStyle: { ...n.itemStyle, opacity: isRelated ? 1 : fadeOpacity }, label: { ...n.label, opacity: isRelated ? 1 : fadeOpacity } }; }) : nodes; const processedLinks = focusId ? links.map(l => { const isRelated = l.source === focusId || l.target === focusId; return { ...l, lineStyle: { ...l.lineStyle, opacity: isRelated ? 1 : fadeOpacity }, label: { ...l.label, opacity: isRelated ? 1 : fadeOpacity } }; }) : links; relationChart.setOption({ backgroundColor: 'transparent', tooltip: { show: false }, hoverLayerThreshold: Infinity, series: [{ type: 'graph', layout: 'force', roam: true, draggable: true, animation: true, animationDuration: 800, animationDurationUpdate: 300, animationEasingUpdate: 'cubicInOut', progressive: 0, hoverAnimation: false, data: processedNodes, links: processedLinks, force: { initLayout: 'circular', repulsion: 350, edgeLength: [80, 160], gravity: .12, friction: .6, layoutAnimation: true }, label: { show: true }, edgeLabel: { show: true, position: 'middle' }, emphasis: { disabled: true } }] }); }; updateChart(allNodes, allLinks); setTimeout(() => relationChart.resize(), 0); relationChart.off('click'); relationChart.on('click', p => { if (p.dataType === 'node') { hideRelationTooltip(); const id = p.data.id; selectCharacter(id); updateChart(allNodes, allLinks, id); } else if (p.dataType === 'edge') { const d = p.data; const e = p.event?.event; if (e) { const rect = dom.getBoundingClientRect(); showRelationTooltip(d.fromName, d.toName, d.fromLabel, d.toLabel, d.fromTrend, d.toTrend, e.offsetX || (e.clientX - rect.left), e.offsetY || (e.clientY - rect.top), dom); } } }); relationChart.getZr().on('click', p => { if (!p.target) { hideRelationTooltip(); updateChart(allNodes, allLinks); } }); } function selectCharacter(id) { currentCharacterId = id; const txt = $('sel-char-text'); const opts = $('char-sel-opts'); if (opts && id) { opts.querySelectorAll('.sel-opt').forEach(o => { if (o.dataset.value === id) { o.classList.add('sel'); if (txt) txt.textContent = o.textContent; } else { o.classList.remove('sel'); } }); } else if (!id && txt) { txt.textContent = '选择角色'; } renderCharacterProfile(); if (relationChart && id) { const opt = relationChart.getOption(); const idx = opt?.series?.[0]?.data?.findIndex(n => n.id === id || n.name === id); if (idx >= 0) relationChart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: idx }); } } function updateCharacterSelector(arcs) { const opts = $('char-sel-opts'); const txt = $('sel-char-text'); if (!opts) return; if (!arcs?.length) { setHtml(opts, '
暂无角色
'); if (txt) txt.textContent = '暂无角色'; currentCharacterId = null; return; } setHtml(opts, arcs.map(a => `
${h(a.name || '角色')}
`).join('')); opts.querySelectorAll('.sel-opt').forEach(o => { o.onclick = e => { e.stopPropagation(); if (o.dataset.value) { selectCharacter(o.dataset.value); $('char-sel').classList.remove('open'); } }; }); if (currentCharacterId && arcs.some(a => (a.id || a.name) === currentCharacterId)) { selectCharacter(currentCharacterId); } else if (arcs.length) { selectCharacter(arcs[0].id || arcs[0].name); } } function renderCharacterProfile() { const c = $('profile-content'); const arcs = summaryData.arcs || []; const rels = summaryData.characters?.relationships || []; if (!currentCharacterId || !arcs.length) { setHtml(c, '
暂无角色数据
'); return; } const arc = arcs.find(a => (a.id || a.name) === currentCharacterId); if (!arc) { setHtml(c, '
未找到角色数据
'); return; } const name = arc.name || '角色'; const moments = (arc.moments || arc.beats || []).map(m => typeof m === 'string' ? m : m.text); const outRels = rels.filter(r => r.from === name); const inRels = rels.filter(r => r.to === name); setHtml(c, `
${h(name)}
${h(arc.trajectory || arc.phase || '')}
弧光进度 ${Math.round((arc.progress || 0) * 100)}%
${moments.length ? `
关键时刻
${moments.map(m => `
${h(m)}
`).join('')}
` : ''}
${h(name)}对别人的羁绊:
${outRels.length ? outRels.map(r => `
对${h(r.to)}: ${h(r.label || '—')} ${r.trend ? `${h(r.trend)}` : ''}
`).join('') : '
暂无关系记录
'}
别人对${h(name)}的羁绊:
${inRels.length ? inRels.map(r => `
${h(r.from)}: ${h(r.label || '—')} ${r.trend ? `${h(r.trend)}` : ''}
`).join('') : '
暂无关系记录
'}
`); } function renderArcs(arcs) { summaryData.arcs = arcs || []; updateCharacterSelector(arcs || []); renderCharacterProfile(); } function updateStats(s) { if (!s) return; $('stat-summarized').textContent = s.summarizedUpTo ?? 0; $('stat-events').textContent = s.eventsCount ?? 0; const p = s.pendingFloors ?? 0; $('stat-pending').textContent = p; $('pending-warning').classList.toggle('hidden', p !== -1); } // ═══════════════════════════════════════════════════════════════════════════ // Modals // ═══════════════════════════════════════════════════════════════════════════ function openRelationsFullscreen() { $('rel-fs-modal').classList.add('active'); const dom = $('relation-chart-fullscreen'); if (!relationChartFullscreen) relationChartFullscreen = echarts.init(dom); if (!allNodes.length) { relationChartFullscreen.clear(); return; } relationChartFullscreen.setOption({ tooltip: { show: false }, hoverLayerThreshold: Infinity, series: [{ type: 'graph', layout: 'force', roam: true, draggable: true, animation: true, animationDuration: 800, animationDurationUpdate: 300, animationEasingUpdate: 'cubicInOut', progressive: 0, hoverAnimation: false, data: allNodes.map(n => ({ ...n, symbolSize: Array.isArray(n.symbolSize) ? [n.symbolSize[0] * 1.3, n.symbolSize[1] * 1.3] : n.symbolSize * 1.3, label: { ...n.label, fontSize: 14 } })), links: allLinks.map(l => ({ ...l, label: { ...l.label, fontSize: 18 } })), force: { repulsion: 700, edgeLength: [150, 280], gravity: .06, friction: .6, layoutAnimation: true }, label: { show: true }, edgeLabel: { show: true, position: 'middle' }, emphasis: { disabled: true } }] }); setTimeout(() => relationChartFullscreen.resize(), 100); postMsg('FULLSCREEN_OPENED'); } function closeRelationsFullscreen() { $('rel-fs-modal').classList.remove('active'); postMsg('FULLSCREEN_CLOSED'); } function openHfGuide() { $('hf-guide-modal').classList.add('active'); renderHfGuideContent(); postMsg('FULLSCREEN_OPENED'); } function closeHfGuide() { $('hf-guide-modal').classList.remove('active'); postMsg('FULLSCREEN_CLOSED'); } function renderHfGuideContent() { const body = $('hf-guide-body'); if (!body || body.innerHTML.trim()) return; setHtml(body, `
免费自建 Embedding 服务,10 分钟搞定
🆓 完全免费 ⚡ 速度不快 🔐 数据私有
1创建 Space

访问 huggingface.co/new-space,登录后创建:

  • Space name: 随便取(如 my-embedding
  • SDK: 选 Docker
  • Hardware: 选 CPU basic (Free)
2上传 3 个文件

在 Space 的 Files 页面,依次创建以下文件:

📄requirements.txt
fastapi
uvicorn
sentence-transformers
torch
🐍app.py主程序
import os
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["TOKENIZERS_PARALLELISM"] = "false"

import torch
torch.set_num_threads(1)

import threading
from functools import lru_cache
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Header
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer

ACCESS_KEY = os.environ.get("ACCESS_KEY", "")
MODEL_ID = "BAAI/bge-m3"

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])

@lru_cache(maxsize=1)
def get_model():
    return SentenceTransformer(MODEL_ID, trust_remote_code=True)

class EmbedRequest(BaseModel):
    input: List[str]
    model: Optional[str] = "bge-m3"

@app.post("/v1/embeddings")
async def embed(req: EmbedRequest, authorization: Optional[str] = Header(None)):
    if ACCESS_KEY and (authorization or "").replace("Bearer ", "").strip() != ACCESS_KEY:
        raise HTTPException(401, "Unauthorized")
    embeddings = get_model().encode(req.input, normalize_embeddings=True)
    return {"data": [{"embedding": e.tolist(), "index": i} for i, e in enumerate(embeddings)]}

@app.get("/v1/models")
async def models():
    return {"data": [{"id": "bge-m3"}]}

@app.get("/health")
async def health():
    return {"status": "ok"}

@app.on_event("startup")
async def startup():
    threading.Thread(target=get_model, daemon=True).start()
🐳Dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-m3', trust_remote_code=True)"
EXPOSE 7860
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "2"]
3等待构建

上传完成后自动开始构建,约需 10 分钟(下载模型)。

成功后状态变为 Running

4在插件中配置
服务渠道OpenAI 兼容
API URLhttps://用户名-空间名.hf.space
API Key随便填
模型点"拉取" → 选 bge-m3
💡 小提示
`); // Add copy button handlers body.querySelectorAll('.copy-btn').forEach(btn => { btn.onclick = async () => { const code = btn.previousElementSibling?.textContent || ''; try { await navigator.clipboard.writeText(code); btn.textContent = '已复制'; setTimeout(() => btn.textContent = '复制', 1200); } catch { const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); btn.textContent = '已复制'; setTimeout(() => btn.textContent = '复制', 1200); } }; }); } // ═══════════════════════════════════════════════════════════════════════════ // Recall Log // ═══════════════════════════════════════════════════════════════════════════ function setRecallLog(text) { lastRecallLogText = text || ''; updateRecallLogDisplay(); } function updateRecallLogDisplay() { const content = $('recall-log-content'); if (!content) return; if (lastRecallLogText) { content.textContent = lastRecallLogText; content.classList.remove('recall-empty'); } else { setHtml(content, '
暂无召回日志

当 AI 生成回复时,系统会自动进行记忆召回。
召回日志将显示:
• 查询文本
• L1 片段匹配结果
• L2 事件召回详情
• 耗时统计
'); } } function openRecallLog() { updateRecallLogDisplay(); $('recall-log-modal').classList.add('active'); postMsg('FULLSCREEN_OPENED'); } function closeRecallLog() { $('recall-log-modal').classList.remove('active'); postMsg('FULLSCREEN_CLOSED'); } // ═══════════════════════════════════════════════════════════════════════════ // Editor // ═══════════════════════════════════════════════════════════════════════════ function preserveAddedAt(n, o) { if (o?._addedAt != null) n._addedAt = o._addedAt; return n; } function createDelBtn() { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-sm btn-del'; b.textContent = '删除'; return b; } function addDeleteHandler(item) { const del = createDelBtn(); (item.querySelector('.struct-actions') || item).appendChild(del); del.onclick = () => item.remove(); } function renderEventsEditor(events) { const list = events?.length ? events : [{ id: 'evt-1', title: '', timeLabel: '', summary: '', participants: [], type: '日常', weight: '点睛' }]; let maxId = 0; list.forEach(e => { const m = e.id?.match(/evt-(\d+)/); if (m) maxId = Math.max(maxId, +m[1]); }); const es = $('editor-struct'); setHtml(es, list.map(ev => { const id = ev.id || `evt-${++maxId}`; return `
ID:${h(id)}
`; }).join('') + '
'); es.querySelectorAll('.event-item').forEach(addDeleteHandler); $('event-add').onclick = () => { let nmax = maxId; es.querySelectorAll('.event-item').forEach(it => { const m = it.dataset.id?.match(/evt-(\d+)/); if (m) nmax = Math.max(nmax, +m[1]); }); const nid = `evt-${nmax + 1}`; const div = document.createElement('div'); div.className = 'struct-item event-item'; div.dataset.id = nid; setHtml(div, `
ID:${h(nid)}
`); addDeleteHandler(div); es.insertBefore(div, $('event-add').parentElement); }; } function renderCharactersEditor(data) { const d = data || { main: [], relationships: [] }; const main = (d.main || []).map(getCharName); const rels = d.relationships || []; const trendOpts = ['破裂', '厌恶', '反感', '陌生', '投缘', '亲密', '交融']; const es = $('editor-struct'); setHtml(es, `
角色列表
${(main.length ? main : ['']).map(n => `
`).join('')}
人物关系
${(rels.length ? rels : [{ from: '', to: '', label: '', trend: '陌生' }]).map(r => `
`).join('')}
`); es.querySelectorAll('.char-main-item,.char-rel-item').forEach(addDeleteHandler); $('char-main-add').onclick = () => { const div = document.createElement('div'); div.className = 'struct-row char-main-item'; setHtml(div, ''); addDeleteHandler(div); $('char-main-list').appendChild(div); }; $('char-rel-add').onclick = () => { const div = document.createElement('div'); div.className = 'struct-row char-rel-item'; setHtml(div, ` `); addDeleteHandler(div); $('char-rel-list').appendChild(div); }; } function renderArcsEditor(arcs) { const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }]; const es = $('editor-struct'); setHtml(es, `
${list.map((a, i) => `
角色弧光 ${i + 1}
`).join('')}
`); es.querySelectorAll('.arc-item').forEach(addDeleteHandler); $('arc-add').onclick = () => { const listEl = $('arc-list'); const idx = listEl.querySelectorAll('.arc-item').length; const div = document.createElement('div'); div.className = 'struct-item arc-item'; div.dataset.index = idx; setHtml(div, `
角色弧光 ${idx + 1}
`); addDeleteHandler(div); listEl.appendChild(div); }; } function openEditor(section) { currentEditSection = section; const meta = SECTION_META[section]; const es = $('editor-struct'); const ta = $('editor-ta'); $('editor-title').textContent = meta.title; $('editor-hint').textContent = meta.hint; $('editor-err').classList.remove('visible'); $('editor-err').textContent = ''; es.classList.add('hidden'); ta.classList.remove('hidden'); if (section === 'keywords') { ta.value = summaryData.keywords.map(k => `${k.text}|${k.weight || '一般'}`).join('\n'); } else if (section === 'world') { ta.value = (summaryData.world || []) .map(w => `${w.category || ''}|${w.topic || ''}|${w.content || ''}`) .join('\n'); } else { ta.classList.add('hidden'); es.classList.remove('hidden'); if (section === 'events') renderEventsEditor(summaryData.events || []); else if (section === 'characters') renderCharactersEditor(summaryData.characters || { main: [], relationships: [] }); else if (section === 'arcs') renderArcsEditor(summaryData.arcs || []); } $('editor-modal').classList.add('active'); postMsg('EDITOR_OPENED'); } function closeEditor() { $('editor-modal').classList.remove('active'); currentEditSection = null; postMsg('EDITOR_CLOSED'); } function saveEditor() { const section = currentEditSection; const es = $('editor-struct'); const ta = $('editor-ta'); let parsed; try { if (section === 'keywords') { const oldMap = new Map((summaryData.keywords || []).map(k => [k.text, k])); parsed = ta.value.trim().split('\n').filter(l => l.trim()).map(line => { const [text, weight] = line.split('|').map(s => s.trim()); return preserveAddedAt({ text: text || '', weight: weight || '一般' }, oldMap.get(text)); }); } else if (section === 'events') { const oldMap = new Map((summaryData.events || []).map(e => [e.id, e])); parsed = Array.from(es.querySelectorAll('.event-item')).map(it => { const id = it.dataset.id; return preserveAddedAt({ id, title: it.querySelector('.event-title').value.trim(), timeLabel: it.querySelector('.event-time').value.trim(), summary: it.querySelector('.event-summary').value.trim(), participants: it.querySelector('.event-participants').value.trim().split(/[,、,]/).map(s => s.trim()).filter(Boolean), type: it.querySelector('.event-type').value, weight: it.querySelector('.event-weight').value }, oldMap.get(id)); }).filter(e => e.title || e.summary); } else if (section === 'characters') { const oldMainMap = new Map((summaryData.characters?.main || []).map(m => [getCharName(m), m])); const mainNames = Array.from(es.querySelectorAll('.char-main-name')).map(i => i.value.trim()).filter(Boolean); const main = mainNames.map(n => preserveAddedAt({ name: n }, oldMainMap.get(n))); const oldRelMap = new Map((summaryData.characters?.relationships || []).map(r => [`${r.from}->${r.to}`, r])); const rels = Array.from(es.querySelectorAll('.char-rel-item')).map(it => { const from = it.querySelector('.char-rel-from').value.trim(); const to = it.querySelector('.char-rel-to').value.trim(); return preserveAddedAt({ from, to, label: it.querySelector('.char-rel-label').value.trim(), trend: it.querySelector('.char-rel-trend').value }, oldRelMap.get(`${from}->${to}`)); }).filter(r => r.from && r.to); parsed = { main, relationships: rels }; } else if (section === 'arcs') { const oldArcMap = new Map((summaryData.arcs || []).map(a => [a.name, a])); parsed = Array.from(es.querySelectorAll('.arc-item')).map(it => { const name = it.querySelector('.arc-name').value.trim(); const oldArc = oldArcMap.get(name); const oldMomentMap = new Map((oldArc?.moments || []).map(m => [typeof m === 'string' ? m : m.text, m])); const momentsRaw = it.querySelector('.arc-moments').value.trim(); const moments = momentsRaw ? momentsRaw.split('\n').map(s => s.trim()).filter(Boolean).map(t => preserveAddedAt({ text: t }, oldMomentMap.get(t))) : []; return preserveAddedAt({ name, trajectory: it.querySelector('.arc-trajectory').value.trim(), progress: Math.max(0, Math.min(1, (parseFloat(it.querySelector('.arc-progress').value) || 0) / 100)), moments }, oldArc); }).filter(a => a.name || a.trajectory || a.moments?.length); } else if (section === 'world') { const oldWorldMap = new Map((summaryData.world || []).map(w => [`${w.category}|${w.topic}`, w])); parsed = ta.value .split('\n') .map(l => l.trim()) .filter(Boolean) .map(line => { const parts = line.split('|').map(s => s.trim()); const category = parts[0]; const topic = parts[1]; const content = parts.slice(2).join('|').trim(); if (!category || !topic) return null; if (!content || content.toLowerCase() === 'cleared') return null; const key = `${category}|${topic}`; return preserveAddedAt({ category, topic, content }, oldWorldMap.get(key)); }) .filter(Boolean); } } catch (e) { $('editor-err').textContent = `格式错误: ${e.message}`; $('editor-err').classList.add('visible'); return; } postMsg('UPDATE_SECTION', { section, data: parsed }); if (section === 'keywords') renderKeywords(parsed); else if (section === 'events') { renderTimeline(parsed); $('stat-events').textContent = parsed.length; } else if (section === 'characters') renderRelations(parsed); else if (section === 'arcs') renderArcs(parsed); else if (section === 'world') renderWorldState(parsed); closeEditor(); } // ═══════════════════════════════════════════════════════════════════════════ // Message Handler // ═══════════════════════════════════════════════════════════════════════════ function handleParentMessage(e) { if (e.origin !== PARENT_ORIGIN || e.source !== window.parent) return; const d = e.data; if (!d || d.source !== 'LittleWhiteBox') return; const btn = $('btn-generate'); switch (d.type) { case 'GENERATION_STATE': localGenerating = !!d.isGenerating; btn.textContent = localGenerating ? '停止' : '总结'; break; case 'SUMMARY_BASE_DATA': if (d.stats) { updateStats(d.stats); $('summarized-count').textContent = d.stats.hiddenCount ?? 0; } if (d.hideSummarized !== undefined) $('hide-summarized').checked = d.hideSummarized; if (d.keepVisibleCount !== undefined) $('keep-visible-count').value = d.keepVisibleCount; break; case 'SUMMARY_FULL_DATA': if (d.payload) { const p = d.payload; if (p.keywords) renderKeywords(p.keywords); if (p.events) renderTimeline(p.events); if (p.characters) renderRelations(p.characters); if (p.arcs) renderArcs(p.arcs); if (p.world) renderWorldState(p.world); $('stat-events').textContent = p.events?.length || 0; if (p.lastSummarizedMesId != null) $('stat-summarized').textContent = p.lastSummarizedMesId + 1; if (p.stats) updateStats(p.stats); } break; case 'SUMMARY_ERROR': console.error('Summary error:', d.message); break; case 'SUMMARY_CLEARED': { const t = d.payload?.totalFloors || 0; $('stat-events').textContent = 0; $('stat-summarized').textContent = 0; $('stat-pending').textContent = t; $('summarized-count').textContent = 0; summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] }; renderKeywords([]); renderTimeline([]); renderRelations(null); renderArcs([]); renderWorldState([]); break; } case 'LOAD_PANEL_CONFIG': if (d.config) applyConfig(d.config); break; case 'VECTOR_CONFIG': if (d.config) loadVectorConfig(d.config); break; case 'VECTOR_LOCAL_MODEL_STATUS': updateLocalModelStatus(d.status, d.message); break; case 'VECTOR_LOCAL_MODEL_PROGRESS': updateLocalModelProgress(d.percent); break; case 'VECTOR_ONLINE_STATUS': updateOnlineStatus(d.status, d.message); break; case 'VECTOR_ONLINE_MODELS': updateOnlineModels(d.models || []); break; case 'VECTOR_STATS': updateVectorStats(d.stats); if (d.mismatch !== undefined) showVectorMismatchWarning(d.mismatch); break; case 'VECTOR_GEN_PROGRESS': updateVectorGenProgress(d.phase, d.current, d.total); break; case 'VECTOR_EXPORT_RESULT': $('btn-export-vectors').disabled = false; if (d.success) { $('vector-io-status').textContent = `导出成功: ${d.filename} (${(d.size / 1024 / 1024).toFixed(2)}MB)`; } else { $('vector-io-status').textContent = '导出失败: ' + (d.error || '未知错误'); } break; case 'VECTOR_IMPORT_RESULT': $('btn-import-vectors').disabled = false; if (d.success) { let msg = `导入成功: ${d.chunkCount} 片段, ${d.eventCount} 事件`; if (d.warnings?.length) { msg += '\n⚠️ ' + d.warnings.join('\n⚠️ '); } $('vector-io-status').textContent = msg; // 刷新统计 postMsg('REQUEST_VECTOR_STATS'); } else { $('vector-io-status').textContent = '导入失败: ' + (d.error || '未知错误'); } break; case 'RECALL_LOG': setRecallLog(d.text || ''); break; } } // ═══════════════════════════════════════════════════════════════════════════ // Event Bindings // ═══════════════════════════════════════════════════════════════════════════ function bindEvents() { // Section edit buttons $$('.sec-btn[data-section]').forEach(b => b.onclick = () => openEditor(b.dataset.section)); // Editor modal $('editor-backdrop').onclick = closeEditor; $('editor-close').onclick = closeEditor; $('editor-cancel').onclick = closeEditor; $('editor-save').onclick = saveEditor; // Settings modal $('btn-settings').onclick = openSettings; $('settings-backdrop').onclick = () => closeSettings(false); $('settings-close').onclick = () => closeSettings(false); $('settings-cancel').onclick = () => closeSettings(false); $('settings-save').onclick = () => closeSettings(true); // API provider change $('api-provider').onchange = e => { const pv = PROVIDER_DEFAULTS[e.target.value]; $('api-url').value = ''; if (!pv.canFetch) config.api.modelCache = []; updateProviderUI(e.target.value); }; $('btn-connect').onclick = fetchModels; $('api-model-select').onchange = e => { config.api.model = e.target.value; }; // Trigger timing $('trigger-timing').onchange = e => { const en = $('trigger-enabled'); if (e.target.value === 'manual') { en.checked = false; en.disabled = true; en.parentElement.style.opacity = '.5'; } else { en.disabled = false; en.parentElement.style.opacity = '1'; } }; // Main actions $('btn-clear').onclick = () => postMsg('REQUEST_CLEAR'); $('btn-generate').onclick = () => { const btn = $('btn-generate'); if (!localGenerating) { localGenerating = true; btn.textContent = '停止'; postMsg('REQUEST_GENERATE', { config: { api: config.api, gen: config.gen, trigger: config.trigger } }); } else { localGenerating = false; btn.textContent = '总结'; postMsg('REQUEST_CANCEL'); } }; // Hide summarized $('hide-summarized').onchange = e => postMsg('TOGGLE_HIDE_SUMMARIZED', { enabled: e.target.checked }); $('keep-visible-count').onchange = e => { const c = Math.max(0, Math.min(50, parseInt(e.target.value) || 3)); e.target.value = c; postMsg('UPDATE_KEEP_VISIBLE', { count: c }); }; // Fullscreen relations $('btn-fullscreen-relations').onclick = openRelationsFullscreen; $('rel-fs-backdrop').onclick = closeRelationsFullscreen; $('rel-fs-close').onclick = closeRelationsFullscreen; // HF guide $('hf-guide-backdrop').onclick = closeHfGuide; $('hf-guide-close').onclick = closeHfGuide; // Recall log $('btn-recall').onclick = openRecallLog; $('recall-log-backdrop').onclick = closeRecallLog; $('recall-log-close').onclick = closeRecallLog; // Character selector $('char-sel-trigger').onclick = e => { e.stopPropagation(); $('char-sel').classList.toggle('open'); }; document.onclick = e => { const cs = $('char-sel'); if (cs && !cs.contains(e.target)) cs.classList.remove('open'); }; // Vector UI initVectorUI(); // Resize window.onresize = () => { relationChart?.resize(); relationChartFullscreen?.resize(); }; // Parent messages window.onmessage = handleParentMessage; } // ═══════════════════════════════════════════════════════════════════════════ // Init // ═══════════════════════════════════════════════════════════════════════════ function init() { loadConfig(); // Initial state $('stat-events').textContent = '—'; $('stat-summarized').textContent = '—'; $('stat-pending').textContent = '—'; $('summarized-count').textContent = '0'; renderKeywords([]); renderTimeline([]); renderArcs([]); renderWorldState([]); bindEvents(); // Notify parent postMsg('FRAME_READY'); } // Start if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } function renderWorldState(world) { summaryData.world = world || []; const container = $('world-state-list'); if (!container) return; if (!world?.length) { setHtml(container, '
暂无世界状态
'); return; } const labels = { status: '状态', inventory: '物品', knowledge: '认知', relation: '关系', rule: '规则' }; const categoryOrder = ['status', 'inventory', 'relation', 'knowledge', 'rule']; const grouped = {}; world.forEach(w => { const cat = w.category || 'other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(w); }); const html = categoryOrder .filter(cat => grouped[cat]?.length) .map(cat => { const items = grouped[cat].sort((a, b) => (b.floor || 0) - (a.floor || 0)); return `
${labels[cat] || cat}
${items.map(w => `
${h(w.topic)} ${h(w.content)}
`).join('')}
`; }).join(''); setHtml(container, html || '
暂无世界状态
'); } })();