mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 14:20:35 +08:00
962 lines
31 KiB
JavaScript
962 lines
31 KiB
JavaScript
const STORY_TENSE_VALUES = new Set([
|
|
"past",
|
|
"ongoing",
|
|
"future",
|
|
"flashback",
|
|
"hypothetical",
|
|
"unknown",
|
|
]);
|
|
|
|
const STORY_RELATION_VALUES = new Set([
|
|
"same",
|
|
"after",
|
|
"before",
|
|
"parallel",
|
|
"unknown",
|
|
]);
|
|
|
|
const STORY_CONFIDENCE_VALUES = new Set(["high", "medium", "low"]);
|
|
const STORY_SOURCE_VALUES = new Set(["extract", "derived", "manual"]);
|
|
|
|
export const STORY_TIMELINE_VERSION = 1;
|
|
export const STORY_TEMPORAL_BUCKETS = Object.freeze({
|
|
CURRENT: "current",
|
|
ADJACENT_PAST: "adjacentPast",
|
|
DISTANT_PAST: "distantPast",
|
|
FLASHBACK: "flashback",
|
|
FUTURE: "future",
|
|
UNDATED: "undated",
|
|
});
|
|
|
|
function normalizeString(value) {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function normalizeKey(value) {
|
|
return normalizeString(value)
|
|
.replace(/\s+/g, " ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function normalizeEnum(value, allowed, fallback) {
|
|
const normalized = normalizeString(value);
|
|
return allowed.has(normalized) ? normalized : fallback;
|
|
}
|
|
|
|
function uniqueStrings(values = []) {
|
|
const result = [];
|
|
const seen = new Set();
|
|
for (const value of Array.isArray(values) ? values : [values]) {
|
|
const normalized = normalizeString(value);
|
|
const key = normalizeKey(normalized);
|
|
if (!normalized || seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(normalized);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function buildMatcherKey(label = "", anchorLabel = "", relation = "unknown") {
|
|
return [normalizeKey(label), normalizeKey(anchorLabel), normalizeString(relation)]
|
|
.filter(Boolean)
|
|
.join("::");
|
|
}
|
|
|
|
function buildStorySegmentId() {
|
|
const native = globalThis.crypto?.randomUUID?.();
|
|
if (native) return `tl-${native}`;
|
|
const suffix = Math.random().toString(36).slice(2, 10);
|
|
return `tl-${Date.now().toString(36)}-${suffix}`;
|
|
}
|
|
|
|
export function createDefaultStoryTime(overrides = {}) {
|
|
return {
|
|
segmentId: normalizeString(overrides.segmentId),
|
|
label: normalizeString(overrides.label),
|
|
tense: normalizeEnum(overrides.tense, STORY_TENSE_VALUES, "unknown"),
|
|
relation: normalizeEnum(
|
|
overrides.relation,
|
|
STORY_RELATION_VALUES,
|
|
"unknown",
|
|
),
|
|
anchorLabel: normalizeString(overrides.anchorLabel),
|
|
confidence: normalizeEnum(
|
|
overrides.confidence,
|
|
STORY_CONFIDENCE_VALUES,
|
|
"medium",
|
|
),
|
|
source: normalizeEnum(overrides.source, STORY_SOURCE_VALUES, "derived"),
|
|
};
|
|
}
|
|
|
|
export function createDefaultStoryTimeSpan(overrides = {}) {
|
|
return {
|
|
startSegmentId: normalizeString(overrides.startSegmentId),
|
|
endSegmentId: normalizeString(overrides.endSegmentId),
|
|
startLabel: normalizeString(overrides.startLabel),
|
|
endLabel: normalizeString(overrides.endLabel),
|
|
mixed: overrides.mixed === true,
|
|
source: normalizeEnum(overrides.source, STORY_SOURCE_VALUES, "derived"),
|
|
};
|
|
}
|
|
|
|
export function createDefaultTimelineSegment(overrides = {}) {
|
|
const label = normalizeString(overrides.label);
|
|
const anchorLabel = normalizeString(overrides.anchorLabel);
|
|
const relationToParent = normalizeEnum(
|
|
overrides.relationToParent,
|
|
STORY_RELATION_VALUES,
|
|
"unknown",
|
|
);
|
|
return {
|
|
id: normalizeString(overrides.id) || buildStorySegmentId(),
|
|
label,
|
|
normalizedKey: normalizeKey(overrides.normalizedKey || label),
|
|
matcherKey:
|
|
normalizeString(overrides.matcherKey) ||
|
|
buildMatcherKey(label, anchorLabel, relationToParent),
|
|
order: Number.isFinite(Number(overrides.order))
|
|
? Math.max(1, Math.trunc(Number(overrides.order)))
|
|
: 1,
|
|
aliases: uniqueStrings(overrides.aliases),
|
|
parentId: normalizeString(overrides.parentId),
|
|
relationToParent,
|
|
anchorLabel,
|
|
confidence: normalizeEnum(
|
|
overrides.confidence,
|
|
STORY_CONFIDENCE_VALUES,
|
|
"medium",
|
|
),
|
|
source: normalizeEnum(overrides.source, STORY_SOURCE_VALUES, "derived"),
|
|
updatedAt: Number.isFinite(Number(overrides.updatedAt))
|
|
? Number(overrides.updatedAt)
|
|
: 0,
|
|
};
|
|
}
|
|
|
|
export function createDefaultTimelineState(overrides = {}) {
|
|
return {
|
|
version: STORY_TIMELINE_VERSION,
|
|
segments: Array.isArray(overrides.segments) ? overrides.segments : [],
|
|
nextOrder: Number.isFinite(Number(overrides.nextOrder))
|
|
? Math.max(1, Math.trunc(Number(overrides.nextOrder)))
|
|
: 1,
|
|
manualActiveSegmentId: normalizeString(overrides.manualActiveSegmentId),
|
|
lastExtractedSegmentId: normalizeString(overrides.lastExtractedSegmentId),
|
|
recentSegmentIds: uniqueStrings(overrides.recentSegmentIds).slice(0, 12),
|
|
};
|
|
}
|
|
|
|
function canReuseNormalizedStoryTime(value = {}, defaults = {}) {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
Array.isArray(value) ||
|
|
(defaults && typeof defaults === "object" && Object.keys(defaults).length > 0)
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
normalizeString(value.segmentId || "") === String(value.segmentId || "") &&
|
|
normalizeString(value.label || "") === String(value.label || "") &&
|
|
normalizeEnum(value.tense, STORY_TENSE_VALUES, "unknown") === value.tense &&
|
|
normalizeEnum(value.relation, STORY_RELATION_VALUES, "unknown") === value.relation &&
|
|
normalizeString(value.anchorLabel || "") === String(value.anchorLabel || "") &&
|
|
normalizeEnum(value.confidence, STORY_CONFIDENCE_VALUES, "medium") ===
|
|
value.confidence &&
|
|
normalizeEnum(value.source, STORY_SOURCE_VALUES, "derived") === value.source
|
|
);
|
|
}
|
|
|
|
function canReuseNormalizedStoryTimeSpan(value = {}, defaults = {}) {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
Array.isArray(value) ||
|
|
(defaults && typeof defaults === "object" && Object.keys(defaults).length > 0)
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
normalizeString(value.startSegmentId || "") === String(value.startSegmentId || "") &&
|
|
normalizeString(value.endSegmentId || "") === String(value.endSegmentId || "") &&
|
|
normalizeString(value.startLabel || "") === String(value.startLabel || "") &&
|
|
normalizeString(value.endLabel || "") === String(value.endLabel || "") &&
|
|
(value.mixed === true || value.mixed === false) &&
|
|
normalizeEnum(value.source, STORY_SOURCE_VALUES, "derived") === value.source
|
|
);
|
|
}
|
|
|
|
export function normalizeStoryTime(value = {}, defaults = {}) {
|
|
if (canReuseNormalizedStoryTime(value, defaults)) {
|
|
return value;
|
|
}
|
|
return createDefaultStoryTime({
|
|
...defaults,
|
|
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),
|
|
});
|
|
}
|
|
|
|
export function normalizeStoryTimeSpan(value = {}, defaults = {}) {
|
|
if (canReuseNormalizedStoryTimeSpan(value, defaults)) {
|
|
return value;
|
|
}
|
|
return createDefaultStoryTimeSpan({
|
|
...defaults,
|
|
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),
|
|
});
|
|
}
|
|
|
|
export function normalizeTimelineState(state = {}) {
|
|
const normalized = createDefaultTimelineState(state);
|
|
const segments = [];
|
|
const seenIds = new Set();
|
|
for (const rawSegment of Array.isArray(normalized.segments)
|
|
? normalized.segments
|
|
: []) {
|
|
const segment = createDefaultTimelineSegment(rawSegment);
|
|
if (!segment.label || seenIds.has(segment.id)) continue;
|
|
seenIds.add(segment.id);
|
|
segments.push(segment);
|
|
}
|
|
segments.sort((left, right) => {
|
|
if ((left.order || 0) !== (right.order || 0)) {
|
|
return (left.order || 0) - (right.order || 0);
|
|
}
|
|
return String(left.updatedAt || 0).localeCompare(String(right.updatedAt || 0));
|
|
});
|
|
const nextOrder = Math.max(
|
|
normalized.nextOrder || 1,
|
|
segments.reduce((max, segment) => Math.max(max, Number(segment.order) || 0), 0) +
|
|
1,
|
|
);
|
|
return {
|
|
version: STORY_TIMELINE_VERSION,
|
|
segments,
|
|
nextOrder,
|
|
manualActiveSegmentId: normalized.manualActiveSegmentId,
|
|
lastExtractedSegmentId: normalized.lastExtractedSegmentId,
|
|
recentSegmentIds: uniqueStrings(normalized.recentSegmentIds)
|
|
.filter((segmentId) => segments.some((segment) => segment.id === segmentId))
|
|
.slice(0, 12),
|
|
};
|
|
}
|
|
|
|
export function normalizeNodeStoryTimeline(node, defaults = {}) {
|
|
if (!node || typeof node !== "object") return node;
|
|
node.storyTime = normalizeStoryTime(node.storyTime, defaults.storyTime || {});
|
|
node.storyTimeSpan = normalizeStoryTimeSpan(
|
|
node.storyTimeSpan,
|
|
defaults.storyTimeSpan || {},
|
|
);
|
|
return node;
|
|
}
|
|
|
|
export function normalizeGraphStoryTimeline(graph) {
|
|
if (!graph || typeof graph !== "object") return graph;
|
|
graph.timelineState = normalizeTimelineState(graph.timelineState);
|
|
if (Array.isArray(graph.nodes)) {
|
|
graph.nodes.forEach((node) => normalizeNodeStoryTimeline(node));
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
function pushRecentSegment(timelineState, segmentId = "") {
|
|
const normalizedSegmentId = normalizeString(segmentId);
|
|
if (!normalizedSegmentId) return;
|
|
timelineState.recentSegmentIds = [
|
|
normalizedSegmentId,
|
|
...timelineState.recentSegmentIds.filter((value) => value !== normalizedSegmentId),
|
|
].slice(0, 12);
|
|
}
|
|
|
|
function getTimelineState(graphOrState) {
|
|
return graphOrState?.timelineState && typeof graphOrState.timelineState === "object"
|
|
? graphOrState.timelineState
|
|
: graphOrState;
|
|
}
|
|
|
|
export function getTimelineSegmentById(graphOrState, segmentId = "") {
|
|
const timelineState = getTimelineState(graphOrState);
|
|
const normalizedSegmentId = normalizeString(segmentId);
|
|
if (!normalizedSegmentId) return null;
|
|
return (
|
|
(Array.isArray(timelineState?.segments) ? timelineState.segments : []).find(
|
|
(segment) => segment.id === normalizedSegmentId,
|
|
) || null
|
|
);
|
|
}
|
|
|
|
export function findTimelineSegmentByLabel(graphOrState, label = "") {
|
|
const timelineState = getTimelineState(graphOrState);
|
|
const normalizedLabelKey = normalizeKey(label);
|
|
if (!normalizedLabelKey) return null;
|
|
return (
|
|
(Array.isArray(timelineState?.segments) ? timelineState.segments : []).find(
|
|
(segment) =>
|
|
segment.normalizedKey === normalizedLabelKey ||
|
|
(Array.isArray(segment.aliases) &&
|
|
segment.aliases.some((alias) => normalizeKey(alias) === normalizedLabelKey)),
|
|
) || null
|
|
);
|
|
}
|
|
|
|
function getTimelineSegmentOrder(graphOrState, segmentId = "") {
|
|
return Number(getTimelineSegmentById(graphOrState, segmentId)?.order || 0) || null;
|
|
}
|
|
|
|
function shiftSegmentOrders(timelineState, minOrder, delta = 1) {
|
|
for (const segment of timelineState.segments || []) {
|
|
if ((Number(segment.order) || 0) >= minOrder) {
|
|
segment.order = Math.max(1, (Number(segment.order) || 1) + delta);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createStoryTimeMatcher(storyTime = {}) {
|
|
return buildMatcherKey(
|
|
storyTime.label,
|
|
storyTime.anchorLabel,
|
|
storyTime.relation || "unknown",
|
|
);
|
|
}
|
|
|
|
export function resolveTimelineSegment(graphOrState, storyTime = {}) {
|
|
const timelineState = getTimelineState(graphOrState);
|
|
const normalizedStoryTime = normalizeStoryTime(storyTime);
|
|
if (!normalizedStoryTime.segmentId && !normalizedStoryTime.label) {
|
|
return null;
|
|
}
|
|
|
|
if (normalizedStoryTime.segmentId) {
|
|
const byId = getTimelineSegmentById(timelineState, normalizedStoryTime.segmentId);
|
|
if (byId) return byId;
|
|
}
|
|
|
|
const matcherKey = createStoryTimeMatcher(normalizedStoryTime);
|
|
const segments = Array.isArray(timelineState?.segments) ? timelineState.segments : [];
|
|
if (matcherKey) {
|
|
const byMatcher = segments.find((segment) => segment.matcherKey === matcherKey);
|
|
if (byMatcher) return byMatcher;
|
|
}
|
|
|
|
return findTimelineSegmentByLabel(timelineState, normalizedStoryTime.label);
|
|
}
|
|
|
|
export function upsertTimelineSegment(
|
|
graph,
|
|
storyTime = {},
|
|
{ referenceSegmentId = "", source = "extract" } = {},
|
|
) {
|
|
if (!graph || typeof graph !== "object") {
|
|
return {
|
|
segment: null,
|
|
storyTime: createDefaultStoryTime(storyTime),
|
|
created: false,
|
|
reused: false,
|
|
};
|
|
}
|
|
|
|
graph.timelineState = normalizeTimelineState(graph.timelineState);
|
|
const timelineState = graph.timelineState;
|
|
const normalizedStoryTime = normalizeStoryTime(storyTime, { source });
|
|
if (!normalizedStoryTime.label && !normalizedStoryTime.segmentId) {
|
|
return {
|
|
segment: null,
|
|
storyTime: normalizedStoryTime,
|
|
created: false,
|
|
reused: false,
|
|
};
|
|
}
|
|
|
|
const existing = resolveTimelineSegment(timelineState, normalizedStoryTime);
|
|
if (existing) {
|
|
if (normalizedStoryTime.label && existing.label !== normalizedStoryTime.label) {
|
|
existing.aliases = uniqueStrings([
|
|
...(existing.aliases || []),
|
|
normalizedStoryTime.label,
|
|
]);
|
|
}
|
|
existing.updatedAt = Date.now();
|
|
pushRecentSegment(timelineState, existing.id);
|
|
return {
|
|
segment: existing,
|
|
storyTime: normalizeStoryTime({
|
|
...normalizedStoryTime,
|
|
segmentId: existing.id,
|
|
label: existing.label || normalizedStoryTime.label,
|
|
}),
|
|
created: false,
|
|
reused: true,
|
|
};
|
|
}
|
|
|
|
const referenceSegment = getTimelineSegmentById(timelineState, referenceSegmentId);
|
|
const relation = normalizedStoryTime.relation || "unknown";
|
|
let desiredOrder = Number(timelineState.nextOrder || 1) || 1;
|
|
|
|
if (referenceSegment) {
|
|
if (relation === "same" || relation === "parallel") {
|
|
desiredOrder = Number(referenceSegment.order || desiredOrder) || desiredOrder;
|
|
} else if (relation === "after") {
|
|
desiredOrder = (Number(referenceSegment.order || 0) || 0) + 1;
|
|
shiftSegmentOrders(timelineState, desiredOrder, 1);
|
|
} else if (relation === "before") {
|
|
desiredOrder = Math.max(1, Number(referenceSegment.order || 1) || 1);
|
|
shiftSegmentOrders(timelineState, desiredOrder, 1);
|
|
}
|
|
}
|
|
|
|
const createdSegment = createDefaultTimelineSegment({
|
|
label: normalizedStoryTime.label || normalizedStoryTime.anchorLabel || "未命名时间段",
|
|
order: desiredOrder,
|
|
aliases: [normalizedStoryTime.label],
|
|
parentId:
|
|
relation === "after" ||
|
|
relation === "before" ||
|
|
relation === "same" ||
|
|
relation === "parallel"
|
|
? normalizeString(referenceSegment?.id)
|
|
: "",
|
|
relationToParent: relation,
|
|
anchorLabel: normalizedStoryTime.anchorLabel,
|
|
confidence: normalizedStoryTime.confidence,
|
|
source,
|
|
updatedAt: Date.now(),
|
|
});
|
|
|
|
timelineState.segments.push(createdSegment);
|
|
timelineState.segments.sort((left, right) => (left.order || 0) - (right.order || 0));
|
|
timelineState.nextOrder = Math.max(
|
|
Number(timelineState.nextOrder || 1),
|
|
...timelineState.segments.map((segment) => Number(segment.order || 0) + 1),
|
|
);
|
|
pushRecentSegment(timelineState, createdSegment.id);
|
|
return {
|
|
segment: createdSegment,
|
|
storyTime: normalizeStoryTime({
|
|
...normalizedStoryTime,
|
|
segmentId: createdSegment.id,
|
|
label: createdSegment.label,
|
|
source,
|
|
}),
|
|
created: true,
|
|
reused: false,
|
|
};
|
|
}
|
|
|
|
export function createSpanFromStoryTime(storyTime = {}, source = "derived") {
|
|
const normalizedStoryTime = normalizeStoryTime(storyTime, { source });
|
|
if (!normalizedStoryTime.segmentId && !normalizedStoryTime.label) {
|
|
return createDefaultStoryTimeSpan({ source });
|
|
}
|
|
return createDefaultStoryTimeSpan({
|
|
startSegmentId: normalizedStoryTime.segmentId,
|
|
endSegmentId: normalizedStoryTime.segmentId,
|
|
startLabel: normalizedStoryTime.label,
|
|
endLabel: normalizedStoryTime.label,
|
|
mixed: false,
|
|
source,
|
|
});
|
|
}
|
|
|
|
export function deriveStoryTimeSpanFromNodes(graph, nodes = [], source = "derived") {
|
|
const safeNodes = (Array.isArray(nodes) ? nodes : []).filter(Boolean);
|
|
if (safeNodes.length === 0) {
|
|
return createDefaultStoryTimeSpan({ source });
|
|
}
|
|
|
|
const points = [];
|
|
for (const node of safeNodes) {
|
|
const storyTime = normalizeStoryTime(node?.storyTime);
|
|
const storyTimeSpan = normalizeStoryTimeSpan(node?.storyTimeSpan);
|
|
if (storyTime.segmentId || storyTime.label) {
|
|
points.push({
|
|
type: "point",
|
|
segmentId: storyTime.segmentId,
|
|
label: storyTime.label,
|
|
order: getTimelineSegmentOrder(graph, storyTime.segmentId),
|
|
seq: Number(node?.seq ?? 0) || 0,
|
|
});
|
|
}
|
|
if (storyTimeSpan.startSegmentId || storyTimeSpan.startLabel) {
|
|
points.push({
|
|
type: "span-start",
|
|
segmentId: storyTimeSpan.startSegmentId,
|
|
label: storyTimeSpan.startLabel,
|
|
order: getTimelineSegmentOrder(graph, storyTimeSpan.startSegmentId),
|
|
seq: Number(node?.seq ?? 0) || 0,
|
|
});
|
|
}
|
|
if (storyTimeSpan.endSegmentId || storyTimeSpan.endLabel) {
|
|
points.push({
|
|
type: "span-end",
|
|
segmentId: storyTimeSpan.endSegmentId,
|
|
label: storyTimeSpan.endLabel,
|
|
order: getTimelineSegmentOrder(graph, storyTimeSpan.endSegmentId),
|
|
seq: Number(node?.seq ?? 0) || 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (points.length === 0) {
|
|
return createDefaultStoryTimeSpan({ source });
|
|
}
|
|
|
|
points.sort((left, right) => {
|
|
const leftOrder = Number.isFinite(left.order) ? left.order : Number.MAX_SAFE_INTEGER;
|
|
const rightOrder = Number.isFinite(right.order)
|
|
? right.order
|
|
: Number.MAX_SAFE_INTEGER;
|
|
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
|
return (left.seq || 0) - (right.seq || 0);
|
|
});
|
|
|
|
const first = points[0];
|
|
const last = points[points.length - 1];
|
|
const uniqueLabels = new Set(points.map((point) => normalizeKey(point.label)).filter(Boolean));
|
|
const uniqueSegments = new Set(
|
|
points.map((point) => normalizeString(point.segmentId)).filter(Boolean),
|
|
);
|
|
|
|
return createDefaultStoryTimeSpan({
|
|
startSegmentId: first.segmentId,
|
|
endSegmentId: last.segmentId,
|
|
startLabel: first.label,
|
|
endLabel: last.label,
|
|
mixed: uniqueLabels.size > 1 || uniqueSegments.size > 1,
|
|
source,
|
|
});
|
|
}
|
|
|
|
function resolveSegmentFromHistory(graph) {
|
|
const historyState = graph?.historyState || {};
|
|
const activeSegmentId = normalizeString(historyState.activeStorySegmentId);
|
|
if (activeSegmentId) {
|
|
const byId = getTimelineSegmentById(graph, activeSegmentId);
|
|
if (byId) return { segment: byId, source: normalizeString(historyState.activeStoryTimeSource) || "history" };
|
|
}
|
|
const activeLabel = normalizeString(historyState.activeStoryTimeLabel);
|
|
if (activeLabel) {
|
|
const byLabel = findTimelineSegmentByLabel(graph, activeLabel);
|
|
if (byLabel) return { segment: byLabel, source: normalizeString(historyState.activeStoryTimeSource) || "history" };
|
|
}
|
|
const extractedSegmentId = normalizeString(historyState.lastExtractedStorySegmentId);
|
|
if (extractedSegmentId) {
|
|
const byExtracted = getTimelineSegmentById(graph, extractedSegmentId);
|
|
if (byExtracted) return { segment: byExtracted, source: "extract" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function resolveActiveStoryContext(graph, preferred = {}) {
|
|
const timelineState = normalizeTimelineState(graph?.timelineState);
|
|
const preferredSegmentId = normalizeString(preferred.segmentId);
|
|
const preferredLabel = normalizeString(preferred.label);
|
|
if (timelineState.manualActiveSegmentId) {
|
|
const manualSegment = getTimelineSegmentById(timelineState, timelineState.manualActiveSegmentId);
|
|
if (manualSegment) {
|
|
return {
|
|
activeSegmentId: manualSegment.id,
|
|
activeStoryTimeLabel: manualSegment.label,
|
|
source: "manual",
|
|
segment: manualSegment,
|
|
resolved: true,
|
|
};
|
|
}
|
|
}
|
|
if (preferredSegmentId) {
|
|
const preferredSegment = getTimelineSegmentById(timelineState, preferredSegmentId);
|
|
if (preferredSegment) {
|
|
return {
|
|
activeSegmentId: preferredSegment.id,
|
|
activeStoryTimeLabel: preferredSegment.label,
|
|
source: "runtime",
|
|
segment: preferredSegment,
|
|
resolved: true,
|
|
};
|
|
}
|
|
}
|
|
if (preferredLabel) {
|
|
const preferredSegment = findTimelineSegmentByLabel(timelineState, preferredLabel);
|
|
if (preferredSegment) {
|
|
return {
|
|
activeSegmentId: preferredSegment.id,
|
|
activeStoryTimeLabel: preferredSegment.label,
|
|
source: "runtime",
|
|
segment: preferredSegment,
|
|
resolved: true,
|
|
};
|
|
}
|
|
}
|
|
const historyMatch = resolveSegmentFromHistory(graph);
|
|
if (historyMatch?.segment) {
|
|
return {
|
|
activeSegmentId: historyMatch.segment.id,
|
|
activeStoryTimeLabel: historyMatch.segment.label,
|
|
source: historyMatch.source,
|
|
segment: historyMatch.segment,
|
|
resolved: true,
|
|
};
|
|
}
|
|
const recentSegmentId = timelineState.recentSegmentIds.find((segmentId) =>
|
|
Boolean(getTimelineSegmentById(timelineState, segmentId)),
|
|
);
|
|
if (recentSegmentId) {
|
|
const recentSegment = getTimelineSegmentById(timelineState, recentSegmentId);
|
|
return {
|
|
activeSegmentId: recentSegment.id,
|
|
activeStoryTimeLabel: recentSegment.label,
|
|
source: "recent",
|
|
segment: recentSegment,
|
|
resolved: true,
|
|
};
|
|
}
|
|
return {
|
|
activeSegmentId: "",
|
|
activeStoryTimeLabel: "",
|
|
source: "",
|
|
segment: null,
|
|
resolved: false,
|
|
};
|
|
}
|
|
|
|
export function applyBatchStoryTime(graph, batchStoryTime = {}, source = "extract") {
|
|
if (!graph || typeof graph !== "object") {
|
|
return {
|
|
ok: false,
|
|
activeSegmentId: "",
|
|
activeStoryTimeLabel: "",
|
|
timelineAdvanceApplied: false,
|
|
extractedSegmentId: "",
|
|
};
|
|
}
|
|
|
|
graph.timelineState = normalizeTimelineState(graph.timelineState);
|
|
graph.historyState ||= {};
|
|
const normalizedBatch = normalizeStoryTime(batchStoryTime, { source });
|
|
if (!normalizedBatch.label && !normalizedBatch.segmentId) {
|
|
return {
|
|
ok: false,
|
|
activeSegmentId: normalizeString(graph.historyState.activeStorySegmentId),
|
|
activeStoryTimeLabel: normalizeString(graph.historyState.activeStoryTimeLabel),
|
|
timelineAdvanceApplied: false,
|
|
extractedSegmentId: "",
|
|
};
|
|
}
|
|
|
|
const activeContext = resolveActiveStoryContext(graph);
|
|
const upserted = upsertTimelineSegment(graph, normalizedBatch, {
|
|
referenceSegmentId: activeContext.activeSegmentId,
|
|
source,
|
|
});
|
|
const storyTime = upserted.storyTime;
|
|
const shouldAdvance =
|
|
batchStoryTime?.advancesActiveTimeline === true &&
|
|
!["future", "hypothetical", "flashback"].includes(storyTime.tense);
|
|
|
|
graph.timelineState.lastExtractedSegmentId = storyTime.segmentId || "";
|
|
pushRecentSegment(graph.timelineState, storyTime.segmentId);
|
|
graph.historyState.lastExtractedStorySegmentId = storyTime.segmentId || "";
|
|
|
|
if (shouldAdvance) {
|
|
graph.historyState.activeStorySegmentId = storyTime.segmentId || "";
|
|
graph.historyState.activeStoryTimeLabel = storyTime.label || "";
|
|
graph.historyState.activeStoryTimeSource = source;
|
|
} else if (
|
|
!normalizeString(graph.historyState.activeStorySegmentId) &&
|
|
storyTime.segmentId
|
|
) {
|
|
graph.historyState.activeStorySegmentId = storyTime.segmentId;
|
|
graph.historyState.activeStoryTimeLabel = storyTime.label || "";
|
|
graph.historyState.activeStoryTimeSource = source;
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
activeSegmentId: normalizeString(graph.historyState.activeStorySegmentId),
|
|
activeStoryTimeLabel: normalizeString(graph.historyState.activeStoryTimeLabel),
|
|
timelineAdvanceApplied: shouldAdvance,
|
|
extractedSegmentId: storyTime.segmentId || "",
|
|
storyTime,
|
|
};
|
|
}
|
|
|
|
export function isStoryTimeCompatible(leftNode, rightNode) {
|
|
const leftStoryTime = normalizeStoryTime(leftNode?.storyTime);
|
|
const rightStoryTime = normalizeStoryTime(rightNode?.storyTime);
|
|
const leftSpan = normalizeStoryTimeSpan(leftNode?.storyTimeSpan);
|
|
const rightSpan = normalizeStoryTimeSpan(rightNode?.storyTimeSpan);
|
|
|
|
const leftIds = [
|
|
leftStoryTime.segmentId,
|
|
leftSpan.startSegmentId,
|
|
leftSpan.endSegmentId,
|
|
].filter(Boolean);
|
|
const rightIds = [
|
|
rightStoryTime.segmentId,
|
|
rightSpan.startSegmentId,
|
|
rightSpan.endSegmentId,
|
|
].filter(Boolean);
|
|
|
|
if (leftIds.length === 0 || rightIds.length === 0) {
|
|
return { compatible: true, reason: "undated" };
|
|
}
|
|
const overlaps = leftIds.some((segmentId) => rightIds.includes(segmentId));
|
|
if (overlaps) {
|
|
return { compatible: true, reason: "overlap" };
|
|
}
|
|
return { compatible: false, reason: "different-story-segment" };
|
|
}
|
|
|
|
export function describeStoryTime(storyTime = {}) {
|
|
const normalized = normalizeStoryTime(storyTime);
|
|
if (!normalized.label) return "";
|
|
const parts = [normalized.label];
|
|
if (normalized.tense && normalized.tense !== "unknown") {
|
|
parts.push(normalized.tense);
|
|
}
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
export function describeStoryTimeSpan(storyTimeSpan = {}) {
|
|
const normalized = normalizeStoryTimeSpan(storyTimeSpan);
|
|
if (!normalized.startLabel && !normalized.endLabel) return "";
|
|
if (
|
|
normalized.startLabel &&
|
|
normalized.endLabel &&
|
|
normalized.startLabel !== normalized.endLabel
|
|
) {
|
|
return `${normalized.startLabel} -> ${normalized.endLabel}`;
|
|
}
|
|
return normalized.startLabel || normalized.endLabel || "";
|
|
}
|
|
|
|
export function describeNodeStoryTime(node = {}) {
|
|
return (
|
|
describeStoryTime(node.storyTime) ||
|
|
describeStoryTimeSpan(node.storyTimeSpan) ||
|
|
""
|
|
);
|
|
}
|
|
|
|
export function resolveStoryCueMode(userMessage = "", recentMessages = []) {
|
|
const text = [userMessage, ...(Array.isArray(recentMessages) ? recentMessages : [])]
|
|
.map((value) => normalizeString(value))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
if (!text) return "";
|
|
if (/(曾经|以前|当年|从前|回忆|过去|背景|来历|小时候|往事)/i.test(text)) {
|
|
return "flashback";
|
|
}
|
|
if (/(未来|以后|之后会|将要|明天|预告|计划|打算|准备|承诺)/i.test(text)) {
|
|
return "future";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function classifyStoryTemporalBucket(
|
|
graph,
|
|
node,
|
|
{ activeSegmentId = "", cueMode = "" } = {},
|
|
) {
|
|
const storyTime = normalizeStoryTime(node?.storyTime);
|
|
const storyTimeSpan = normalizeStoryTimeSpan(node?.storyTimeSpan);
|
|
const activeOrder = getTimelineSegmentOrder(graph, activeSegmentId);
|
|
const pointOrder = getTimelineSegmentOrder(graph, storyTime.segmentId);
|
|
const spanStartOrder = getTimelineSegmentOrder(graph, storyTimeSpan.startSegmentId);
|
|
const spanEndOrder = getTimelineSegmentOrder(graph, storyTimeSpan.endSegmentId);
|
|
const hasStoryTime = Boolean(
|
|
storyTime.segmentId ||
|
|
storyTime.label ||
|
|
storyTimeSpan.startSegmentId ||
|
|
storyTimeSpan.startLabel ||
|
|
storyTimeSpan.endSegmentId ||
|
|
storyTimeSpan.endLabel,
|
|
);
|
|
|
|
if (!hasStoryTime) {
|
|
return {
|
|
bucket: STORY_TEMPORAL_BUCKETS.UNDATED,
|
|
weight: 0.88,
|
|
suppressed: false,
|
|
rescued: false,
|
|
reason: "undated",
|
|
};
|
|
}
|
|
|
|
if (storyTime.tense === "future" || storyTime.tense === "hypothetical") {
|
|
const allowFutureCue = cueMode === "future";
|
|
return {
|
|
bucket: STORY_TEMPORAL_BUCKETS.FUTURE,
|
|
weight: allowFutureCue ? 0.72 : 0.2,
|
|
suppressed: !allowFutureCue,
|
|
rescued: false,
|
|
reason: allowFutureCue ? "future-cue" : "future-suppressed",
|
|
};
|
|
}
|
|
|
|
if (!Number.isFinite(activeOrder)) {
|
|
return {
|
|
bucket: STORY_TEMPORAL_BUCKETS.UNDATED,
|
|
weight: 0.92,
|
|
suppressed: false,
|
|
rescued: false,
|
|
reason: "no-active-story-time",
|
|
};
|
|
}
|
|
|
|
const effectiveStart = Number.isFinite(spanStartOrder) ? spanStartOrder : pointOrder;
|
|
const effectiveEnd = Number.isFinite(spanEndOrder) ? spanEndOrder : pointOrder;
|
|
|
|
if (Number.isFinite(effectiveStart) && Number.isFinite(effectiveEnd)) {
|
|
if (activeOrder >= effectiveStart && activeOrder <= effectiveEnd) {
|
|
return {
|
|
bucket: STORY_TEMPORAL_BUCKETS.CURRENT,
|
|
weight: 1.15,
|
|
suppressed: false,
|
|
rescued: false,
|
|
reason: "span-current",
|
|
};
|
|
}
|
|
if (effectiveEnd < activeOrder) {
|
|
const distance = activeOrder - effectiveEnd;
|
|
if (storyTime.tense === "flashback" || cueMode === "flashback") {
|
|
return {
|
|
bucket: STORY_TEMPORAL_BUCKETS.FLASHBACK,
|
|
weight: cueMode === "flashback" ? 1.02 : 0.72,
|
|
suppressed: false,
|
|
rescued: cueMode === "flashback",
|
|
reason: cueMode === "flashback" ? "flashback-rescued" : "flashback",
|
|
};
|
|
}
|
|
return {
|
|
bucket:
|
|
distance <= 2
|
|
? STORY_TEMPORAL_BUCKETS.ADJACENT_PAST
|
|
: STORY_TEMPORAL_BUCKETS.DISTANT_PAST,
|
|
weight: distance <= 2 ? 1.0 : 0.64,
|
|
suppressed: false,
|
|
rescued: false,
|
|
reason: distance <= 2 ? "adjacent-past" : "distant-past",
|
|
};
|
|
}
|
|
if (effectiveStart > activeOrder) {
|
|
const allowFutureCue = cueMode === "future";
|
|
return {
|
|
bucket: STORY_TEMPORAL_BUCKETS.FUTURE,
|
|
weight: allowFutureCue ? 0.74 : 0.22,
|
|
suppressed: !allowFutureCue,
|
|
rescued: false,
|
|
reason: allowFutureCue ? "future-cue" : "future-suppressed",
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
bucket: STORY_TEMPORAL_BUCKETS.UNDATED,
|
|
weight: 0.9,
|
|
suppressed: false,
|
|
rescued: false,
|
|
reason: "temporal-unknown",
|
|
};
|
|
}
|
|
|
|
export function setManualActiveStorySegment(
|
|
graph,
|
|
{ segmentId = "", label = "" } = {},
|
|
) {
|
|
if (!graph || typeof graph !== "object") {
|
|
return { ok: false, reason: "missing-graph", activeStorySegmentId: "", activeStoryTimeLabel: "" };
|
|
}
|
|
graph.timelineState = normalizeTimelineState(graph.timelineState);
|
|
graph.historyState ||= {};
|
|
|
|
let segment = null;
|
|
if (segmentId) {
|
|
segment = getTimelineSegmentById(graph, segmentId);
|
|
}
|
|
if (!segment && label) {
|
|
segment = findTimelineSegmentByLabel(graph, label);
|
|
}
|
|
if (!segment && label) {
|
|
const upserted = upsertTimelineSegment(
|
|
graph,
|
|
{ label, relation: "same", confidence: "low", source: "manual" },
|
|
{ source: "manual" },
|
|
);
|
|
segment = upserted.segment;
|
|
}
|
|
|
|
graph.timelineState.manualActiveSegmentId = segment?.id || "";
|
|
graph.historyState.activeStorySegmentId = segment?.id || "";
|
|
graph.historyState.activeStoryTimeLabel = segment?.label || "";
|
|
graph.historyState.activeStoryTimeSource = segment ? "manual" : "";
|
|
if (segment?.id) {
|
|
pushRecentSegment(graph.timelineState, segment.id);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
activeStorySegmentId: graph.historyState.activeStorySegmentId || "",
|
|
activeStoryTimeLabel: graph.historyState.activeStoryTimeLabel || "",
|
|
};
|
|
}
|
|
|
|
export function clearManualActiveStorySegment(graph) {
|
|
if (!graph || typeof graph !== "object") {
|
|
return { ok: false, reason: "missing-graph" };
|
|
}
|
|
graph.timelineState = normalizeTimelineState(graph.timelineState);
|
|
graph.historyState ||= {};
|
|
graph.timelineState.manualActiveSegmentId = "";
|
|
const fallback = resolveSegmentFromHistory({
|
|
...graph,
|
|
historyState: {
|
|
...(graph.historyState || {}),
|
|
activeStorySegmentId: "",
|
|
activeStoryTimeLabel: "",
|
|
activeStoryTimeSource: "",
|
|
},
|
|
});
|
|
graph.historyState.activeStorySegmentId = fallback?.segment?.id || "";
|
|
graph.historyState.activeStoryTimeLabel = fallback?.segment?.label || "";
|
|
graph.historyState.activeStoryTimeSource = fallback?.source || "";
|
|
return {
|
|
ok: true,
|
|
activeStorySegmentId: graph.historyState.activeStorySegmentId || "",
|
|
activeStoryTimeLabel: graph.historyState.activeStoryTimeLabel || "",
|
|
};
|
|
}
|
|
|
|
export function setNodeStoryTimeManual(graph, nodeId = "", storyTime = {}) {
|
|
if (!graph || typeof graph !== "object") {
|
|
return { ok: false, reason: "missing-graph" };
|
|
}
|
|
const node = Array.isArray(graph.nodes)
|
|
? graph.nodes.find((candidate) => candidate?.id === normalizeString(nodeId))
|
|
: null;
|
|
if (!node) {
|
|
return { ok: false, reason: "node-not-found" };
|
|
}
|
|
|
|
const normalizedStoryTime = normalizeStoryTime(storyTime, {
|
|
source: "manual",
|
|
confidence: "medium",
|
|
});
|
|
if (!normalizedStoryTime.label && !normalizedStoryTime.segmentId) {
|
|
node.storyTime = createDefaultStoryTime();
|
|
node.storyTimeSpan = createDefaultStoryTimeSpan();
|
|
return { ok: true, nodeId: node.id, storyTime: node.storyTime };
|
|
}
|
|
|
|
const activeSegmentId = normalizeString(graph?.historyState?.activeStorySegmentId);
|
|
const upserted = upsertTimelineSegment(graph, normalizedStoryTime, {
|
|
referenceSegmentId: activeSegmentId,
|
|
source: "manual",
|
|
});
|
|
node.storyTime = upserted.storyTime;
|
|
node.storyTimeSpan = createDefaultStoryTimeSpan();
|
|
return { ok: true, nodeId: node.id, storyTime: node.storyTime };
|
|
}
|