// 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
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // 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);
            }
        }
        return {
            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
            }
        };
    }

    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;
        }
    }

    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(); };
    }

    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-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');
    }
    // ═══════════════════════════════════════════════════════════════════════════
    // 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 '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>');
    }
})();
