feat(panel): editable node detail with save and graph persist

Made-with: Cursor
This commit is contained in:
Youzini-afk
2026-04-06 16:05:42 +08:00
parent 06cd922e75
commit cacbc12e2c
4 changed files with 347 additions and 46 deletions

View File

@@ -110,6 +110,7 @@ import {
getGraphStats,
getNode,
importGraph,
updateNode,
} from "./graph.js";
import {
HOST_ADAPTER_STATE_SEMANTICS,
@@ -9502,6 +9503,27 @@ async function onManualCompress() {
});
}
function onSavePanelGraphNode(payload = {}) {
const nodeId = String(payload.nodeId || "");
const updates = payload.updates;
if (!nodeId || !updates || typeof updates !== "object" || !currentGraph) {
return { ok: false, error: "invalid-payload" };
}
if (!getNode(currentGraph, nodeId)) {
return { ok: false, error: "node-not-found" };
}
const updated = updateNode(currentGraph, nodeId, updates);
if (!updated) {
return { ok: false, error: "update-failed" };
}
const persist = saveGraphToChat({ reason: "panel-node-edit" });
return {
ok: true,
persist,
persistBlocked: Boolean(persist?.blocked),
};
}
async function onExportGraph() {
return await onExportGraphController({
document,
@@ -9807,6 +9829,7 @@ async function onReembedDirect() {
inspectTaskRegexReuse(getSettings(), taskType),
applyCurrentHide: () => applyMessageHideNow("panel-manual-apply"),
clearCurrentHide: () => clearAllHiddenMessages("panel-manual-clear"),
saveGraphNode: onSavePanelGraphNode,
rebuildVectorIndex: () => onRebuildVectorIndex(),
rebuildVectorRange: (range) => onRebuildVectorIndex(range),
reembedDirect: onReembedDirect,

View File

@@ -442,13 +442,24 @@
<div class="bme-node-detail" id="bme-node-detail">
<div class="bme-node-detail-header">
<h3 id="bme-detail-title">节点详情</h3>
<button
class="bme-panel-close"
id="bme-detail-close"
type="button"
>
<i class="fa-solid fa-xmark"></i>
</button>
<div class="bme-node-detail-actions">
<button
class="bme-detail-action-btn"
id="bme-detail-save"
type="button"
title="保存修改"
>
<i class="fa-solid fa-floppy-disk"></i>
</button>
<button
class="bme-panel-close"
id="bme-detail-close"
type="button"
title="关闭"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div id="bme-detail-body"></div>
</div>

277
panel.js
View File

@@ -93,6 +93,7 @@ const GRAPH_WRITE_ACTION_IDS = [
"bme-act-vector-range",
"bme-act-vector-reembed",
"bme-act-reroll",
"bme-detail-save",
];
const TASK_PROFILE_GENERATION_GROUPS = [
@@ -449,6 +450,7 @@ export async function initPanel({
_bindTabs();
_bindClose();
_bindNodeDetailPanel();
_bindResizeHandle();
_bindPanelResize();
_bindGraphControls();
@@ -1252,6 +1254,79 @@ function _bindGraphControls() {
// ==================== 节点详情 ====================
function _appendNodeDetailReadOnly(container, labelText, valueText) {
const row = document.createElement("div");
row.className = "bme-node-detail-field";
const label = document.createElement("label");
label.textContent = labelText;
const value = document.createElement("div");
value.className = "value";
value.textContent = String(valueText ?? "—");
row.append(label, value);
container.appendChild(row);
}
function _appendNodeDetailNumberInput(
container,
labelText,
inputId,
value,
{ min, max, step } = {},
) {
const row = document.createElement("div");
row.className = "bme-node-detail-field";
const label = document.createElement("label");
label.setAttribute("for", inputId);
label.textContent = labelText;
const input = document.createElement("input");
input.type = "number";
input.id = inputId;
input.className = "bme-node-detail-input";
if (min != null) input.min = String(min);
if (max != null) input.max = String(max);
if (step != null) input.step = String(step);
input.value =
value === undefined || value === null ? "" : String(Number(value));
row.append(label, input);
container.appendChild(row);
}
function _appendNodeDetailTextInput(container, labelText, inputId, value) {
const row = document.createElement("div");
row.className = "bme-node-detail-field";
const label = document.createElement("label");
label.setAttribute("for", inputId);
label.textContent = labelText;
const input = document.createElement("input");
input.type = "text";
input.id = inputId;
input.className = "bme-node-detail-input";
input.value = String(value ?? "");
row.append(label, input);
container.appendChild(row);
}
function _appendNodeDetailTextareaField(
container,
labelText,
fieldKey,
fieldType,
text,
) {
const row = document.createElement("div");
row.className = "bme-node-detail-field";
const label = document.createElement("label");
label.textContent = labelText;
const ta = document.createElement("textarea");
ta.className = "bme-node-detail-textarea";
ta.dataset.bmeFieldKey = fieldKey;
ta.dataset.bmeFieldType = fieldType;
ta.rows = String(text || "").length > 160 ? 6 : 3;
ta.value = text;
row.append(label, ta);
container.appendChild(row);
}
function _showNodeDetail(node) {
const detailEl = document.getElementById("bme-node-detail");
const titleEl = document.getElementById("bme-detail-title");
@@ -1261,61 +1336,185 @@ function _showNodeDetail(node) {
const raw = node.raw || node;
const fields = raw.fields || {};
titleEl.textContent = getNodeDisplayName(raw);
detailEl.dataset.editNodeId = raw.id || "";
const fragment = document.createDocumentFragment();
_appendNodeDetailReadOnly(fragment, "类型", _typeLabel(raw.type));
_appendNodeDetailReadOnly(
fragment,
"作用域",
buildScopeBadgeText(raw.scope),
);
_appendNodeDetailReadOnly(fragment, "ID", raw.id || "—");
_appendNodeDetailReadOnly(
fragment,
"序列号",
raw.seqRange?.[1] ?? raw.seq ?? 0,
);
const items = [
{ label: "类型", value: _typeLabel(raw.type) },
{ label: "作用域", value: buildScopeBadgeText(raw.scope) },
{ label: "ID", value: raw.id || "—" },
{ label: "重要度", value: raw.importance || 5 },
{ label: "访问次数", value: raw.accessCount || 0 },
{ label: "序列号", value: raw.seqRange?.[1] ?? raw.seq ?? 0 },
];
const scope = normalizeMemoryScope(raw.scope);
if (scope.layer === "pov") {
items.push({
label: "POV 归属",
value: `${scope.ownerType || "unknown"} / ${scope.ownerName || scope.ownerId || "—"}`,
});
_appendNodeDetailReadOnly(
fragment,
"POV 归属",
`${scope.ownerType || "unknown"} / ${scope.ownerName || scope.ownerId || "—"}`,
);
}
const regionLine = buildRegionLine(scope);
if (regionLine) {
items.push({ label: "地区", value: regionLine });
_appendNodeDetailReadOnly(fragment, "地区", regionLine);
}
if (Array.isArray(raw.seqRange)) {
_appendNodeDetailReadOnly(
fragment,
"序列范围",
`${raw.seqRange[0]} ~ ${raw.seqRange[1]}`,
);
}
if (Array.isArray(raw.seqRange)) {
items.push({
label: "序列范围",
value: `${raw.seqRange[0]} ~ ${raw.seqRange[1]}`,
});
}
if (Array.isArray(raw.clusters) && raw.clusters.length > 0) {
items.push({ label: "聚类标签", value: raw.clusters.join(", ") });
}
_appendNodeDetailNumberInput(
fragment,
"重要度 (010)",
"bme-detail-importance",
raw.importance ?? 5,
{ min: 0, max: 10, step: 0.1 },
);
_appendNodeDetailNumberInput(
fragment,
"访问次数",
"bme-detail-accesscount",
raw.accessCount ?? 0,
{ min: 0, step: 1 },
);
const clustersStr = Array.isArray(raw.clusters)
? raw.clusters.join(", ")
: "";
_appendNodeDetailTextInput(
fragment,
"聚类标签 (逗号分隔)",
"bme-detail-clusters",
clustersStr,
);
const section = document.createElement("div");
section.className = "bme-node-detail-section";
section.textContent = "记忆字段";
fragment.appendChild(section);
for (const [key, value] of Object.entries(fields)) {
items.push({
label: key,
value: typeof value === "object" ? JSON.stringify(value, null, 2) : value,
});
const isJson = typeof value === "object" && value !== null;
const displayVal = isJson
? JSON.stringify(value, null, 2)
: String(value ?? "");
_appendNodeDetailTextareaField(
fragment,
key,
key,
isJson ? "json" : "string",
displayVal,
);
}
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const row = document.createElement("div");
row.className = "bme-node-detail-field";
const label = document.createElement("label");
label.textContent = item.label;
const value = document.createElement("div");
value.className = "value";
value.textContent = String(item.value ?? "—");
row.append(label, value);
fragment.appendChild(row);
});
bodyEl.replaceChildren(fragment);
detailEl.classList.add("open");
}
function _saveNodeDetail() {
const detailEl = document.getElementById("bme-node-detail");
const bodyEl = document.getElementById("bme-detail-body");
const nodeId = detailEl?.dataset?.editNodeId;
if (!nodeId || !bodyEl) return;
if (_isGraphWriteBlocked()) {
toastr.error("当前图谱不可写入,请稍后再试", "ST-BME");
return;
}
const updates = { fields: {} };
const impEl = document.getElementById("bme-detail-importance");
if (impEl && impEl.value !== "") {
const imp = Number.parseFloat(impEl.value);
if (Number.isFinite(imp)) {
updates.importance = Math.max(0, Math.min(10, imp));
}
}
const accessEl = document.getElementById("bme-detail-accesscount");
if (accessEl && accessEl.value !== "") {
const ac = Number.parseInt(accessEl.value, 10);
if (Number.isFinite(ac)) {
updates.accessCount = Math.max(0, ac);
}
}
const clustersEl = document.getElementById("bme-detail-clusters");
if (clustersEl) {
updates.clusters = clustersEl.value
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean);
}
const fieldEls = bodyEl.querySelectorAll("[data-bme-field-key]");
for (const el of fieldEls) {
const key = el.dataset.bmeFieldKey;
const type = el.dataset.bmeFieldType || "string";
const rawVal = el.value;
if (type === "json") {
try {
updates.fields[key] = JSON.parse(rawVal || "null");
} catch {
toastr.error(`字段「${key}」须为合法 JSON`, "ST-BME");
return;
}
} else {
updates.fields[key] = rawVal;
}
}
const result = _actionHandlers.saveGraphNode?.({
nodeId,
updates,
});
if (!result?.ok) {
toastr.error(
result?.error === "node-not-found"
? "节点已不存在,请关闭后重试"
: "保存失败",
"ST-BME",
);
return;
}
if (result.persistBlocked) {
toastr.warning(
"内容已更新,但写回聊天元数据可能被拦截,请查看图谱状态",
"ST-BME",
);
} else {
toastr.success("节点已保存", "ST-BME");
}
const r = _getActiveGraphRenderer();
const sel = r?.selectedNode;
if (sel?.id === nodeId && sel.raw) {
_showNodeDetail(sel);
} else {
const g = _getGraph?.();
const rawN = g?.nodes?.find((n) => n.id === nodeId);
if (rawN) {
_showNodeDetail({ raw: rawN, id: rawN.id });
}
}
refreshLiveState();
}
function _bindNodeDetailPanel() {
const saveBtn = document.getElementById("bme-detail-save");
if (saveBtn && saveBtn.dataset.bmeBound !== "true") {
saveBtn.addEventListener("click", () => _saveNodeDetail());
saveBtn.dataset.bmeBound = "true";
}
}
function _bindClose() {
document
.getElementById("bme-panel-close")

View File

@@ -2096,13 +2096,81 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.bme-node-detail-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.bme-detail-action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--bme-primary);
cursor: pointer;
border-radius: 4px;
font-size: 13px;
transition: all 0.15s;
}
.bme-detail-action-btn:hover:not(:disabled) {
background: var(--bme-primary-dim, rgba(233, 69, 96, 0.15));
color: var(--bme-primary);
}
.bme-detail-action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bme-node-detail h3 {
font-size: 14px;
color: var(--bme-on-surface);
margin: 0;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bme-node-detail-section {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bme-on-surface-dim);
margin: 14px 0 8px;
padding-top: 10px;
border-top: 1px solid var(--bme-border);
}
.bme-node-detail-input,
.bme-node-detail-textarea {
width: 100%;
box-sizing: border-box;
background: var(--bme-surface-lowest, #0e0e11);
border: 1px solid var(--bme-border);
border-radius: 6px;
color: var(--bme-on-surface);
font-size: 11px;
padding: 6px 8px;
}
.bme-node-detail-textarea {
resize: vertical;
min-height: 52px;
line-height: 1.45;
font-family: inherit;
}
.bme-node-detail-field {