feat: iframe 支持外部链接渲染 + 剧情总结 Prompt 自定义 + 记忆包导入导出
[外挂卡片支持外链加载]
- 代码块直接写一个 URL 链接(或注释 <!-- xb-src: URL -->),小白盒会自动抓取并渲染成卡片
- 支持抓取失败自动降级为普通 iframe 直接显示
- 外链内容同样支持 {{xbgetvar::变量名}} 宏注入
[剧情总结 Prompt 全面开放自定义]
- 总结面板设置页新增 10 项 Prompt 编辑框,留空即使用默认值
- 包括:系统提示词、各段助手提示词、记忆注入模板等全部可改
- 记忆注入模板支持 {} 占位符替换成实际记忆内容
[剧情总结记忆包导入/导出]
- 新增「复制记忆包」按钮,一键把当前聊天的全部总结数据复制到剪贴板
- 新增「导入记忆包」按钮,把从别处复制来的记忆包 JSON 粘贴进来即可覆盖生效
- 方便跨设备、跨聊天迁移总结状态
This commit is contained in:
@@ -944,10 +944,8 @@ function initButtonsForAll() {
|
||||
|
||||
async function sendSavedConfigToFrame() {
|
||||
try {
|
||||
const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
|
||||
if (savedConfig) {
|
||||
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
|
||||
}
|
||||
const savedConfig = getSummaryPanelConfig();
|
||||
postToFrame({ type: "LOAD_PANEL_CONFIG", config: savedConfig });
|
||||
} catch (e) {
|
||||
xbLog.warn(MODULE_ID, "加载面板配置失败", e);
|
||||
}
|
||||
@@ -1031,6 +1029,270 @@ function buildFramePayload(store) {
|
||||
};
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text) {
|
||||
const value = String(text ?? "");
|
||||
if (!value) {
|
||||
throw new Error("没有可复制的内容");
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = value;
|
||||
ta.setAttribute("readonly", "");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, ta.value.length);
|
||||
const ok = document.execCommand?.("copy");
|
||||
ta.remove();
|
||||
if (!ok) {
|
||||
throw new Error("浏览器不支持自动复制");
|
||||
}
|
||||
}
|
||||
|
||||
function stripFloorMarker(summary) {
|
||||
return String(summary || "")
|
||||
.replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeInternalFact(item) {
|
||||
const fact = item && typeof item === "object" ? item : {};
|
||||
const base = {
|
||||
id: String(fact?.id || "").trim(),
|
||||
s: String(fact?.s ?? "").trim(),
|
||||
p: String(fact?.p ?? "").trim(),
|
||||
o: String(fact?.o ?? "").trim(),
|
||||
};
|
||||
|
||||
const stateValue = fact?._isState ?? fact?.isState;
|
||||
if (stateValue != null) {
|
||||
base._isState = !!stateValue;
|
||||
}
|
||||
|
||||
const trendValue = String(fact?.trend ?? "").trim();
|
||||
if (trendValue) {
|
||||
base.trend = trendValue;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function normalizePortableFact(item) {
|
||||
const fact = item && typeof item === "object" ? item : {};
|
||||
const base = {
|
||||
id: String(fact?.id || "").trim(),
|
||||
s: String(fact?.人物名字 ?? "").trim(),
|
||||
p: String(fact?.种类 ?? "").trim(),
|
||||
o: String(fact?.描述 ?? "").trim(),
|
||||
};
|
||||
|
||||
const stateValue = fact?._isState ?? fact?.isState ?? fact?.核心事实;
|
||||
if (stateValue != null) {
|
||||
base._isState = !!stateValue;
|
||||
}
|
||||
|
||||
const trendValue = String(fact?.trend ?? fact?.趋势 ?? "").trim();
|
||||
if (trendValue) {
|
||||
base.trend = trendValue;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function serializePortableFact(fact) {
|
||||
const out = {
|
||||
人物名字: String(fact?.s || "").trim(),
|
||||
种类: String(fact?.p || "").trim(),
|
||||
描述: String(fact?.o || "").trim(),
|
||||
};
|
||||
|
||||
if (fact?._isState != null) {
|
||||
out.核心事实 = !!fact._isState;
|
||||
}
|
||||
|
||||
if (fact?.trend) {
|
||||
out.趋势 = String(fact.trend).trim();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function cloneSummaryJsonForPortability(json) {
|
||||
const src = json && typeof json === "object" ? json : {};
|
||||
const characters = src.characters && typeof src.characters === "object" ? src.characters : {};
|
||||
return {
|
||||
keywords: Array.isArray(src.keywords)
|
||||
? src.keywords.map((item) => ({
|
||||
text: String(item?.text || "").trim(),
|
||||
weight: String(item?.weight || "").trim(),
|
||||
})).filter((item) => item.text)
|
||||
: [],
|
||||
events: Array.isArray(src.events)
|
||||
? src.events.map((item) => ({
|
||||
id: String(item?.id || "").trim(),
|
||||
title: String(item?.title || "").trim(),
|
||||
timeLabel: String(item?.timeLabel || "").trim(),
|
||||
summary: stripFloorMarker(item?.summary),
|
||||
participants: Array.isArray(item?.participants)
|
||||
? item.participants.map((name) => String(name || "").trim()).filter(Boolean)
|
||||
: [],
|
||||
type: String(item?.type || "").trim(),
|
||||
weight: String(item?.weight || "").trim(),
|
||||
causedBy: Array.isArray(item?.causedBy)
|
||||
? item.causedBy.map((id) => String(id || "").trim()).filter(Boolean)
|
||||
: [],
|
||||
})).filter((item) => item.id || item.title || item.summary)
|
||||
: [],
|
||||
characters: {
|
||||
main: Array.isArray(characters.main)
|
||||
? characters.main
|
||||
.map((item) => typeof item === "string"
|
||||
? { name: String(item).trim() }
|
||||
: { name: String(item?.name || "").trim() })
|
||||
.filter((item) => item.name)
|
||||
: (Array.isArray(characters)
|
||||
? characters
|
||||
.map((item) => typeof item === "string"
|
||||
? { name: String(item).trim() }
|
||||
: { name: String(item?.name || "").trim() })
|
||||
.filter((item) => item.name)
|
||||
: []),
|
||||
},
|
||||
arcs: Array.isArray(src.arcs)
|
||||
? src.arcs.map((item) => ({
|
||||
name: String(item?.name || "").trim(),
|
||||
trajectory: String(item?.trajectory || "").trim(),
|
||||
progress: Number.isFinite(Number(item?.progress)) ? Number(item.progress) : 0,
|
||||
moments: Array.isArray(item?.moments)
|
||||
? item.moments
|
||||
.map((moment) => typeof moment === "string"
|
||||
? { text: String(moment).trim() }
|
||||
: { text: String(moment?.text || "").trim() })
|
||||
.filter((moment) => moment.text)
|
||||
: [],
|
||||
})).filter((item) => item.name)
|
||||
: [],
|
||||
facts: Array.isArray(src.facts)
|
||||
? src.facts.map(normalizeInternalFact).filter((item) => item.s && item.p && item.o)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function extractSummaryImportJson(raw) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("文件内容不是有效 JSON 对象");
|
||||
}
|
||||
|
||||
const candidate =
|
||||
(raw.type === "LittleWhiteBoxStorySummaryMemory" && raw.data && typeof raw.data === "object" ? raw.data : null) ||
|
||||
(raw.storySummary?.json && typeof raw.storySummary.json === "object" ? raw.storySummary.json : null) ||
|
||||
(raw.json && typeof raw.json === "object" ? raw.json : null) ||
|
||||
raw;
|
||||
|
||||
const hasSummaryShape =
|
||||
Array.isArray(candidate.keywords) ||
|
||||
Array.isArray(candidate.events) ||
|
||||
Array.isArray(candidate.arcs) ||
|
||||
Array.isArray(candidate.facts) ||
|
||||
(candidate.characters && typeof candidate.characters === "object");
|
||||
|
||||
if (!hasSummaryShape) {
|
||||
throw new Error("未识别到可导入的总结数据");
|
||||
}
|
||||
|
||||
const json = cloneSummaryJsonForPortability(candidate);
|
||||
json.facts = Array.isArray(candidate.facts)
|
||||
? candidate.facts.map(normalizePortableFact).filter((item) => item.s && item.p && item.o)
|
||||
: [];
|
||||
return json;
|
||||
}
|
||||
|
||||
function buildSummaryExportPackage(store) {
|
||||
const json = cloneSummaryJsonForPortability(store?.json || {});
|
||||
const data = {
|
||||
...json,
|
||||
facts: json.facts.map(serializePortableFact),
|
||||
};
|
||||
return {
|
||||
type: "LittleWhiteBoxStorySummaryMemory",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
data,
|
||||
counts: {
|
||||
keywords: json.keywords.length,
|
||||
events: json.events.length,
|
||||
characters: json.characters.main.length,
|
||||
arcs: json.arcs.length,
|
||||
facts: json.facts.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function importSummaryMemoryPackage(rawText) {
|
||||
if (!String(rawText || "").trim()) {
|
||||
throw new Error("记忆包内容为空");
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(String(rawText));
|
||||
} catch {
|
||||
throw new Error("JSON 解析失败");
|
||||
}
|
||||
|
||||
const importedJson = extractSummaryImportJson(parsed);
|
||||
const { chatId, chat } = getContext();
|
||||
if (!chatId) {
|
||||
throw new Error("当前没有打开聊天");
|
||||
}
|
||||
|
||||
await clearAllAtomsAndVectors(chatId);
|
||||
await clearAllChunks(chatId);
|
||||
await clearEventVectors(chatId);
|
||||
await clearStateVectors(chatId);
|
||||
await updateMeta(chatId, { lastChunkFloor: -1, fingerprint: null });
|
||||
|
||||
invalidateLexicalIndex();
|
||||
|
||||
const store = getSummaryStore();
|
||||
if (!store) {
|
||||
throw new Error("无法读取当前聊天的总结存储");
|
||||
}
|
||||
|
||||
store.json = importedJson;
|
||||
store.lastSummarizedMesId = -1;
|
||||
store.summaryHistory = [];
|
||||
store.updatedAt = Date.now();
|
||||
saveSummaryStore();
|
||||
|
||||
_lastBuiltPromptText = "";
|
||||
|
||||
refreshEntityLexiconAndWarmup();
|
||||
scheduleLexicalWarmup();
|
||||
|
||||
await clearHideState();
|
||||
const totalFloors = Array.isArray(chat) ? chat.length : 0;
|
||||
await sendFrameBaseData(store, totalFloors);
|
||||
sendFrameFullData(store, totalFloors);
|
||||
await sendAnchorStatsToFrame();
|
||||
await sendVectorStatsToFrame();
|
||||
|
||||
return {
|
||||
counts: {
|
||||
keywords: importedJson.keywords.length,
|
||||
events: importedJson.events.length,
|
||||
characters: importedJson.characters.main.length,
|
||||
arcs: importedJson.arcs.length,
|
||||
facts: importedJson.facts.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Compatibility export for ena-planner.
|
||||
// Returns a compact plain-text snapshot of story-summary memory.
|
||||
export function getStorySummaryForEna() {
|
||||
@@ -1426,6 +1688,43 @@ async function handleFrameMessage(event) {
|
||||
})();
|
||||
break;
|
||||
|
||||
case "SUMMARY_COPY":
|
||||
(async () => {
|
||||
try {
|
||||
const store = getSummaryStore();
|
||||
const payload = buildSummaryExportPackage(store);
|
||||
await copyTextToClipboard(JSON.stringify(payload, null, 2));
|
||||
postToFrame({
|
||||
type: "SUMMARY_COPY_RESULT",
|
||||
success: true,
|
||||
events: payload.counts.events,
|
||||
facts: payload.counts.facts,
|
||||
});
|
||||
} catch (e) {
|
||||
postToFrame({ type: "SUMMARY_COPY_RESULT", success: false, error: e.message });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
|
||||
case "SUMMARY_IMPORT_TEXT":
|
||||
if (guard.isAnyRunning('summary', 'vector', 'anchor')) {
|
||||
postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: "请等待当前总结/向量任务结束" });
|
||||
break;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const result = await importSummaryMemoryPackage(data.text || "");
|
||||
postToFrame({
|
||||
type: "SUMMARY_IMPORT_RESULT",
|
||||
success: true,
|
||||
counts: result.counts,
|
||||
});
|
||||
} catch (e) {
|
||||
postToFrame({ type: "SUMMARY_IMPORT_RESULT", success: false, error: e.message });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
|
||||
case "VECTOR_IMPORT_PICK":
|
||||
// 在 parent 创建 file picker,避免 iframe 传大文件
|
||||
(async () => {
|
||||
|
||||
Reference in New Issue
Block a user