// story-summary-ui.js
// iframe 内 UI 逻辑
(function() {
'use strict';
// ═══════════════════════════════════════════════════════════════════════════
// DOM Helpers
// ═══════════════════════════════════════════════════════════════════════════
const $ = id => document.getElementById(id);
const $$ = sel => document.querySelectorAll(sel);
const h = v => String(v ?? '').replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
);
const setHtml = (el, html) => {
if (!el) return;
const range = document.createRange();
range.selectNodeContents(el);
// eslint-disable-next-line no-unsanitized/method
const fragment = range.createContextualFragment(String(html ?? ''));
el.replaceChildren(fragment);
};
const setSelectOptions = (select, items, placeholderText) => {
if (!select) return;
select.replaceChildren();
if (placeholderText != null) {
const option = document.createElement('option');
option.value = '';
option.textContent = placeholderText;
select.appendChild(option);
}
(items || []).forEach(item => {
const option = document.createElement('option');
option.value = item;
option.textContent = item;
select.appendChild(option);
});
};
// ═══════════════════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════════════════
const PARENT_ORIGIN = (() => {
try { return new URL(document.referrer).origin; }
catch { return window.location.origin; }
})();
const PROVIDER_DEFAULTS = {
st: { url: '', needKey: false, canFetch: false, needManualModel: false },
openai: { url: 'https://api.openai.com', needKey: true, canFetch: true, needManualModel: false },
google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false, needManualModel: true },
claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false, needManualModel: true },
deepseek: { url: 'https://api.deepseek.com', needKey: true, canFetch: true, needManualModel: false },
cohere: { url: 'https://api.cohere.ai', needKey: true, canFetch: false, needManualModel: true },
custom: { url: '', needKey: true, canFetch: true, needManualModel: false }
};
const SECTION_META = {
keywords: { title: '编辑关键词', hint: '每行一个关键词,格式:关键词|权重(核心/重要/一般)' },
events: { title: '编辑事件时间线', hint: '编辑时,每个事件要素都应完整' },
characters: { title: '编辑人物关系', hint: '编辑时,每个要素都应完整' },
arcs: { title: '编辑角色弧光', hint: '编辑时,每个要素都应完整' },
world: { title: '编辑世界状态', hint: '每行一条:category|topic|content。清除用:category|topic|(留空)或 category|topic|cleared' }
};
const TREND_COLORS = {
'破裂': '#444444', '厌恶': '#8b0000', '反感': '#cd5c5c',
'陌生': '#888888', '投缘': '#4a9a7e', '亲密': '#d87a7a', '交融': '#c71585'
};
const TREND_CLASS = {
'破裂': 'trend-broken', '厌恶': 'trend-hate', '反感': 'trend-dislike',
'陌生': 'trend-stranger', '投缘': 'trend-click', '亲密': 'trend-close', '交融': 'trend-merge'
};
const LOCAL_MODELS_INFO = {
'bge-small-zh': { desc: '手机/低配适用' },
'bge-base-zh': { desc: 'PC 推荐,效果更好' },
'e5-small': { desc: '非中文用户' }
};
const ONLINE_PROVIDERS_INFO = {
siliconflow: {
url: 'https://api.siliconflow.cn',
models: ['BAAI/bge-m3', 'BAAI/bge-large-zh-v1.5', 'BAAI/bge-small-zh-v1.5'],
hint: '💡 硅基流动 注册即送额度,推荐 BAAI/bge-m3',
canFetch: false, urlEditable: false
},
cohere: {
url: 'https://api.cohere.ai',
models: ['embed-multilingual-v3.0', 'embed-english-v3.0'],
hint: '💡 Cohere 提供免费试用额度',
canFetch: false, urlEditable: false
},
openai: {
url: '',
models: [],
hint: '💡 可用 Hugging Face Space 免费自建
',
canFetch: true, urlEditable: true
}
};
// ═══════════════════════════════════════════════════════════════════════════
// 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, '');
} 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 =>
``
).join(''));
}
updateProviderUI(config.api.provider);
if (config.vector) loadVectorConfig(config.vector);
$('settings-modal').classList.add('active');
postMsg('SETTINGS_OPENED');
}
function closeSettings(save) {
if (save) {
const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v); };
const provider = $('api-provider').value;
const pv = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.custom;
config.api.provider = provider;
config.api.url = $('api-url').value;
config.api.key = $('api-key').value;
config.api.model = provider === 'st' ? '' : pv.needManualModel ? $('api-model-text').value : $('api-model-select').value;
config.gen.temperature = pn('gen-temp');
config.gen.top_p = pn('gen-top-p');
config.gen.top_k = pn('gen-top-k');
config.gen.presence_penalty = pn('gen-presence');
config.gen.frequency_penalty = pn('gen-frequency');
const timing = $('trigger-timing').value;
config.trigger.timing = timing;
config.trigger.enabled = timing === 'manual' ? false : $('trigger-enabled').checked;
config.trigger.interval = parseInt($('trigger-interval').value) || 20;
config.trigger.useStream = $('trigger-stream').checked;
config.trigger.maxPerRun = parseInt($('trigger-max-per-run').value) || 100;
config.trigger.wrapperHead = $('trigger-wrapper-head').value;
config.trigger.wrapperTail = $('trigger-wrapper-tail').value;
config.trigger.forceInsertAtEnd = $('trigger-insert-at-end').checked;
config.vector = getVectorConfig();
saveConfig();
}
$('settings-modal').classList.remove('active');
postMsg('SETTINGS_CLOSED');
}
async function fetchModels() {
const btn = $('btn-connect');
const provider = $('api-provider').value;
if (!PROVIDER_DEFAULTS[provider]?.canFetch) {
alert('当前渠道不支持自动拉取模型');
return;
}
let baseUrl = $('api-url').value.trim().replace(/\/+$/, '');
const apiKey = $('api-key').value.trim();
if (!apiKey) {
alert('请先填写 API KEY');
return;
}
btn.disabled = true;
btn.textContent = '连接中...';
try {
const tryFetch = async url => {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }
});
return res.ok ? (await res.json())?.data?.map(m => m?.id).filter(Boolean) || null : null;
};
if (baseUrl.endsWith('/v1')) baseUrl = baseUrl.slice(0, -3);
let models = await tryFetch(`${baseUrl}/v1/models`);
if (!models) models = await tryFetch(`${baseUrl}/models`);
if (!models?.length) throw new Error('未获取到模型列表');
config.api.modelCache = [...new Set(models)];
const sel = $('api-model-select');
setSelectOptions(sel, config.api.modelCache);
$('api-model-select-row').classList.remove('hidden');
if (!config.api.model && models.length) {
config.api.model = models[0];
sel.value = models[0];
} else if (config.api.model) {
sel.value = config.api.model;
}
saveConfig();
alert(`成功获取 ${models.length} 个模型`);
} catch (e) {
alert('连接失败:' + (e.message || '请检查 URL 和 KEY'));
} finally {
btn.disabled = false;
btn.textContent = '连接 / 拉取模型列表';
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Rendering Functions
// ═══════════════════════════════════════════════════════════════════════════
function renderKeywords(kw) {
summaryData.keywords = kw || [];
const wc = { '核心': 'p', '重要': 's', high: 'p', medium: 's' };
setHtml($('keywords-cloud'), kw.length
? kw.map(k => `${h(k.text)}`).join('')
: '
访问 huggingface.co/new-space,登录后创建:
my-embedding)在 Space 的 Files 页面,依次创建以下文件:
fastapi
uvicorn
sentence-transformers
torch
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()
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"]
上传完成后自动开始构建,约需 10 分钟(下载模型)。
成功后状态变为 Running
https://用户名-空间名.hf.spacebge-m3https://用户名-空间名.hf.space(减号连接,非斜杠)/healthACCESS_KEY 环境变量