feat: 增强运行阶段托管通知反馈

This commit is contained in:
Youzini-afk
2026-03-25 01:22:46 +08:00
parent a3889297a4
commit cf067a4fcd
2 changed files with 601 additions and 0 deletions

177
index.js
View File

@@ -32,6 +32,7 @@ import {
import { estimateTokens, formatInjection } from "./injector.js";
import { fetchMemoryLLMModels, testLLMConnection } from "./llm.js";
import { getNodeDisplayName } from "./node-labels.js";
import { showManagedBmeNotice } from "./notice.js";
import { retrieve } from "./retriever.js";
import {
appendBatchJournal,
@@ -189,6 +190,12 @@ let sendIntentHookCleanup = [];
let sendIntentHookRetryTimer = null;
let pendingHistoryRecoveryTimer = null;
let pendingHistoryRecoveryTrigger = "";
const stageNoticeHandles = {
extraction: null,
vector: null,
recall: null,
history: null,
};
function createUiStatus(text = "待命", meta = "", level = "idle") {
return {
@@ -199,6 +206,99 @@ function createUiStatus(text = "待命", meta = "", level = "idle") {
};
}
function normalizeStageNoticeLevel(level = "info") {
if (level === "running" || level === "idle") return "info";
if (level === "success" || level === "warning" || level === "error") {
return level;
}
return "info";
}
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";
}
}
function getStageNoticeDuration(level = "info") {
switch (level) {
case "error":
return 5600;
case "warning":
return 4600;
case "success":
return 2800;
default:
return 3200;
}
}
function createNoticePanelAction() {
if (!_panelModule?.openPanel) return undefined;
return {
label: "打开面板",
kind: "neutral",
onClick: () => {
_panelModule?.openPanel?.();
},
};
}
function dismissStageNotice(stage) {
stageNoticeHandles[stage]?.dismiss?.();
stageNoticeHandles[stage] = null;
}
function dismissAllStageNotices() {
for (const stage of Object.keys(stageNoticeHandles)) {
dismissStageNotice(stage);
}
}
function updateStageNotice(
stage,
text,
meta = "",
level = "info",
options = {},
) {
const noticeLevel = normalizeStageNoticeLevel(level);
const busy = options.busy ?? level === "running";
const persist = options.persist ?? busy;
const title = options.title || getStageNoticeTitle(stage);
const message = [text, meta].filter(Boolean).join("\n");
const input = {
title,
message,
level: noticeLevel,
busy,
persist,
duration_ms: options.duration_ms ?? getStageNoticeDuration(noticeLevel),
action:
options.action === undefined &&
(noticeLevel === "warning" || noticeLevel === "error")
? createNoticePanelAction()
: options.action,
};
const currentHandle = stageNoticeHandles[stage];
if (!currentHandle || currentHandle.isClosed?.()) {
stageNoticeHandles[stage] = showManagedBmeNotice(input);
return;
}
currentHandle.update(input);
}
function createRecallInputRecord(overrides = {}) {
return {
text: "",
@@ -427,6 +527,9 @@ function clearInjectionState() {
lastRecalledItems = [];
lastRecallStatus = createUiStatus("待命", "当前无有效注入内容", "idle");
runtimeStatus = createUiStatus("待命", "当前无有效注入内容", "idle");
if (!isRecalling) {
dismissStageNotice("recall");
}
try {
const context = getContext();
@@ -473,6 +576,9 @@ function setLastExtractionStatus(
} else {
refreshPanelLiveState();
}
updateStageNotice("extraction", text, meta, level, {
title: toastTitle,
});
if (toastKind) {
notifyStatusToast(`extract:${toastKind}`, toastKind, meta || text, toastTitle);
}
@@ -490,6 +596,9 @@ function setLastVectorStatus(
} else {
refreshPanelLiveState();
}
updateStageNotice("vector", text, meta, level, {
title: toastTitle,
});
if (toastKind) {
notifyStatusToast(`vector:${toastKind}`, toastKind, meta || text, toastTitle);
}
@@ -507,6 +616,9 @@ function setLastRecallStatus(
} else {
refreshPanelLiveState();
}
updateStageNotice("recall", text, meta, level, {
title: toastTitle,
});
if (toastKind) {
notifyStatusToast(`recall:${toastKind}`, toastKind, meta || text, toastTitle);
}
@@ -881,6 +993,7 @@ function updateModuleSettings(patch = {}) {
Object.prototype.hasOwnProperty.call(patch, "enabled") &&
patch.enabled === false
) {
dismissAllStageNotices();
try {
const context = getContext();
context.setExtensionPrompt(
@@ -1326,6 +1439,16 @@ function getLastProcessedAssistantFloor() {
}
function notifyHistoryDirty(dirtyFrom, reason) {
updateStageNotice(
"history",
"检测到楼层历史变化",
`将从楼层 ${dirtyFrom} 之后自动恢复${reason ? `\n${reason}` : ""}`,
"warning",
{
persist: true,
busy: true,
},
);
const now = Date.now();
if (now - lastHistoryWarningAt < 3000) return;
lastHistoryWarningAt = now;
@@ -1355,6 +1478,16 @@ function scheduleImmediateHistoryRecovery(
})
.catch((error) => {
console.error("[ST-BME] 事件触发的历史恢复失败:", error);
updateStageNotice(
"history",
"历史恢复失败",
error?.message || String(error),
"error",
{
busy: false,
persist: false,
},
);
toastr.error(`历史恢复失败: ${error?.message || error}`);
});
}, delayMs);
@@ -1561,6 +1694,19 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
let replayedBatches = 0;
let usedFullRebuild = false;
updateStageNotice(
"history",
"历史恢复中",
Number.isFinite(initialDirtyFrom)
? `受影响起点楼层 ${initialDirtyFrom} · 正在回滚并重放`
: "正在回滚并重放受影响后缀",
"running",
{
persist: true,
busy: true,
},
);
try {
const recoveryPoint = findJournalRecoveryPoint(currentGraph, initialDirtyFrom);
if (recoveryPoint) {
@@ -1586,6 +1732,16 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
);
saveGraphToChat();
refreshPanelLiveState();
updateStageNotice(
"history",
usedFullRebuild ? "历史恢复完成(全量重建)" : "历史恢复完成",
`起点楼层 ${initialDirtyFrom} · 回放 ${replayedBatches}`,
usedFullRebuild ? "warning" : "success",
{
busy: false,
persist: false,
},
);
toastr.success(
usedFullRebuild
@@ -1610,6 +1766,16 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
);
saveGraphToChat();
refreshPanelLiveState();
updateStageNotice(
"history",
"历史恢复已退化为全量重建",
`起点楼层 ${initialDirtyFrom} · 回放 ${replayedBatches}`,
"warning",
{
busy: false,
persist: false,
},
);
toastr.warning("历史恢复已退化为全量重建");
return true;
} catch (fallbackError) {
@@ -1619,6 +1785,16 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
});
saveGraphToChat();
refreshPanelLiveState();
updateStageNotice(
"history",
"历史恢复失败",
fallbackError?.message || String(fallbackError),
"error",
{
busy: false,
persist: false,
},
);
toastr.error(`历史恢复失败: ${fallbackError?.message || fallbackError}`);
return false;
}
@@ -1863,6 +2039,7 @@ function onChatChanged() {
clearTimeout(pendingHistoryRecoveryTimer);
pendingHistoryRecoveryTimer = null;
pendingHistoryRecoveryTrigger = "";
dismissAllStageNotices();
loadGraphFromChat();
clearInjectionState();
clearRecallInputTracking();

424
notice.js Normal file
View File

@@ -0,0 +1,424 @@
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::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);
}
.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__title {
margin: 0;
font-size: 17px;
line-height: 1.18;
font-weight: 800;
letter-spacing: 0.01em;
color: #f0f6ff;
}
.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__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;
}
.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";
item.dataset.level = level;
item.dataset.busy = input.busy ? "true" : "false";
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 || "";
}
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) {
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);
}