mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Reorganize modules into layered directories
This commit is contained in:
1004
ui/graph-renderer.js
Normal file
1004
ui/graph-renderer.js
Normal file
File diff suppressed because it is too large
Load Diff
556
ui/hide-engine.js
Normal file
556
ui/hide-engine.js
Normal 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
479
ui/notice.js
Normal 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
95
ui/panel-bridge.js
Normal 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
2364
ui/panel.html
Normal file
File diff suppressed because it is too large
Load Diff
6449
ui/panel.js
Normal file
6449
ui/panel.js
Normal file
File diff suppressed because it is too large
Load Diff
719
ui/recall-message-ui.js
Normal file
719
ui/recall-message-ui.js
Normal 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
237
ui/themes.js
Normal 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
862
ui/ui-actions-controller.js
Normal 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
378
ui/ui-status.js
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user