Files
LittleWhiteBox/modules/story-summary/story-summary-ui.js

1834 lines
86 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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: '💡 <a href="https://siliconflow.cn" target="_blank">硅基流动</a> 注册即送额度,推荐 BAAI/bge-m3',
canFetch: false, urlEditable: false
},
cohere: {
url: 'https://api.cohere.ai',
models: ['embed-multilingual-v3.0', 'embed-english-v3.0'],
hint: '💡 <a href="https://cohere.com" target="_blank">Cohere</a> 提供免费试用额度',
canFetch: false, urlEditable: false
},
openai: {
url: '',
models: [],
hint: '💡 可用 Hugging Face Space 免费自建<br><button class="btn btn-sm" id="btn-hf-guide" style="margin-top:6px">查看部署指南</button>',
canFetch: true, urlEditable: true
}
};
const DEFAULT_FILTER_RULES = [
{ start: '<think>', end: '</think>' },
{ start: '<thinking>', end: '</thinking>' },
];
// ═══════════════════════════════════════════════════════════════════════════
// 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, '<option value="">点击拉取或手动输入</option>');
} 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) => `
<div class="filter-rule-item" data-idx="${i}" style="display:flex;gap:6px;align-items:center">
<input type="text" class="filter-rule-start" placeholder="起始(可空)" value="${h(r.start || '')}" style="flex:1;padding:6px 8px;font-size:.8125rem">
<span style="color:var(--txt3)">→</span>
<input type="text" class="filter-rule-end" placeholder="结束(可空)" value="${h(r.end || '')}" style="flex:1;padding:6px 8px;font-size:.8125rem">
<button class="btn btn-sm btn-del filter-rule-del" style="padding:4px 8px">✕</button>
</div>
`).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, `
<input type="text" class="filter-rule-start" placeholder="起始(可空)" value="" style="flex:1;padding:6px 8px;font-size:.8125rem">
<span style="color:var(--txt3)">→</span>
<input type="text" class="filter-rule-end" placeholder="结束(可空)" value="" style="flex:1;padding:6px 8px;font-size:.8125rem">
<button class="btn btn-sm btn-del filter-rule-del" style="padding:4px 8px">✕</button>
`);
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 =>
`<option value="${m}"${m === config.api.model ? ' selected' : ''}>${m}</option>`
).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 => `<span class="tag ${wc[k.weight] || wc[k.level] || ''}">${h(k.text)}</span>`).join('')
: '<div class="empty">暂无关键词</div>');
}
function renderTimeline(ev) {
summaryData.events = ev || [];
const c = $('timeline-list');
if (!ev?.length) {
setHtml(c, '<div class="empty">暂无事件记录</div>');
return;
}
setHtml(c, ev.map(e => {
const participants = (e.participants || e.characters || []).map(h).join('、');
return `<div class="tl-item${e.weight === '核心' || e.weight === '主线' ? ' crit' : ''}">
<div class="tl-dot"></div>
<div class="tl-head">
<div class="tl-title">${h(e.title || '')}</div>
<div class="tl-time">${h(e.timeLabel || '')}</div>
</div>
<div class="tl-brief">${h(e.summary || e.brief || '')}</div>
<div class="tl-meta">
<span>人物:${participants || '—'}</span>
<span class="imp">${h(e.type || '')}${e.type && e.weight ? ' · ' : ''}${h(e.weight || '')}</span>
</div>
</div>`;
}).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, `<div style="line-height:1.8">
${fromLabel ? `<div><small>${h(from)}${h(to)}</small> <span style="color:${fc}">${h(fromLabel)}</span> <span style="font-size:10px;color:${fc}">[${h(fromTrend)}]</span></div>` : ''}
${toLabel ? `<div><small>${h(to)}${h(from)}</small> <span style="color:${tc}">${h(toLabel)}</span> <span style="font-size:10px;color:${tc}">[${h(toTrend)}]</span></div>` : ''}
</div>`);
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, '<div class="sel-opt" data-value="">暂无角色</div>');
if (txt) txt.textContent = '暂无角色';
currentCharacterId = null;
return;
}
setHtml(opts, arcs.map(a => `<div class="sel-opt" data-value="${h(a.id || a.name)}">${h(a.name || '角色')}</div>`).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, '<div class="empty">暂无角色数据</div>');
return;
}
const arc = arcs.find(a => (a.id || a.name) === currentCharacterId);
if (!arc) {
setHtml(c, '<div class="empty">未找到角色数据</div>');
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, `
<div class="prof-arc">
<div>
<div class="prof-name">${h(name)}</div>
<div class="prof-traj">${h(arc.trajectory || arc.phase || '')}</div>
</div>
<div class="prof-prog-wrap">
<div class="prof-prog-lbl">
<span>弧光进度</span>
<span>${Math.round((arc.progress || 0) * 100)}%</span>
</div>
<div class="prof-prog">
<div class="prof-prog-inner" style="width:${(arc.progress || 0) * 100}%"></div>
</div>
</div>
${moments.length ? `
<div class="prof-moments">
<div class="prof-moments-title">关键时刻</div>
${moments.map(m => `<div class="prof-moment">${h(m)}</div>`).join('')}
</div>
` : ''}
</div>
<div class="prof-rels">
<div class="rels-group">
<div class="rels-group-title">${h(name)}对别人的羁绊:</div>
${outRels.length ? outRels.map(r => `
<div class="rel-item">
<span class="rel-target">对${h(r.to)}</span>
<span class="rel-label">${h(r.label || '—')}</span>
${r.trend ? `<span class="rel-trend ${TREND_CLASS[r.trend] || ''}">${h(r.trend)}</span>` : ''}
</div>
`).join('') : '<div class="empty" style="padding:16px">暂无关系记录</div>'}
</div>
<div class="rels-group">
<div class="rels-group-title">别人对${h(name)}的羁绊:</div>
${inRels.length ? inRels.map(r => `
<div class="rel-item">
<span class="rel-target">${h(r.from)}</span>
<span class="rel-label">${h(r.label || '—')}</span>
${r.trend ? `<span class="rel-trend ${TREND_CLASS[r.trend] || ''}">${h(r.trend)}</span>` : ''}
</div>
`).join('') : '<div class="empty" style="padding:16px">暂无关系记录</div>'}
</div>
</div>
`);
}
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, `
<div class="hf-guide">
<div class="hf-section hf-intro">
<div class="hf-intro-text"><strong>免费自建 Embedding 服务</strong>10 分钟搞定</div>
<div class="hf-intro-badges">
<span class="hf-badge">🆓 完全免费</span>
<span class="hf-badge">⚡ 速度不快</span>
<span class="hf-badge">🔐 数据私有</span>
</div>
</div>
<div class="hf-section">
<div class="hf-step-header"><span class="hf-step-num">1</span><span class="hf-step-title">创建 Space</span></div>
<div class="hf-step-content">
<p>访问 <a href="https://huggingface.co/new-space" target="_blank">huggingface.co/new-space</a>,登录后创建:</p>
<ul class="hf-checklist">
<li>Space name: 随便取(如 <code>my-embedding</code></li>
<li>SDK: 选 <strong>Docker</strong></li>
<li>Hardware: 选 <strong>CPU basic (Free)</strong></li>
</ul>
</div>
</div>
<div class="hf-section">
<div class="hf-step-header"><span class="hf-step-num">2</span><span class="hf-step-title">上传 3 个文件</span></div>
<div class="hf-step-content">
<p>在 Space 的 Files 页面,依次创建以下文件:</p>
<div class="hf-file">
<div class="hf-file-header"><span class="hf-file-icon">📄</span><span class="hf-file-name">requirements.txt</span></div>
<pre class="hf-code"><code>fastapi
uvicorn
sentence-transformers
torch</code><button class="copy-btn">复制</button></pre>
</div>
<div class="hf-file">
<div class="hf-file-header"><span class="hf-file-icon">🐍</span><span class="hf-file-name">app.py</span><span class="hf-file-note">主程序</span></div>
<pre class="hf-code"><code>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()</code><button class="copy-btn">复制</button></pre>
</div>
<div class="hf-file">
<div class="hf-file-header"><span class="hf-file-icon">🐳</span><span class="hf-file-name">Dockerfile</span></div>
<pre class="hf-code"><code>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"]</code><button class="copy-btn">复制</button></pre>
</div>
</div>
</div>
<div class="hf-section">
<div class="hf-step-header"><span class="hf-step-num">3</span><span class="hf-step-title">等待构建</span></div>
<div class="hf-step-content">
<p>上传完成后自动开始构建,约需 <strong>10 分钟</strong>(下载模型)。</p>
<p>成功后状态变为 <span class="hf-status-badge">Running</span></p>
</div>
</div>
<div class="hf-section">
<div class="hf-step-header"><span class="hf-step-num">4</span><span class="hf-step-title">在插件中配置</span></div>
<div class="hf-step-content">
<div class="hf-config-table">
<div class="hf-config-row"><span class="hf-config-label">服务渠道</span><span class="hf-config-value">OpenAI 兼容</span></div>
<div class="hf-config-row"><span class="hf-config-label">API URL</span><span class="hf-config-value"><code>https://用户名-空间名.hf.space</code></span></div>
<div class="hf-config-row"><span class="hf-config-label">API Key</span><span class="hf-config-value">随便填</span></div>
<div class="hf-config-row"><span class="hf-config-label">模型</span><span class="hf-config-value">点"拉取" → 选 <code>bge-m3</code></span></div>
</div>
</div>
</div>
<div class="hf-section hf-faq">
<div class="hf-faq-title">💡 小提示</div>
<ul>
<li>URL 格式:<code>https://用户名-空间名.hf.space</code>(减号连接,非斜杠)</li>
<li>免费 Space 一段时间无请求会休眠,首次唤醒需等 20-30 秒</li>
<li>如需保持常驻,可用 <a href="https://cron-job.org" target="_blank">cron-job.org</a> 每 5 分钟 ping <code>/health</code></li>
<li>如需密码,在 Space Settings 设置 <code>ACCESS_KEY</code> 环境变量</li>
</ul>
</div>
</div>
`);
// 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, '<div class="recall-empty">暂无召回日志<br><br>当 AI 生成回复时,系统会自动进行记忆召回。<br>召回日志将显示:<br>• 查询文本<br>• L1 片段匹配结果<br>• L2 事件召回详情<br>• 耗时统计</div>');
}
}
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 `<div class="struct-item event-item" data-id="${h(id)}">
<div class="struct-row">
<input type="text" class="event-title" placeholder="事件标题" value="${h(ev.title || '')}">
<input type="text" class="event-time" placeholder="时间标签" value="${h(ev.timeLabel || '')}">
</div>
<div class="struct-row">
<textarea class="event-summary" rows="2" placeholder="一句话描述">${h(ev.summary || '')}</textarea>
</div>
<div class="struct-row">
<input type="text" class="event-participants" placeholder="人物(顿号分隔)" value="${h((ev.participants || []).join('、'))}">
</div>
<div class="struct-row">
<select class="event-type">${['相遇', '冲突', '揭示', '抉择', '羁绊', '转变', '收束', '日常'].map(t => `<option ${ev.type === t ? 'selected' : ''}>${t}</option>`).join('')}</select>
<select class="event-weight">${['核心', '主线', '转折', '点睛', '氛围'].map(t => `<option ${ev.weight === t ? 'selected' : ''}>${t}</option>`).join('')}</select>
</div>
<div class="struct-actions"><span>ID${h(id)}</span></div>
</div>`;
}).join('') + '<div style="margin-top:8px"><button type="button" class="btn btn-sm" id="event-add"> 新增事件</button></div>');
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, `
<div class="struct-row"><input type="text" class="event-title" placeholder="事件标题"><input type="text" class="event-time" placeholder="时间标签"></div>
<div class="struct-row"><textarea class="event-summary" rows="2" placeholder="一句话描述"></textarea></div>
<div class="struct-row"><input type="text" class="event-participants" placeholder="人物(顿号分隔)"></div>
<div class="struct-row">
<select class="event-type">${['相遇', '冲突', '揭示', '抉择', '羁绊', '转变', '收束', '日常'].map(t => `<option>${t}</option>`).join('')}</select>
<select class="event-weight">${['核心', '主线', '转折', '点睛', '氛围'].map(t => `<option>${t}</option>`).join('')}</select>
</div>
<div class="struct-actions"><span>ID${h(nid)}</span></div>
`);
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, `
<div class="struct-item">
<div class="struct-row"><strong>角色列表</strong></div>
<div id="char-main-list">
${(main.length ? main : ['']).map(n => `<div class="struct-row char-main-item"><input type="text" class="char-main-name" placeholder="角色名" value="${h(n || '')}"></div>`).join('')}
</div>
<div style="margin-top:8px"><button type="button" class="btn btn-sm" id="char-main-add"> 新增角色</button></div>
</div>
<div class="struct-item">
<div class="struct-row"><strong>人物关系</strong></div>
<div id="char-rel-list">
${(rels.length ? rels : [{ from: '', to: '', label: '', trend: '陌生' }]).map(r => `
<div class="struct-row char-rel-item">
<input type="text" class="char-rel-from" placeholder="角色 A" value="${h(r.from || '')}">
<input type="text" class="char-rel-to" placeholder="角色 B" value="${h(r.to || '')}">
<input type="text" class="char-rel-label" placeholder="关系" value="${h(r.label || '')}">
<select class="char-rel-trend">${trendOpts.map(t => `<option ${r.trend === t ? 'selected' : ''}>${t}</option>`).join('')}</select>
</div>
`).join('')}
</div>
<div style="margin-top:8px"><button type="button" class="btn btn-sm" id="char-rel-add"> 新增关系</button></div>
</div>
`);
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, '<input type="text" class="char-main-name" placeholder="角色名">');
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, `
<input type="text" class="char-rel-from" placeholder="角色 A">
<input type="text" class="char-rel-to" placeholder="角色 B">
<input type="text" class="char-rel-label" placeholder="关系">
<select class="char-rel-trend">${trendOpts.map(t => `<option>${t}</option>`).join('')}</select>
`);
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, `
<div id="arc-list">
${list.map((a, i) => `
<div class="struct-item arc-item" data-index="${i}">
<div class="struct-row"><input type="text" class="arc-name" placeholder="角色名" value="${h(a.name || '')}"></div>
<div class="struct-row"><textarea class="arc-trajectory" rows="2" placeholder="当前状态描述">${h(a.trajectory || '')}</textarea></div>
<div class="struct-row">
<label style="font-size:.75rem;color:var(--txt3)">进度:<input type="number" class="arc-progress" min="0" max="100" value="${Math.round((a.progress || 0) * 100)}" style="width:64px;display:inline-block"> %</label>
</div>
<div class="struct-row"><textarea class="arc-moments" rows="3" placeholder="关键时刻,一行一个">${h((a.moments || []).map(m => typeof m === 'string' ? m : m.text).join('\n'))}</textarea></div>
<div class="struct-actions"><span>角色弧光 ${i + 1}</span></div>
</div>
`).join('')}
</div>
<div style="margin-top:8px"><button type="button" class="btn btn-sm" id="arc-add"> 新增角色弧光</button></div>
`);
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, `
<div class="struct-row"><input type="text" class="arc-name" placeholder="角色名"></div>
<div class="struct-row"><textarea class="arc-trajectory" rows="2" placeholder="当前状态描述"></textarea></div>
<div class="struct-row">
<label style="font-size:.75rem;color:var(--txt3)">进度:<input type="number" class="arc-progress" min="0" max="100" value="0" style="width:64px;display:inline-block"> %</label>
</div>
<div class="struct-row"><textarea class="arc-moments" rows="3" placeholder="关键时刻,一行一个"></textarea></div>
<div class="struct-actions"><span>角色弧光 ${idx + 1}</span></div>
`);
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, '<div class="empty">暂无世界状态</div>');
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 `
<div class="world-group">
<div class="world-group-title">${labels[cat] || cat}</div>
${items.map(w => `
<div class="world-item">
<span class="world-topic">${h(w.topic)}</span>
<span class="world-content">${h(w.content)}</span>
</div>
`).join('')}
</div>
`;
}).join('');
setHtml(container, html || '<div class="empty">暂无世界状态</div>');
}
})();