Reorganize modules into layered directories

This commit is contained in:
Youzini-afk
2026-04-08 01:17:47 +08:00
parent 59942541ea
commit feec17f3e3
90 changed files with 284 additions and 219 deletions

1004
ui/graph-renderer.js Normal file

File diff suppressed because it is too large Load Diff

556
ui/hide-engine.js Normal file
View File

@@ -0,0 +1,556 @@
// ST-BME: old-message hide engine
// Uses the host's native /hide and /unhide slash commands instead of
// mutating chat messages into is_system=true.
const hideState = {
managedChatRef: null,
managedChatKey: null,
managedSystemIndices: new Set(),
hiddenRangeEnd: -1,
lastProcessedLength: 0,
scheduledTimer: null,
operationVersion: 0,
};
const BME_HIDE_HASH_MARKER = "__st_bme_hide_managed";
function getTimerApi(runtime = {}) {
const rawSetTimeout =
typeof runtime.setTimeout === "function"
? runtime.setTimeout
: globalThis.setTimeout;
const rawClearTimeout =
typeof runtime.clearTimeout === "function"
? runtime.clearTimeout
: globalThis.clearTimeout;
return {
setTimeout(...args) {
return Reflect.apply(rawSetTimeout, globalThis, args);
},
clearTimeout(...args) {
return Reflect.apply(rawClearTimeout, globalThis, args);
},
};
}
function getCurrentContext(runtime = {}) {
try {
return typeof runtime.getContext === "function" ? runtime.getContext() : null;
} catch {
return null;
}
}
function getCurrentChatInfo(runtime = {}) {
const context = getCurrentContext(runtime);
return {
chat: Array.isArray(context?.chat) ? context.chat : null,
chatId:
context?.chatId != null && context.chatId !== ""
? String(context.chatId)
: null,
};
}
function getCurrentChatKey(runtime = {}) {
const { chat, chatId } = getCurrentChatInfo(runtime);
if (chatId) return chatId;
return Array.isArray(chat) ? chat : null;
}
function getSlashExecutor(runtime = {}) {
if (typeof runtime.executeSlashCommands === "function") {
return runtime.executeSlashCommands.bind(runtime);
}
const context = getCurrentContext(runtime);
if (typeof context?.executeSlashCommands === "function") {
return context.executeSlashCommands.bind(context);
}
if (typeof globalThis.executeSlashCommands === "function") {
return globalThis.executeSlashCommands.bind(globalThis);
}
if (typeof globalThis.executeSlashCommandsOnChatInput === "function") {
return globalThis.executeSlashCommandsOnChatInput.bind(globalThis);
}
return null;
}
async function executeSlashCommand(command, runtime = {}) {
const executor = getSlashExecutor(runtime);
if (!executor) {
throw new Error("executeSlashCommands is not available");
}
return await executor(command, true);
}
function normalizeHideSettings(settings = {}) {
return {
enabled: Boolean(settings.enabled),
hideLastN: Math.max(
0,
Math.trunc(
Number(
settings.hideLastN ??
settings.hide_last_n ??
settings.keepLastN ??
settings.keep_last_n ??
0,
) || 0,
),
),
};
}
function getJquery(runtime = {}) {
if (typeof runtime.$ === "function") return runtime.$;
if (typeof globalThis.$ === "function") return globalThis.$;
return null;
}
function syncSystemAttribute(chat, indices = [], value = "true", runtime = {}) {
if (!Array.isArray(chat) || !Array.isArray(indices) || indices.length === 0) {
return;
}
const currentChat = getCurrentChatInfo(runtime).chat;
if (currentChat !== chat) return;
const jq = getJquery(runtime);
if (!jq) return;
const selector = indices.map((index) => `.mes[mesid="${index}"]`).join(",");
if (!selector) return;
jq(selector).attr("is_system", value);
}
function calcHideRange(chatLength, hideLastN) {
if (!Number.isFinite(chatLength) || chatLength <= 0 || hideLastN <= 0) {
return null;
}
const visibleStart =
hideLastN >= chatLength ? 0 : Math.max(0, chatLength - hideLastN);
const hideEnd = visibleStart - 1;
if (hideEnd < 0) return null;
return {
start: 0,
end: hideEnd,
};
}
function beginOperation() {
hideState.operationVersion += 1;
return hideState.operationVersion;
}
function isOperationCurrent(version) {
return version === hideState.operationVersion;
}
function clearScheduledTimer(runtime = {}) {
const timers = getTimerApi(runtime);
if (hideState.scheduledTimer) {
timers.clearTimeout(hideState.scheduledTimer);
hideState.scheduledTimer = null;
}
}
function clearManagedState() {
hideState.managedChatRef = null;
hideState.managedChatKey = null;
hideState.managedSystemIndices.clear();
hideState.hiddenRangeEnd = -1;
hideState.lastProcessedLength = 0;
}
function isManagedSystemMessage(message) {
return Boolean(
message?.is_system === true &&
message?.extra &&
typeof message.extra === "object" &&
message.extra[BME_HIDE_HASH_MARKER] === true,
);
}
function collectManagedSystemIndices(chat) {
if (!Array.isArray(chat) || chat.length === 0) return [];
const indices = [];
for (let index = 0; index < chat.length; index++) {
if (isManagedSystemMessage(chat[index])) {
indices.push(index);
}
}
return indices;
}
function hydrateManagedStateFromChat(
chat,
chatKey = getCurrentChatKey(),
{ bootstrapLength = false } = {},
) {
if (!Array.isArray(chat)) {
hideState.managedSystemIndices.clear();
hideState.hiddenRangeEnd = -1;
return { managedCount: 0, hiddenRangeEnd: -1 };
}
const managedIndices = collectManagedSystemIndices(chat);
hideState.managedSystemIndices.clear();
for (const index of managedIndices) {
hideState.managedSystemIndices.add(index);
}
hideState.managedChatRef = chat;
hideState.managedChatKey = chatKey;
hideState.hiddenRangeEnd =
managedIndices.length > 0 ? managedIndices[managedIndices.length - 1] : -1;
if (managedIndices.length > 0 && bootstrapLength) {
hideState.lastProcessedLength = chat.length;
}
return {
managedCount: managedIndices.length,
hiddenRangeEnd: hideState.hiddenRangeEnd,
};
}
function restoreManagedSystemFlags(chat, runtime = {}) {
if (!Array.isArray(chat)) {
hideState.managedSystemIndices.clear();
return 0;
}
if (hideState.managedSystemIndices.size === 0) {
hydrateManagedStateFromChat(chat, getCurrentChatKey(runtime), {
bootstrapLength: false,
});
}
if (hideState.managedSystemIndices.size === 0) {
return 0;
}
const restored = [];
for (const index of hideState.managedSystemIndices) {
const message = chat[index];
if (!message || message.is_system !== true) continue;
message.is_system = false;
if (message.extra && typeof message.extra === "object") {
delete message.extra[BME_HIDE_HASH_MARKER];
if (Object.keys(message.extra).length === 0) {
delete message.extra;
}
}
restored.push(index);
}
syncSystemAttribute(chat, restored, "false", runtime);
hideState.managedSystemIndices.clear();
return restored.length;
}
function markManagedSystemRange(chat, start, end, runtime = {}) {
if (!Array.isArray(chat) || start > end) return 0;
const marked = [];
for (let index = start; index <= end && index < chat.length; index++) {
const message = chat[index];
if (!message || message.is_system === true) continue;
message.is_system = true;
const extra =
message.extra && typeof message.extra === "object" ? message.extra : {};
extra[BME_HIDE_HASH_MARKER] = true;
message.extra = extra;
hideState.managedSystemIndices.add(index);
marked.push(index);
}
syncSystemAttribute(chat, marked, "true", runtime);
return marked.length;
}
function adoptManagedChat(chat, chatKey, runtime = {}) {
const previousChat = hideState.managedChatRef;
if (previousChat && previousChat !== chat) {
restoreManagedSystemFlags(previousChat, runtime);
hideState.hiddenRangeEnd = -1;
hideState.lastProcessedLength = 0;
}
hideState.managedChatRef = chat;
hideState.managedChatKey = chatKey;
}
function buildResult({
active = false,
hiddenCount = 0,
shownCount = 0,
chatLength = 0,
incremental = false,
stale = false,
} = {}) {
return {
active,
hiddenCount,
shownCount,
managedCount: active ? hiddenCount : 0,
chatLength,
incremental,
stale,
};
}
async function unhideCurrentRange(runtime = {}, version = null, options = {}) {
const { chat } = getCurrentChatInfo(runtime);
const chatLength = Array.isArray(chat) ? chat.length : 0;
const full = Boolean(options.full);
const previousEnd = full
? Math.max(-1, chatLength - 1)
: Math.min(hideState.hiddenRangeEnd, chatLength - 1);
if (previousEnd < 0) {
return { shownCount: 0, chatLength };
}
await executeSlashCommand(`/unhide 0-${previousEnd}`, runtime);
if (!isOperationCurrent(version ?? hideState.operationVersion)) {
return { shownCount: 0, chatLength, stale: true };
}
return { shownCount: previousEnd + 1, chatLength };
}
async function runHideApply(settings = {}, runtime = {}, options = {}) {
const normalized = normalizeHideSettings(settings);
const { incrementalPreferred = false, version = beginOperation() } = options;
const chatInfo = getCurrentChatInfo(runtime);
const chat = chatInfo.chat;
const chatLength = Array.isArray(chat) ? chat.length : 0;
if (!chat || chatLength === 0) {
clearManagedState();
return buildResult();
}
const chatKey = getCurrentChatKey(runtime);
const previousChatKey = hideState.managedChatKey;
const hadTrackedState =
hideState.managedSystemIndices.size > 0 ||
hideState.hiddenRangeEnd >= 0 ||
(Number.isFinite(hideState.lastProcessedLength) &&
hideState.lastProcessedLength > 0);
adoptManagedChat(chat, chatKey, runtime);
hydrateManagedStateFromChat(chat, chatKey, {
bootstrapLength: !hadTrackedState,
});
const sameChat =
previousChatKey !== null && chatKey !== null && previousChatKey === chatKey;
const previousHiddenEnd = hideState.hiddenRangeEnd;
const previousLength =
sameChat && Number.isFinite(hideState.lastProcessedLength)
? hideState.lastProcessedLength
: 0;
hideState.lastProcessedLength = chatLength;
if (!normalized.enabled || normalized.hideLastN <= 0) {
if (previousHiddenEnd >= 0) {
const { shownCount } = await unhideCurrentRange(runtime, version);
if (!isOperationCurrent(version)) {
return buildResult({ chatLength, shownCount, stale: true });
}
restoreManagedSystemFlags(chat, runtime);
hideState.hiddenRangeEnd = -1;
return buildResult({ chatLength, shownCount });
}
restoreManagedSystemFlags(chat, runtime);
hideState.hiddenRangeEnd = -1;
return buildResult({ chatLength });
}
const nextRange = calcHideRange(chatLength, normalized.hideLastN);
if (!nextRange) {
if (previousHiddenEnd >= 0) {
const { shownCount } = await unhideCurrentRange(runtime, version);
if (!isOperationCurrent(version)) {
return buildResult({ chatLength, shownCount, stale: true });
}
restoreManagedSystemFlags(chat, runtime);
hideState.hiddenRangeEnd = -1;
return buildResult({ chatLength, shownCount });
}
restoreManagedSystemFlags(chat, runtime);
hideState.hiddenRangeEnd = -1;
return buildResult({
active: true,
hiddenCount: 0,
chatLength,
});
}
if (
incrementalPreferred &&
sameChat &&
previousHiddenEnd >= 0 &&
chatLength > previousLength &&
previousLength > 0
) {
const previousRange = calcHideRange(previousLength, normalized.hideLastN);
const canExtendOnly =
previousRange &&
previousRange.end === previousHiddenEnd &&
nextRange.end >= previousHiddenEnd;
if (canExtendOnly && nextRange.end > previousHiddenEnd) {
const start = previousHiddenEnd + 1;
const end = nextRange.end;
await executeSlashCommand(`/hide ${start}-${end}`, runtime);
if (!isOperationCurrent(version)) {
return buildResult({ chatLength, stale: true });
}
markManagedSystemRange(chat, start, end, runtime);
hideState.hiddenRangeEnd = end;
return buildResult({
active: true,
hiddenCount: end + 1,
shownCount: 0,
chatLength,
incremental: true,
});
}
}
let shownCount = 0;
if (previousHiddenEnd >= 0) {
const unhideResult = await unhideCurrentRange(runtime, version);
if (!isOperationCurrent(version)) {
return buildResult({
chatLength,
shownCount: unhideResult.shownCount ?? 0,
stale: true,
});
}
shownCount = unhideResult.shownCount ?? 0;
}
restoreManagedSystemFlags(chat, runtime);
await executeSlashCommand(`/hide ${nextRange.start}-${nextRange.end}`, runtime);
if (!isOperationCurrent(version)) {
return buildResult({ chatLength, shownCount, stale: true });
}
markManagedSystemRange(chat, nextRange.start, nextRange.end, runtime);
hideState.hiddenRangeEnd = nextRange.end;
hideState.lastProcessedLength = chatLength;
return buildResult({
active: true,
hiddenCount: nextRange.end + 1,
shownCount,
chatLength,
incremental: false,
});
}
export async function runFullHideCheck(settings = {}, runtime = {}) {
return await runHideApply(settings, runtime, {
incrementalPreferred: false,
version: beginOperation(),
});
}
export async function runIncrementalHideCheck(settings = {}, runtime = {}) {
return await runHideApply(settings, runtime, {
incrementalPreferred: true,
version: beginOperation(),
});
}
export async function applyHideSettings(settings = {}, runtime = {}) {
return await runFullHideCheck(settings, runtime);
}
export function scheduleHideSettingsApply(
settings = {},
runtime = {},
delayMs = 120,
) {
clearScheduledTimer(runtime);
const timers = getTimerApi(runtime);
const snapshot = normalizeHideSettings(settings);
hideState.scheduledTimer = timers.setTimeout(() => {
hideState.scheduledTimer = null;
void applyHideSettings(snapshot, runtime).catch((error) => {
console.warn?.("[ST-BME] scheduled hide apply failed", error);
});
}, Math.max(0, Math.trunc(Number(delayMs) || 0)));
}
export async function unhideAll(runtime = {}) {
clearScheduledTimer(runtime);
const version = beginOperation();
const chatInfo = getCurrentChatInfo(runtime);
const chatLength = Array.isArray(chatInfo.chat) ? chatInfo.chat.length : 0;
if (chatLength === 0) {
hideState.lastProcessedLength = chatLength;
hideState.hiddenRangeEnd = -1;
hideState.managedChatKey = getCurrentChatKey(runtime);
return buildResult({ chatLength });
}
hydrateManagedStateFromChat(chatInfo.chat, getCurrentChatKey(runtime), {
bootstrapLength: false,
});
const { shownCount } = await unhideCurrentRange(runtime, version, {
full: true,
});
if (!isOperationCurrent(version)) {
return buildResult({ chatLength, shownCount, stale: true });
}
restoreManagedSystemFlags(chatInfo.chat, runtime);
hideState.hiddenRangeEnd = -1;
hideState.lastProcessedLength = chatLength;
hideState.managedChatRef = chatInfo.chat;
hideState.managedChatKey = getCurrentChatKey(runtime);
return buildResult({ chatLength, shownCount });
}
export function resetHideState(runtime = {}) {
clearScheduledTimer(runtime);
beginOperation();
const chatInfo = getCurrentChatInfo(runtime);
if (Array.isArray(chatInfo.chat)) {
hydrateManagedStateFromChat(chatInfo.chat, chatInfo.chatId || null, {
bootstrapLength: false,
});
}
restoreManagedSystemFlags(hideState.managedChatRef, runtime);
clearManagedState();
}
export function getHideStateSnapshot() {
return {
hasManagedChat: hideState.managedChatRef !== null,
managedHiddenCount: hideState.hiddenRangeEnd >= 0 ? hideState.hiddenRangeEnd + 1 : 0,
lastProcessedLength: hideState.lastProcessedLength,
scheduled: Boolean(hideState.scheduledTimer),
};
}
export function isInManagedHideRange(index, chat = null) {
if (!Number.isFinite(index) || index < 0) return false;
if (!hideState.managedChatRef) return false;
if (Array.isArray(chat) && chat !== hideState.managedChatRef) return false;
return hideState.managedSystemIndices.has(index);
}

