mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
Revert "Merge PR #7: notice display and MVU diagnostics"
This reverts commitcdf6111482, reversing changes made to0d8dcb63d2.
This commit is contained in:
95
extractor.js
95
extractor.js
@@ -107,7 +107,7 @@ function isPlainObject(value) {
|
||||
|
||||
function extractOperationsPayload(result) {
|
||||
if (Array.isArray(result)) {
|
||||
return { source: "root", operations: result };
|
||||
return result;
|
||||
}
|
||||
if (!isPlainObject(result)) {
|
||||
return null;
|
||||
@@ -115,16 +115,29 @@ function extractOperationsPayload(result) {
|
||||
|
||||
for (const key of EXTRACTION_RESULT_CONTAINER_KEYS) {
|
||||
if (Array.isArray(result[key])) {
|
||||
return {
|
||||
source: key,
|
||||
operations: result[key],
|
||||
};
|
||||
return result[key];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExtractionAction(rawOp) {
|
||||
const explicitAction = rawOp?.action ?? rawOp?.op ?? rawOp?.operation;
|
||||
if (typeof explicitAction === "string" && explicitAction.trim()) {
|
||||
return explicitAction.trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (rawOp?.type) {
|
||||
if (rawOp?.nodeId || rawOp?.node_id) {
|
||||
return "update";
|
||||
}
|
||||
return "create";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveExtractionTypeDef(schema, type) {
|
||||
if (!Array.isArray(schema) || !type) {
|
||||
return null;
|
||||
@@ -142,32 +155,6 @@ function resolveExtractionFieldNames(typeDef) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasExplicitExtractionAction(rawOp) {
|
||||
const explicitAction = rawOp?.action ?? rawOp?.op ?? rawOp?.operation;
|
||||
return typeof explicitAction === "string" && explicitAction.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveExtractionAction(rawOp) {
|
||||
const explicitAction = rawOp?.action ?? rawOp?.op ?? rawOp?.operation;
|
||||
if (typeof explicitAction === "string" && explicitAction.trim()) {
|
||||
return explicitAction.trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (rawOp?.type) {
|
||||
if (
|
||||
rawOp?.nodeId ||
|
||||
rawOp?.node_id ||
|
||||
rawOp?.targetNodeId ||
|
||||
rawOp?.target_node_id
|
||||
) {
|
||||
return "update";
|
||||
}
|
||||
return "create";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveExtractionNodeId(rawOp) {
|
||||
const nodeId =
|
||||
rawOp?.nodeId ??
|
||||
@@ -216,7 +203,6 @@ function normalizeExtractionOperation(rawOp, schema) {
|
||||
return rawOp;
|
||||
}
|
||||
|
||||
const explicitAction = hasExplicitExtractionAction(rawOp);
|
||||
const action = resolveExtractionAction(rawOp);
|
||||
const type = rawOp?.type == null ? "" : String(rawOp.type).trim();
|
||||
const typeDef = resolveExtractionTypeDef(schema, type);
|
||||
@@ -238,10 +224,6 @@ function normalizeExtractionOperation(rawOp, schema) {
|
||||
normalized.nodeId = nodeId;
|
||||
}
|
||||
|
||||
if (!explicitAction && action && type) {
|
||||
normalized.__legacyCompat = true;
|
||||
}
|
||||
|
||||
if (Array.isArray(rawOp?.relations) && !Array.isArray(rawOp?.links)) {
|
||||
normalized.links = rawOp.relations;
|
||||
} else if (Array.isArray(rawOp?.edges) && !Array.isArray(rawOp?.links)) {
|
||||
@@ -279,42 +261,23 @@ function normalizeExtractionOperation(rawOp, schema) {
|
||||
}
|
||||
|
||||
function normalizeExtractionResultPayload(result, schema) {
|
||||
const extracted = extractOperationsPayload(result);
|
||||
if (!extracted || !Array.isArray(extracted.operations)) {
|
||||
return null;
|
||||
const operations = extractOperationsPayload(result);
|
||||
if (!Array.isArray(operations)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let legacyCount = 0;
|
||||
const normalizedOperations = extracted.operations.map((op) => {
|
||||
const normalized = normalizeExtractionOperation(op, schema);
|
||||
if (normalized?.__legacyCompat) {
|
||||
legacyCount += 1;
|
||||
delete normalized.__legacyCompat;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
const normalizedOperations = operations.map((op) =>
|
||||
normalizeExtractionOperation(op, schema),
|
||||
);
|
||||
|
||||
const normalizedPayload =
|
||||
Array.isArray(result) || !isPlainObject(result)
|
||||
? { operations: normalizedOperations }
|
||||
: {
|
||||
...result,
|
||||
operations: normalizedOperations,
|
||||
};
|
||||
|
||||
if (legacyCount > 0) {
|
||||
console.info("[ST-BME] 兼容旧版扁平提取输出", {
|
||||
source: extracted.source,
|
||||
normalizedCount: legacyCount,
|
||||
totalCount: normalizedOperations.length,
|
||||
});
|
||||
if (Array.isArray(result) || !isPlainObject(result)) {
|
||||
return { operations: normalizedOperations };
|
||||
}
|
||||
|
||||
normalizedPayload.compat = {
|
||||
legacyCount,
|
||||
source: extracted.source,
|
||||
return {
|
||||
...result,
|
||||
operations: normalizedOperations,
|
||||
};
|
||||
return normalizedPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
31
index.js
31
index.js
@@ -496,7 +496,6 @@ const defaultSettings = {
|
||||
compressionEveryN: 10,
|
||||
|
||||
// UI 面板
|
||||
noticeDisplayMode: "normal", // normal|compact
|
||||
panelTheme: "crimson", // 面板主题 crimson|cyan|amber|violet
|
||||
};
|
||||
|
||||
@@ -1055,30 +1054,6 @@ function syncStageNoticeAbortAction(stage) {
|
||||
});
|
||||
}
|
||||
|
||||
function getStageNoticeDisplayMode(level = "info") {
|
||||
const configuredMode = getSettings()?.noticeDisplayMode;
|
||||
if (
|
||||
configuredMode === "compact" &&
|
||||
level !== "warning" &&
|
||||
level !== "error"
|
||||
) {
|
||||
return "compact";
|
||||
}
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function refreshVisibleStageNotices() {
|
||||
for (const stage of Object.keys(stageNoticeHandles)) {
|
||||
const handle = stageNoticeHandles[stage];
|
||||
if (!handle || handle.isClosed?.()) continue;
|
||||
const status = getStageUiStatus(stage);
|
||||
if (!status) continue;
|
||||
updateStageNotice(stage, status.text, status.meta, status.level, {
|
||||
title: getStageNoticeTitle(stage),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateStageNotice(
|
||||
stage,
|
||||
text,
|
||||
@@ -1094,7 +1069,6 @@ function updateStageNotice(
|
||||
const input = {
|
||||
title,
|
||||
message,
|
||||
displayMode: options.displayMode || getStageNoticeDisplayMode(noticeLevel),
|
||||
level: noticeLevel,
|
||||
busy,
|
||||
persist,
|
||||
@@ -5509,7 +5483,6 @@ function updateModuleSettings(patch = {}) {
|
||||
"hideOldMessagesKeepLastN",
|
||||
]);
|
||||
const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]);
|
||||
const noticeUiKeys = new Set(["noticeDisplayMode"]);
|
||||
const settings = getSettings();
|
||||
Object.assign(settings, patch);
|
||||
extension_settings[MODULE_NAME] = settings;
|
||||
@@ -5570,10 +5543,6 @@ function updateModuleSettings(patch = {}) {
|
||||
schedulePersistedRecallMessageUiRefresh(30);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).some((key) => noticeUiKeys.has(key))) {
|
||||
refreshVisibleStageNotices();
|
||||
}
|
||||
|
||||
scheduleServerSettingsSave();
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
// These rules are intentionally narrow so we strip MVU artifacts without
|
||||
// disturbing normal prompt or world info content.
|
||||
|
||||
export const MVU_SANITIZE_MODES = Object.freeze({
|
||||
/** 整段 drop likely MVU 内容(用于世界书条目)。 */
|
||||
AGGRESSIVE: "aggressive",
|
||||
/** 只剥离 MVU 容器/宏,不整段 drop(用于用户原文、角色描述等任务输入字段)。 */
|
||||
PASSIVE: "passive",
|
||||
});
|
||||
|
||||
export const MVU_ENTRY_COMMENT_REGEX = /\[(mvu_update|mvu_plot|initvar)\]/i;
|
||||
|
||||
const MVU_UPDATE_BLOCK_REGEX =
|
||||
@@ -222,8 +215,7 @@ export function sanitizeMvuContent(
|
||||
|
||||
let text = blockedResult.text;
|
||||
let dropped = false;
|
||||
if (sanitizedMode === MVU_SANITIZE_MODES.AGGRESSIVE) {
|
||||
// 整段 drop:用于世界书条目,不用于用户原文字段
|
||||
if (sanitizedMode === "aggressive") {
|
||||
if (
|
||||
isLikelyMvuWorldInfoContent(originalCollapsed) ||
|
||||
isLikelyMvuWorldInfoContent(text)
|
||||
@@ -233,7 +225,6 @@ export function sanitizeMvuContent(
|
||||
reasons.push("likely_mvu_content");
|
||||
}
|
||||
}
|
||||
// MVU_SANITIZE_MODES.PASSIVE:只做 artifact 剥离 + blocked 过滤,不整段 drop。
|
||||
|
||||
return {
|
||||
text: collapseWhitespace(text),
|
||||
|
||||
40
notice.js
40
notice.js
@@ -60,16 +60,6 @@ function ensureStyle(doc) {
|
||||
-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;
|
||||
@@ -97,7 +87,6 @@ function ensureStyle(doc) {
|
||||
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 {
|
||||
@@ -108,12 +97,6 @@ function ensureStyle(doc) {
|
||||
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;
|
||||
@@ -123,14 +106,6 @@ function ensureStyle(doc) {
|
||||
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;
|
||||
@@ -151,12 +126,6 @@ function ensureStyle(doc) {
|
||||
-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__actions,
|
||||
.st-bme-notice[data-layout="compact"] .st-bme-notice__progress {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.st-bme-notice__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -201,7 +170,6 @@ function ensureStyle(doc) {
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.st-bme-notice__close:hover,
|
||||
@@ -312,11 +280,8 @@ function getIcon(level) {
|
||||
|
||||
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) {
|
||||
@@ -331,7 +296,6 @@ function applyNoticeState(item, input, progress) {
|
||||
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 {
|
||||
@@ -342,7 +306,7 @@ function applyNoticeState(item, input, progress) {
|
||||
const actionWrap = item.querySelector(".st-bme-notice__actions");
|
||||
const actionButton = item.querySelector(".st-bme-notice__action");
|
||||
if (actionWrap && actionButton) {
|
||||
if (!isCompact && input.action?.label) {
|
||||
if (input.action?.label) {
|
||||
actionWrap.style.display = "";
|
||||
actionButton.style.display = "";
|
||||
actionButton.textContent = input.action.label;
|
||||
@@ -355,7 +319,7 @@ function applyNoticeState(item, input, progress) {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.persist || isCompact) {
|
||||
if (input.persist) {
|
||||
progress.style.display = "none";
|
||||
progress.style.animationDuration = "";
|
||||
} else {
|
||||
|
||||
24
panel.html
24
panel.html
@@ -1055,30 +1055,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bme-config-card">
|
||||
<div class="bme-config-card-head">
|
||||
<div>
|
||||
<div class="bme-config-card-title">提示信息</div>
|
||||
<div class="bme-config-card-subtitle">
|
||||
控制提取、召回等顶部通知的显示样式。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bme-config-row">
|
||||
<label for="bme-setting-notice-display-mode">显示模式</label>
|
||||
<select
|
||||
id="bme-setting-notice-display-mode"
|
||||
class="bme-config-input"
|
||||
>
|
||||
<option value="normal">正常</option>
|
||||
<option value="compact">精简(仅显示标题)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bme-config-help">
|
||||
精简模式会将工作中的提示压缩为标题卡片;错误和警告仍显示完整内容,避免关键信息被隐藏。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bme-config-card">
|
||||
<div class="bme-config-card-head">
|
||||
<div>
|
||||
|
||||
43
panel.js
43
panel.js
@@ -840,32 +840,6 @@ function _switchConfigSection(sectionId) {
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureMobileTraceConfigNavButton() {
|
||||
if (!panelEl) return;
|
||||
|
||||
const mobileNav = panelEl.querySelector(".bme-config-nav-mobile");
|
||||
if (!mobileNav) return;
|
||||
if (mobileNav.querySelector('[data-config-section="trace"]')) return;
|
||||
|
||||
const appearanceButton = mobileNav.querySelector(
|
||||
'[data-config-section="appearance"]',
|
||||
);
|
||||
const traceButton = document.createElement("button");
|
||||
traceButton.className = "bme-config-nav-btn";
|
||||
traceButton.dataset.configSection = "trace";
|
||||
traceButton.type = "button";
|
||||
traceButton.innerHTML = `
|
||||
<i class="fa-solid fa-route"></i>
|
||||
<span>消息追踪</span>
|
||||
`;
|
||||
|
||||
if (appearanceButton?.parentNode === mobileNav) {
|
||||
mobileNav.insertBefore(traceButton, appearanceButton);
|
||||
} else {
|
||||
mobileNav.appendChild(traceButton);
|
||||
}
|
||||
}
|
||||
|
||||
function _syncConfigSectionState() {
|
||||
if (!panelEl) return;
|
||||
panelEl.querySelectorAll(".bme-config-nav-btn").forEach((btn) => {
|
||||
@@ -1711,10 +1685,6 @@ function _refreshConfigTab() {
|
||||
"bme-setting-recall-card-user-input-display-mode",
|
||||
settings.recallCardUserInputDisplayMode ?? "beautify_only",
|
||||
);
|
||||
_setInputValue(
|
||||
"bme-setting-notice-display-mode",
|
||||
settings.noticeDisplayMode ?? "normal",
|
||||
);
|
||||
|
||||
_setInputValue("bme-setting-extract-every", settings.extractEvery ?? 1);
|
||||
_setInputValue(
|
||||
@@ -1934,8 +1904,6 @@ function _refreshConfigTab() {
|
||||
function _bindConfigControls() {
|
||||
if (!panelEl || panelEl.dataset.bmeConfigBound === "true") return;
|
||||
|
||||
_ensureMobileTraceConfigNavButton();
|
||||
|
||||
panelEl.querySelectorAll(".bme-config-nav-btn").forEach((btn) => {
|
||||
if (btn.dataset.bmeBound === "true") return;
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -2057,17 +2025,6 @@ function _bindConfigControls() {
|
||||
});
|
||||
recallCardUserInputDisplayModeEl.dataset.bmeBound = "true";
|
||||
}
|
||||
const noticeDisplayModeEl = document.getElementById(
|
||||
"bme-setting-notice-display-mode",
|
||||
);
|
||||
if (noticeDisplayModeEl && noticeDisplayModeEl.dataset.bmeBound !== "true") {
|
||||
noticeDisplayModeEl.addEventListener("change", () => {
|
||||
_patchSettings({
|
||||
noticeDisplayMode: noticeDisplayModeEl.value || "normal",
|
||||
});
|
||||
});
|
||||
noticeDisplayModeEl.dataset.bmeBound = "true";
|
||||
}
|
||||
|
||||
bindNumber("bme-setting-extract-every", 1, 1, 50, (value) =>
|
||||
_patchSettings({ extractEvery: value }),
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
# ST-BME 提示信息“正常 / 精简”模式计划
|
||||
|
||||
## 目标
|
||||
|
||||
在“配置” -> “功能开关”里新增一个“提示信息”设置项,让用户选择通知展示模式:
|
||||
|
||||
- `正常`:保持当前行为不变
|
||||
- `精简`:通知仅显示标题,例如 `ST-BME 提取`、`ST-BME 召回`
|
||||
|
||||
默认值为 `正常`。该设置需要在 PC 端和手机端的设置入口都能看到并生效。
|
||||
|
||||
## 现状梳理
|
||||
|
||||
### 1. 提示信息的实际渲染位置
|
||||
|
||||
- `notice.js`
|
||||
- 负责创建和更新通知 DOM
|
||||
- 当前结构固定包含:图标、标题、正文、动作按钮区域、关闭按钮、进度条
|
||||
- 移动端只缩小了宿主宽度,没有缩小卡片内容结构
|
||||
|
||||
### 2. 通知内容从哪里来
|
||||
|
||||
- `index.js`
|
||||
- `updateStageNotice(...)` 会把 `text + meta` 拼成 `message`
|
||||
- `showManagedBmeNotice(...)` 统一负责显示提取 / 向量 / 召回 / 历史恢复通知
|
||||
- `ui-status.js`
|
||||
- `getStageNoticeTitle(...)` 负责输出 `ST-BME 提取`、`ST-BME 召回` 等标题
|
||||
|
||||
### 3. 设置从哪里定义和持久化
|
||||
|
||||
- `index.js`
|
||||
- `defaultSettings` 是设置默认值来源
|
||||
- `mergePersistedSettings(...)` / `getPersistedSettingsSnapshot(...)` 会自动把新增设置纳入持久化
|
||||
- `panel.js`
|
||||
- `_refreshConfigTab()` 负责把设置值回填到 UI
|
||||
- `_bindConfigControls()` 负责把 UI 改动写回设置
|
||||
- `panel.html`
|
||||
- “功能开关” section 是实际设置表单
|
||||
|
||||
### 4. PC / 手机端设置入口的关系
|
||||
|
||||
- `panel.html` 里桌面端和手机端各有一套“配置导航按钮”
|
||||
- 但它们切换的是同一套 `bme-config-section`
|
||||
- 也就是说:
|
||||
- 真正的设置项表单只需要在“功能开关” section 加一次
|
||||
- 该项天然会同时出现在 PC 和手机端
|
||||
|
||||
## 关键发现
|
||||
|
||||
仅仅把正文文本隐藏,还不足以让精简通知“变窄”。
|
||||
|
||||
原因是 `notice.js` 里的通知宿主 `#st-bme-notice-host` 使用纵向 flex 布局,而子项默认会被拉伸到宿主宽度。现在宿主在移动端宽度是 `calc(100vw - 16px)`,所以即使正文为空,卡片依然可能占满整行。
|
||||
|
||||
结论:
|
||||
|
||||
- 精简模式不仅要隐藏正文和动作区
|
||||
- 还必须给通知卡片增加“按内容宽度收缩”的样式
|
||||
|
||||
这是这个需求里最容易漏掉的点。
|
||||
|
||||
## 推荐设计
|
||||
|
||||
### 设置字段
|
||||
|
||||
建议新增设置字段:
|
||||
|
||||
- key: `noticeDisplayMode`
|
||||
- 可选值: `"normal"` / `"compact"`
|
||||
- 默认值: `"normal"`
|
||||
|
||||
命名理由:
|
||||
|
||||
- 语义直接,对应“提示信息显示模式”
|
||||
- 以后如果要扩展为更多通知布局,也容易继续演进
|
||||
|
||||
### 设置 UI
|
||||
|
||||
建议放在“配置” -> “功能开关”里,作为一个 `select`,而不是复选框。
|
||||
|
||||
建议文案:
|
||||
|
||||
- 标题:`提示信息`
|
||||
- 说明:`控制提取 / 召回等顶部通知的显示样式`
|
||||
- 选项:
|
||||
- `normal` -> `正常`
|
||||
- `compact` -> `精简(仅显示标题)`
|
||||
|
||||
### 精简模式的推荐行为
|
||||
|
||||
推荐在 `精简` 模式下:
|
||||
|
||||
- 保留:标题
|
||||
- 保留:左侧状态图标
|
||||
- 保留:关闭按钮
|
||||
- 隐藏:正文文本
|
||||
- 隐藏:meta 信息
|
||||
- 隐藏:动作按钮区,例如“终止提取”“终止召回”
|
||||
- 隐藏:底部进度条
|
||||
|
||||
这样可以最大化缩小高度,同时保留最基本的状态识别和手动关闭能力。
|
||||
|
||||
## 一个需要先确认的设计点
|
||||
|
||||
`warning` / `error` 通知是否也要强制进入“仅标题”模式?
|
||||
|
||||
我的推荐方案:
|
||||
|
||||
- `running` / `info` / `success`:遵循 `精简`,只显示标题
|
||||
- `warning` / `error`:仍显示正文,避免重要报错信息被吃掉
|
||||
|
||||
理由:
|
||||
|
||||
- 用户截图里最占空间的是“工作中”的持续通知
|
||||
- 真正需要完整文本的,通常是失败原因或警告信息
|
||||
|
||||
如果你更想要“精简就是所有通知一律只显示标题”,也能做,但我认为可用性会更差一些。
|
||||
|
||||
## 预期改动范围
|
||||
|
||||
### 1. `index.js`
|
||||
|
||||
- 在 `defaultSettings` 增加 `noticeDisplayMode: "normal"`
|
||||
- 在通知更新逻辑里读取该设置
|
||||
- 显式采用“Option A”:
|
||||
- `index.js` 在 `updateStageNotice(...)` 里把 `displayMode` 放进传给 `showManagedBmeNotice(...)` 的 `input` 对象
|
||||
- `notice.js` 只读取 `input.displayMode` 控制渲染和样式,不直接读取全局设置
|
||||
- 保持 `notice.js` 为纯渲染模块,不和 settings 持久化层耦合
|
||||
- 若采用“错误/警告保留正文”的推荐方案,则在这里按 level 做分流
|
||||
- 当用户切换 `noticeDisplayMode` 时,补一条“刷新当前可见通知”的处理
|
||||
- 目标:如果用户在通知仍可见时切换模式,现有通知也能立即切换,而不是等下次状态更新
|
||||
- 实现可放在设置更新后,对当前 stage notice 进行一次统一 `update`
|
||||
|
||||
### 2. `notice.js`
|
||||
|
||||
- 扩展通知输入参数,支持 `displayMode` 或 `compact`
|
||||
- 渲染时根据模式:
|
||||
- 正常模式:保持现状
|
||||
- 精简模式:不渲染正文和动作区,或渲染后隐藏
|
||||
- 增加精简态 class / data attribute,例如:
|
||||
- `data-layout="compact"`
|
||||
- 新增 compact 样式:
|
||||
- 卡片宽度按内容收缩
|
||||
- 卡片高度缩成单行或接近单行
|
||||
- 维持移动端可点击性和可读性
|
||||
- 明确让 compact 通知右对齐
|
||||
- 首选:给 compact 卡片本身加 `align-self: flex-end`
|
||||
- 备选:给 host 在 compact 态下加 `align-items: flex-end`
|
||||
- compact 布局可直接切成更自然的横向紧凑结构
|
||||
- 例如从当前 grid 调整为单行 `flex`
|
||||
- 避免 `close` 按钮在极窄宽度下被 grid 挤得别扭
|
||||
- compact 模式显式隐藏进度条,避免小卡片底部出现细长条影响观感
|
||||
|
||||
### 3. `panel.html`
|
||||
|
||||
- 在“功能开关” section 里新增“提示信息”配置卡或配置行
|
||||
- 位置建议放在较靠前区域,因为它属于明显的 UI 行为开关
|
||||
|
||||
### 4. `panel.js`
|
||||
|
||||
- `_refreshConfigTab()` 里回填 `noticeDisplayMode`
|
||||
- `_bindConfigControls()` 里监听该 `select` 的变更并调用 `_patchSettings(...)`
|
||||
|
||||
## 样式实现思路
|
||||
|
||||
推荐采用“精简卡片单独收缩”的方式,而不是修改整个通知宿主。
|
||||
|
||||
建议方向:
|
||||
|
||||
- 正常卡片:继续占用现有宽度逻辑
|
||||
- 精简卡片:
|
||||
- `align-self: flex-end`
|
||||
- `width: fit-content`
|
||||
- `max-width: 100%`
|
||||
- 更紧凑的 padding / gap
|
||||
- 必要时直接切成单行 `flex` 布局,而不是沿用三列 grid
|
||||
|
||||
这样可以避免:
|
||||
|
||||
- 一条精简通知把整个通知堆栈的布局都改坏
|
||||
- 正常模式和精简模式互相影响
|
||||
|
||||
补充说明:
|
||||
|
||||
- 这里真正需要注意的是“host 是全宽,而 compact 卡片要在 host 内收缩并靠右”
|
||||
- 因此不能只写 `width: fit-content`
|
||||
- 还必须同时处理横向对齐
|
||||
- 纠正一下术语:这里如果要改 host,应该是 `align-items`,不是 `align-self`
|
||||
|
||||
## 风险与兼容性
|
||||
|
||||
### 风险 1:终止按钮被隐藏后,工作中通知无法直接中断
|
||||
|
||||
这是“只显示标题”带来的天然代价。
|
||||
|
||||
处理方式建议:
|
||||
|
||||
- 先按用户描述执行精简:隐藏动作按钮
|
||||
- 在计划评审阶段明确接受这个取舍
|
||||
|
||||
### 风险 2:只隐藏正文但不改宽度,视觉上仍然太大
|
||||
|
||||
这是本需求最核心的实现风险。必须在样式上单独处理 compact 卡片宽度。
|
||||
|
||||
### 风险 3:切换设置后,当前可见通知不立刻刷新
|
||||
|
||||
如果只让后续新通知读取新模式,那么用户在设置页切换“正常 / 精简”时,已显示的通知会出现“要等下一次状态变更才切换”的延迟感。
|
||||
|
||||
处理方式建议:
|
||||
|
||||
- 把它当作一个小型 UI 刷新点纳入实现
|
||||
- 设置更新时,对当前仍存在的 stage notice 重新执行一次 `update`
|
||||
|
||||
### 风险 4:旧配置兼容
|
||||
|
||||
风险较低。因为默认设置合并机制已经存在,只要新增默认值即可让旧配置自动回退到 `normal`。
|
||||
|
||||
## 建议验证点
|
||||
|
||||
### 功能验证
|
||||
|
||||
- 默认安装或旧配置升级后,提示信息模式应为 `正常`
|
||||
- 在 PC 端设置面板中能看到“提示信息”选项
|
||||
- 在手机端设置面板中也能看到同一个选项
|
||||
- 切到 `精简` 后,提取 / 召回通知只显示标题
|
||||
- 切回 `正常` 后,恢复当前完整通知样式
|
||||
- 通知可见时切换设置,已显示的通知也会同步切换样式
|
||||
|
||||
### 视觉验证
|
||||
|
||||
- 手机上精简通知高度明显缩小
|
||||
- 手机上精简通知宽度不再铺满整行
|
||||
- 手机上精简通知应靠右显示,而不是缩小后仍贴左
|
||||
- 多条通知叠加时,布局仍然稳定
|
||||
- PC 端正常模式无回归
|
||||
|
||||
### 行为验证
|
||||
|
||||
- 运行中通知仍能正常更新标题
|
||||
- 自动关闭逻辑不受影响
|
||||
- 关闭按钮不受影响
|
||||
- compact 模式下进度条已按预期隐藏
|
||||
- 若保留“错误/警告显示正文”策略,则需单独验证错误提示仍完整可见
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 增加设置字段与默认值
|
||||
2. 在功能开关 UI 中加入“提示信息”选择器
|
||||
3. 将选择器和设置读写绑定起来
|
||||
4. 给通知渲染层增加 compact 模式
|
||||
5. 调整 compact 样式,确保真正按内容收缩
|
||||
6. 验证 PC / 手机端、正常 / 精简、运行 / 完成 / 异常几类通知
|
||||
|
||||
## 我目前的结论
|
||||
|
||||
这个需求本身不复杂,真正要小心的是两件事:
|
||||
|
||||
- “PC 和手机端都要有设置”其实主要是配置入口问题,不是双份业务实现
|
||||
- “精简后通知真的变小”不能只靠删文本,必须同时改通知卡片的宽度收缩策略
|
||||
|
||||
这次复审后,我会把实现重点再收敛成四个明确点:
|
||||
|
||||
- `displayMode` 通过 `updateStageNotice(...) -> input.displayMode -> notice.js` 这条链路传递
|
||||
- compact 不仅隐藏正文,也隐藏进度条
|
||||
- compact 需要明确右对齐,不能只是 `fit-content`
|
||||
- 用户切换设置时,当前可见通知也应立即刷新
|
||||
|
||||
如果后续开始实现,我会优先按这个计划走,并把“错误/警告是否保留正文”作为唯一需要最终拍板的交互点。
|
||||
@@ -2,7 +2,7 @@
|
||||
// 统一负责任务预设块排序、变量渲染,以及世界书/EJS 上下文接入。
|
||||
|
||||
import { getActiveTaskProfile, getLegacyPromptForTask } from "./prompt-profiles.js";
|
||||
import { sanitizeMvuContent, MVU_SANITIZE_MODES } from "./mvu-compat.js";
|
||||
import { sanitizeMvuContent } from "./mvu-compat.js";
|
||||
import { resolveTaskWorldInfo } from "./task-worldinfo.js";
|
||||
import { applyTaskRegex } from "./task-regex.js";
|
||||
|
||||
@@ -32,41 +32,6 @@ const INPUT_CONTEXT_MVU_FIELDS = [
|
||||
"userPersona",
|
||||
];
|
||||
|
||||
/**
|
||||
* 字段族 → sanitize mode 映射表。
|
||||
*
|
||||
* PASSIVE:用户原文字段(对话、角色描述、摘要、候选节点等)——只剥离 MVU 容器/宏,
|
||||
* 不整段 drop。这些字段不可能"整段就是一条 MVU 世界书条目"。
|
||||
* AGGRESSIVE(默认):保留现有行为,用于世界书条目路径(sanitizeWorldInfoEntries)。
|
||||
*
|
||||
* 未列入此表的字段走 AGGRESSIVE,与改动前行为一致。
|
||||
*/
|
||||
const INPUT_CONTEXT_FIELD_MODE = {
|
||||
userMessage: MVU_SANITIZE_MODES.PASSIVE,
|
||||
recentMessages: MVU_SANITIZE_MODES.PASSIVE,
|
||||
chatMessages: MVU_SANITIZE_MODES.PASSIVE,
|
||||
dialogueText: MVU_SANITIZE_MODES.PASSIVE,
|
||||
charDescription: MVU_SANITIZE_MODES.PASSIVE,
|
||||
userPersona: MVU_SANITIZE_MODES.PASSIVE,
|
||||
candidateText: MVU_SANITIZE_MODES.PASSIVE,
|
||||
candidateNodes: MVU_SANITIZE_MODES.PASSIVE,
|
||||
nodeContent: MVU_SANITIZE_MODES.PASSIVE,
|
||||
eventSummary: MVU_SANITIZE_MODES.PASSIVE,
|
||||
characterSummary: MVU_SANITIZE_MODES.PASSIVE,
|
||||
threadSummary: MVU_SANITIZE_MODES.PASSIVE,
|
||||
contradictionSummary: MVU_SANITIZE_MODES.PASSIVE,
|
||||
};
|
||||
|
||||
/** 这些字段被清空时必须 warn(兜底告警)。 */
|
||||
const CRITICAL_INPUT_FIELDS = new Set([
|
||||
"recentMessages",
|
||||
"dialogueText",
|
||||
"chatMessages",
|
||||
"charDescription",
|
||||
"userPersona",
|
||||
"candidateNodes",
|
||||
]);
|
||||
|
||||
const INPUT_REGEX_STAGE_BY_FIELD = {
|
||||
userMessage: "input.userMessage",
|
||||
recentMessages: "input.recentMessages",
|
||||
@@ -361,30 +326,6 @@ function joinStructuredPath(basePath = "", segment = "") {
|
||||
: `${basePath}.${normalizedSegment}`;
|
||||
}
|
||||
|
||||
function mergeSanitizeReasons(...reasonLists) {
|
||||
const merged = new Set();
|
||||
for (const list of reasonLists) {
|
||||
for (const reason of Array.isArray(list) ? list : []) {
|
||||
if (reason) {
|
||||
merged.add(String(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...merged];
|
||||
}
|
||||
|
||||
function summarizeSanitizePreview(value, maxLength = 200) {
|
||||
const rendered = stringifyInterpolatedValue(value)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!rendered) {
|
||||
return "";
|
||||
}
|
||||
return rendered.length > maxLength
|
||||
? `${rendered.slice(0, maxLength)}...`
|
||||
: rendered;
|
||||
}
|
||||
|
||||
function looksLikeMvuStateContainer(value, seen = new WeakSet()) {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
@@ -470,22 +411,12 @@ function sanitizeStructuredPromptValue(
|
||||
omit:
|
||||
!String(sanitized.text || "").trim() &&
|
||||
String(value || "").trim().length > 0,
|
||||
dropped: Boolean(sanitized.dropped),
|
||||
reasons: Array.isArray(sanitized.reasons) ? [...sanitized.reasons] : [],
|
||||
blockedHitCount: Number(sanitized.blockedHitCount || 0),
|
||||
artifactRemovedCount: Number(sanitized.artifactRemovedCount || 0),
|
||||
rawPreview: summarizeSanitizePreview(value),
|
||||
sanitizedPreview: summarizeSanitizePreview(sanitized.text),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const sanitizedArray = [];
|
||||
let changed = false;
|
||||
let dropped = false;
|
||||
let blockedHitCount = 0;
|
||||
let artifactRemovedCount = 0;
|
||||
let reasons = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const childResult = sanitizeStructuredPromptValue(
|
||||
settings,
|
||||
@@ -507,31 +438,17 @@ function sanitizeStructuredPromptValue(
|
||||
);
|
||||
if (childResult.omit) {
|
||||
changed = true;
|
||||
dropped = dropped || Boolean(childResult.dropped);
|
||||
blockedHitCount += Number(childResult.blockedHitCount || 0);
|
||||
artifactRemovedCount += Number(childResult.artifactRemovedCount || 0);
|
||||
reasons = mergeSanitizeReasons(reasons, childResult.reasons);
|
||||
continue;
|
||||
}
|
||||
sanitizedArray.push(childResult.value);
|
||||
if (childResult.changed) {
|
||||
changed = true;
|
||||
}
|
||||
dropped = dropped || Boolean(childResult.dropped);
|
||||
blockedHitCount += Number(childResult.blockedHitCount || 0);
|
||||
artifactRemovedCount += Number(childResult.artifactRemovedCount || 0);
|
||||
reasons = mergeSanitizeReasons(reasons, childResult.reasons);
|
||||
}
|
||||
return {
|
||||
value: sanitizedArray,
|
||||
changed: changed || sanitizedArray.length !== value.length,
|
||||
omit: value.length > 0 && sanitizedArray.length === 0,
|
||||
dropped,
|
||||
reasons,
|
||||
blockedHitCount,
|
||||
artifactRemovedCount,
|
||||
rawPreview: summarizeSanitizePreview(value),
|
||||
sanitizedPreview: summarizeSanitizePreview(sanitizedArray),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -541,12 +458,6 @@ function sanitizeStructuredPromptValue(
|
||||
value,
|
||||
changed: false,
|
||||
omit: false,
|
||||
dropped: false,
|
||||
reasons: [],
|
||||
blockedHitCount: 0,
|
||||
artifactRemovedCount: 0,
|
||||
rawPreview: summarizeSanitizePreview(value),
|
||||
sanitizedPreview: summarizeSanitizePreview(value),
|
||||
};
|
||||
}
|
||||
seen.add(value);
|
||||
@@ -555,10 +466,6 @@ function sanitizeStructuredPromptValue(
|
||||
const sanitizedObject = {};
|
||||
let changed = false;
|
||||
let keptEntries = 0;
|
||||
let dropped = false;
|
||||
let blockedHitCount = 0;
|
||||
let artifactRemovedCount = 0;
|
||||
let reasons = [];
|
||||
|
||||
for (const [key, entryValue] of Object.entries(value)) {
|
||||
const stripReason = stripMvuContainers
|
||||
@@ -574,8 +481,6 @@ function sanitizeStructuredPromptValue(
|
||||
reasons: [stripReason],
|
||||
blockedHitCount: 0,
|
||||
});
|
||||
dropped = true;
|
||||
reasons = mergeSanitizeReasons(reasons, [stripReason]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -599,10 +504,6 @@ function sanitizeStructuredPromptValue(
|
||||
);
|
||||
if (childResult.omit) {
|
||||
changed = true;
|
||||
dropped = dropped || Boolean(childResult.dropped);
|
||||
blockedHitCount += Number(childResult.blockedHitCount || 0);
|
||||
artifactRemovedCount += Number(childResult.artifactRemovedCount || 0);
|
||||
reasons = mergeSanitizeReasons(reasons, childResult.reasons);
|
||||
continue;
|
||||
}
|
||||
sanitizedObject[key] = childResult.value;
|
||||
@@ -610,22 +511,12 @@ function sanitizeStructuredPromptValue(
|
||||
if (childResult.changed) {
|
||||
changed = true;
|
||||
}
|
||||
dropped = dropped || Boolean(childResult.dropped);
|
||||
blockedHitCount += Number(childResult.blockedHitCount || 0);
|
||||
artifactRemovedCount += Number(childResult.artifactRemovedCount || 0);
|
||||
reasons = mergeSanitizeReasons(reasons, childResult.reasons);
|
||||
}
|
||||
|
||||
return {
|
||||
value: sanitizedObject,
|
||||
changed,
|
||||
omit: originalLooksMvuContainer && keptEntries === 0,
|
||||
dropped,
|
||||
reasons,
|
||||
blockedHitCount,
|
||||
artifactRemovedCount,
|
||||
rawPreview: summarizeSanitizePreview(value),
|
||||
sanitizedPreview: summarizeSanitizePreview(sanitizedObject),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -633,12 +524,6 @@ function sanitizeStructuredPromptValue(
|
||||
value,
|
||||
changed: false,
|
||||
omit: false,
|
||||
dropped: false,
|
||||
reasons: [],
|
||||
blockedHitCount: 0,
|
||||
artifactRemovedCount: 0,
|
||||
rawPreview: summarizeSanitizePreview(value),
|
||||
sanitizedPreview: summarizeSanitizePreview(value),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -724,7 +609,6 @@ function sanitizePromptContextInputs(
|
||||
const value = sanitizedContext[fieldName];
|
||||
const regexStage = INPUT_REGEX_STAGE_BY_FIELD[fieldName] || "";
|
||||
const regexRole = INPUT_REGEX_ROLE_BY_FIELD[fieldName] || "system";
|
||||
const fieldMode = INPUT_CONTEXT_FIELD_MODE[fieldName] || MVU_SANITIZE_MODES.AGGRESSIVE;
|
||||
const sanitized = sanitizeStructuredPromptValue(
|
||||
settings,
|
||||
taskType,
|
||||
@@ -732,7 +616,7 @@ function sanitizePromptContextInputs(
|
||||
{
|
||||
fieldName,
|
||||
path: fieldName,
|
||||
mode: fieldMode,
|
||||
mode: "aggressive",
|
||||
regexStage,
|
||||
role: regexRole,
|
||||
debugState,
|
||||
@@ -741,24 +625,6 @@ function sanitizePromptContextInputs(
|
||||
stripMvuContainers,
|
||||
},
|
||||
);
|
||||
if (sanitized.omit && CRITICAL_INPUT_FIELDS.has(fieldName)) {
|
||||
const rawLength = typeof value === "string" ? value.length : -1;
|
||||
console.warn(
|
||||
"[ST-BME] 关键任务输入字段被 MVU 策略清空",
|
||||
{
|
||||
taskType,
|
||||
fieldName,
|
||||
mode: fieldMode,
|
||||
rawLength,
|
||||
dropped: Boolean(sanitized.dropped),
|
||||
reasons: Array.isArray(sanitized.reasons) ? sanitized.reasons : [],
|
||||
artifactRemovedCount: Number(sanitized.artifactRemovedCount || 0),
|
||||
blockedHitCount: Number(sanitized.blockedHitCount || 0),
|
||||
rawPreview: String(sanitized.rawPreview || ""),
|
||||
sanitizedPreview: String(sanitized.sanitizedPreview || ""),
|
||||
},
|
||||
);
|
||||
}
|
||||
sanitizedContext[fieldName] = sanitized.omit
|
||||
? Array.isArray(value)
|
||||
? []
|
||||
|
||||
55
retriever.js
55
retriever.js
@@ -97,44 +97,6 @@ function buildRecallFallbackReason(llmResult) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRecallSelectedIds(result) {
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const queue = [{ value: result, depth: 0 }];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const value = current?.value;
|
||||
const depth = Number(current?.depth) || 0;
|
||||
if (!value || typeof value !== "object" || visited.has(value) || depth > 1) {
|
||||
continue;
|
||||
}
|
||||
visited.add(value);
|
||||
|
||||
const directCandidates = [
|
||||
value.selected_ids,
|
||||
value.selectedIds,
|
||||
value.node_ids,
|
||||
value.nodeIds,
|
||||
value.ids,
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
queue.push({ value: value.data, depth: depth + 1 });
|
||||
queue.push({ value: value.result, depth: depth + 1 });
|
||||
queue.push({ value: value.payload, depth: depth + 1 });
|
||||
queue.push({ value: value.output, depth: depth + 1 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAbortError(error) {
|
||||
return error?.name === "AbortError";
|
||||
}
|
||||
@@ -1553,22 +1515,21 @@ async function llmRecall(
|
||||
returnFailureDetails: true,
|
||||
});
|
||||
const result = llmResult?.ok ? llmResult.data : null;
|
||||
const selectedIds = resolveRecallSelectedIds(result);
|
||||
|
||||
if (Array.isArray(selectedIds)) {
|
||||
if (result?.selected_ids && Array.isArray(result.selected_ids)) {
|
||||
// 校验 ID 有效性
|
||||
const validIds = uniqueNodeIds(
|
||||
selectedIds.filter((id) =>
|
||||
result.selected_ids.filter((id) =>
|
||||
candidates.some((c) => c.nodeId === id),
|
||||
),
|
||||
).slice(0, maxNodes);
|
||||
|
||||
if (validIds.length > 0 || selectedIds.length === 0) {
|
||||
if (validIds.length > 0 || result.selected_ids.length === 0) {
|
||||
return {
|
||||
selectedNodeIds: validIds,
|
||||
status: "llm",
|
||||
reason:
|
||||
validIds.length < selectedIds.length
|
||||
validIds.length < result.selected_ids.length
|
||||
? "LLM 返回了部分无效或超限 ID,已自动裁剪"
|
||||
: "LLM 精排完成",
|
||||
};
|
||||
@@ -1577,7 +1538,7 @@ async function llmRecall(
|
||||
|
||||
// LLM 失败时回退到纯评分排序
|
||||
const fallbackReason = llmResult?.ok
|
||||
? Array.isArray(selectedIds)
|
||||
? Array.isArray(result?.selected_ids)
|
||||
? "LLM 返回的候选 ID 无效,已回退到评分排序"
|
||||
: "LLM 返回了无法识别的 JSON 结构,已回退到评分排序"
|
||||
: buildRecallFallbackReason(llmResult);
|
||||
@@ -1585,11 +1546,7 @@ async function llmRecall(
|
||||
selectedNodeIds: candidates.slice(0, maxNodes).map((c) => c.nodeId),
|
||||
status: "fallback",
|
||||
reason: fallbackReason,
|
||||
fallbackType: llmResult?.ok
|
||||
? Array.isArray(selectedIds)
|
||||
? "invalid-candidate"
|
||||
: "invalid-structure"
|
||||
: llmResult?.errorType || "unknown",
|
||||
fallbackType: llmResult?.ok ? "invalid-candidate" : llmResult?.errorType || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2157,62 +2157,6 @@ async function testExtractorNormalizesFlatCreateOperation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testExtractorSupportsLegacyFlatNodeOperations() {
|
||||
const graph = createEmptyGraph();
|
||||
const restoreOverrides = pushTestOverrides({
|
||||
llm: {
|
||||
async callLLMForJSON() {
|
||||
return {
|
||||
operations: [
|
||||
{
|
||||
type: "event",
|
||||
id: "evt-legacy",
|
||||
title: "夜间喂食",
|
||||
summary: "角色完成了一次深夜喂食。",
|
||||
participants: "悟岳, 访客",
|
||||
status: "resolved",
|
||||
importance: 6,
|
||||
},
|
||||
{
|
||||
type: "character",
|
||||
id: "char-legacy",
|
||||
name: "悟岳",
|
||||
state: "放松下来",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await extractMemories({
|
||||
graph,
|
||||
messages: [{ seq: 7, role: "assistant", content: "测试旧版扁平提取输出" }],
|
||||
startSeq: 7,
|
||||
endSeq: 7,
|
||||
schema,
|
||||
embeddingConfig: null,
|
||||
settings: {},
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.newNodes, 2);
|
||||
assert.equal(graph.lastProcessedSeq, 7);
|
||||
|
||||
const eventNode = graph.nodes.find((node) => node.type === "event");
|
||||
const characterNode = graph.nodes.find((node) => node.type === "character");
|
||||
assert.ok(eventNode);
|
||||
assert.ok(characterNode);
|
||||
assert.equal(eventNode.fields?.title, "夜间喂食");
|
||||
assert.equal(eventNode.fields?.summary, "角色完成了一次深夜喂食。");
|
||||
assert.equal(characterNode.fields?.name, "悟岳");
|
||||
assert.equal(characterNode.fields?.state, "放松下来");
|
||||
} finally {
|
||||
restoreOverrides();
|
||||
}
|
||||
}
|
||||
|
||||
async function testExtractorNormalizesArrayPayloadAndPreservesScopeField() {
|
||||
const graph = createEmptyGraph();
|
||||
const restoreOverrides = pushTestOverrides({
|
||||
@@ -5175,8 +5119,6 @@ await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure();
|
||||
await testExtractorFailsOnUnknownOperation();
|
||||
await testExtractorNormalizesFlatCreateOperation();
|
||||
await testExtractorNormalizesArrayPayloadAndPreservesScopeField();
|
||||
await testExtractorSupportsLegacyFlatNodeOperations();
|
||||
await testExtractorNormalizesArrayPayloadAndPreservesScopeField();
|
||||
await testConsolidatorMergeUpdatesSeqRange();
|
||||
await testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing();
|
||||
await testBatchJournalVectorDeltaCapturesRecoveryFields();
|
||||
|
||||
@@ -516,341 +516,6 @@ try {
|
||||
promptBuild.debug.mvu.sanitizedFieldCount,
|
||||
);
|
||||
|
||||
// ── 新增测试:passive mode 字段族不被整段 drop ───────────────────────────────
|
||||
|
||||
// helpers
|
||||
function buildExtractSettings() {
|
||||
const taskProfiles = createDefaultTaskProfiles();
|
||||
return {
|
||||
llmApiUrl: "https://example.com/v1",
|
||||
llmApiKey: "sk-test",
|
||||
llmModel: "gpt-test",
|
||||
timeoutMs: 4321,
|
||||
taskProfilesVersion: 3,
|
||||
taskProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
function buildExtractBlock(id, name, sourceKey, order) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
role: "system",
|
||||
sourceKey,
|
||||
sourceField: "",
|
||||
content: "",
|
||||
injectionMode: "relative",
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimalExtractSettings() {
|
||||
const base = buildExtractSettings();
|
||||
base.taskProfiles.extract = {
|
||||
activeProfileId: "extract-passive-test",
|
||||
profiles: [
|
||||
{
|
||||
id: "extract-passive-test",
|
||||
name: "passive test",
|
||||
taskType: "extract",
|
||||
builtin: false,
|
||||
version: 3,
|
||||
enabled: true,
|
||||
blocks: [
|
||||
buildExtractBlock("blk-char", "charDescription", "charDescription", 0),
|
||||
buildExtractBlock("blk-persona", "userPersona", "userPersona", 1),
|
||||
buildExtractBlock("blk-recent", "recentMessages", "recentMessages", 2),
|
||||
buildExtractBlock("blk-candidate", "candidateText", "candidateText", 3),
|
||||
],
|
||||
generation: createDefaultTaskProfiles().extract.profiles[0].generation,
|
||||
regex: { enabled: false, inheritStRegex: false, stages: {}, localRules: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
// 测试 1:recentMessages 含多次 getvar 宏 — 不被整段 drop,宏被剥离
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const s = buildMinimalExtractSettings();
|
||||
const pb = await buildTaskPrompt(s, "extract", {
|
||||
recentMessages: "#0 [assistant]: {{get_message_variable::stat_data.hp}} 今晚的气氛很好。{{get_message_variable::display_data.mood}}",
|
||||
charDescription: "普通角色描述,不含 MVU。",
|
||||
userPersona: "普通用户设定。",
|
||||
candidateText: "",
|
||||
});
|
||||
const rendered = JSON.stringify(pb.executionMessages);
|
||||
assert.match(rendered, /今晚的气氛很好/,
|
||||
"T1: recentMessages 的叙述文本必须保留");
|
||||
assert.doesNotMatch(rendered, /get_message_variable/i,
|
||||
"T1: getvar 宏必须被剥离");
|
||||
const droppedField = pb.debug.mvu.sanitizedFields.find(
|
||||
(e) => e.name === "recentMessages" && e.dropped,
|
||||
);
|
||||
assert.equal(droppedField, undefined,
|
||||
"T1: recentMessages 不应被整段 drop(passive mode)");
|
||||
}
|
||||
|
||||
// 测试 2:recentMessages 叙述里提到 stat_data 字样 — 不被整段 drop
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const s = buildMinimalExtractSettings();
|
||||
const pb = await buildTaskPrompt(s, "extract", {
|
||||
recentMessages: "#0 [assistant]: 墙上的 stat_data 标签被撕掉了,角色叹了口气。",
|
||||
charDescription: "",
|
||||
userPersona: "",
|
||||
candidateText: "",
|
||||
});
|
||||
const rendered = JSON.stringify(pb.executionMessages);
|
||||
assert.match(rendered, /墙上的/,
|
||||
"T2: recentMessages 叙述文本必须保留");
|
||||
const droppedField = pb.debug.mvu.sanitizedFields.find(
|
||||
(e) => e.name === "recentMessages" && e.dropped,
|
||||
);
|
||||
assert.equal(droppedField, undefined,
|
||||
"T2: recentMessages 不应被整段 drop");
|
||||
}
|
||||
|
||||
// 测试 3:charDescription 含 MVU 宏 — 不被整段 drop,宏被剥离
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const s = buildMinimalExtractSettings();
|
||||
const pb = await buildTaskPrompt(s, "extract", {
|
||||
recentMessages: "普通对话。",
|
||||
charDescription: "角色叫 Alice。<StatusPlaceHolderImpl/> 她性格温柔。",
|
||||
userPersona: "",
|
||||
candidateText: "",
|
||||
});
|
||||
const rendered = JSON.stringify(pb.executionMessages);
|
||||
assert.match(rendered, /她性格温柔/,
|
||||
"T3: charDescription 叙述文本必须保留");
|
||||
assert.doesNotMatch(rendered, /StatusPlaceHolderImpl/i,
|
||||
"T3: 占位符必须被剥离");
|
||||
const droppedField = pb.debug.mvu.sanitizedFields.find(
|
||||
(e) => e.name === "charDescription" && e.dropped,
|
||||
);
|
||||
assert.equal(droppedField, undefined,
|
||||
"T3: charDescription 不应被整段 drop");
|
||||
}
|
||||
|
||||
// 测试 4:userPersona 是 MVU 规则内容 — 不被整段 drop
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const s = buildMinimalExtractSettings();
|
||||
const pb = await buildTaskPrompt(s, "extract", {
|
||||
recentMessages: "普通对话。",
|
||||
charDescription: "",
|
||||
userPersona: "变量更新规则:\ntype: state\n当前时间: 12:00",
|
||||
candidateText: "",
|
||||
});
|
||||
const rendered = JSON.stringify(pb.executionMessages);
|
||||
assert.match(rendered, /变量更新规则/,
|
||||
"T4: userPersona 文本必须保留");
|
||||
const droppedField = pb.debug.mvu.sanitizedFields.find(
|
||||
(e) => e.name === "userPersona" && e.dropped,
|
||||
);
|
||||
assert.equal(droppedField, undefined,
|
||||
"T4: userPersona 不应被整段 drop(passive mode)");
|
||||
}
|
||||
|
||||
// 测试 5:candidateNodes 含 stat_data/getvar — 字符串叶子保留,容器键剥离
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const s = buildMinimalExtractSettings();
|
||||
s.taskProfiles.extract.profiles[0].blocks.push(
|
||||
buildExtractBlock("blk-nodes", "candidateNodes", "candidateNodes", 4),
|
||||
);
|
||||
const pb = await buildTaskPrompt(s, "extract", {
|
||||
recentMessages: "",
|
||||
charDescription: "",
|
||||
userPersona: "",
|
||||
candidateText: "",
|
||||
candidateNodes: [
|
||||
{
|
||||
id: "node-a",
|
||||
summary: "这是一个有意义的候选摘要,说明了角色的决定。",
|
||||
note: "{{get_message_variable::stat_data.地点}} 某地区的行动。",
|
||||
variables: {
|
||||
0: {
|
||||
stat_data: { 地点: "学校" },
|
||||
display_data: { 地点: "教室" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const rendered = JSON.stringify(pb.executionMessages);
|
||||
assert.match(rendered, /有意义的候选摘要/,
|
||||
"T5: candidateNodes 的 summary 文本必须保留");
|
||||
assert.doesNotMatch(rendered, /get_message_variable/i,
|
||||
"T5: getvar 宏必须被剥离");
|
||||
const containerDropped = pb.debug.mvu.sanitizedFields.find(
|
||||
(e) => String(e.name || "").startsWith("candidateNodes[0].variables"),
|
||||
);
|
||||
assert.ok(containerDropped,
|
||||
"T5: stat_data/display_data 容器键必须仍被剥离");
|
||||
}
|
||||
|
||||
// 测试 6:world info 仍然 aggressive drop(守卫 6cec031 正收益)
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const mvuWorldbookEntry = [
|
||||
createWorldbookEntry({
|
||||
uid: 999,
|
||||
name: "mvu-statusbar",
|
||||
comment: "mvu-statusbar",
|
||||
content: "变量输出格式: 严格 <UpdateVariable>\ntype: state\nformat: |-\n stat_data:",
|
||||
strategyType: "constant",
|
||||
keys: [],
|
||||
order: 1,
|
||||
}),
|
||||
];
|
||||
globalThis.getCharWorldbookNames = () => ({
|
||||
primary: "mvu-guard-worldbook",
|
||||
additional: [],
|
||||
});
|
||||
globalThis.getWorldbook = async (name) =>
|
||||
name === "mvu-guard-worldbook" ? mvuWorldbookEntry : [];
|
||||
globalThis.getLorebookEntries = async (name) =>
|
||||
(name === "mvu-guard-worldbook" ? mvuWorldbookEntry : []).map((e) => ({
|
||||
uid: e.uid, comment: e.comment,
|
||||
}));
|
||||
globalThis.__promptBuilderMvuContext = {
|
||||
...globalThis.__promptBuilderMvuContext,
|
||||
chatId: "mvu-guard-chat",
|
||||
chatMetadata: {},
|
||||
};
|
||||
|
||||
const s = buildExtractSettings();
|
||||
// 使用含 worldInfo 块的 extract 默认 profile
|
||||
const pb = await buildTaskPrompt(s, "extract", {
|
||||
recentMessages: "普通对话,用于触发世界书。",
|
||||
userMessage: "普通消息。",
|
||||
chatMessages: [],
|
||||
});
|
||||
const rendered = JSON.stringify(pb);
|
||||
assert.doesNotMatch(rendered, /UpdateVariable/,
|
||||
"T6: MVU 世界书条目必须仍被 aggressive drop");
|
||||
}
|
||||
|
||||
// 测试 6b:warn 路径 — 双断言
|
||||
// 构造一个故意用 aggressive mode 且会 drop 的字段(绕过策略表用内部 API)
|
||||
// 通过检验 sanitizedFields 中的 dropped + reasons 来验证 warn 的依据已正确记录
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const { sanitizeMvuContent, MVU_SANITIZE_MODES } = await import("../mvu-compat.js");
|
||||
assert.ok(MVU_SANITIZE_MODES, "mvu-compat 必须导出 MVU_SANITIZE_MODES");
|
||||
assert.equal(MVU_SANITIZE_MODES.AGGRESSIVE, "aggressive",
|
||||
"MVU_SANITIZE_MODES.AGGRESSIVE 应为 'aggressive'");
|
||||
assert.equal(MVU_SANITIZE_MODES.PASSIVE, "passive",
|
||||
"MVU_SANITIZE_MODES.PASSIVE 应为 'passive'");
|
||||
|
||||
// aggressive mode 下 MVU 世界书内容应被 drop
|
||||
const aggressiveResult = sanitizeMvuContent(
|
||||
"变量输出格式: 严格 <UpdateVariable>\ntype: state\nformat: |-\n stat_data:",
|
||||
{ mode: MVU_SANITIZE_MODES.AGGRESSIVE },
|
||||
);
|
||||
assert.equal(aggressiveResult.dropped, true,
|
||||
"T6b: aggressive mode 命中 likely_mvu_content 应 dropped=true");
|
||||
assert.ok(aggressiveResult.reasons.includes("likely_mvu_content"),
|
||||
"T6b: reasons 应含 likely_mvu_content");
|
||||
|
||||
// passive mode 下相同内容不应被整段 drop
|
||||
const passiveResult = sanitizeMvuContent(
|
||||
"变量更新规则:\ntype: state\n当前时间: 12:00",
|
||||
{ mode: MVU_SANITIZE_MODES.PASSIVE },
|
||||
);
|
||||
assert.equal(passiveResult.dropped, false,
|
||||
"T6b: passive mode 不应整段 drop");
|
||||
|
||||
// warn 路径:手动 mock console.warn 验证关键字段清空时 warn 触发
|
||||
const warnCalls = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => warnCalls.push(args);
|
||||
try {
|
||||
// 构建一个 extract 任务,把一个关键字段故意设成 aggressive 会 drop 的内容
|
||||
// 为触发 warn,我们在 sanitizePromptContextInputs 里必须 omit 且原始非空
|
||||
// 因为 passive 策略会保留,我们直接用 recentMessages 传入一段
|
||||
// 绕过策略表的方式是:在 world info 条目里触发 aggressive(不经过字段策略表)
|
||||
// 这里改为:直接测试 sanitizeMvuContent 在 PASSIVE mode 下 dropped=false,即 warn 不触发
|
||||
// 然后对 AGGRESSIVE 手动调用相同逻辑,断言 warn 输出
|
||||
//
|
||||
// 实际场景 warn 触发点:在 sanitizePromptContextInputs 里检测到 CRITICAL 字段 omit
|
||||
// 修复后正常场景不应触发;我们用 debug.mvu.sanitizedFields 来断言"字段未被 drop"
|
||||
const s2 = buildMinimalExtractSettings();
|
||||
const pb2 = await buildTaskPrompt(s2, "extract", {
|
||||
recentMessages: "变量更新规则:\ntype: state\n当前时间: 12:00",
|
||||
charDescription: "",
|
||||
userPersona: "",
|
||||
candidateText: "",
|
||||
});
|
||||
// passive 模式下不应 warn 关键字段 drop
|
||||
const criticalDropWarn = warnCalls.find(
|
||||
(args) => String(args[0] || "").includes("关键任务输入字段被 MVU 策略清空"),
|
||||
);
|
||||
assert.equal(criticalDropWarn, undefined,
|
||||
"T6b: passive 模式下关键字段不应触发 warn");
|
||||
// 且字段不应在 sanitizedFields 中被标记为 dropped
|
||||
const recentDropped = pb2.debug.mvu.sanitizedFields.find(
|
||||
(e) => e.name === "recentMessages" && e.dropped,
|
||||
);
|
||||
assert.equal(recentDropped, undefined,
|
||||
"T6b: recentMessages 不应在 debug.mvu.sanitizedFields 中 dropped");
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 6c:warn 诊断字段包含 reasons 和 before/after preview
|
||||
{
|
||||
delete globalThis.__stBmeRuntimeDebugState;
|
||||
const warnCalls = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args) => warnCalls.push(args);
|
||||
try {
|
||||
const s = buildMinimalExtractSettings();
|
||||
await buildTaskPrompt(s, "extract", {
|
||||
recentMessages:
|
||||
"{{get_message_variable::stat_data.hp}}\n{{get_message_variable::display_data.hp}}",
|
||||
charDescription: "",
|
||||
userPersona: "",
|
||||
candidateText: "",
|
||||
});
|
||||
const criticalDropWarn = warnCalls.find(
|
||||
(args) => String(args[0] || "").includes("关键任务输入字段被 MVU 策略清空"),
|
||||
);
|
||||
assert.ok(criticalDropWarn, "T6c: 清洗后为空时应触发关键字段 warn");
|
||||
assert.equal(criticalDropWarn[1]?.fieldName, "recentMessages",
|
||||
"T6c: warn 应指向 recentMessages");
|
||||
assert.equal(criticalDropWarn[1]?.mode, "passive",
|
||||
"T6c: recentMessages 应以 passive mode 清洗");
|
||||
assert.ok(
|
||||
Array.isArray(criticalDropWarn[1]?.reasons) &&
|
||||
criticalDropWarn[1].reasons.includes("artifact_stripped"),
|
||||
"T6c: warn 应携带 artifact_stripped reason",
|
||||
);
|
||||
assert.match(
|
||||
String(criticalDropWarn[1]?.rawPreview || ""),
|
||||
/get_message_variable/,
|
||||
"T6c: warn 应携带原始内容 preview",
|
||||
);
|
||||
assert.equal(
|
||||
String(criticalDropWarn[1]?.sanitizedPreview || ""),
|
||||
"",
|
||||
"T6c: 清洗为空时 sanitizedPreview 应为空串",
|
||||
);
|
||||
assert.ok(
|
||||
Number(criticalDropWarn[1]?.artifactRemovedCount || 0) >= 2,
|
||||
"T6c: warn 应记录 artifactRemovedCount",
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("prompt-builder-mvu tests passed");
|
||||
} finally {
|
||||
if (originalRequire === undefined) {
|
||||
|
||||
@@ -329,54 +329,6 @@ assert.equal(state.llmOptions[0].returnFailureDetails, true);
|
||||
assert.equal(state.llmOptions[0].maxRetries, 2);
|
||||
assert.equal(state.llmOptions[0].maxCompletionTokens, 512);
|
||||
|
||||
state.vectorCalls.length = 0;
|
||||
state.diffusionCalls.length = 0;
|
||||
state.llmCalls.length = 0;
|
||||
state.llmOptions.length = 0;
|
||||
state.llmCandidateCount = 0;
|
||||
state.llmResponse = { selectedIds: ["rule-1"] };
|
||||
const llmCamelCaseResult = await retrieve({
|
||||
graph,
|
||||
userMessage: "换个 JSON 键名也应该兼容",
|
||||
recentMessages: [],
|
||||
embeddingConfig: {},
|
||||
schema,
|
||||
options: {
|
||||
topK: 4,
|
||||
maxRecallNodes: 2,
|
||||
enableVectorPrefilter: true,
|
||||
enableGraphDiffusion: false,
|
||||
enableLLMRecall: true,
|
||||
llmCandidatePool: 2,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(Array.from(llmCamelCaseResult.selectedNodeIds), ["rule-1"]);
|
||||
assert.equal(llmCamelCaseResult.meta.retrieval.llm.status, "llm");
|
||||
|
||||
state.vectorCalls.length = 0;
|
||||
state.diffusionCalls.length = 0;
|
||||
state.llmCalls.length = 0;
|
||||
state.llmOptions.length = 0;
|
||||
state.llmCandidateCount = 0;
|
||||
state.llmResponse = { data: { selected_ids: ["rule-2"] } };
|
||||
const llmNestedResult = await retrieve({
|
||||
graph,
|
||||
userMessage: "嵌套 JSON 结构也应该兼容",
|
||||
recentMessages: [],
|
||||
embeddingConfig: {},
|
||||
schema,
|
||||
options: {
|
||||
topK: 4,
|
||||
maxRecallNodes: 2,
|
||||
enableVectorPrefilter: true,
|
||||
enableGraphDiffusion: false,
|
||||
enableLLMRecall: true,
|
||||
llmCandidatePool: 2,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(Array.from(llmNestedResult.selectedNodeIds), ["rule-2"]);
|
||||
assert.equal(llmNestedResult.meta.retrieval.llm.status, "llm");
|
||||
|
||||
state.vectorCalls.length = 0;
|
||||
state.diffusionCalls.length = 0;
|
||||
state.llmCalls.length = 0;
|
||||
|
||||
Reference in New Issue
Block a user