From 0003cc00a5a3908ebeb103c577f25cb54c7d7535 Mon Sep 17 00:00:00 2001 From: youzini Date: Fri, 5 Jun 2026 11:20:53 +0000 Subject: [PATCH] feat(i18n): add UI-only memory label formatter --- i18n/en-US.js | 26 +++++++ i18n/zh-CN.js | 26 +++++++ package.json | 3 +- tests/ui-label-formatter.mjs | 88 +++++++++++++++++++++ ui/panel.js | 104 +++++++------------------ ui/ui-label-formatter.js | 145 +++++++++++++++++++++++++++++++++++ 6 files changed, 314 insertions(+), 78 deletions(-) create mode 100644 tests/ui-label-formatter.mjs create mode 100644 ui/ui-label-formatter.js diff --git a/i18n/en-US.js b/i18n/en-US.js index e09d63d..6042bba 100644 --- a/i18n/en-US.js +++ b/i18n/en-US.js @@ -278,4 +278,30 @@ export default { "llm.providerHelp.modelFetchUnsupported": "This provider cannot fetch models automatically yet; enter the model name manually.", "llm.providerHelp.normalizedUrl": "Normalized URL: {apiUrl}", "llm.providerHelp.transport": "Transport: {transport}", + + "memory.type.character": "Character", + "memory.type.event": "Event", + "memory.type.location": "Location", + "memory.type.pov_memory": "POV Memory", + "memory.type.reflection": "Reflection", + "memory.type.rule": "Rule", + "memory.type.synopsis": "Global Synopsis (legacy)", + "memory.type.thread": "Thread", + + "scope.badge.characterPov": "Character POV · {owner}", + "scope.badge.objectiveGlobal": "Objective · Global", + "scope.badge.objectiveRegion": "Objective · {region}", + "scope.badge.userPov": "User POV · {owner}", + "scope.owner.character": "Character", + "scope.owner.unnamed": "Unnamed", + "scope.owner.user": "User", + "scope.meta.characterPov": "Character POV: {owner}", + "scope.meta.userPov": "User POV: {owner}", + "scope.region.path": "Region path: {path}", + "scope.region.primary": "Primary region: {region}", + "scope.region.secondary": "Secondary regions: {regions}", + + "storyTime.meta": "Story time: {time}", + "storyTime.mixed": "mixed", + "storyTime.mixedTime": "Mixed time", }; diff --git a/i18n/zh-CN.js b/i18n/zh-CN.js index d03670a..6dbc2ef 100644 --- a/i18n/zh-CN.js +++ b/i18n/zh-CN.js @@ -278,4 +278,30 @@ export default { "llm.providerHelp.modelFetchUnsupported": "该渠道暂不支持自动拉取模型,请手动填写模型名", "llm.providerHelp.normalizedUrl": "规范化地址:{apiUrl}", "llm.providerHelp.transport": "请求通道:{transport}", + + "memory.type.character": "角色", + "memory.type.event": "事件", + "memory.type.location": "地点", + "memory.type.pov_memory": "主观记忆", + "memory.type.reflection": "反思", + "memory.type.rule": "规则", + "memory.type.synopsis": "全局概要(旧)", + "memory.type.thread": "主线", + + "scope.badge.characterPov": "角色 POV · {owner}", + "scope.badge.objectiveGlobal": "客观 · 全局", + "scope.badge.objectiveRegion": "客观 · {region}", + "scope.badge.userPov": "用户 POV · {owner}", + "scope.owner.character": "角色", + "scope.owner.unnamed": "未命名", + "scope.owner.user": "用户", + "scope.meta.characterPov": "角色 POV: {owner}", + "scope.meta.userPov": "用户 POV: {owner}", + "scope.region.path": "地区路径: {path}", + "scope.region.primary": "主地区: {region}", + "scope.region.secondary": "次级地区: {regions}", + + "storyTime.meta": "剧情时间: {time}", + "storyTime.mixed": "混合", + "storyTime.mixedTime": "混合时间", }; diff --git a/package.json b/package.json index 3d9c0a4..8800ef1 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "test:i18n-dom": "node tests/i18n-dom.mjs", "test:i18n-boundary": "node tests/i18n-boundary.mjs", "test:i18n-status": "node tests/i18n-status.mjs", - "test:i18n": "npm run test:i18n-catalog && npm run test:i18n-dom && npm run test:i18n-boundary && npm run test:i18n-status", + "test:ui-label-formatter": "node tests/ui-label-formatter.mjs", + "test:i18n": "npm run test:i18n-catalog && npm run test:i18n-dom && npm run test:i18n-boundary && npm run test:i18n-status && npm run test:ui-label-formatter", "bench:graph-layout": "node tests/perf/graph-layout-bench.mjs", "bench:persist-delta": "node tests/perf/persist-delta-bench.mjs", "bench:persist-load": "node tests/perf/persist-load-bench.mjs", diff --git a/tests/ui-label-formatter.mjs b/tests/ui-label-formatter.mjs new file mode 100644 index 0000000..65f6641 --- /dev/null +++ b/tests/ui-label-formatter.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; + +import { setLocale } from "../i18n/index.js"; +import { + uiBuildRegionLine, + uiBuildScopeMetaText, + uiDescribeStoryTimeSpanDisplay, + uiMemoryNodeTypeClass, + uiOwnerTypeLabel, + uiScopeBadgeText, + uiTypeLabel, +} from "../ui/ui-label-formatter.js"; + +setLocale("zh-CN"); +assert.equal(uiTypeLabel("event"), "事件"); +assert.equal(uiTypeLabel("pov_memory"), "主观记忆"); +assert.equal(uiTypeLabel("custom_type"), "custom_type"); +assert.equal(uiTypeLabel(""), "—"); +assert.equal(uiMemoryNodeTypeClass("character"), "type-character"); +assert.equal(uiMemoryNodeTypeClass("pov_memory"), "type-character"); +assert.equal(uiMemoryNodeTypeClass("event"), "type-event"); +assert.equal(uiOwnerTypeLabel("user"), "用户"); +assert.equal(uiOwnerTypeLabel("character"), "角色"); +assert.equal( + uiScopeBadgeText({ layer: "pov", ownerType: "character", ownerName: "艾琳" }), + "角色 POV · 艾琳", +); +assert.equal( + uiScopeBadgeText({ layer: "objective", regionPrimary: "钟楼" }), + "客观 · 钟楼", +); +assert.equal( + uiBuildRegionLine({ + regionPrimary: "钟楼", + regionPath: ["王城", "钟楼"], + regionSecondary: ["地下室"], + }), + "主地区: 钟楼 | 地区路径: 王城 / 钟楼 | 次级地区: 地下室", +); +assert.equal(uiDescribeStoryTimeSpanDisplay({ mixed: true }), "混合时间"); +assert.equal( + uiDescribeStoryTimeSpanDisplay({ startLabel: "第一章", endLabel: "第二章", mixed: true }), + "第一章 → 第二章 · 混合", +); +assert.equal( + uiBuildScopeMetaText({ + scope: { layer: "pov", ownerType: "user", ownerName: "玩家" }, + storyTimeSpan: { startLabel: "第一章", mixed: true }, + }), + "用户 POV: 玩家 · 剧情时间: 第一章 · 混合", +); + +setLocale("en-US"); +assert.equal(uiTypeLabel("event"), "Event"); +assert.equal(uiTypeLabel("pov_memory"), "POV Memory"); +assert.equal(uiOwnerTypeLabel("user"), "User"); +assert.equal(uiOwnerTypeLabel("character"), "Character"); +assert.equal( + uiScopeBadgeText({ layer: "pov", ownerType: "character", ownerName: "Eileen" }), + "Character POV · Eileen", +); +assert.equal( + uiScopeBadgeText({ layer: "objective", regionPrimary: "Clocktower" }), + "Objective · Clocktower", +); +assert.equal( + uiBuildRegionLine({ + regionPrimary: "Clocktower", + regionPath: ["Capital", "Clocktower"], + regionSecondary: ["Basement"], + }), + "Primary region: Clocktower | Region path: Capital / Clocktower | Secondary regions: Basement", +); +assert.equal(uiDescribeStoryTimeSpanDisplay({ mixed: true }), "Mixed time"); +assert.equal( + uiDescribeStoryTimeSpanDisplay({ startLabel: "Chapter 1", endLabel: "Chapter 2", mixed: true }), + "Chapter 1 → Chapter 2 · mixed", +); +assert.equal( + uiBuildScopeMetaText({ + scope: { layer: "pov", ownerType: "user", ownerName: "Player" }, + storyTimeSpan: { startLabel: "Chapter 1", mixed: true }, + }), + "User POV: Player · Story time: Chapter 1 · mixed", +); + +setLocale("zh-CN"); +console.log("ui label formatter tests passed"); diff --git a/ui/panel.js b/ui/panel.js index f9d8777..984e6df 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -13,8 +13,6 @@ import { } from "./panel-ena-sections.js"; import { getNodeDisplayName } from "../graph/node-labels.js"; import { - buildRegionLine, - buildScopeBadgeText, normalizeMemoryScope, } from "../graph/memory-scope.js"; import { listKnowledgeOwners } from "../graph/knowledge-state.js"; @@ -72,6 +70,15 @@ import { setLocale, t, } from "../i18n/index.js"; +import { + normalizeOwnerUiType, + uiBuildScopeMetaText, + uiBuildRegionLine, + uiMemoryNodeTypeClass, + uiOwnerTypeLabel, + uiScopeBadgeText, + uiTypeLabel, +} from "./ui-label-formatter.js"; let defaultPromptCache = null; @@ -2726,24 +2733,6 @@ function _refreshTaskTimeline() { // ---------- Memory Browser (Master-Detail) ---------- -function _getMemoryNodeTypeClass(type) { - switch (type) { - case "pov_memory": - case "character": - return "type-character"; - case "event": - return "type-event"; - case "location": - return "type-location"; - case "rule": - return "type-rule"; - case "thread": - return "type-thread"; - default: - return "type-default"; - } -} - function _parseFloorFilter(raw) { const text = String(raw || "").trim(); if (!text) return null; @@ -2917,13 +2906,13 @@ function _refreshTaskMemoryBrowser() { const listItems = sorted.map((node) => { const sel = node.id === currentSelectedMemoryNodeId ? "selected" : ""; const preview = _getNodeSnippet(node); - const scopeBadge = buildScopeBadgeText(node.scope); + const scopeBadge = uiScopeBadgeText(node.scope); const metaText = _buildScopeMetaText(node); const displayName = getNodeDisplayName(node); return `
- ${_escHtml(_typeLabel(node.type))} + ${_escHtml(uiTypeLabel(node.type))} IMP: ${typeof node.importance === "number" ? node.importance.toFixed(1) : "—"}
${_escHtml(displayName)}
@@ -3016,12 +3005,12 @@ function _renderTaskMemoryDetailPanel(detailEl, node, graph) { (e?.fromId === node.id || e?.toId === node.id), ); const detailSummary = _getNodeSnippet(node); - const scopeBadge = buildScopeBadgeText(node.scope); + const scopeBadge = uiScopeBadgeText(node.scope); const displayName = getNodeDisplayName(node); const writeBlocked = _isGraphWriteBlocked(); const disabledAttr = writeBlocked ? " disabled" : ""; const badges = [ - node.type ? `${_escHtml(_typeLabel(node.type))}` : "", + node.type ? `${_escHtml(uiTypeLabel(node.type))}` : "", scopeBadge ? `${_escHtml(scopeBadge)}` : "", node.archived ? 'ARCHIVED' : "", ].filter(Boolean).join(""); @@ -3111,9 +3100,9 @@ function _openMemoryPopup(node, graph) { if (!popup || !bodyEl) return; const displayName = getNodeDisplayName(node); - const scopeBadge = buildScopeBadgeText(node.scope); + const scopeBadge = uiScopeBadgeText(node.scope); const badges = [ - node.type ? `${_escHtml(_typeLabel(node.type))}` : "", + node.type ? `${_escHtml(uiTypeLabel(node.type))}` : "", scopeBadge ? `${_escHtml(scopeBadge)}` : "", node.archived ? 'ARCHIVED' : "", ].filter(Boolean).join(""); @@ -4006,13 +3995,6 @@ function _ownerAvatarHsl(name) { return `hsl(${hue}, 55%, 42%)`; } -function _normalizeOwnerUiType(ownerType = "") { - const normalized = String(ownerType || "").trim(); - if (normalized === "user") return "user"; - if (normalized === "character") return "character"; - return ""; -} - function _inferOwnerTypeFromKey(ownerKey = "") { const normalizedOwnerKey = String(ownerKey || "").trim().toLowerCase(); if (normalizedOwnerKey.startsWith("user:")) return "user"; @@ -4020,13 +4002,6 @@ function _inferOwnerTypeFromKey(ownerKey = "") { return ""; } -function _getOwnerTypeDisplayLabel(ownerType = "") { - const normalizedType = _normalizeOwnerUiType(ownerType); - if (normalizedType === "user") return "用户"; - if (normalizedType === "character") return "角色"; - return "Owner"; -} - function _buildOwnerCollisionIndex(owners = []) { const collisionIndex = new Map(); for (const owner of Array.isArray(owners) ? owners : []) { @@ -4034,7 +4009,7 @@ function _buildOwnerCollisionIndex(owners = []) { String(owner?.ownerName || owner?.ownerKey || "未命名角色").trim() || "未命名角色"; const nameKey = baseName.toLocaleLowerCase("zh-Hans-CN"); - const ownerType = _normalizeOwnerUiType(owner?.ownerType) || "unknown"; + const ownerType = normalizeOwnerUiType(owner?.ownerType) || "unknown"; const entry = collisionIndex.get(nameKey) || { count: 0, typeCounts: new Map(), @@ -4058,8 +4033,8 @@ function _getOwnerDisplayInfo(owner = {}, collisionIndex = null) { "未命名角色"; const ownerKey = String(owner?.ownerKey || "").trim(); const ownerType = - _normalizeOwnerUiType(owner?.ownerType) || _inferOwnerTypeFromKey(ownerKey); - const typeLabel = _getOwnerTypeDisplayLabel(ownerType); + normalizeOwnerUiType(owner?.ownerType) || _inferOwnerTypeFromKey(ownerKey); + const typeLabel = uiOwnerTypeLabel(ownerType); const collisionInfo = collisionIndex instanceof Map ? collisionIndex.get(baseName.toLocaleLowerCase("zh-Hans-CN")) || null @@ -5282,7 +5257,7 @@ function _renderRecentList(elementId, items) { const badge = document.createElement("span"); badge.className = `bme-type-badge ${_safeCssToken(item.type)}`; - badge.textContent = _typeLabel(item.type); + badge.textContent = uiTypeLabel(item.type); li.appendChild(badge); const content = document.createElement("div"); @@ -5393,11 +5368,11 @@ function _refreshMemoryBrowser() { const badge = document.createElement("span"); badge.className = `bme-type-badge ${_safeCssToken(node.type)}`; - badge.textContent = _typeLabel(node.type); + badge.textContent = uiTypeLabel(node.type); const scopeChip = document.createElement("span"); scopeChip.className = "bme-memory-scope-chip"; - scopeChip.textContent = buildScopeBadgeText(node.scope); + scopeChip.textContent = uiScopeBadgeText(node.scope); head.append(badge, scopeChip); @@ -6076,9 +6051,9 @@ function _describeStoryTimeSpanDisplay(storyTimeSpan = {}) { : normalized.startLabel || normalized.endLabel || ""; if (!label) { - return normalized.mixed ? "混合时间" : ""; + return normalized.mixed ? t("storyTime.mixedTime") : ""; } - return normalized.mixed ? `${label} · 混合` : label; + return normalized.mixed ? `${label} · ${t("storyTime.mixed")}` : label; } function _describeNodeStoryTimeDisplay(node = {}) { @@ -6208,11 +6183,11 @@ function _buildNodeDetailEditorFragment(raw, { idPrefix = "bme-detail" } = {}) { const fragment = document.createDocumentFragment(); const inputId = (suffix) => `${idPrefix}-${suffix}`; - _appendNodeDetailReadOnly(fragment, "类型", _typeLabel(raw.type)); + _appendNodeDetailReadOnly(fragment, "类型", uiTypeLabel(raw.type)); _appendNodeDetailReadOnly( fragment, "作用域", - buildScopeBadgeText(raw.scope), + uiScopeBadgeText(raw.scope), ); _appendNodeDetailReadOnly(fragment, "ID", raw.id || "—"); _appendNodeDetailReadOnly( @@ -6228,7 +6203,7 @@ function _buildNodeDetailEditorFragment(raw, { idPrefix = "bme-detail" } = {}) { `${scope.ownerType || "unknown"} / ${scope.ownerName || scope.ownerId || "—"}`, ); } - const regionLine = buildRegionLine(scope); + const regionLine = uiBuildRegionLine(scope); if (regionLine) { _appendNodeDetailReadOnly(fragment, "地区", regionLine); } @@ -14843,18 +14818,7 @@ function _matchesMemoryFilter(node, filter = "all") { } function _buildScopeMetaText(node) { - const scope = normalizeMemoryScope(node?.scope); - const parts = []; - if (scope.layer === "pov") { - parts.push( - `${scope.ownerType === "user" ? "用户 POV" : "角色 POV"}: ${scope.ownerName || scope.ownerId || "未命名"}`, - ); - } - const regionLine = buildRegionLine(scope); - if (regionLine) parts.push(regionLine); - const storyTime = _describeNodeStoryTimeDisplay(node); - if (storyTime) parts.push(`剧情时间: ${storyTime}`); - return parts.join(" · "); + return uiBuildScopeMetaText(node); } /** 记忆列表等指标:避免浮点误差打出 9.499999999999998 */ @@ -14878,20 +14842,6 @@ function _formatMemoryInt(value, fallback = 0) { return String(Math.trunc(x)); } -function _typeLabel(type) { - const map = { - character: "角色", - event: "事件", - location: "地点", - thread: "主线", - rule: "规则", - synopsis: "全局概要(旧)", - reflection: "反思", - pov_memory: "主观记忆", - }; - return map[type] || type || "—"; -} - function _getNodeSnippet(node) { const fields = node.fields || {}; const storyTime = _describeNodeStoryTimeDisplay(node); diff --git a/ui/ui-label-formatter.js b/ui/ui-label-formatter.js new file mode 100644 index 0000000..6c5e5b7 --- /dev/null +++ b/ui/ui-label-formatter.js @@ -0,0 +1,145 @@ +// UI-only label formatting for ST-BME. +// +// Keep this module on the frontend boundary: these labels are translated for +// human-facing UI only. Do not import it from prompt/model/persistence paths. + +import { normalizeMemoryScope } from "../graph/memory-scope.js"; +import { + normalizeStoryTime, + normalizeStoryTimeSpan, +} from "../graph/story-timeline.js"; +import { t } from "../i18n/index.js"; + +const UI_MEMORY_SCOPE_LAYER = Object.freeze({ + OBJECTIVE: "objective", + POV: "pov", +}); + +const UI_MEMORY_SCOPE_OWNER_TYPE = Object.freeze({ + CHARACTER: "character", + USER: "user", +}); + +export function uiMemoryNodeTypeClass(type) { + switch (type) { + case "pov_memory": + case "character": + return "type-character"; + case "event": + return "type-event"; + case "location": + return "type-location"; + case "rule": + return "type-rule"; + case "thread": + return "type-thread"; + default: + return "type-default"; + } +} + +export function uiTypeLabel(type) { + const normalized = String(type || "").trim(); + if (!normalized) return "—"; + const key = `memory.type.${normalized}`; + const translated = t(key); + return translated === key ? normalized : translated; +} + +export function normalizeOwnerUiType(ownerType = "") { + const normalized = String(ownerType || "").trim(); + if (normalized === "user") return "user"; + if (normalized === "character") return "character"; + return ""; +} + +export function uiOwnerTypeLabel(ownerType = "") { + const normalizedType = normalizeOwnerUiType(ownerType); + if (normalizedType === "user") return t("scope.owner.user"); + if (normalizedType === "character") return t("scope.owner.character"); + return "Owner"; +} + +export function uiScopeBadgeText(scope) { + const normalized = normalizeMemoryScope(scope); + if (normalized.layer === UI_MEMORY_SCOPE_LAYER.POV) { + const ownerLabel = normalized.ownerName || normalized.ownerId || "POV"; + return normalized.ownerType === UI_MEMORY_SCOPE_OWNER_TYPE.USER + ? t("scope.badge.userPov", { owner: ownerLabel }) + : t("scope.badge.characterPov", { owner: ownerLabel }); + } + return normalized.regionPrimary + ? t("scope.badge.objectiveRegion", { region: normalized.regionPrimary }) + : t("scope.badge.objectiveGlobal"); +} + +export function uiBuildRegionLine(scope) { + const normalized = normalizeMemoryScope(scope); + const regionPath = Array.isArray(normalized.regionPath) + ? normalized.regionPath.filter(Boolean) + : []; + const regionSecondary = Array.isArray(normalized.regionSecondary) + ? normalized.regionSecondary.filter(Boolean) + : []; + const parts = []; + if (normalized.regionPrimary) { + parts.push(t("scope.region.primary", { region: normalized.regionPrimary })); + } + if (regionPath.length > 0) { + parts.push(t("scope.region.path", { path: regionPath.join(" / ") })); + } + if (regionSecondary.length > 0) { + parts.push(t("scope.region.secondary", { regions: regionSecondary.join(", ") })); + } + return parts.join(" | "); +} + +export function uiDescribeStoryTimeDisplay(storyTime = {}) { + const normalized = normalizeStoryTime(storyTime); + const parts = []; + if (normalized.arc) parts.push(normalized.arc); + if (normalized.chapter) parts.push(normalized.chapter); + if (normalized.scene) parts.push(normalized.scene); + return parts.join(" / "); +} + +export function uiDescribeStoryTimeSpanDisplay(storyTimeSpan = {}) { + const normalized = normalizeStoryTimeSpan(storyTimeSpan); + const label = + normalized.startLabel && + normalized.endLabel && + normalized.startLabel !== normalized.endLabel + ? `${normalized.startLabel} → ${normalized.endLabel}` + : normalized.startLabel || normalized.endLabel || ""; + + if (!label) { + return normalized.mixed ? t("storyTime.mixedTime") : ""; + } + return normalized.mixed ? `${label} · ${t("storyTime.mixed")}` : label; +} + +export function uiDescribeNodeStoryTimeDisplay(node = {}) { + return ( + uiDescribeStoryTimeDisplay(node.storyTime) || + uiDescribeStoryTimeSpanDisplay(node.storyTimeSpan) || + "" + ); +} + +export function uiBuildScopeMetaText(node = {}) { + const scope = normalizeMemoryScope(node?.scope); + const parts = []; + if (scope.layer === UI_MEMORY_SCOPE_LAYER.POV) { + const ownerLabel = scope.ownerName || scope.ownerId || t("scope.owner.unnamed"); + parts.push( + scope.ownerType === UI_MEMORY_SCOPE_OWNER_TYPE.USER + ? t("scope.meta.userPov", { owner: ownerLabel }) + : t("scope.meta.characterPov", { owner: ownerLabel }), + ); + } + const regionLine = uiBuildRegionLine(scope); + if (regionLine) parts.push(regionLine); + const storyTime = uiDescribeNodeStoryTimeDisplay(node); + if (storyTime) parts.push(t("storyTime.meta", { time: storyTime })); + return parts.join(" · "); +}