479
ui/notice.js Normal file
View File

@@ -0,0 +1,479 @@
const STYLE_ID = "st-bme-notice-style";
const HOST_ID = "st-bme-notice-host";
function resolveNoticeDocument() {
const runtime = globalThis;
const chatDocument = runtime?.SillyTavern?.Chat?.document;
if (chatDocument && typeof chatDocument.createElement === "function") {
return chatDocument;
}
try {
return (window.parent && window.parent !== window ? window.parent : window).document;
} catch {
return document;
}
}
function ensureStyle(doc) {
if (doc.getElementById(STYLE_ID)) return;
const style = doc.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${HOST_ID} {
position: fixed;
top: 14px;
right: 14px;
z-index: 12020;
width: min(400px, calc(100vw - 28px));
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.st-bme-notice {
--st-bme-accent: #73b8ff;
pointer-events: auto;
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: start;
padding: 12px 12px 12px 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
background:
radial-gradient(circle at 10% -10%, rgba(115, 184, 255, 0.2), transparent 52%),
linear-gradient(145deg, rgba(27, 37, 54, 0.95), rgba(12, 18, 29, 0.93));
box-shadow:
0 14px 34px rgba(4, 10, 17, 0.46),
inset 0 0 0 1px rgba(255, 255, 255, 0.04);
color: #edf3fb;
overflow: hidden;
transform: translateY(-8px) scale(0.985);
opacity: 0;
animation: stBmeNoticeIn 190ms ease forwards;
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", sans-serif;
backdrop-filter: blur(10px) saturate(125%);
-webkit-backdrop-filter: blur(10px) saturate(125%);
}
.st-bme-notice[data-layout="compact"] {
display: flex;
align-items: center;
gap: 10px;
width: fit-content;
max-width: 100%;
align-self: flex-end;
padding: 10px;
}
.st-bme-notice::after {
content: "";
position: absolute;
inset: 0;
border-left: 3px solid var(--st-bme-accent);
border-radius: 14px;
pointer-events: none;
opacity: 0.9;
}
.st-bme-notice--out {
animation: stBmeNoticeOut 160ms ease forwards;
}
.st-bme-notice__icon {
width: 30px;
height: 30px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 800;
color: #f4f8ff;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.16);
flex-shrink: 0;
}
.st-bme-notice[data-busy="true"] .st-bme-notice__icon {
animation: stBmeNoticeBusy 900ms linear infinite;
}
.st-bme-notice__content {
min-width: 0;
}
.st-bme-notice[data-layout="compact"] .st-bme-notice__content {
display: flex;
align-items: center;
min-width: 0;
}
.st-bme-notice__title {
margin: 0;
font-size: 17px;
line-height: 1.18;
font-weight: 800;
letter-spacing: 0.01em;
color: #f0f6ff;
}
.st-bme-notice[data-layout="compact"] .st-bme-notice__title {
font-size: 16px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st-bme-notice__message {
margin: 4px 0 0;
font-size: 14px;
line-height: 1.38;
color: rgba(240, 246, 255, 0.86);
white-space: pre-wrap;
word-break: break-word;
}
.st-bme-notice__message--marquee {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
font-size: 12.5px;
color: rgba(240, 246, 255, 0.72);
mask-image: linear-gradient(90deg, transparent 0%, black 6%, black 88%, transparent 100%);
-webkit-mask-image: linear-gradient(90deg, transparent 0%, black 6%, black 88%, transparent 100%);
}
.st-bme-notice[data-layout="compact"] .st-bme-notice__message,
.st-bme-notice[data-layout="compact"] .st-bme-notice__progress {
display: none !important;
}
.st-bme-notice[data-layout="compact"] .st-bme-notice__actions {
margin: 0 0 0 8px;
}
.st-bme-notice__actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.st-bme-notice__action {
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
color: #eef4ff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.st-bme-notice__action:hover,
.st-bme-notice__action:focus-visible {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.24);
transform: translateY(-1px);
outline: none;
}
.st-bme-notice__action[data-kind="danger"] {
background: rgba(245, 123, 143, 0.16);
border-color: rgba(245, 123, 143, 0.42);
color: #ffd9df;
}
.st-bme-notice__close {
width: 22px;
height: 22px;
border: none;
border-radius: 7px;
background: rgba(255, 255, 255, 0.08);
color: #d7e0ec;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: background 140ms ease;
flex-shrink: 0;
}
.st-bme-notice__close:hover,
.st-bme-notice__close:focus-visible {
background: rgba(255, 255, 255, 0.2);
outline: none;
}
.st-bme-notice__progress {
position: absolute;
left: 0;
bottom: 0;
height: 2px;
width: 100%;
background: linear-gradient(90deg, var(--st-bme-accent), rgba(255, 255, 255, 0.24));
transform-origin: left center;
animation: stBmeNoticeProgress linear forwards;
}
.st-bme-notice[data-level="success"] {
--st-bme-accent: #65d39c;
}
.st-bme-notice[data-level="error"] {
--st-bme-accent: #f57b8f;
}
.st-bme-notice[data-level="warning"] {
--st-bme-accent: #eab96f;
}
@keyframes stBmeNoticeIn {
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes stBmeNoticeOut {
to {
transform: translateY(-6px) scale(0.98);
opacity: 0;
}
}
@keyframes stBmeNoticeProgress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
@keyframes stBmeNoticeBusy {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 900px) {
#${HOST_ID} {
top: 8px;
right: 8px;
width: calc(100vw - 16px);
}
}
@media (prefers-reduced-motion: reduce) {
.st-bme-notice,
.st-bme-notice--out,
.st-bme-notice__progress {
animation-duration: 1ms !important;
}
}
`;
(doc.head || doc.documentElement).appendChild(style);
}
function ensureHost(doc) {
let host = doc.getElementById(HOST_ID);
if (host) return host;
host = doc.createElement("div");
host.id = HOST_ID;
host.setAttribute("aria-live", "polite");
host.setAttribute("aria-atomic", "false");
(doc.body || doc.documentElement).appendChild(host);
return host;
}
function getIcon(level) {
switch (level) {
case "success":
return "✓";
case "error":
return "!";
case "warning":
return "△";
default:
return "i";
}
}
function applyNoticeState(item, input, progress) {
const level = input.level || "info";
const displayMode = input.displayMode === "compact" ? "compact" : "normal";
const isCompact = displayMode === "compact";
item.dataset.level = level;
item.dataset.busy = input.busy ? "true" : "false";
item.dataset.layout = displayMode;
const icon = item.querySelector(".st-bme-notice__icon");
if (icon) {
icon.textContent = input.busy ? "◌" : getIcon(level);
}
const title = item.querySelector(".st-bme-notice__title");
if (title) {
title.textContent = input.title || "ST-BME";
}
const message = item.querySelector(".st-bme-notice__message");
if (message) {
message.textContent = input.message || "";
message.hidden = isCompact || !String(input.message || "").trim();
if (input.marquee) {
message.classList.add("st-bme-notice__message--marquee");
} else {
message.classList.remove("st-bme-notice__message--marquee");
}
}
const actionWrap = item.querySelector(".st-bme-notice__actions");
const actionButton = item.querySelector(".st-bme-notice__action");
if (actionWrap && actionButton) {
if (input.action?.label) {
actionWrap.style.display = "";
actionButton.style.display = "";
actionButton.textContent = input.action.label;
actionButton.dataset.kind = input.action.kind || "neutral";
} else {
actionWrap.style.display = "none";
actionButton.style.display = "none";
actionButton.textContent = "";
actionButton.dataset.kind = "neutral";
}
}
if (input.persist || isCompact) {
progress.style.display = "none";
progress.style.animationDuration = "";
} else {
const duration = Math.max(1400, input.duration_ms || 3200);
progress.style.display = "";
progress.style.animationDuration = `${duration}ms`;
}
}
export function showManagedBmeNotice(input) {
const doc = resolveNoticeDocument();
ensureStyle(doc);
const host = ensureHost(doc);
const item = doc.createElement("article");
item.className = "st-bme-notice";
const icon = doc.createElement("span");
icon.className = "st-bme-notice__icon";
const content = doc.createElement("div");
content.className = "st-bme-notice__content";
const title = doc.createElement("h4");
title.className = "st-bme-notice__title";
const message = doc.createElement("p");
message.className = "st-bme-notice__message";
const actions = doc.createElement("div");
actions.className = "st-bme-notice__actions";
const actionButton = doc.createElement("button");
actionButton.className = "st-bme-notice__action";
actionButton.type = "button";
actionButton.style.display = "none";
const closeButton = doc.createElement("button");
closeButton.className = "st-bme-notice__close";
closeButton.type = "button";
closeButton.setAttribute("aria-label", "关闭提示");
closeButton.textContent = "×";
const progress = doc.createElement("div");
progress.className = "st-bme-notice__progress";
content.appendChild(title);
content.appendChild(message);
actions.appendChild(actionButton);
content.appendChild(actions);
item.appendChild(icon);
item.appendChild(content);
item.appendChild(closeButton);
item.appendChild(progress);
let currentInput = input || {};
let closed = false;
let closeTimer = null;
const clearCloseTimer = () => {
if (!closeTimer) return;
clearTimeout(closeTimer);
closeTimer = null;
};
const close = () => {
if (closed) return;
clearCloseTimer();
closed = true;
item.classList.add("st-bme-notice--out");
setTimeout(() => {
item.remove();
if (!host.childElementCount) {
host.remove();
}
}, 170);
};
const scheduleAutoClose = (nextInput) => {
clearCloseTimer();
if (nextInput.persist) return;
const duration = Math.max(1400, nextInput.duration_ms || 3200);
closeTimer = setTimeout(close, duration);
};
const update = (nextInput) => {
if (closed) return;
currentInput = nextInput || {};
applyNoticeState(item, currentInput, progress);
scheduleAutoClose(currentInput);
};
applyNoticeState(item, currentInput, progress);
scheduleAutoClose(currentInput);
actionButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
currentInput.action?.onClick?.();
});
closeButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
close();
});
host.appendChild(item);
return {
update,
dismiss: close,
isClosed: () => closed,
};
}
export function showBmeNotice(input) {
return showManagedBmeNotice(input);
}

95
ui/panel-bridge.js Normal file
View File

@@ -0,0 +1,95 @@
import { debugLog } from "../runtime/debug-logging.js";
function resolvePanelTheme(settings) {
return settings?.panelTheme || "crimson";
}
export function createNoticePanelActionController(runtime) {
if (!runtime.getPanelModule()?.openPanel) return undefined;
return {
label: "打开面板",
kind: "neutral",
onClick: () => {
runtime.getPanelModule()?.openPanel?.();
},
};
}
export function refreshPanelLiveStateController(runtime) {
runtime.getPanelModule()?.refreshLiveState?.();
}
export function openPanelController(runtime) {
runtime.getPanelModule()?.openPanel?.();
}
function injectOptionsMenuEntry(runtime) {
if (runtime.document.getElementById("option_st_bme_panel")) {
return;
}
const $menuItem = runtime.$(`
<a id="option_st_bme_panel">
<i class="fa-lg fa-solid fa-brain"></i>
<span>记忆图谱</span>
</a>
`).on("click", () => {
openPanelController(runtime);
runtime.$("#options").hide();
});
const $optionsContent = runtime.$("#options .options-content");
const $anchor = runtime.$("#option_toggle_logprobs");
if ($anchor.length > 0) {
$anchor.after($menuItem);
} else if ($optionsContent.length > 0) {
$optionsContent.append($menuItem);
}
}
export async function initializePanelBridgeController(runtime) {
try {
const panelModule = await runtime.importPanelModule();
const themesModule = await runtime.importThemesModule();
runtime.setPanelModule(panelModule);
runtime.setThemesModule(themesModule);
const settings = runtime.getSettings();
const theme = resolvePanelTheme(settings);
themesModule.applyTheme(theme);
await panelModule.initPanel({
getGraph: runtime.getGraph,
getSettings: runtime.getSettings,
getLastExtract: runtime.getLastExtract,
getLastRecall: runtime.getLastRecall,
getRuntimeStatus: runtime.getRuntimeStatus,
getLastExtractionStatus: runtime.getLastExtractionStatus,
getLastVectorStatus: runtime.getLastVectorStatus,
getLastRecallStatus: runtime.getLastRecallStatus,
getLastBatchStatus: runtime.getLastBatchStatus,
getLastInjection: runtime.getLastInjection,
getRuntimeDebugSnapshot: runtime.getRuntimeDebugSnapshot,
getGraphPersistenceState: runtime.getGraphPersistenceState,
updateSettings: (patch) => {
const nextSettings = runtime.updateSettings(patch);
if (Object.prototype.hasOwnProperty.call(patch || {}, "panelTheme")) {
const nextTheme = resolvePanelTheme(nextSettings);
runtime.getThemesModule()?.applyTheme?.(nextTheme);
runtime.getPanelModule()?.updatePanelTheme?.(nextTheme);
}
return nextSettings;
},
actions: runtime.actions,
});
injectOptionsMenuEntry(runtime);
debugLog("[ST-BME] 操控面板初始化完成");
} catch (panelError) {
runtime.console.error(
"[ST-BME] 操控面板加载失败(核心功能不受影响):",
panelError,
);
}
}

2364
ui/panel.html Normal file

File diff suppressed because it is too large Load Diff

6449
ui/panel.js Normal file

File diff suppressed because it is too large Load Diff

719
ui/recall-message-ui.js Normal file
View File

@@ -0,0 +1,719 @@
// ST-BME: 消息级召回卡片 UI
// 纯 DOM 构建模块,不含模块级 mutable state
import { getContext } from "../../../extensions.js";
import { GraphRenderer } from "./graph-renderer.js";
function _hostUserPovAliasHintsForRecallCanvas() {
try {
const ctx = typeof getContext === "function" ? getContext() : null;
const out = [];
if (ctx?.name1 && String(ctx.name1).trim()) {
out.push(String(ctx.name1).trim());
}
return out;
} catch {
return [];
}
}
// ==================== 常量 ====================
export const RECALL_CARD_FORCE_CONFIG = {
repulsion: 1200,
springLength: 50,
springK: 0.04,
damping: 0.85,
centerGravity: 0.08,
maxIterations: 80,
minNodeRadius: 6,
maxNodeRadius: 14,
labelFontSize: 11,
gridSpacing: 0,
gridColor: "transparent",
};
const DELETE_CONFIRM_TIMEOUT_MS = 3000;
// ==================== 子图构建 ====================
/**
* 从完整图谱中提取召回节点子图
* @param {object} graph - currentGraph
* @param {string[]} selectedNodeIds
* @returns {{ nodes: Array, edges: Array }}
*/
export function buildRecallSubGraph(graph, selectedNodeIds) {
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(selectedNodeIds)) {
return { nodes: [], edges: [] };
}
const idSet = new Set(selectedNodeIds);
const nodes = graph.nodes
.filter((n) => idSet.has(n.id) && !n.archived)
.map((n) => ({ ...n }));
const edges = (graph.edges || [])
.filter(
(e) =>
!e.invalidAt &&
!e.expiredAt &&
idSet.has(e.fromId) &&
idSet.has(e.toId),
);
return { nodes, edges };
}
// ==================== 辅助 DOM ====================
function el(tag, className, textContent) {
const element = document.createElement(tag);
if (className) element.className = className;
if (textContent !== undefined) element.textContent = textContent;
return element;
}
function formatTokenHint(tokenEstimate) {
if (!Number.isFinite(tokenEstimate) || tokenEstimate <= 0) return "";
return `~${tokenEstimate} tokens`;
}
function formatMetaLine(record) {
const parts = [];
if (record.recallSource) parts.push(`来源: ${record.recallSource}`);
if (record.tokenEstimate > 0) parts.push(`~${record.tokenEstimate} tokens`);
if (Number.isFinite(record.generationCount) && record.generationCount > 0) {
parts.push(`回退 ${record.generationCount}`);
}
if (record.updatedAt) {
const dateStr = String(record.updatedAt).replace(/T/, " ").replace(/\.\d+Z$/, "");
parts.push(dateStr);
}
return parts.join(" · ");
}
function normalizeUserInputDisplayMode(mode) {
const normalized = String(mode || "").trim();
if (
normalized === "off" ||
normalized === "beautify_only" ||
normalized === "mirror"
) {
return normalized;
}
return "beautify_only";
}
function stableSerialize(value) {
if (value === null || value === undefined) return "null";
const type = typeof value;
if (type === "number") {
return Number.isFinite(value) ? String(value) : "null";
}
if (type === "boolean") return value ? "true" : "false";
if (type === "string") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
}
if (type === "object") {
const keys = Object.keys(value).sort();
return `{${keys
.map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
.join(",")}}`;
}
return "null";
}
function normalizeSelectedNodeIds(selectedNodeIds = []) {
return Array.isArray(selectedNodeIds)
? selectedNodeIds
.map((id) => String(id || "").trim())
.filter(Boolean)
.sort()
: [];
}
function summarizeSubGraphForSignature(subGraph) {
const nodes = Array.isArray(subGraph?.nodes)
? subGraph.nodes
.map((node) => ({
id: String(node?.id || ""),
type: String(node?.type || ""),
archived: Boolean(node?.archived),
seq: Number.isFinite(node?.seq) ? node.seq : 0,
seqRange: Array.isArray(node?.seqRange)
? [
Number.isFinite(node.seqRange[0]) ? node.seqRange[0] : 0,
Number.isFinite(node.seqRange[1]) ? node.seqRange[1] : 0,
]
: [],
fields: node?.fields && typeof node.fields === "object" ? { ...node.fields } : {},
}))
.sort((left, right) => left.id.localeCompare(right.id))
: [];
const edges = Array.isArray(subGraph?.edges)
? subGraph.edges
.map((edge) => ({
fromId: String(edge?.fromId || ""),
toId: String(edge?.toId || ""),
relation: String(edge?.relation || ""),
strength: Number.isFinite(edge?.strength) ? edge.strength : 0,
}))
.sort((left, right) => {
const leftKey = `${left.fromId}->${left.toId}:${left.relation}`;
const rightKey = `${right.fromId}->${right.toId}:${right.relation}`;
return leftKey.localeCompare(rightKey);
})
: [];
return { nodes, edges };
}
function buildExpandedRenderSignature({
record,
userMessageText,
selectedNodeIds,
subGraph,
} = {}) {
return stableSerialize({
updatedAt: String(record?.updatedAt || ""),
manuallyEdited: Boolean(record?.manuallyEdited),
generationCount: Number.isFinite(record?.generationCount)
? record.generationCount
: 0,
tokenEstimate: Number.isFinite(record?.tokenEstimate) ? record.tokenEstimate : 0,
recallSource: String(record?.recallSource || ""),
hookName: String(record?.hookName || ""),
injectionText: String(record?.injectionText || ""),
selectedNodeIds: normalizeSelectedNodeIds(selectedNodeIds),
userMessageText: String(userMessageText || ""),
subGraph: summarizeSubGraphForSignature(subGraph),
});
}
// ==================== 卡片 DOM 构建 ====================
/**
* 创建消息级召回卡片 DOM
* @param {object} params
* @param {number} params.messageIndex
* @param {object} params.record - bme_recall record
* @param {string} params.userMessageText
* @param {object|null} params.graph - currentGraph
* @param {string} params.themeName
* @param {object} params.callbacks
* @returns {HTMLElement}
*/
export function createRecallCardElement({
messageIndex,
record,
userMessageText = "",
graph = null,
themeName = "crimson",
userInputDisplayMode = "beautify_only",
callbacks = {},
}) {
const card = el("div", "bme-recall-card");
card.dataset.messageIndex = String(messageIndex);
card.dataset.updatedAt = String(record?.updatedAt || "");
card.dataset.expandedRenderSignature = "";
let activeRecord = record || {};
let activeUserMessageText = String(userMessageText || "");
let activeGraph = graph || null;
let activeCallbacks = callbacks || {};
let activeUserInputDisplayMode = normalizeUserInputDisplayMode(
userInputDisplayMode,
);
let expandedRenderSignature = "";
// -- 用户消息区 --
const userLabel = el("div", "bme-recall-user-label");
userLabel.innerHTML = "💬 <span>本轮用户输入</span>";
card.appendChild(userLabel);
const userText = el("div", "bme-recall-user-text", activeUserMessageText || "(empty)");
card.appendChild(userText);
// -- 召回条 --
const initialNodeCount = Array.isArray(activeRecord?.selectedNodeIds)
? activeRecord.selectedNodeIds.length
: 0;
const bar = el("div", "bme-recall-bar");
const barIcon = el("span", "bme-recall-bar-icon", "🧠");
bar.appendChild(barIcon);
const barTitle = el("span", "bme-recall-bar-title", "相关记忆召回");
bar.appendChild(barTitle);
const badge = el(
"span",
"bme-recall-count-badge",
initialNodeCount > 0 ? `记忆 ${initialNodeCount}` : "记忆 ✓",
);
bar.appendChild(badge);
const tokenHint = el(
"span",
"bme-recall-token-hint",
formatTokenHint(activeRecord?.tokenEstimate),
);
bar.appendChild(tokenHint);
const arrow = el("span", "bme-recall-expand-arrow", "▶");
bar.appendChild(arrow);
card.appendChild(bar);
// -- 展开内容区 --
const body = el("div", "bme-recall-body");
card.appendChild(body);
// renderer 实例管理
let renderer = null;
function destroyRenderer() {
if (renderer) {
renderer.stopAnimation();
renderer.destroy();
renderer = null;
}
}
function buildExpandedContent(subGraph = null, nextSignature = "") {
body.innerHTML = "";
const resolvedSubGraph =
subGraph ||
(activeGraph
? buildRecallSubGraph(activeGraph, activeRecord?.selectedNodeIds || [])
: { nodes: [], edges: [] });
if (resolvedSubGraph.nodes.length === 0) {
const emptyMsg = el(
"div",
"bme-recall-empty",
activeGraph ? "召回节点已不存在或图谱已重建" : "图谱未就绪",
);
body.appendChild(emptyMsg);
} else {
// Canvas 容器
const canvasWrap = el("div", "bme-recall-canvas-wrap");
const canvas = document.createElement("canvas");
canvasWrap.appendChild(canvas);
body.appendChild(canvasWrap);
// 创建小画布 GraphRenderer
renderer = new GraphRenderer(canvas, {
theme: themeName,
forceConfig: RECALL_CARD_FORCE_CONFIG,
userPovAliases: _hostUserPovAliasHintsForRecallCanvas(),
onNodeClick: (node) => {
if (typeof activeCallbacks.onNodeClick === "function") {
activeCallbacks.onNodeClick(messageIndex, node);
}
},
onNodeDoubleClick: (node) => {
if (typeof activeCallbacks.onNodeClick === "function") {
activeCallbacks.onNodeClick(messageIndex, node);
}
},
});
renderer.loadGraph(resolvedSubGraph, {
userPovAliases: _hostUserPovAliasHintsForRecallCanvas(),
});
}
// 元信息行
const meta = el("div", "bme-recall-meta", formatMetaLine(activeRecord || {}));
if (activeRecord?.manuallyEdited) {
const tag = el("span", "bme-recall-meta-tag", "✍ 手动编辑");
meta.appendChild(tag);
}
body.appendChild(meta);
// 操作按钮行
const actions = el("div", "bme-recall-actions");
const editBtn = el("button", "bme-recall-action-btn");
editBtn.innerHTML = '<span class="bme-recall-btn-icon">✏️</span> 编辑';
editBtn.type = "button";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
activeCallbacks.onEdit?.(messageIndex);
});
actions.appendChild(editBtn);
const deleteBtn = el("button", "bme-recall-action-btn");
deleteBtn.innerHTML = '<span class="bme-recall-btn-icon">🗑</span> 删除';
deleteBtn.type = "button";
setupDeleteConfirmation(deleteBtn, () => {
activeCallbacks.onDelete?.(messageIndex);
});
actions.appendChild(deleteBtn);
const recallBtn = el("button", "bme-recall-action-btn");
recallBtn.innerHTML = '<span class="bme-recall-btn-icon">🔄</span> 重新召回';
recallBtn.type = "button";
recallBtn.addEventListener("click", async (e) => {
e.stopPropagation();
setRecallButtonLoading(recallBtn, true);
try {
await activeCallbacks.onRerunRecall?.(messageIndex);
} finally {
setRecallButtonLoading(recallBtn, false);
}
});
actions.appendChild(recallBtn);
body.appendChild(actions);
expandedRenderSignature =
nextSignature ||
buildExpandedRenderSignature({
record: activeRecord,
userMessageText: activeUserMessageText,
selectedNodeIds: activeRecord?.selectedNodeIds || [],
subGraph: resolvedSubGraph,
});
card.dataset.expandedRenderSignature = expandedRenderSignature;
}
function applyCardRuntimeData(next = {}, { skipExpandedRerender = false } = {}) {
if (next.record && typeof next.record === "object") {
activeRecord = next.record;
}
if (Object.prototype.hasOwnProperty.call(next, "userMessageText")) {
activeUserMessageText = String(next.userMessageText || "");
}
if (Object.prototype.hasOwnProperty.call(next, "userInputDisplayMode")) {
activeUserInputDisplayMode = normalizeUserInputDisplayMode(
next.userInputDisplayMode,
);
}
if (Object.prototype.hasOwnProperty.call(next, "graph")) {
activeGraph = next.graph || null;
}
if (next.callbacks && typeof next.callbacks === "object") {
activeCallbacks = next.callbacks;
}
card.dataset.updatedAt = String(activeRecord?.updatedAt || "");
card.dataset.expandedRenderSignature = expandedRenderSignature;
card.dataset.userInputDisplayMode = activeUserInputDisplayMode;
card.classList.toggle(
"bme-recall-hide-user-input",
activeUserInputDisplayMode === "off",
);
userText.textContent = activeUserMessageText || "(empty)";
const nodeCount = Array.isArray(activeRecord?.selectedNodeIds)
? activeRecord.selectedNodeIds.length
: 0;
badge.textContent = nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓";
tokenHint.textContent = formatTokenHint(activeRecord?.tokenEstimate);
if (skipExpandedRerender || !card.classList.contains("expanded")) return;
const nextSubGraph = activeGraph
? buildRecallSubGraph(activeGraph, activeRecord?.selectedNodeIds || [])
: { nodes: [], edges: [] };
const nextSignature = buildExpandedRenderSignature({
record: activeRecord,
userMessageText: activeUserMessageText,
selectedNodeIds: activeRecord?.selectedNodeIds || [],
subGraph: nextSubGraph,
});
if (nextSignature === expandedRenderSignature) return;
destroyRenderer();
buildExpandedContent(nextSubGraph, nextSignature);
}
card._bmeUpdateRecallCard = applyCardRuntimeData;
// 点击召回条 toggle 展开/折叠
bar.addEventListener("click", (e) => {
e.stopPropagation();
const isExpanded = card.classList.toggle("expanded");
if (isExpanded) {
applyCardRuntimeData({}, { skipExpandedRerender: true });
buildExpandedContent();
} else {
destroyRenderer();
body.innerHTML = "";
expandedRenderSignature = "";
card.dataset.expandedRenderSignature = "";
}
});
applyCardRuntimeData({}, { skipExpandedRerender: true });
// 暴露清理方法
card._bmeDestroyRenderer = () => {
destroyRenderer();
expandedRenderSignature = "";
card.dataset.expandedRenderSignature = "";
};
return card;
}
/**
* 更新已有卡片的 badge / token hint / meta不重建整个卡片
*/
export function updateRecallCardData(cardElement, record, options = {}) {
if (!cardElement || !record) return;
if (typeof cardElement._bmeUpdateRecallCard === "function") {
cardElement._bmeUpdateRecallCard({
record,
userMessageText: options?.userMessageText,
userInputDisplayMode: options?.userInputDisplayMode,
graph: options?.graph,
callbacks: options?.callbacks,
});
return;
}
cardElement.dataset.updatedAt = String(record.updatedAt || "");
}
// ==================== 删除二次确认 ====================
export function setupDeleteConfirmation(button, onConfirm) {
let confirmTimer = null;
let pendingConfirm = false;
const originalHTML = button.innerHTML;
function reset() {
clearTimeout(confirmTimer);
confirmTimer = null;
pendingConfirm = false;
button.innerHTML = originalHTML;
button.classList.remove("danger");
}
button.addEventListener("click", (e) => {
e.stopPropagation();
if (pendingConfirm) {
reset();
onConfirm();
return;
}
pendingConfirm = true;
button.textContent = "确认删除?";
button.classList.add("danger");
confirmTimer = setTimeout(reset, DELETE_CONFIRM_TIMEOUT_MS);
});
}
// ==================== Loading 状态 ====================
export function setRecallButtonLoading(button, loading) {
if (loading) {
button._bmeOriginalHTML = button.innerHTML;
button.innerHTML =
'<span class="bme-recall-btn-icon" style="display:inline-block">⟳</span> 召回中...';
button.classList.add("loading");
button.disabled = true;
} else {
button.innerHTML = button._bmeOriginalHTML || button.innerHTML;
button.classList.remove("loading");
button.disabled = false;
}
}
// ==================== 侧边栏 ====================
let sidebarBackdrop = null;
let sidebarElement = null;
function ensureSidebarDOM() {
if (sidebarBackdrop && sidebarElement) return;
sidebarBackdrop = el("div", "bme-recall-sidebar-backdrop");
sidebarBackdrop.addEventListener("click", () => closeRecallSidebar());
sidebarElement = el("div", "bme-recall-sidebar");
document.body.appendChild(sidebarBackdrop);
document.body.appendChild(sidebarElement);
}
/**
* 打开召回编辑/查看侧边栏
* @param {object} params
* @param {'view'|'edit'} params.mode
* @param {number} params.messageIndex
* @param {object} params.record
* @param {object|null} params.node - 点击的节点view 模式)
* @param {object|null} params.graph
* @param {object} params.callbacks
*/
export function openRecallSidebar({
mode = "edit",
messageIndex,
record,
node = null,
graph = null,
callbacks = {},
}) {
ensureSidebarDOM();
sidebarElement.innerHTML = "";
// Header
const header = el("div", "bme-recall-sidebar-header");
const headerTitle = el("div", "bme-recall-sidebar-header-title");
headerTitle.textContent =
mode === "edit" ? "📝 编辑召回注入" : "🔍 节点详情";
header.appendChild(headerTitle);
const closeBtn = el("button", "bme-recall-sidebar-close");
closeBtn.innerHTML = "✕";
closeBtn.type = "button";
closeBtn.addEventListener("click", () => closeRecallSidebar());
header.appendChild(closeBtn);
sidebarElement.appendChild(header);
// Node info (if viewing a specific node)
if (node && mode === "view") {
const nodeInfo = el("div", "bme-recall-sidebar-node-info");
const rows = [
["类型", node.type || node.raw?.type || "-"],
["名称", node.name || node.raw?.name || "-"],
["重要度", String(node.importance ?? node.raw?.importance ?? "-")],
];
for (const [label, value] of rows) {
const row = el("div", "bme-recall-sidebar-node-info-row");
const labelEl = el("span", "bme-recall-sidebar-node-info-label", label);
const valueEl = el("span", "", value);
row.appendChild(labelEl);
row.appendChild(valueEl);
nodeInfo.appendChild(row);
}
// Show edges to other recalled nodes
if (graph && record?.selectedNodeIds) {
const idSet = new Set(record.selectedNodeIds);
const relatedEdges = (graph.edges || []).filter(
(e) =>
!e.invalidAt &&
!e.expiredAt &&
((e.fromId === node.id && idSet.has(e.toId)) ||
(e.toId === node.id && idSet.has(e.fromId))),
);
if (relatedEdges.length > 0) {
const edgeRow = el("div", "bme-recall-sidebar-node-info-row");
const edgeLabel = el("span", "bme-recall-sidebar-node-info-label", "关联");
const edgeValue = el("span", "", `${relatedEdges.length} 条边`);
edgeRow.appendChild(edgeLabel);
edgeRow.appendChild(edgeValue);
nodeInfo.appendChild(edgeRow);
}
}
sidebarElement.appendChild(nodeInfo);
}
// Body
const body = el("div", "bme-recall-sidebar-body");
const sectionLabel = el(
"div",
"bme-recall-sidebar-section-label",
mode === "edit" ? "注入文本(可编辑)" : "注入文本",
);
body.appendChild(sectionLabel);
let textarea = null;
const injectionText = record?.injectionText || "";
if (mode === "edit") {
textarea = document.createElement("textarea");
textarea.className = "bme-recall-sidebar-textarea";
textarea.value = injectionText;
textarea.placeholder = "输入注入文本...";
body.appendChild(textarea);
const tokenHint = el("div", "bme-recall-sidebar-token-hint");
const updateTokenHint = () => {
const count =
typeof callbacks.estimateTokens === "function"
? callbacks.estimateTokens(textarea.value)
: textarea.value.length;
tokenHint.textContent = `~${count} tokens`;
};
updateTokenHint();
let debounceTimer = null;
textarea.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(updateTokenHint, 300);
});
body.appendChild(tokenHint);
} else {
const readonlyEl = el("div", "bme-recall-sidebar-readonly", injectionText || "(empty)");
body.appendChild(readonlyEl);
}
sidebarElement.appendChild(body);
// Footer
const footer = el("div", "bme-recall-sidebar-footer");
if (mode === "edit") {
const saveBtn = el("button", "bme-recall-sidebar-btn primary", "保存");
saveBtn.type = "button";
saveBtn.addEventListener("click", () => {
const newText = textarea?.value || "";
callbacks.onSave?.(messageIndex, newText);
closeRecallSidebar();
});
footer.appendChild(saveBtn);
const cancelBtn = el("button", "bme-recall-sidebar-btn secondary", "取消");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", () => closeRecallSidebar());
footer.appendChild(cancelBtn);
} else {
// View mode: offer edit button
const editBtn = el("button", "bme-recall-sidebar-btn primary", "✏️ 编辑");
editBtn.type = "button";
editBtn.addEventListener("click", () => {
openRecallSidebar({
mode: "edit",
messageIndex,
record,
node: null,
graph,
callbacks,
});
});
footer.appendChild(editBtn);
const closeFooterBtn = el("button", "bme-recall-sidebar-btn secondary", "关闭");
closeFooterBtn.type = "button";
closeFooterBtn.addEventListener("click", () => closeRecallSidebar());
footer.appendChild(closeFooterBtn);
}
sidebarElement.appendChild(footer);
// Animate in
requestAnimationFrame(() => {
sidebarBackdrop.classList.add("open");
sidebarElement.classList.add("open");
if (textarea) textarea.focus();
});
}
export function closeRecallSidebar() {
if (sidebarBackdrop) sidebarBackdrop.classList.remove("open");
if (sidebarElement) sidebarElement.classList.remove("open");
}

237
ui/themes.js Normal file
View File

@@ -0,0 +1,237 @@
// ST-BME: 主题配色系统
// 多套 CSS 变量主题,通过 data-bme-theme 属性切换
export const THEMES = {
crimson: {
name: 'Crimson Synth',
primary: '#e94560',
primaryDim: 'rgba(233, 69, 96, 0.15)',
primaryGlow: 'rgba(233, 69, 96, 0.35)',
primaryText: '#ffb2b7',
secondary: '#fc536d',
accent2: '#4edea3', // tertiary / success
accent3: '#ffc107', // warning / P1
surface: '#131316',
surfaceContainer: '#1f1f22',
surfaceHigh: '#2a2a2d',
surfaceHighest: '#353438',
surfaceLow: '#1b1b1e',
surfaceLowest: '#0e0e11',
onSurface: '#e4e1e6',
onSurfaceDim: 'rgba(228, 225, 230, 0.6)',
border: 'rgba(255, 255, 255, 0.08)',
borderActive: 'rgba(233, 69, 96, 0.4)',
// 节点颜色
nodeCharacter: '#e94560',
nodeEvent: '#4fc3f7',
nodeLocation: '#66bb6a',
nodeThread: '#ffd54f',
nodeRule: '#ab47bc',
nodeSynopsis: '#b388ff',
nodeReflection: '#80deea',
},
cyan: {
name: 'Neon Cyan',
primary: '#00e5ff',
primaryDim: 'rgba(0, 229, 255, 0.15)',
primaryGlow: 'rgba(0, 229, 255, 0.35)',
primaryText: '#80f0ff',
secondary: '#2979ff',
accent2: '#00e676',
accent3: '#ffab40',
surface: '#131316',
surfaceContainer: '#1a1f22',
surfaceHigh: '#222a2d',
surfaceHighest: '#2d3538',
surfaceLow: '#171d1e',
surfaceLowest: '#0e1111',
onSurface: '#e0f7fa',
onSurfaceDim: 'rgba(224, 247, 250, 0.6)',
border: 'rgba(0, 229, 255, 0.1)',
borderActive: 'rgba(0, 229, 255, 0.4)',
nodeCharacter: '#00e5ff',
nodeEvent: '#2979ff',
nodeLocation: '#00bfa5',
nodeThread: '#ffab40',
nodeRule: '#7c4dff',
nodeSynopsis: '#18ffff',
nodeReflection: '#84ffff',
},
amber: {
name: 'Amber Console',
primary: '#ffb300',
primaryDim: 'rgba(255, 179, 0, 0.15)',
primaryGlow: 'rgba(255, 179, 0, 0.35)',
primaryText: '#ffd79b',
secondary: '#e65100',
accent2: '#00d2fe',
accent3: '#ff6e40',
surface: '#131316',
surfaceContainer: '#1f1d1a',
surfaceHigh: '#2a2822',
surfaceHighest: '#35322a',
surfaceLow: '#1b1a17',
surfaceLowest: '#0e0d0b',
onSurface: '#e4e1d6',
onSurfaceDim: 'rgba(228, 225, 214, 0.6)',
border: 'rgba(255, 179, 0, 0.1)',
borderActive: 'rgba(255, 179, 0, 0.4)',
nodeCharacter: '#ffb300',
nodeEvent: '#e65100',
nodeLocation: '#00d2fe',
nodeThread: '#ff6e40',
nodeRule: '#9e9d24',
nodeSynopsis: '#ffd740',
nodeReflection: '#ffab40',
},
violet: {
name: 'Violet Haze',
primary: '#b388ff',
primaryDim: 'rgba(179, 136, 255, 0.15)',
primaryGlow: 'rgba(179, 136, 255, 0.35)',
primaryText: '#d1b3ff',
secondary: '#7c4dff',
accent2: '#ea80fc',
accent3: '#ff80ab',
surface: '#131316',
surfaceContainer: '#1e1a22',
surfaceHigh: '#28222d',
surfaceHighest: '#332b38',
surfaceLow: '#1a171e',
surfaceLowest: '#0e0c11',
onSurface: '#e8e0f0',
onSurfaceDim: 'rgba(232, 224, 240, 0.6)',
border: 'rgba(179, 136, 255, 0.1)',
borderActive: 'rgba(179, 136, 255, 0.4)',
nodeCharacter: '#ea80fc',
nodeEvent: '#7c4dff',
nodeLocation: '#80cbc4',
nodeThread: '#ff80ab',
nodeRule: '#b388ff',
nodeSynopsis: '#ce93d8',
nodeReflection: '#80deea',
},
/** 亮色 · 晨光纸感(暖纸面 + 青绿主色 + 琥珀强调) */
paperDawn: {
name: '晨光纸感',
primary: '#0d9488',
primaryDim: 'rgba(13, 148, 136, 0.14)',
primaryGlow: 'rgba(13, 148, 136, 0.32)',
primaryText: '#0f766e',
secondary: '#d97706',
accent2: '#0284c7',
accent3: '#ea580c',
surface: '#f7f4ef',
surfaceContainer: '#fffcf7',
surfaceHigh: '#efeae2',
surfaceHighest: '#e2ddd4',
surfaceLow: '#faf8f5',
surfaceLowest: '#f0ebe4',
onSurface: '#1c1917',
onSurfaceDim: 'rgba(28, 25, 23, 0.78)',
border: 'rgba(28, 25, 23, 0.09)',
borderActive: 'rgba(13, 148, 136, 0.42)',
nodeCharacter: '#ea580c',
nodeEvent: '#0284c7',
nodeLocation: '#16a34a',
nodeThread: '#d97706',
nodeRule: '#7c3aed',
nodeSynopsis: '#0d9488',
nodeReflection: '#64748b',
},
/** 亮色 · 冰川晴空(冷灰底 + 蓝主色 + 青/紫辅色) */
glacierSky: {
name: '冰川晴空',
primary: '#2563eb',
primaryDim: 'rgba(37, 99, 235, 0.12)',
primaryGlow: 'rgba(37, 99, 235, 0.28)',
primaryText: '#1d4ed8',
secondary: '#0891b2',
accent2: '#7c3aed',
accent3: '#f59e0b',
surface: '#f8fafc',
surfaceContainer: '#ffffff',
surfaceHigh: '#e2e8f0',
surfaceHighest: '#cbd5e1',
surfaceLow: '#f1f5f9',
surfaceLowest: '#e2e8f0',
onSurface: '#0f172a',
onSurfaceDim: 'rgba(15, 23, 42, 0.76)',
border: 'rgba(15, 23, 42, 0.08)',
borderActive: 'rgba(37, 99, 235, 0.42)',
nodeCharacter: '#c026d3',
nodeEvent: '#0369a1',
nodeLocation: '#059669',
nodeThread: '#f59e0b',
nodeRule: '#7c3aed',
nodeSynopsis: '#2563eb',
nodeReflection: '#0891b2',
},
};
/** 使用亮色 color-scheme 的面板主题(原生 number/select 等控件配色) */
export const LIGHT_PANEL_THEMES = new Set(['paperDawn', 'glacierSky']);
/**
* 将主题配色应用为 CSS 变量
* @param {string} themeName - crimson | cyan | amber | violet | paperDawn | glacierSky
* @param {HTMLElement} [root] - 目标元素,默认 document.documentElement
*/
export function applyTheme(themeName, root = null) {
const theme = THEMES[themeName] || THEMES.crimson;
const el = root || document.documentElement;
const vars = {
'--bme-primary': theme.primary,
'--bme-primary-dim': theme.primaryDim,
'--bme-primary-glow': theme.primaryGlow,
'--bme-primary-text': theme.primaryText,
'--bme-secondary': theme.secondary,
'--bme-accent2': theme.accent2,
'--bme-accent3': theme.accent3,
'--bme-surface': theme.surface,
'--bme-surface-container': theme.surfaceContainer,
'--bme-surface-high': theme.surfaceHigh,
'--bme-surface-highest': theme.surfaceHighest,
'--bme-surface-low': theme.surfaceLow,
'--bme-surface-lowest': theme.surfaceLowest,
'--bme-on-surface': theme.onSurface,
'--bme-on-surface-dim': theme.onSurfaceDim,
'--bme-border': theme.border,
'--bme-border-active': theme.borderActive,
'--bme-node-character': theme.nodeCharacter,
'--bme-node-event': theme.nodeEvent,
'--bme-node-location': theme.nodeLocation,
'--bme-node-thread': theme.nodeThread,
'--bme-node-rule': theme.nodeRule,
'--bme-node-synopsis': theme.nodeSynopsis,
'--bme-node-reflection': theme.nodeReflection,
};
for (const [key, value] of Object.entries(vars)) {
el.style.setProperty(key, value);
}
el.setAttribute('data-bme-theme', themeName);
el.setAttribute(
'data-bme-color-scheme',
LIGHT_PANEL_THEMES.has(themeName) ? 'light' : 'dark',
);
}
/**
* 获取当前主题的节点颜色映射
* @param {string} themeName
* @returns {Object<string, string>}
*/
export function getNodeColors(themeName) {
const theme = THEMES[themeName] || THEMES.crimson;
return {
character: theme.nodeCharacter,
event: theme.nodeEvent,
location: theme.nodeLocation,
thread: theme.nodeThread,
rule: theme.nodeRule,
synopsis: theme.nodeSynopsis,
reflection: theme.nodeReflection,
};
}

862
ui/ui-actions-controller.js Normal file
View File

@@ -0,0 +1,862 @@
function getTimerApi(runtime = {}) {
const rawSetTimeout =
typeof runtime.setTimeout === "function"
? runtime.setTimeout
: globalThis.setTimeout;
const rawClearTimeout =
typeof runtime.clearTimeout === "function"
? runtime.clearTimeout
: globalThis.clearTimeout;
return {
setTimeout(...args) {
return Reflect.apply(rawSetTimeout, globalThis, args);
},
clearTimeout(...args) {
return Reflect.apply(rawClearTimeout, globalThis, args);
},
};
}
function hasCompressionMutation(result = {}) {
return (
Math.max(0, Number(result?.created) || 0) > 0 ||
Math.max(0, Number(result?.archived) || 0) > 0
);
}
function hasSleepMutation(result = {}) {
return Math.max(0, Number(result?.forgotten) || 0) > 0;
}
function hasConsolidationMutation(result = {}) {
return (
Math.max(0, Number(result?.merged) || 0) > 0 ||
Math.max(0, Number(result?.skipped) || 0) > 0 ||
Math.max(0, Number(result?.evolved) || 0) > 0 ||
Math.max(0, Number(result?.connections) || 0) > 0 ||
Math.max(0, Number(result?.updates) || 0) > 0
);
}
function findGraphNode(graph, nodeId) {
if (!graph || !Array.isArray(graph.nodes)) return null;
return graph.nodes.find((node) => node?.id === nodeId) || null;
}
function isManualEvolutionCandidateNode(node) {
if (!node || node.archived) return false;
if (Number(node.level || 0) > 0) return false;
return !["synopsis", "reflection"].includes(String(node.type || ""));
}
function normalizeManualEvolutionCandidateIds(graph, nodeIds = []) {
const unique = new Set();
for (const rawId of Array.isArray(nodeIds) ? nodeIds : []) {
const nodeId = String(rawId || "").trim();
if (!nodeId || unique.has(nodeId)) continue;
const node = findGraphNode(graph, nodeId);
if (!isManualEvolutionCandidateNode(node)) continue;
unique.add(nodeId);
}
return [...unique];
}
function resolveManualEvolutionCandidates(runtime, graph) {
const liveRecentIds = normalizeManualEvolutionCandidateIds(
graph,
runtime.getLastExtractedItems?.()
?.map((item) => item?.id)
.filter(Boolean) || [],
);
if (liveRecentIds.length > 0) {
return {
ids: liveRecentIds,
source: "recent-extract",
};
}
const currentExtractionCount = Math.max(
0,
Number(graph?.historyState?.extractionCount) || 0,
);
const batchJournal = Array.isArray(graph?.batchJournal) ? graph.batchJournal : [];
for (let index = batchJournal.length - 1; index >= 0; index -= 1) {
const entry = batchJournal[index];
const beforeExtractionCount = Math.max(
0,
Number(entry?.stateBefore?.extractionCount) || 0,
);
if (beforeExtractionCount >= currentExtractionCount) {
continue;
}
const fallbackIds = normalizeManualEvolutionCandidateIds(
graph,
entry?.createdNodeIds || [],
);
if (fallbackIds.length > 0) {
return {
ids: fallbackIds,
source: "latest-extraction-batch",
};
}
}
return {
ids: [],
source: "none",
};
}
function describeManualEvolutionSource(source, count) {
switch (String(source || "")) {
case "recent-extract":
return `使用最近提取的 ${count} 个节点`;
case "latest-extraction-batch":
return `使用最近一批提取落盘的 ${count} 个节点`;
default:
return `候选节点 ${count}`;
}
}
function updateManualActionUiState(runtime, text, meta = "", level = "idle") {
if (typeof runtime?.setRuntimeStatus === "function") {
runtime.setRuntimeStatus(text, meta, level);
}
runtime?.refreshPanelLiveState?.();
}
function rebindImportedGraphToCurrentChat(runtime, importedGraph) {
if (!importedGraph || typeof importedGraph !== "object") {
return {
rebound: false,
reason: "missing-graph",
};
}
const chat = runtime.getContext?.()?.chat;
const assistantTurns =
typeof runtime.getAssistantTurns === "function" && Array.isArray(chat)
? runtime.getAssistantTurns(chat)
: [];
if (typeof runtime.rebindProcessedHistoryStateToChat === "function") {
return runtime.rebindProcessedHistoryStateToChat(
importedGraph,
chat,
assistantTurns,
);
}
importedGraph.historyState.processedMessageHashesNeedRefresh = true;
return {
rebound: false,
reason: "missing-history-rebind-helper",
};
}
export async function onViewGraphController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) {
runtime.toastr.warning("当前没有加载的图谱");
return;
}
const stats = runtime.getGraphStats(graph);
const statsText = [
`节点: ${stats.activeNodes} 活跃 / ${stats.archivedNodes} 归档`,
`边: ${stats.totalEdges}`,
`最后处理楼层: ${stats.lastProcessedSeq}`,
`类型分布: ${
Object.entries(stats.typeCounts)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "(空)"
}`,
].join("\n");
runtime.toastr.info(statsText, "ST-BME 图谱状态", { timeOut: 10000 });
}
export async function onTestEmbeddingController(runtime) {
const config = runtime.getEmbeddingConfig();
const validation = runtime.validateVectorConfig(config);
if (!validation.valid) {
runtime.toastr.warning(validation.error);
return;
}
runtime.toastr.info("正在测试 Embedding API 连通性...");
const result = await runtime.testVectorConnection(config, runtime.getCurrentChatId());
if (result.success) {
runtime.toastr.success(`连接成功!向量维度: ${result.dimensions}`);
} else {
runtime.toastr.error(`连接失败: ${result.error}`);
}
}
export async function onTestMemoryLLMController(runtime) {
runtime.toastr.info("正在测试记忆 LLM 连通性...");
const result = await runtime.testLLMConnection();
if (result.success) {
runtime.toastr.success(`连接成功!模式: ${result.mode}`);
} else {
runtime.toastr.error(`连接失败: ${result.error}`);
}
}
export async function onFetchMemoryLLMModelsController(runtime) {
runtime.toastr.info("正在拉取记忆 LLM 模型列表...");
const result = await runtime.fetchMemoryLLMModels();
if (result.success) {
runtime.toastr.success(`已拉取 ${result.models.length} 个记忆 LLM 模型`);
} else {
runtime.toastr.error(`拉取失败: ${result.error}`);
}
return result;
}
export async function onFetchEmbeddingModelsController(runtime, mode = null) {
const config = runtime.getEmbeddingConfig(mode);
const targetMode = mode || config?.mode || "direct";
const validation = runtime.validateVectorConfig(config);
if (!validation.valid) {
runtime.toastr.warning(validation.error);
return { success: false, models: [], error: validation.error };
}
runtime.toastr.info("正在拉取 Embedding 模型列表...");
const result = await runtime.fetchAvailableEmbeddingModels(config);
if (result.success) {
const modeLabel = targetMode === "backend" ? "后端" : "直连";
runtime.toastr.success(
`已拉取 ${result.models.length}${modeLabel} Embedding 模型`,
);
} else {
runtime.toastr.error(`拉取失败: ${result.error}`);
}
return result;
}
export async function onManualCompressController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) return;
if (!runtime.ensureGraphMutationReady("手动压缩")) return;
updateManualActionUiState(runtime, "手动压缩中", "正在检查可压缩候选组", "running");
try {
const schema = runtime.getSchema();
const inspection = runtime.inspectCompressionCandidates?.(graph, schema, true);
if (inspection && !inspection.hasCandidates) {
const reason = String(
inspection.reason || "当前没有可压缩候选组,本次未发起 LLM 压缩",
);
updateManualActionUiState(runtime, "手动压缩未执行", reason, "idle");
runtime.toastr.info(reason);
return {
handledToast: true,
requestDispatched: false,
mutated: false,
reason,
};
}
updateManualActionUiState(runtime, "手动压缩中", "正在请求 LLM 压缩候选组", "running");
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const result = await runtime.compressAll(
graph,
schema,
runtime.getEmbeddingConfig(),
true,
undefined,
undefined,
runtime.getSettings(),
);
const mutated = hasCompressionMutation(result);
if (mutated) {
runtime.recordMaintenanceAction?.({
action: "compress",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["compression"],
});
updateManualActionUiState(
runtime,
"手动压缩完成",
`新建 ${result.created},归档 ${result.archived}`,
"success",
);
runtime.toastr.success(
`手动压缩完成:新建 ${result.created},归档 ${result.archived}`,
);
} else {
updateManualActionUiState(
runtime,
"手动压缩无变更",
"已尝试压缩,但本轮没有产生可持久化变化",
"idle",
);
runtime.toastr.info("已尝试手动压缩,但本轮没有产生可持久化变化");
}
return {
handledToast: true,
requestDispatched: true,
mutated,
result,
};
} catch (error) {
updateManualActionUiState(
runtime,
"手动压缩失败",
error?.message || String(error),
"error",
);
throw error;
}
}
export async function onExportGraphController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) return;
const json = runtime.exportGraph(graph);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = runtime.document.createElement("a");
a.href = url;
a.download = `st-bme-graph-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
runtime.toastr.success("图谱已导出");
}
export async function onViewLastInjectionController(runtime) {
const content = runtime.getLastInjectionContent();
if (!content) {
runtime.toastr.info("暂无注入内容");
return;
}
const popup = runtime.document.createElement("div");
popup.style.cssText =
"position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1a1a2e;color:#eee;padding:24px;border-radius:12px;max-width:80vw;max-height:80vh;overflow:auto;z-index:99999;white-space:pre-wrap;font-size:13px;box-shadow:0 8px 32px rgba(0,0,0,0.5);";
popup.textContent = content;
const close = runtime.document.createElement("button");
close.textContent = "关闭";
close.style.cssText =
"position:absolute;top:8px;right:12px;background:#e94560;color:white;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;";
close.onclick = () => popup.remove();
popup.appendChild(close);
runtime.document.body.appendChild(popup);
}
export async function onRebuildController(runtime) {
if (!runtime.confirm("确定要从当前聊天重建图谱?这将清除现有图谱数据。")) {
return;
}
if (!runtime.ensureGraphMutationReady("重建图谱")) return;
const context = runtime.getContext();
const chat = context?.chat;
if (!Array.isArray(chat)) {
runtime.toastr.warning("当前聊天上下文不可用,无法重建");
return;
}
const previousGraphSnapshot = runtime.getCurrentGraph()
? runtime.cloneGraphSnapshot(runtime.getCurrentGraph())
: runtime.cloneGraphSnapshot(
runtime.normalizeGraphRuntimeState(
runtime.createEmptyGraph(),
runtime.getCurrentChatId(),
),
);
const previousUiState = runtime.snapshotRuntimeUiState();
const settings = runtime.getSettings();
runtime.setRuntimeStatus(
"图谱重建中",
`当前聊天 ${Array.isArray(chat) ? chat.length : 0} 条消息`,
"running",
);
const nextGraph = runtime.normalizeGraphRuntimeState(
runtime.createEmptyGraph(),
runtime.getCurrentChatId(),
);
nextGraph.batchJournal = [];
runtime.setCurrentGraph(nextGraph);
runtime.clearInjectionState();
try {
await runtime.prepareVectorStateForReplay(true);
const replayedBatches = await runtime.replayExtractionFromHistory(chat, settings);
runtime.clearHistoryDirty(
runtime.getCurrentGraph(),
runtime.buildRecoveryResult("full-rebuild", {
fromFloor: 0,
batches: replayedBatches,
path: "full-rebuild",
detectionSource: "manual-rebuild",
affectedBatchCount: runtime.getCurrentGraph().batchJournal?.length || 0,
replayedBatchCount: replayedBatches,
reason: "用户手动触发全量重建",
}),
);
runtime.saveGraphToChat({ reason: "manual-rebuild-complete" });
runtime.setLastExtractionStatus(
"图谱重建完成",
`已回放 ${replayedBatches} 批提取`,
"success",
{
syncRuntime: false,
},
);
if (runtime.getCurrentGraph().vectorIndexState?.lastWarning) {
runtime.setRuntimeStatus(
"图谱重建完成",
`已回放 ${replayedBatches} 批,但向量仍待修复`,
"warning",
);
runtime.toastr.warning(
`图谱已重建,但向量索引仍待修复: ${runtime.getCurrentGraph().vectorIndexState.lastWarning}`,
);
} else {
runtime.setRuntimeStatus(
"图谱重建完成",
`已回放 ${replayedBatches} 批,图谱与向量索引已刷新`,
"success",
);
runtime.toastr.success("图谱与向量索引已按当前聊天全量重建");
}
} catch (error) {
runtime.setCurrentGraph(
runtime.normalizeGraphRuntimeState(
previousGraphSnapshot,
runtime.getCurrentChatId(),
),
);
runtime.restoreRuntimeUiState(previousUiState);
runtime.saveGraphToChat({ reason: "manual-rebuild-restore-previous" });
runtime.setLastExtractionStatus("图谱重建失败", error?.message || String(error), "error", {
syncRuntime: true,
});
throw new Error(
`图谱重建失败,已恢复到重建前状态: ${error?.message || error}`,
);
} finally {
runtime.refreshPanelLiveState();
}
}
export async function onImportGraphController(runtime) {
if (!runtime.ensureGraphMutationReady("导入图谱")) {
return { cancelled: true };
}
const input = runtime.document.createElement("input");
input.type = "file";
input.accept = ".json";
return await new Promise((resolve, reject) => {
const timers = getTimerApi(runtime);
let settled = false;
let focusTimer = null;
const cleanup = () => {
if (focusTimer) {
timers.clearTimeout(focusTimer);
focusTimer = null;
}
input.onchange = null;
runtime.window.removeEventListener("focus", onWindowFocus, true);
};
const finish = (value, isError = false) => {
if (settled) return;
settled = true;
cleanup();
if (isError) {
reject(value);
} else {
resolve(value);
}
};
const onWindowFocus = () => {
focusTimer = timers.setTimeout(() => {
if (!settled) {
finish({ cancelled: true });
}
}, 180);
};
runtime.window.addEventListener("focus", onWindowFocus, true);
input.addEventListener(
"cancel",
() => {
finish({ cancelled: true });
},
{ once: true },
);
input.onchange = async (event) => {
const file = event.target.files?.[0];
if (!file) {
finish({ cancelled: true });
return;
}
try {
const text = await file.text();
const importedGraph = runtime.normalizeGraphRuntimeState(
runtime.importGraph(text),
runtime.getCurrentChatId(),
);
const historyRebind = rebindImportedGraphToCurrentChat(
runtime,
importedGraph,
);
runtime.setCurrentGraph(importedGraph);
runtime.markVectorStateDirty("导入图谱后需要重建向量索引");
runtime.setExtractionCount(
Math.max(0, Number(importedGraph?.historyState?.extractionCount) || 0),
);
runtime.setLastExtractedItems([]);
runtime.updateLastRecalledItems(importedGraph.lastRecallResult || []);
runtime.clearInjectionState();
runtime.saveGraphToChat({ reason: "graph-import-complete" });
runtime.toastr.success(
historyRebind?.rebound === true
? "图谱已导入,并已重新绑定当前聊天历史"
: "图谱已导入",
);
finish({ imported: true, handledToast: true });
} catch (err) {
const error =
err instanceof Error ? err : new Error(String(err || "导入失败"));
runtime.toastr.error(`导入失败: ${error.message}`);
error._stBmeToastHandled = true;
finish(error, true);
}
};
input.click();
});
}
export async function onRebuildVectorIndexController(runtime, range = null) {
if (!runtime.ensureGraphMutationReady(range ? "范围重建向量" : "重建向量")) return;
runtime.ensureCurrentGraphRuntimeState();
const config = runtime.getEmbeddingConfig();
const validation = runtime.validateVectorConfig(config);
if (!validation.valid) {
runtime.toastr.warning(validation.error);
return;
}
const vectorController = runtime.beginStageAbortController("vector");
try {
const result = await runtime.syncVectorState({
force: true,
purge: runtime.isBackendVectorConfig(config) && !range,
range,
signal: vectorController.signal,
});
runtime.saveGraphToChat({ reason: "vector-rebuild-complete" });
if (result?.aborted) {
return;
}
if (result?.error) {
throw new Error(result.error);
}
runtime.toastr.success(
range
? `范围向量重建完成indexed=${result.stats.indexed}, pending=${result.stats.pending}`
: `当前聊天向量重建完成indexed=${result.stats.indexed}, pending=${result.stats.pending}`,
);
} finally {
runtime.finishStageAbortController("vector", vectorController);
runtime.refreshPanelLiveState();
}
}
export async function onReembedDirectController(runtime) {
const config = runtime.getEmbeddingConfig();
if (!runtime.isDirectVectorConfig(config)) {
runtime.toastr.info("当前不是直连模式,无需执行重嵌");
return;
}
await runtime.onRebuildVectorIndex();
}
export async function onManualSleepController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) return;
if (!runtime.ensureGraphMutationReady("执行遗忘")) return;
updateManualActionUiState(runtime, "执行遗忘中", "正在评估可归档节点", "running");
try {
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const result = runtime.sleepCycle(graph, runtime.getSettings());
const mutated = hasSleepMutation(result);
if (mutated) {
runtime.recordMaintenanceAction?.({
action: "sleep",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["sleep"],
});
updateManualActionUiState(
runtime,
"执行遗忘完成",
`归档 ${result.forgotten} 个节点`,
"success",
);
runtime.toastr.success(`执行遗忘完成:归档 ${result.forgotten} 个节点`);
} else {
updateManualActionUiState(
runtime,
"执行遗忘无变更",
"当前没有符合遗忘条件的节点",
"idle",
);
runtime.toastr.info(
"当前没有符合遗忘条件的节点。本操作只做本地图清理,不会发送 LLM 请求。",
);
}
return {
handledToast: true,
requestDispatched: false,
mutated,
result,
};
} catch (error) {
updateManualActionUiState(
runtime,
"执行遗忘失败",
error?.message || String(error),
"error",
);
throw error;
}
}
export async function onManualSynopsisController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) return;
if (!runtime.ensureGraphMutationReady("更新概要")) return;
updateManualActionUiState(runtime, "更新概要中", "正在生成新的概要节点", "running");
try {
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
await runtime.generateSynopsis({
graph,
schema: runtime.getSchema(),
currentSeq: runtime.getCurrentChatSeq(),
customPrompt: undefined,
settings: runtime.getSettings(),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["synopsis"],
});
updateManualActionUiState(runtime, "概要生成完成", "概要节点已更新", "success");
runtime.toastr.success("概要生成完成");
return {
handledToast: true,
requestDispatched: true,
mutated: true,
};
} catch (error) {
updateManualActionUiState(
runtime,
"概要生成失败",
error?.message || String(error),
"error",
);
throw error;
}
}
export async function onManualEvolveController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) return;
if (!runtime.ensureGraphMutationReady("强制进化")) return;
updateManualActionUiState(runtime, "强制进化中", "正在整理候选节点", "running");
try {
const embeddingConfig = runtime.getEmbeddingConfig();
const vectorValidation = runtime.validateVectorConfig?.(embeddingConfig);
if (vectorValidation && !vectorValidation.valid) {
updateManualActionUiState(
runtime,
"强制进化未执行",
vectorValidation.error,
"warning",
);
runtime.toastr.warning(vectorValidation.error);
return {
handledToast: true,
requestDispatched: false,
mutated: false,
reason: vectorValidation.error,
};
}
const candidateResolution = resolveManualEvolutionCandidates(runtime, graph);
const candidateIds = candidateResolution.ids;
if (candidateIds.length === 0) {
updateManualActionUiState(
runtime,
"强制进化未执行",
"当前没有可用于进化的最近提取节点",
"idle",
);
runtime.toastr.info("当前没有可用于进化的最近提取节点,本次未发起整合请求");
return {
handledToast: true,
requestDispatched: false,
mutated: false,
reason: "no-candidates",
};
}
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const settings = runtime.getSettings();
updateManualActionUiState(
runtime,
"强制进化中",
`正在处理 ${candidateIds.length} 个候选节点`,
"running",
);
const result = await runtime.consolidateMemories({
graph,
newNodeIds: candidateIds,
embeddingConfig,
customPrompt: undefined,
settings,
options: {
neighborCount: settings.consolidationNeighborCount,
conflictThreshold: settings.consolidationThreshold,
},
});
const mutated = hasConsolidationMutation(result);
const sourceLabel = describeManualEvolutionSource(
candidateResolution.source,
candidateIds.length,
);
if (mutated) {
runtime.recordMaintenanceAction?.({
action: "consolidate",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("consolidate", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["consolidation"],
});
updateManualActionUiState(
runtime,
"强制进化完成",
`合并 ${result.merged},进化 ${result.evolved},更新 ${result.updates}`,
"success",
);
runtime.toastr.success(
`强制进化完成:合并 ${result.merged},跳过 ${result.skipped},保留 ${result.kept},进化 ${result.evolved},新链接 ${result.connections},回溯更新 ${result.updates}${sourceLabel}`,
);
} else {
updateManualActionUiState(
runtime,
"强制进化无变更",
`已完成整合判定,但本轮没有图谱变化。${sourceLabel}`,
"idle",
);
runtime.toastr.info(
`已完成整合判定,但本轮没有产生图谱变更。${sourceLabel}`,
);
}
return {
handledToast: true,
requestDispatched: true,
mutated,
result,
candidateSource: candidateResolution.source,
};
} catch (error) {
updateManualActionUiState(
runtime,
"强制进化失败",
error?.message || String(error),
"error",
);
throw error;
}
}
export async function onUndoLastMaintenanceController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) return;
if (!runtime.ensureGraphMutationReady("撤销最近维护")) return;
updateManualActionUiState(runtime, "撤销最近维护中", "正在恢复上一条维护变更", "running");
try {
const result = runtime.undoLastMaintenance?.();
if (!result?.ok) {
updateManualActionUiState(
runtime,
"撤销最近维护失败",
result?.reason || "当前没有可撤销的维护记录",
"warning",
);
runtime.toastr.warning(result?.reason || "撤销最近维护失败");
return { handledToast: true };
}
runtime.markVectorStateDirty?.("撤销维护后需要重建向量索引");
runtime.saveGraphToChat?.({ reason: "maintenance-undo-complete" });
updateManualActionUiState(
runtime,
"撤销最近维护完成",
result.entry?.summary || result.entry?.action || "已恢复最近维护",
"success",
);
runtime.toastr.success(
`已撤销最近维护:${result.entry?.summary || result.entry?.action || "未知操作"}`,
);
return {
handledToast: true,
result,
};
} catch (error) {
updateManualActionUiState(
runtime,
"撤销最近维护失败",
error?.message || String(error),
"error",
);
throw error;
}
}

