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,
|
getGraphStats,
|
||||||
getNode,
|
getNode,
|
||||||
importGraph,
|
importGraph,
|
||||||
|
updateNode,
|
||||||
} from "./graph.js";
|
} from "./graph.js";
|
||||||
import {
|
import {
|
||||||
HOST_ADAPTER_STATE_SEMANTICS,
|
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() {
|
async function onExportGraph() {
|
||||||
return await onExportGraphController({
|
return await onExportGraphController({
|
||||||
document,
|
document,
|
||||||
@@ -9807,6 +9829,7 @@ async function onReembedDirect() {
|
|||||||
inspectTaskRegexReuse(getSettings(), taskType),
|
inspectTaskRegexReuse(getSettings(), taskType),
|
||||||
applyCurrentHide: () => applyMessageHideNow("panel-manual-apply"),
|
applyCurrentHide: () => applyMessageHideNow("panel-manual-apply"),
|
||||||
clearCurrentHide: () => clearAllHiddenMessages("panel-manual-clear"),
|
clearCurrentHide: () => clearAllHiddenMessages("panel-manual-clear"),
|
||||||
|
saveGraphNode: onSavePanelGraphNode,
|
||||||
rebuildVectorIndex: () => onRebuildVectorIndex(),
|
rebuildVectorIndex: () => onRebuildVectorIndex(),
|
||||||
rebuildVectorRange: (range) => onRebuildVectorIndex(range),
|
rebuildVectorRange: (range) => onRebuildVectorIndex(range),
|
||||||
reembedDirect: onReembedDirect,
|
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" id="bme-node-detail">
|
||||||
<div class="bme-node-detail-header">
|
<div class="bme-node-detail-header">
|
||||||
<h3 id="bme-detail-title">节点详情</h3>
|
<h3 id="bme-detail-title">节点详情</h3>
|
||||||
<button
|
<div class="bme-node-detail-actions">
|
||||||
class="bme-panel-close"
|
<button
|
||||||
id="bme-detail-close"
|
class="bme-detail-action-btn"
|
||||||
type="button"
|
id="bme-detail-save"
|
||||||
>
|
type="button"
|
||||||
<i class="fa-solid fa-xmark"></i>
|
title="保存修改"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
||||||
<div id="bme-detail-body"></div>
|
<div id="bme-detail-body"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
277
panel.js
277
panel.js
@@ -93,6 +93,7 @@ const GRAPH_WRITE_ACTION_IDS = [
|
|||||||
"bme-act-vector-range",
|
"bme-act-vector-range",
|
||||||
"bme-act-vector-reembed",
|
"bme-act-vector-reembed",
|
||||||
"bme-act-reroll",
|
"bme-act-reroll",
|
||||||
|
"bme-detail-save",
|
||||||
];
|
];
|
||||||
|
|
||||||
const TASK_PROFILE_GENERATION_GROUPS = [
|
const TASK_PROFILE_GENERATION_GROUPS = [
|
||||||
@@ -449,6 +450,7 @@ export async function initPanel({
|
|||||||
|
|
||||||
_bindTabs();
|
_bindTabs();
|
||||||
_bindClose();
|
_bindClose();
|
||||||
|
_bindNodeDetailPanel();
|
||||||
_bindResizeHandle();
|
_bindResizeHandle();
|
||||||
_bindPanelResize();
|
_bindPanelResize();
|
||||||
_bindGraphControls();
|
_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) {
|
function _showNodeDetail(node) {
|
||||||
const detailEl = document.getElementById("bme-node-detail");
|
const detailEl = document.getElementById("bme-node-detail");
|
||||||
const titleEl = document.getElementById("bme-detail-title");
|
const titleEl = document.getElementById("bme-detail-title");
|
||||||
@@ -1261,61 +1336,185 @@ function _showNodeDetail(node) {
|
|||||||
const raw = node.raw || node;
|
const raw = node.raw || node;
|
||||||
const fields = raw.fields || {};
|
const fields = raw.fields || {};
|
||||||
titleEl.textContent = getNodeDisplayName(raw);
|
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);
|
const scope = normalizeMemoryScope(raw.scope);
|
||||||
if (scope.layer === "pov") {
|
if (scope.layer === "pov") {
|
||||||
items.push({
|
_appendNodeDetailReadOnly(
|
||||||
label: "POV 归属",
|
fragment,
|
||||||
value: `${scope.ownerType || "unknown"} / ${scope.ownerName || scope.ownerId || "—"}`,
|
"POV 归属",
|
||||||
});
|
`${scope.ownerType || "unknown"} / ${scope.ownerName || scope.ownerId || "—"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const regionLine = buildRegionLine(scope);
|
const regionLine = buildRegionLine(scope);
|
||||||
if (regionLine) {
|
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)) {
|
_appendNodeDetailNumberInput(
|
||||||
items.push({
|
fragment,
|
||||||
label: "序列范围",
|
"重要度 (0–10)",
|
||||||
value: `${raw.seqRange[0]} ~ ${raw.seqRange[1]}`,
|
"bme-detail-importance",
|
||||||
});
|
raw.importance ?? 5,
|
||||||
}
|
{ min: 0, max: 10, step: 0.1 },
|
||||||
if (Array.isArray(raw.clusters) && raw.clusters.length > 0) {
|
);
|
||||||
items.push({ label: "聚类标签", value: raw.clusters.join(", ") });
|
_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)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
items.push({
|
const isJson = typeof value === "object" && value !== null;
|
||||||
label: key,
|
const displayVal = isJson
|
||||||
value: typeof value === "object" ? JSON.stringify(value, null, 2) : value,
|
? 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);
|
bodyEl.replaceChildren(fragment);
|
||||||
|
|
||||||
detailEl.classList.add("open");
|
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() {
|
function _bindClose() {
|
||||||
document
|
document
|
||||||
.getElementById("bme-panel-close")
|
.getElementById("bme-panel-close")
|
||||||
|
|||||||
68
style.css
68
style.css
@@ -2096,13 +2096,81 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
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 {
|
.bme-node-detail h3 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--bme-on-surface);
|
color: var(--bme-on-surface);
|
||||||
margin: 0;
|
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 {
|
.bme-node-detail-field {
|
||||||
|
|||||||
Reference in New Issue
Block a user