mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat(panel): editable node detail with save and graph persist
Made-with: Cursor
This commit is contained in:
23
index.js
23
index.js
@@ -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,
|
||||
|
||||
25
panel.html
25
panel.html
@@ -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
277
panel.js
@@ -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,
|
||||
"重要度 (0–10)",
|
||||
"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")
|
||||
|
||||
68
style.css
68
style.css
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user