From cacbc12e2ca17b17ac0576183352e2a22938207c Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Mon, 6 Apr 2026 16:05:42 +0800 Subject: [PATCH] feat(panel): editable node detail with save and graph persist Made-with: Cursor --- index.js | 23 +++++ panel.html | 25 +++-- panel.js | 277 +++++++++++++++++++++++++++++++++++++++++++++-------- style.css | 68 +++++++++++++ 4 files changed, 347 insertions(+), 46 deletions(-) diff --git a/index.js b/index.js index 49097e2..977c151 100644 --- a/index.js +++ b/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, diff --git a/panel.html b/panel.html index d57bf81..01cabfe 100644 --- a/panel.html +++ b/panel.html @@ -442,13 +442,24 @@

节点详情

- +
+ + +
diff --git a/panel.js b/panel.js index 5a383c1..0d7fa0e 100644 --- a/panel.js +++ b/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") diff --git a/style.css b/style.css index 3c29965..175b343 100644 --- a/style.css +++ b/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 {