diff --git a/graph/knowledge-state.js b/graph/knowledge-state.js index 9fcd094..f13b81c 100644 --- a/graph/knowledge-state.js +++ b/graph/knowledge-state.js @@ -1526,6 +1526,452 @@ export function updateRegionAdjacencyManual(graph, region = "", adjacent = []) { return { ok: true, region: normalizedRegion }; } +function buildKnowledgeOwnerAliasVariantSet(owner = {}) { + return buildOwnerAliasVariantSet([ + owner?.ownerName, + ...(Array.isArray(owner?.aliases) ? owner.aliases : []), + ]); +} + +function doesKnowledgeOwnerVariantSetMatchValue(variantSet, value = "") { + if (!(variantSet instanceof Set) || variantSet.size === 0) return false; + for (const variant of collectAliasMatchVariants(value)) { + if (variantSet.has(variant)) return true; + } + return false; +} + +function doesScopeReferenceCharacterOwner(graph, scope = {}, owner = {}) { + const normalizedScope = normalizeMemoryScope(scope); + if (normalizeOwnerType(normalizedScope.ownerType) !== OWNER_TYPE_CHARACTER) { + return false; + } + + const normalizedOwnerKey = normalizeString(owner?.ownerKey); + if (normalizedOwnerKey) { + const resolvedOwnerKey = resolveKnowledgeOwner(graph, { + ownerType: OWNER_TYPE_CHARACTER, + ownerName: normalizedScope.ownerName || normalizedScope.ownerId, + ownerId: normalizedScope.ownerId, + }).ownerKey; + if (resolvedOwnerKey && resolvedOwnerKey === normalizedOwnerKey) { + return true; + } + } + + const aliasVariantSet = buildKnowledgeOwnerAliasVariantSet(owner); + return ( + doesKnowledgeOwnerVariantSetMatchValue(aliasVariantSet, normalizedScope.ownerName) || + doesKnowledgeOwnerVariantSetMatchValue(aliasVariantSet, normalizedScope.ownerId) + ); +} + +function doesCharacterNodeReferenceOwner(graph, node = {}, owner = {}) { + if ( + !node || + node.archived === true || + normalizeString(node?.type) !== "character" || + !normalizeString(node?.fields?.name) + ) { + return false; + } + + const normalizedOwnerKey = normalizeString(owner?.ownerKey); + if (normalizedOwnerKey) { + const resolvedOwnerKey = resolveKnowledgeOwner(graph, { + ownerType: OWNER_TYPE_CHARACTER, + ownerName: node?.fields?.name, + nodeId: node?.id, + }).ownerKey; + if (resolvedOwnerKey && resolvedOwnerKey === normalizedOwnerKey) { + return true; + } + } + + const aliasVariantSet = buildKnowledgeOwnerAliasVariantSet(owner); + return ( + doesKnowledgeOwnerVariantSetMatchValue(aliasVariantSet, node?.fields?.name) || + normalizeString(owner?.nodeId) === normalizeString(node?.id) + ); +} + +function collectCharacterNodesForOwner(graph, owner = {}) { + return Array.isArray(graph?.nodes) + ? graph.nodes.filter((node) => doesCharacterNodeReferenceOwner(graph, node, owner)) + : []; +} + +function rewriteScopedCharacterOwnerReferences( + graph, + owner = {}, + { ownerName = "", ownerId = "" } = {}, +) { + const normalizedOwnerName = normalizeString(ownerName || ownerId); + const normalizedOwnerId = normalizeString(ownerId || ownerName); + if (!normalizedOwnerName || !normalizedOwnerId) { + return { + changedNodeIds: [], + changedEdgeCount: 0, + }; + } + + const changedNodeIds = []; + for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) { + const normalizedScope = normalizeMemoryScope(node?.scope); + if (!doesScopeReferenceCharacterOwner(graph, normalizedScope, owner)) { + continue; + } + node.scope = normalizeMemoryScope({ + ...normalizedScope, + layer: "pov", + ownerType: OWNER_TYPE_CHARACTER, + ownerName: normalizedOwnerName, + ownerId: normalizedOwnerId, + }); + const normalizedNodeId = normalizeString(node?.id); + if (normalizedNodeId) changedNodeIds.push(normalizedNodeId); + } + + let changedEdgeCount = 0; + for (const edge of Array.isArray(graph?.edges) ? graph.edges : []) { + const normalizedScope = normalizeMemoryScope(edge?.scope); + if (!doesScopeReferenceCharacterOwner(graph, normalizedScope, owner)) { + continue; + } + edge.scope = normalizeMemoryScope({ + ...normalizedScope, + layer: "pov", + ownerType: OWNER_TYPE_CHARACTER, + ownerName: normalizedOwnerName, + ownerId: normalizedOwnerId, + }); + changedEdgeCount += 1; + } + + return { + changedNodeIds: uniqueIds(changedNodeIds), + changedEdgeCount, + }; +} + +function renameCharacterNodesForOwner(graph, owner = {}, nextName = "") { + const normalizedNextName = normalizeString(nextName); + if (!normalizedNextName) return []; + + const changedNodeIds = []; + for (const node of collectCharacterNodesForOwner(graph, owner)) { + node.fields = { + ...(node?.fields || {}), + name: normalizedNextName, + }; + const normalizedNodeId = normalizeString(node?.id); + if (normalizedNodeId) changedNodeIds.push(normalizedNodeId); + } + return uniqueIds(changedNodeIds); +} + +function updateHistoryOwnerReferences(historyState, previousOwner = {}, nextOwner = null) { + if (!historyState || typeof historyState !== "object") return; + + const previousOwnerKey = normalizeString(previousOwner?.ownerKey); + const nextOwnerKey = normalizeString(nextOwner?.ownerKey); + const nextOwnerName = normalizeString(nextOwner?.ownerName); + const previousAliasVariants = buildKnowledgeOwnerAliasVariantSet(previousOwner); + + if ( + doesKnowledgeOwnerVariantSetMatchValue( + previousAliasVariants, + historyState.activeCharacterPovOwner, + ) + ) { + historyState.activeCharacterPovOwner = nextOwnerName; + } + + if (normalizeString(historyState.activeRecallOwnerKey) === previousOwnerKey) { + historyState.activeRecallOwnerKey = nextOwnerKey; + } + + historyState.recentRecallOwnerKeys = uniqueStrings( + (historyState.recentRecallOwnerKeys || []) + .map((value) => { + const normalizedValue = normalizeString(value); + if (!normalizedValue) return ""; + return normalizedValue === previousOwnerKey ? nextOwnerKey : normalizedValue; + }) + .filter(Boolean), + ).slice(0, RECENT_RECALL_OWNER_LIMIT); +} + +function archiveOwnerCharacterNodesForMerge(graph, sourceOwner = {}, targetOwner = {}) { + const sourceNodes = collectCharacterNodesForOwner(graph, sourceOwner); + const archivedNodeIds = []; + let adoptedNodeId = normalizeString(targetOwner?.nodeId); + + if (!adoptedNodeId && sourceNodes.length > 0) { + const keeper = sourceNodes[0]; + keeper.fields = { + ...(keeper?.fields || {}), + name: normalizeString(targetOwner?.ownerName || keeper?.fields?.name), + }; + keeper.archived = false; + adoptedNodeId = normalizeString(keeper?.id); + + for (const node of sourceNodes.slice(1)) { + node.archived = true; + const normalizedNodeId = normalizeString(node?.id); + if (normalizedNodeId) archivedNodeIds.push(normalizedNodeId); + } + return { + adoptedNodeId, + archivedNodeIds: uniqueIds(archivedNodeIds), + }; + } + + for (const node of sourceNodes) { + node.archived = true; + const normalizedNodeId = normalizeString(node?.id); + if (normalizedNodeId) archivedNodeIds.push(normalizedNodeId); + } + return { + adoptedNodeId, + archivedNodeIds: uniqueIds(archivedNodeIds), + }; +} + +function archiveCharacterNodesForOwner(graph, owner = {}) { + const archivedNodeIds = []; + for (const node of collectCharacterNodesForOwner(graph, owner)) { + node.archived = true; + const normalizedNodeId = normalizeString(node?.id); + if (normalizedNodeId) archivedNodeIds.push(normalizedNodeId); + } + return uniqueIds(archivedNodeIds); +} + +function archivePovNodesForOwner(graph, owner = {}) { + const archivedNodeIds = []; + for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) { + const normalizedScope = normalizeMemoryScope(node?.scope); + if (normalizedScope.layer !== "pov") continue; + if (!doesScopeReferenceCharacterOwner(graph, normalizedScope, owner)) continue; + node.archived = true; + const normalizedNodeId = normalizeString(node?.id); + if (normalizedNodeId) archivedNodeIds.push(normalizedNodeId); + } + return uniqueIds(archivedNodeIds); +} + +export function renameKnowledgeOwner(graph, ownerKey = "", nextName = "") { + normalizeGraphCognitiveState(graph); + const normalizedOwnerKey = normalizeString(ownerKey); + const normalizedNextName = normalizeString(nextName); + if (!normalizedOwnerKey || !normalizedNextName) { + return { ok: false, reason: "missing-owner-or-name" }; + } + + const sourceEntry = createDefaultKnowledgeOwnerState( + graph?.knowledgeState?.owners?.[normalizedOwnerKey] || {}, + ); + if (!sourceEntry.ownerKey) { + return { ok: false, reason: "owner-not-found" }; + } + if (normalizeOwnerType(sourceEntry.ownerType) !== OWNER_TYPE_CHARACTER) { + return { ok: false, reason: "unsupported-owner-type" }; + } + if (normalizeKey(sourceEntry.ownerName) === normalizeKey(normalizedNextName)) { + return { + ok: true, + ownerKey: normalizedOwnerKey, + unchanged: true, + }; + } + + const scopeRewrite = rewriteScopedCharacterOwnerReferences(graph, sourceEntry, { + ownerName: normalizedNextName, + ownerId: normalizedNextName, + }); + const renamedCharacterNodeIds = renameCharacterNodesForOwner( + graph, + sourceEntry, + normalizedNextName, + ); + const nextOwnerKey = buildOwnerKey( + OWNER_TYPE_CHARACTER, + normalizedNextName, + sourceEntry.nodeId, + graph, + ); + const nextEntry = createDefaultKnowledgeOwnerState({ + ...sourceEntry, + ownerKey: nextOwnerKey, + ownerName: normalizedNextName, + aliases: uniqueStrings([ + normalizedNextName, + sourceEntry.ownerName, + ...(sourceEntry.aliases || []), + ]), + updatedAt: Date.now(), + lastSource: "manual-owner-rename", + }); + + delete graph.knowledgeState.owners[normalizedOwnerKey]; + graph.knowledgeState.owners[nextOwnerKey] = graph.knowledgeState.owners[nextOwnerKey] + ? mergeKnowledgeOwnerEntries(graph.knowledgeState.owners[nextOwnerKey], nextEntry) + : nextEntry; + + const resolvedNextOwner = createDefaultKnowledgeOwnerState( + graph.knowledgeState.owners[nextOwnerKey], + ); + updateHistoryOwnerReferences(graph.historyState, sourceEntry, resolvedNextOwner); + graph.knowledgeState = normalizeKnowledgeState(graph.knowledgeState, graph); + return { + ok: true, + ownerKey: nextOwnerKey, + previousOwnerKey: normalizedOwnerKey, + renamedCharacterNodeIds, + updatedPovNodeIds: scopeRewrite.changedNodeIds, + updatedPovEdgeCount: scopeRewrite.changedEdgeCount, + }; +} + +export function mergeKnowledgeOwners( + graph, + { sourceOwnerKey = "", targetOwnerKey = "" } = {}, +) { + normalizeGraphCognitiveState(graph); + const normalizedSourceOwnerKey = normalizeString(sourceOwnerKey); + const normalizedTargetOwnerKey = normalizeString(targetOwnerKey); + if (!normalizedSourceOwnerKey || !normalizedTargetOwnerKey) { + return { ok: false, reason: "missing-owner-key" }; + } + if (normalizedSourceOwnerKey === normalizedTargetOwnerKey) { + return { ok: false, reason: "same-owner" }; + } + + const sourceEntry = createDefaultKnowledgeOwnerState( + graph?.knowledgeState?.owners?.[normalizedSourceOwnerKey] || {}, + ); + const targetEntry = createDefaultKnowledgeOwnerState( + graph?.knowledgeState?.owners?.[normalizedTargetOwnerKey] || {}, + ); + if (!sourceEntry.ownerKey || !targetEntry.ownerKey) { + return { ok: false, reason: "owner-not-found" }; + } + if ( + normalizeOwnerType(sourceEntry.ownerType) !== OWNER_TYPE_CHARACTER || + normalizeOwnerType(targetEntry.ownerType) !== OWNER_TYPE_CHARACTER + ) { + return { ok: false, reason: "unsupported-owner-type" }; + } + + const scopeRewrite = rewriteScopedCharacterOwnerReferences(graph, sourceEntry, { + ownerName: targetEntry.ownerName, + ownerId: targetEntry.ownerName, + }); + const characterNodeMerge = archiveOwnerCharacterNodesForMerge( + graph, + sourceEntry, + targetEntry, + ); + const mergedEntry = mergeKnowledgeOwnerEntries( + createDefaultKnowledgeOwnerState({ + ...targetEntry, + nodeId: targetEntry.nodeId || characterNodeMerge.adoptedNodeId || sourceEntry.nodeId, + aliases: uniqueStrings([ + ...(targetEntry.aliases || []), + ...(sourceEntry.aliases || []), + targetEntry.ownerName, + sourceEntry.ownerName, + ]), + updatedAt: Date.now(), + lastSource: "manual-owner-merge", + }), + createDefaultKnowledgeOwnerState({ + ...sourceEntry, + ownerKey: targetEntry.ownerKey, + ownerName: targetEntry.ownerName, + nodeId: targetEntry.nodeId || characterNodeMerge.adoptedNodeId || sourceEntry.nodeId, + aliases: uniqueStrings([ + ...(targetEntry.aliases || []), + ...(sourceEntry.aliases || []), + targetEntry.ownerName, + sourceEntry.ownerName, + ]), + updatedAt: Date.now(), + lastSource: "manual-owner-merge", + }), + ); + + graph.knowledgeState.owners[targetEntry.ownerKey] = mergedEntry; + delete graph.knowledgeState.owners[sourceEntry.ownerKey]; + + updateHistoryOwnerReferences( + graph.historyState, + sourceEntry, + createDefaultKnowledgeOwnerState(graph.knowledgeState.owners[targetEntry.ownerKey]), + ); + graph.knowledgeState = normalizeKnowledgeState(graph.knowledgeState, graph); + return { + ok: true, + ownerKey: targetEntry.ownerKey, + sourceOwnerKey: sourceEntry.ownerKey, + archivedCharacterNodeIds: characterNodeMerge.archivedNodeIds, + adoptedCharacterNodeId: characterNodeMerge.adoptedNodeId || "", + updatedPovNodeIds: scopeRewrite.changedNodeIds, + updatedPovEdgeCount: scopeRewrite.changedEdgeCount, + }; +} + +export function deleteKnowledgeOwner( + graph, + ownerKey = "", + { mode = "owner-only" } = {}, +) { + normalizeGraphCognitiveState(graph); + const normalizedOwnerKey = normalizeString(ownerKey); + const normalizedMode = normalizeString(mode) || "owner-only"; + if (!normalizedOwnerKey) { + return { ok: false, reason: "missing-owner-key" }; + } + if ( + !["owner-only", "archive-character", "archive-all"].includes( + normalizedMode, + ) + ) { + return { ok: false, reason: "invalid-delete-mode" }; + } + + const sourceEntry = createDefaultKnowledgeOwnerState( + graph?.knowledgeState?.owners?.[normalizedOwnerKey] || {}, + ); + if (!sourceEntry.ownerKey) { + return { ok: false, reason: "owner-not-found" }; + } + if (normalizeOwnerType(sourceEntry.ownerType) !== OWNER_TYPE_CHARACTER) { + return { ok: false, reason: "unsupported-owner-type" }; + } + + const archivedCharacterNodeIds = + normalizedMode === "archive-character" || normalizedMode === "archive-all" + ? archiveCharacterNodesForOwner(graph, sourceEntry) + : []; + const archivedPovNodeIds = + normalizedMode === "archive-all" + ? archivePovNodesForOwner(graph, sourceEntry) + : []; + + delete graph.knowledgeState.owners[normalizedOwnerKey]; + updateHistoryOwnerReferences(graph.historyState, sourceEntry, null); + graph.knowledgeState = normalizeKnowledgeState(graph.knowledgeState, graph); + return { + ok: true, + ownerKey: normalizedOwnerKey, + mode: normalizedMode, + archivedCharacterNodeIds, + archivedPovNodeIds, + }; +} + export function getKnowledgeOwnerEntry(graph, ownerKey = "") { normalizeGraphCognitiveState(graph); const normalizedOwnerKey = normalizeString(ownerKey); diff --git a/index.js b/index.js index 3681d78..8c912bd 100644 --- a/index.js +++ b/index.js @@ -271,6 +271,9 @@ import { DEFAULT_NODE_SCHEMA, validateSchema } from "./graph/schema.js"; import { applyManualKnowledgeOverride, clearManualKnowledgeOverride, + deleteKnowledgeOwner, + mergeKnowledgeOwners, + renameKnowledgeOwner, setManualActiveRegion, updateRegionAdjacencyManual, } from "./graph/knowledge-state.js"; @@ -17689,6 +17692,89 @@ function onClearPanelKnowledgeOverride(payload = {}) { }; } +function onRenamePanelKnowledgeOwner(payload = {}) { + const ownerKey = String(payload.ownerKey || "").trim(); + const nextName = String(payload.nextName || "").trim(); + if (!currentGraph || !ownerKey || !nextName) { + return { ok: false, error: "invalid-payload" }; + } + if (!ensureGraphMutationReady("角色认知重命名", { notify: false })) { + return { ok: false, error: "graph-write-blocked" }; + } + + const result = renameKnowledgeOwner(currentGraph, ownerKey, nextName); + if (!result?.ok) { + return { ok: false, error: result?.reason || "rename-owner-failed" }; + } + + const persist = saveGraphToChat({ reason: "panel-knowledge-owner-rename" }); + refreshPanelLiveState(); + return { + ok: true, + ownerKey: result.ownerKey || ownerKey, + previousOwnerKey: result.previousOwnerKey || ownerKey, + persist, + persistBlocked: Boolean(persist?.blocked), + }; +} + +function onMergePanelKnowledgeOwners(payload = {}) { + const sourceOwnerKey = String(payload.sourceOwnerKey || payload.ownerKey || "").trim(); + const targetOwnerKey = String(payload.targetOwnerKey || "").trim(); + if (!currentGraph || !sourceOwnerKey || !targetOwnerKey) { + return { ok: false, error: "invalid-payload" }; + } + if (!ensureGraphMutationReady("角色认知合并", { notify: false })) { + return { ok: false, error: "graph-write-blocked" }; + } + + const result = mergeKnowledgeOwners(currentGraph, { + sourceOwnerKey, + targetOwnerKey, + }); + if (!result?.ok) { + return { ok: false, error: result?.reason || "merge-owner-failed" }; + } + + const persist = saveGraphToChat({ reason: "panel-knowledge-owner-merge" }); + refreshPanelLiveState(); + return { + ok: true, + ownerKey: result.ownerKey || targetOwnerKey, + sourceOwnerKey: result.sourceOwnerKey || sourceOwnerKey, + persist, + persistBlocked: Boolean(persist?.blocked), + }; +} + +function onDeletePanelKnowledgeOwner(payload = {}) { + const ownerKey = String(payload.ownerKey || "").trim(); + const mode = String(payload.mode || "owner-only").trim() || "owner-only"; + if (!currentGraph || !ownerKey) { + return { ok: false, error: "invalid-payload" }; + } + if (!ensureGraphMutationReady("角色认知删除", { notify: false })) { + return { ok: false, error: "graph-write-blocked" }; + } + + const result = deleteKnowledgeOwner(currentGraph, ownerKey, { mode }); + if (!result?.ok) { + return { ok: false, error: result?.reason || "delete-owner-failed" }; + } + + const persist = saveGraphToChat({ + reason: `panel-knowledge-owner-delete-${result.mode || mode}`, + }); + refreshPanelLiveState(); + return { + ok: true, + ownerKey: result.ownerKey || ownerKey, + mode: result.mode || mode, + persist, + persistBlocked: Boolean(persist?.blocked), + }; +} + function onSetPanelActiveRegion(payload = {}) { const region = String(payload.region || "").trim(); if (!currentGraph) { @@ -18681,6 +18767,9 @@ async function onCompactLukerSidecar() { deleteGraphNode: onDeletePanelGraphNode, applyKnowledgeOverride: onApplyPanelKnowledgeOverride, clearKnowledgeOverride: onClearPanelKnowledgeOverride, + renameKnowledgeOwner: onRenamePanelKnowledgeOwner, + mergeKnowledgeOwners: onMergePanelKnowledgeOwners, + deleteKnowledgeOwner: onDeletePanelKnowledgeOwner, setActiveRegion: onSetPanelActiveRegion, setActiveStoryTime: onSetPanelActiveStoryTime, clearActiveStoryTime: onClearPanelActiveStoryTime, diff --git a/tests/knowledge-state.mjs b/tests/knowledge-state.mjs index 07046c6..3147ed6 100644 --- a/tests/knowledge-state.mjs +++ b/tests/knowledge-state.mjs @@ -5,9 +5,12 @@ import { applyCognitionUpdates, applyManualKnowledgeOverride, clearManualKnowledgeOverride, + deleteKnowledgeOwner, + mergeKnowledgeOwners, applyRegionUpdates, computeKnowledgeGateForNode, listKnowledgeOwners, + renameKnowledgeOwner, resolveActiveRegionContext, resolveAdjacentRegions, resolveKnowledgeOwner, @@ -289,4 +292,305 @@ assert.equal( true, ); +const renameGraph = createEmptyGraph(); +const renameCharacter = createNode({ + type: "character", + fields: { name: "艾琳", state: "守塔人" }, + seq: 1, +}); +const renameObjectiveEvent = createNode({ + type: "event", + fields: { title: "塔楼晨钟", summary: "晨钟再次响起" }, + seq: 2, +}); +const renamePovMemory = createNode({ + type: "pov_memory", + fields: { summary: "艾琳记得晨钟响起" }, + seq: 3, + scope: { + layer: "pov", + ownerType: "character", + ownerName: "艾琳", + ownerId: "艾琳", + }, +}); +addNode(renameGraph, renameCharacter); +addNode(renameGraph, renameObjectiveEvent); +addNode(renameGraph, renamePovMemory); +applyCognitionUpdates( + renameGraph, + [ + { + ownerType: "character", + ownerName: "艾琳", + ownerNodeId: renameCharacter.id, + knownRefs: [renameObjectiveEvent.id], + visibility: [{ ref: renameObjectiveEvent.id, score: 1 }], + }, + ], + { changedNodeIds: [renameObjectiveEvent.id] }, +); +const renameOwner = resolveKnowledgeOwner(renameGraph, { + ownerType: "character", + ownerName: "艾琳", + nodeId: renameCharacter.id, +}); +renameGraph.historyState.activeCharacterPovOwner = "艾琳"; +renameGraph.historyState.activeRecallOwnerKey = renameOwner.ownerKey; +renameGraph.historyState.recentRecallOwnerKeys = [renameOwner.ownerKey]; +const renameResult = renameKnowledgeOwner(renameGraph, renameOwner.ownerKey, "艾琳娜"); +assert.equal(renameResult.ok, true); +assert.equal(renameCharacter.fields.name, "艾琳娜"); +assert.equal(renamePovMemory.scope.ownerName, "艾琳娜"); +assert.equal(renamePovMemory.scope.ownerId, "艾琳娜"); +assert.equal(renameGraph.historyState.activeCharacterPovOwner, "艾琳娜"); +assert.equal(renameGraph.historyState.activeRecallOwnerKey, renameResult.ownerKey); +assert.equal(renameGraph.knowledgeState.owners[renameOwner.ownerKey], undefined); +assert.equal(renameGraph.knowledgeState.owners[renameResult.ownerKey].ownerName, "艾琳娜"); +assert.equal( + renameGraph.knowledgeState.owners[renameResult.ownerKey].aliases.includes("艾琳"), + true, +); + +const mergeGraph = createEmptyGraph(); +const mergeSourceCharacter = createNode({ + type: "character", + fields: { name: "艾琳", state: "旧身份" }, + seq: 1, +}); +const mergeTargetCharacter = createNode({ + type: "character", + fields: { name: "艾琳娜", state: "新身份" }, + seq: 2, +}); +const mergeSourceEvent = createNode({ + type: "event", + fields: { title: "旧钟楼记忆", summary: "她想起了旧钟楼" }, + seq: 3, +}); +const mergeTargetEvent = createNode({ + type: "event", + fields: { title: "新花园记忆", summary: "她想起了新花园" }, + seq: 4, +}); +const mergeSourcePov = createNode({ + type: "pov_memory", + fields: { summary: "艾琳的 POV 记忆" }, + seq: 5, + scope: { + layer: "pov", + ownerType: "character", + ownerName: "艾琳", + ownerId: "艾琳", + }, +}); +addNode(mergeGraph, mergeSourceCharacter); +addNode(mergeGraph, mergeTargetCharacter); +addNode(mergeGraph, mergeSourceEvent); +addNode(mergeGraph, mergeTargetEvent); +addNode(mergeGraph, mergeSourcePov); +applyCognitionUpdates( + mergeGraph, + [ + { + ownerType: "character", + ownerName: "艾琳", + ownerNodeId: mergeSourceCharacter.id, + knownRefs: [mergeSourceEvent.id], + visibility: [{ ref: mergeSourceEvent.id, score: 0.95 }], + }, + { + ownerType: "character", + ownerName: "艾琳娜", + ownerNodeId: mergeTargetCharacter.id, + knownRefs: [mergeTargetEvent.id], + visibility: [{ ref: mergeTargetEvent.id, score: 0.9 }], + }, + ], + { changedNodeIds: [mergeSourceEvent.id, mergeTargetEvent.id] }, +); +const mergeSourceOwner = resolveKnowledgeOwner(mergeGraph, { + ownerType: "character", + ownerName: "艾琳", + nodeId: mergeSourceCharacter.id, +}); +const mergeTargetOwner = resolveKnowledgeOwner(mergeGraph, { + ownerType: "character", + ownerName: "艾琳娜", + nodeId: mergeTargetCharacter.id, +}); +mergeGraph.historyState.activeCharacterPovOwner = "艾琳"; +mergeGraph.historyState.activeRecallOwnerKey = mergeSourceOwner.ownerKey; +mergeGraph.historyState.recentRecallOwnerKeys = [ + mergeSourceOwner.ownerKey, + mergeTargetOwner.ownerKey, +]; +const mergeResult = mergeKnowledgeOwners(mergeGraph, { + sourceOwnerKey: mergeSourceOwner.ownerKey, + targetOwnerKey: mergeTargetOwner.ownerKey, +}); +assert.equal(mergeResult.ok, true); +assert.equal(mergeGraph.knowledgeState.owners[mergeSourceOwner.ownerKey], undefined); +assert.equal(mergeGraph.knowledgeState.owners[mergeTargetOwner.ownerKey].knownNodeIds.includes(mergeSourceEvent.id), true); +assert.equal(mergeGraph.knowledgeState.owners[mergeTargetOwner.ownerKey].knownNodeIds.includes(mergeTargetEvent.id), true); +assert.equal(mergeGraph.knowledgeState.owners[mergeTargetOwner.ownerKey].aliases.includes("艾琳"), true); +assert.equal(mergeSourcePov.scope.ownerName, "艾琳娜"); +assert.equal(mergeSourcePov.scope.ownerId, "艾琳娜"); +assert.equal(mergeSourceCharacter.archived, true); +assert.equal(mergeGraph.historyState.activeCharacterPovOwner, "艾琳娜"); +assert.equal(mergeGraph.historyState.activeRecallOwnerKey, mergeTargetOwner.ownerKey); + +const deleteOwnerOnlyGraph = createEmptyGraph(); +const deleteOwnerOnlyCharacter = createNode({ + type: "character", + fields: { name: "米娅", state: "书记官" }, + seq: 1, +}); +const deleteOwnerOnlyEvent = createNode({ + type: "event", + fields: { title: "记录密报", summary: "米娅记录了一份密报" }, + seq: 2, +}); +const deleteOwnerOnlyPov = createNode({ + type: "pov_memory", + fields: { summary: "米娅的 POV" }, + seq: 3, + scope: { + layer: "pov", + ownerType: "character", + ownerName: "米娅", + ownerId: "米娅", + }, +}); +addNode(deleteOwnerOnlyGraph, deleteOwnerOnlyCharacter); +addNode(deleteOwnerOnlyGraph, deleteOwnerOnlyEvent); +addNode(deleteOwnerOnlyGraph, deleteOwnerOnlyPov); +applyCognitionUpdates( + deleteOwnerOnlyGraph, + [ + { + ownerType: "character", + ownerName: "米娅", + ownerNodeId: deleteOwnerOnlyCharacter.id, + knownRefs: [deleteOwnerOnlyEvent.id], + visibility: [{ ref: deleteOwnerOnlyEvent.id, score: 0.85 }], + }, + ], + { changedNodeIds: [deleteOwnerOnlyEvent.id] }, +); +const deleteOwnerOnly = resolveKnowledgeOwner(deleteOwnerOnlyGraph, { + ownerType: "character", + ownerName: "米娅", + nodeId: deleteOwnerOnlyCharacter.id, +}); +const deleteOwnerOnlyResult = deleteKnowledgeOwner(deleteOwnerOnlyGraph, deleteOwnerOnly.ownerKey, { + mode: "owner-only", +}); +assert.equal(deleteOwnerOnlyResult.ok, true); +assert.equal(deleteOwnerOnlyGraph.knowledgeState.owners[deleteOwnerOnly.ownerKey], undefined); +assert.equal(deleteOwnerOnlyCharacter.archived, false); +assert.equal(deleteOwnerOnlyPov.archived, false); + +const deleteArchiveCharacterGraph = createEmptyGraph(); +const deleteArchiveCharacterNode = createNode({ + type: "character", + fields: { name: "诺拉", state: "侍女" }, + seq: 1, +}); +const deleteArchiveCharacterEvent = createNode({ + type: "event", + fields: { title: "诺拉送信", summary: "诺拉送出了一封信" }, + seq: 2, +}); +const deleteArchiveCharacterPov = createNode({ + type: "pov_memory", + fields: { summary: "诺拉的 POV" }, + seq: 3, + scope: { + layer: "pov", + ownerType: "character", + ownerName: "诺拉", + ownerId: "诺拉", + }, +}); +addNode(deleteArchiveCharacterGraph, deleteArchiveCharacterNode); +addNode(deleteArchiveCharacterGraph, deleteArchiveCharacterEvent); +addNode(deleteArchiveCharacterGraph, deleteArchiveCharacterPov); +applyCognitionUpdates( + deleteArchiveCharacterGraph, + [ + { + ownerType: "character", + ownerName: "诺拉", + ownerNodeId: deleteArchiveCharacterNode.id, + knownRefs: [deleteArchiveCharacterEvent.id], + visibility: [{ ref: deleteArchiveCharacterEvent.id, score: 0.82 }], + }, + ], + { changedNodeIds: [deleteArchiveCharacterEvent.id] }, +); +const deleteArchiveCharacterOwner = resolveKnowledgeOwner(deleteArchiveCharacterGraph, { + ownerType: "character", + ownerName: "诺拉", + nodeId: deleteArchiveCharacterNode.id, +}); +const deleteArchiveCharacterResult = deleteKnowledgeOwner( + deleteArchiveCharacterGraph, + deleteArchiveCharacterOwner.ownerKey, + { mode: "archive-character" }, +); +assert.equal(deleteArchiveCharacterResult.ok, true); +assert.equal(deleteArchiveCharacterNode.archived, true); +assert.equal(deleteArchiveCharacterPov.archived, false); + +const deleteArchiveAllGraph = createEmptyGraph(); +const deleteArchiveAllCharacter = createNode({ + type: "character", + fields: { name: "赛拉", state: "守卫" }, + seq: 1, +}); +const deleteArchiveAllEvent = createNode({ + type: "event", + fields: { title: "赛拉巡逻", summary: "赛拉完成了巡逻" }, + seq: 2, +}); +const deleteArchiveAllPov = createNode({ + type: "pov_memory", + fields: { summary: "赛拉的 POV" }, + seq: 3, + scope: { + layer: "pov", + ownerType: "character", + ownerName: "赛拉", + ownerId: "赛拉", + }, +}); +addNode(deleteArchiveAllGraph, deleteArchiveAllCharacter); +addNode(deleteArchiveAllGraph, deleteArchiveAllEvent); +addNode(deleteArchiveAllGraph, deleteArchiveAllPov); +applyCognitionUpdates( + deleteArchiveAllGraph, + [ + { + ownerType: "character", + ownerName: "赛拉", + ownerNodeId: deleteArchiveAllCharacter.id, + knownRefs: [deleteArchiveAllEvent.id], + visibility: [{ ref: deleteArchiveAllEvent.id, score: 0.88 }], + }, + ], + { changedNodeIds: [deleteArchiveAllEvent.id] }, +); +const deleteArchiveAllOwner = resolveKnowledgeOwner(deleteArchiveAllGraph, { + ownerType: "character", + ownerName: "赛拉", + nodeId: deleteArchiveAllCharacter.id, +}); +const deleteArchiveAllResult = deleteKnowledgeOwner(deleteArchiveAllGraph, deleteArchiveAllOwner.ownerKey, { + mode: "archive-all", +}); +assert.equal(deleteArchiveAllResult.ok, true); +assert.equal(deleteArchiveAllCharacter.archived, true); +assert.equal(deleteArchiveAllPov.archived, true); + console.log("knowledge-state tests passed"); diff --git a/ui/panel.js b/ui/panel.js index 43d561f..cfbf1ae 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -2778,6 +2778,56 @@ function _renderCogOwnerDetail(graph, loadInfo, canRender, targetEl) { const suppressedCount = new Set([...(ownerState.manualHiddenNodeIds || []), ...(ownerState.mistakenNodeIds || [])]).size; const disabledAttr = !selectedNode || writeBlocked ? "disabled" : ""; const displayInfo = _getOwnerDisplayInfo(selectedOwner, collisionIndex); + const isCharacterOwner = String(selectedOwner.ownerType || "") === "character"; + const ownerActionDisabledAttr = writeBlocked || !isCharacterOwner ? "disabled" : ""; + const mergeCandidates = _getCognitionOwnerCollection(graph).filter( + (entry) => + String(entry?.ownerType || "") === "character" && + String(entry?.ownerKey || "") !== String(selectedOwner.ownerKey || ""), + ); + const mergeOptions = mergeCandidates.length + ? mergeCandidates + .map((entry) => { + const targetDisplayInfo = _getOwnerDisplayInfo(entry, collisionIndex); + return ``; + }) + .join("") + : ''; + const mergeDisabledAttr = + writeBlocked || !isCharacterOwner || mergeCandidates.length === 0 ? "disabled" : ""; + const ownerManagementSection = isCharacterOwner + ? ` +
+
角色认知管理
+
+ + +
会同步更新 owner 名称、角色节点名和 POV scope,并把旧名加入 aliases。
+ +
+
+ + +
会把当前角色的 POV scope 改写到目标角色,并合并认知状态与 aliases。
+ +
+
+ + +
删除前会再次确认;不会无提示直接删除。
+ +
+
` + : ` +
+
角色认知管理
+
当前条目不是角色 owner,暂不支持重命名、合并或删除。
+
`; const visChips = strongVisibleNames.length ? strongVisibleNames.map((n) => `${_escHtml(n)}`).join("") @@ -2830,6 +2880,8 @@ function _renderCogOwnerDetail(graph, loadInfo, canRender, targetEl) {
${supChips}
+ ${ownerManagementSection} +
对当前选中节点做手动覆盖
${ @@ -5311,7 +5363,155 @@ async function _runCognitionNodeOverrideAction(mode = "") { } else { toastr.success(successMap[mode] || "认知覆盖已更新", "ST-BME"); } + _refreshCognitionSurfaces(); +} + +function _refreshCognitionSurfaces() { _refreshDashboard(); + _refreshCognitionWorkspace(); + _refreshMobileCognitionFull(); +} + +async function _callAction(actionKey = "", payload = {}) { + const handler = _actionHandlers?.[String(actionKey || "")]; + if (typeof handler !== "function") { + return { ok: false, error: "missing-action-handler" }; + } + const result = await handler(payload); + _refreshCognitionSurfaces(); + return result; +} + +async function _runCognitionOwnerManagementAction(mode = "", triggerEl = null) { + const graph = _getGraph?.(); + const ownerEntries = _getCognitionOwnerCollection(graph); + const ownerEntry = + ownerEntries.find((entry) => entry.ownerKey === currentCognitionOwnerKey) || null; + if (!ownerEntry) { + toastr.info("先选择一个角色,再管理认知条目", "ST-BME"); + return; + } + if (String(ownerEntry.ownerType || "") !== "character") { + toastr.info("当前只支持角色 owner 的重命名、合并和删除", "ST-BME"); + return; + } + + const container = + triggerEl?.closest?.(".bme-cog-owner-detail") || + document.getElementById("bme-cog-owner-detail") || + document.getElementById("bme-mobile-cog-owner-detail"); + const collisionIndex = _buildOwnerCollisionIndex(ownerEntries); + const displayInfo = _getOwnerDisplayInfo(ownerEntry, collisionIndex); + let result = null; + + if (mode === "rename") { + const input = container?.querySelector?.("[data-bme-cognition-owner-rename-input]"); + const nextName = String(input?.value || "").trim(); + if (!nextName) { + toastr.info("先输入新的角色名称", "ST-BME"); + return; + } + if (nextName === String(ownerEntry.ownerName || "").trim()) { + toastr.info("新名称与当前名称相同,无需重命名", "ST-BME"); + return; + } + if ( + !window.confirm( + `确定将角色认知「${displayInfo.title}」重命名为「${nextName}」吗?\n\n这会同步更新 owner 名称、角色节点名和 POV scope。`, + ) + ) { + return; + } + result = await _actionHandlers.renameKnowledgeOwner?.({ + ownerKey: ownerEntry.ownerKey, + nextName, + }); + } else if (mode === "merge") { + const select = container?.querySelector?.("[data-bme-cognition-owner-merge-target]"); + const targetOwnerKey = String(select?.value || "").trim(); + if (!targetOwnerKey) { + toastr.info("先选择要合并到的目标角色", "ST-BME"); + return; + } + if (targetOwnerKey === ownerEntry.ownerKey) { + toastr.info("不能把角色合并到自己", "ST-BME"); + return; + } + const targetEntry = + ownerEntries.find((entry) => String(entry.ownerKey || "") === targetOwnerKey) || null; + const targetDisplayInfo = targetEntry + ? _getOwnerDisplayInfo(targetEntry, collisionIndex) + : { title: targetOwnerKey }; + if ( + !window.confirm( + `确定将角色认知「${displayInfo.title}」合并到「${targetDisplayInfo.title}」吗?\n\n这会把当前角色的 POV scope 改写到目标角色,并合并认知状态。`, + ) + ) { + return; + } + result = await _actionHandlers.mergeKnowledgeOwners?.({ + sourceOwnerKey: ownerEntry.ownerKey, + targetOwnerKey, + }); + } else if (mode === "delete") { + const select = container?.querySelector?.("[data-bme-cognition-owner-delete-mode]"); + const deleteMode = String(select?.value || "owner-only").trim() || "owner-only"; + const deleteModeLabelMap = { + "owner-only": "只删除 owner,保留角色节点与 POV", + "archive-character": "删除 owner,并归档角色节点", + "archive-all": "删除 owner,并归档角色节点与 POV 记忆", + }; + if ( + !window.confirm( + `确定删除角色认知「${displayInfo.title}」吗?\n\n删除范围:${deleteModeLabelMap[deleteMode] || deleteMode}\n\n此操作会立即写回图谱。`, + ) + ) { + return; + } + result = await _actionHandlers.deleteKnowledgeOwner?.({ + ownerKey: ownerEntry.ownerKey, + mode: deleteMode, + }); + } else { + return; + } + + if (!result?.ok) { + const messageMap = { + "graph-write-blocked": "当前图谱还在保护写入阶段,请稍后再试", + "owner-not-found": "没有找到这个角色的认知状态,请先让她参与一轮提取", + "same-owner": "不能把角色合并到自己", + "missing-owner-or-name": "缺少角色或新名称", + "invalid-delete-mode": "删除范围无效,请重新选择", + "unsupported-owner-type": "当前只支持角色 owner 操作", + }; + toastr.error(messageMap[result?.error] || "角色认知操作失败", "ST-BME"); + return; + } + + if (mode === "rename") { + currentCognitionOwnerKey = String(result.ownerKey || ownerEntry.ownerKey || "").trim(); + } else if (mode === "merge") { + currentCognitionOwnerKey = String(result.ownerKey || "").trim(); + } else if (mode === "delete") { + currentCognitionOwnerKey = ""; + } + + const successMap = { + rename: "角色认知已重命名", + merge: "角色认知已合并", + delete: "角色认知已删除", + }; + if (result.persistBlocked) { + toastr.warning( + `${successMap[mode] || "角色认知已更新"},但正式写回可能仍在等待图谱就绪`, + "ST-BME", + ); + } else { + toastr.success(successMap[mode] || "角色认知已更新", "ST-BME"); + } + + _refreshCognitionSurfaces(); } async function _applyManualActiveRegionFromDashboard(clear = false) { @@ -5834,6 +6034,43 @@ function _bindActions() { _refreshMobileCognitionFull(); }); + const cogOwnerDetail = document.getElementById("bme-cog-owner-detail"); + if (cogOwnerDetail && cogOwnerDetail.dataset.bmeOwnerActionsBound !== "true") { + cogOwnerDetail.addEventListener("click", async (e) => { + const ownerActionBtn = e.target.closest("[data-bme-cognition-owner-action]"); + if (!ownerActionBtn || ownerActionBtn.disabled) return; + await _runCognitionOwnerManagementAction( + String(ownerActionBtn.dataset.bmeCognitionOwnerAction || ""), + ownerActionBtn, + ); + }); + cogOwnerDetail.dataset.bmeOwnerActionsBound = "true"; + } + + const mobileCogOwnerDetail = document.getElementById("bme-mobile-cog-owner-detail"); + if ( + mobileCogOwnerDetail && + mobileCogOwnerDetail.dataset.bmeOwnerActionsBound !== "true" + ) { + mobileCogOwnerDetail.addEventListener("click", async (e) => { + const ownerActionBtn = e.target.closest("[data-bme-cognition-owner-action]"); + if (ownerActionBtn && !ownerActionBtn.disabled) { + await _runCognitionOwnerManagementAction( + String(ownerActionBtn.dataset.bmeCognitionOwnerAction || ""), + ownerActionBtn, + ); + return; + } + + const nodeActionBtn = e.target.closest("[data-bme-cognition-node-action]"); + if (!nodeActionBtn || nodeActionBtn.disabled) return; + await _runCognitionNodeOverrideAction( + String(nodeActionBtn.dataset.bmeCognitionNodeAction || ""), + ); + }); + mobileCogOwnerDetail.dataset.bmeOwnerActionsBound = "true"; + } + // Dashboard 跳转认知视图 document.getElementById("bme-cognition-jump-to-view")?.addEventListener("click", () => { _switchTab("dashboard");