378
ui/ui-status.js Normal file
View File

@@ -0,0 +1,378 @@
// ST-BME: UI 状态工厂、纯工具函数
// 此模块中的函数均不依赖 index.js 模块级可变状态,
// 可被 index.js 及其他模块安全导入。
import { sanitizePlannerMessageText } from "../runtime/planner-tag-utils.js";
// ═══════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════
export const BATCH_STAGE_ORDER = ["core", "structural", "semantic", "finalize"];
export const BATCH_STAGE_SEVERITY = {
success: 0,
partial: 1,
failed: 2,
};
// ═══════════════════════════════════════════════════════════
// UI 状态工厂
// ═══════════════════════════════════════════════════════════
export function createUiStatus(text = "待命", meta = "", level = "idle") {
return {
text: String(text || "待命"),
meta: String(meta || ""),
level,
updatedAt: Date.now(),
};
}
export function createGraphPersistenceState() {
return {
loadState: "no-chat",
chatId: "",
reason: "当前尚未进入聊天",
attemptIndex: 0,
revision: 0,
lastPersistedRevision: 0,
queuedPersistRevision: 0,
queuedPersistChatId: "",
queuedPersistMode: "",
queuedPersistRotateIntegrity: false,
queuedPersistReason: "",
shadowSnapshotUsed: false,
shadowSnapshotRevision: 0,
shadowSnapshotUpdatedAt: "",
shadowSnapshotReason: "",
lastPersistReason: "",
lastPersistMode: "",
metadataIntegrity: "",
writesBlocked: false,
pendingPersist: false,
storagePrimary: "indexeddb",
storageMode: "indexeddb",
dbReady: false,
indexedDbRevision: 0,
indexedDbLastError: "",
syncState: "idle",
lastSyncUploadedAt: 0,
lastSyncDownloadedAt: 0,
lastSyncedRevision: 0,
lastSyncError: "",
dualWriteLastResult: null,
updatedAt: new Date().toISOString(),
};
}
export function createRecallInputRecord(overrides = {}) {
return {
text: "",
hash: "",
messageId: null,
source: "",
at: 0,
...overrides,
};
}
export function createRecallRunResult(status = "completed", extra = {}) {
const normalizedStatus = String(status || "skipped").trim() || "skipped";
return {
ok: normalizedStatus === "completed",
didRecall: normalizedStatus === "completed",
status: normalizedStatus,
...extra,
};
}
// ═══════════════════════════════════════════════════════════
// 批次状态
// ═══════════════════════════════════════════════════════════
export function createBatchStageStatus(stage, consistency = "strong") {
return {
stage,
outcome: "success",
consistency,
warnings: [],
errors: [],
artifacts: [],
};
}
/**
* @param {object} opts
* @param {number[]} opts.processedRange
* @param {number} opts.extractionCountBefore
* @param {number} [opts.extractionCountAfter] — 如未提供fallback 为 extractionCountBefore
*/
export function createBatchStatusSkeleton({
processedRange,
extractionCountBefore,
extractionCountAfter,
}) {
const countBefore = Number.isFinite(extractionCountBefore)
? extractionCountBefore
: 0;
const countAfter = Number.isFinite(extractionCountAfter)
? extractionCountAfter
: countBefore;
return {
model: "layered-batch-v1",
processedRange: Array.isArray(processedRange)
? [...processedRange]
: [-1, -1],
extractionCountBefore: countBefore,
extractionCountAfter: countAfter,
stages: {
core: createBatchStageStatus("core", "strong"),
structural: createBatchStageStatus("structural", "weak"),
semantic: createBatchStageStatus("semantic", "weak"),
finalize: createBatchStageStatus("finalize", "strong"),
},
outcome: "success",
consistency: "strong",
completed: false,
warnings: [],
errors: [],
};
}
export function setBatchStageOutcome(status, stage, outcome, message = "") {
const stageStatus = status?.stages?.[stage];
if (!stageStatus) return;
const nextSeverity = BATCH_STAGE_SEVERITY[outcome] ?? 0;
const previousSeverity = BATCH_STAGE_SEVERITY[stageStatus.outcome] ?? 0;
if (nextSeverity >= previousSeverity) {
stageStatus.outcome = outcome;
}
if (!message) return;
if (outcome === "failed") {
stageStatus.errors.push(message);
} else if (outcome === "partial") {
stageStatus.warnings.push(message);
}
}
export function pushBatchStageArtifact(status, stage, artifact) {
const stageStatus = status?.stages?.[stage];
if (!stageStatus || !artifact) return;
if (!stageStatus.artifacts.includes(artifact)) {
stageStatus.artifacts.push(artifact);
}
}
/**
* @param {object} status
* @param {number} [currentExtractionCount] — 传入调用方的 extractionCount
*/
export function finalizeBatchStatus(status, currentExtractionCount) {
const stages = status?.stages || {};
const structuralOutcome = stages.structural?.outcome || "success";
const semanticOutcome = stages.semantic?.outcome || "success";
const finalizeOutcome = stages.finalize?.outcome || "failed";
const outcomeList = BATCH_STAGE_ORDER.map(
(stage) => stages[stage]?.outcome || "success",
);
if (finalizeOutcome !== "success") {
status.outcome = "failed";
} else if (outcomeList.includes("failed")) {
status.outcome = "failed";
} else if (structuralOutcome === "partial" || semanticOutcome === "partial") {
status.outcome = "partial";
} else {
status.outcome = "success";
}
status.consistency =
finalizeOutcome === "success" &&
stages.core?.outcome === "success" &&
stages.structural?.outcome === "success"
? "strong"
: "weak";
status.completed = finalizeOutcome === "success";
if (Number.isFinite(currentExtractionCount)) {
status.extractionCountAfter = currentExtractionCount;
}
status.warnings = BATCH_STAGE_ORDER.flatMap(
(stage) => stages[stage]?.warnings || [],
);
status.errors = BATCH_STAGE_ORDER.flatMap(
(stage) => stages[stage]?.errors || [],
);
return status;
}
// ═══════════════════════════════════════════════════════════
// 纯映射 / 纯变换
// ═══════════════════════════════════════════════════════════
export function normalizeStageNoticeLevel(level = "info") {
if (level === "running" || level === "idle") return "info";
if (level === "success" || level === "warning" || level === "error") {
return level;
}
return "info";
}
export function getStageNoticeTitle(stage) {
switch (stage) {
case "extraction":
return "ST-BME 提取";
case "vector":
return "ST-BME 向量";
case "recall":
return "ST-BME 召回";
case "history":
return "ST-BME 历史恢复";
default:
return "ST-BME";
}
}
export function getStageNoticeDuration(level = "info") {
switch (level) {
case "error":
return 6000;
case "warning":
return 5000;
case "success":
return 3000;
default:
return 3200;
}
}
export function getRecallHookLabel(hookName = "") {
switch (hookName) {
case "GENERATION_AFTER_COMMANDS":
return "hook GENERATION_AFTER_COMMANDS";
case "GENERATE_BEFORE_COMBINE_PROMPTS":
return "hook GENERATE_BEFORE_COMBINE_PROMPTS";
default:
return "";
}
}
export function getGenerationRecallHookStateFromResult(result) {
const status = String(result?.status || "").trim();
switch (status) {
case "completed":
return "completed";
case "failed":
return "failed";
case "aborted":
case "superseded":
return "aborted";
default:
return "skipped";
}
}
export function isTerminalGenerationRecallHookState(state = "") {
return ["completed", "failed", "aborted", "skipped"].includes(
String(state || ""),
);
}
export function shouldRunRecallForTransaction(transaction, hookName) {
if (!hookName) return true;
if (!transaction) return true;
const hookStates = transaction.hookStates || {};
const currentHookState = hookStates[hookName];
if (
currentHookState === "running" ||
isTerminalGenerationRecallHookState(currentHookState)
) {
return false;
}
const peerHookName =
hookName === "GENERATION_AFTER_COMMANDS"
? "GENERATE_BEFORE_COMBINE_PROMPTS"
: hookName === "GENERATE_BEFORE_COMBINE_PROMPTS"
? "GENERATION_AFTER_COMMANDS"
: "";
if (!peerHookName) return true;
const peerHookState = hookStates[peerHookName];
if (
peerHookState === "running" ||
isTerminalGenerationRecallHookState(peerHookState)
) {
return false;
}
return true;
}
export function formatRecallContextLine(message) {
return `[${message.is_user ? "user" : "assistant"}]: ${sanitizePlannerMessageText(message)}`;
}
// ═══════════════════════════════════════════════════════════
// 文本 / 数值 工具
// ═══════════════════════════════════════════════════════════
export function normalizeRecallInputText(value) {
return String(value ?? "")
.replace(/\r\n/g, "\n")
.trim();
}
export function isTrivialUserInput(text) {
const normalizedText = normalizeRecallInputText(text);
if (!normalizedText) {
return {
trivial: true,
reason: "empty",
normalizedText,
};
}
if (normalizedText.startsWith("/")) {
return {
trivial: true,
reason: "slash-command",
normalizedText,
};
}
return {
trivial: false,
reason: "",
normalizedText,
};
}
export function hashRecallInput(text) {
let hash = 0;
const normalized = normalizeRecallInputText(text);
for (let index = 0; index < normalized.length; index++) {
hash = (hash * 31 + normalized.charCodeAt(index)) >>> 0;
}
return normalized ? String(hash) : "";
}
export function isFreshRecallInputRecord(record, ttlMs = 60000) {
return Boolean(
record?.text &&
record.at &&
Date.now() - record.at <= ttlMs,
);
}
export function clampInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
const num = Number.parseInt(value, 10);
if (!Number.isFinite(num)) return fallback;
return Math.min(max, Math.max(min, num));
}
export function clampFloat(value, fallback, min = 0, max = 1) {
const num = Number.parseFloat(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(max, Math.max(min, num));
}