feat(wiki): honour KB pageType profile in transformations, agent pages & UI

Wiki page classification was only profile-aware in the main ingest pipeline.
Transformation outputs hard-coded "synthesis", agent-created pages were left
untyped, and the frontend hard-coded the built-in ten types for ordering,
colouring and labels — so custom/synthesis types sank to the bottom, rendered
grey and showed raw keys.

Backend:
- Add nullable target_page_type column to mate_wiki_transformation (V142,
  mysql + h2) plus the entity field and CRUD normalization (blank = use
  profile fallbackType; membership validated at save time, not edit time).
- Route transformation single-run + KB-aggregate page saves through
  WikiPageTypeProfileService.normalizePageType so output joins the KB
  classification; agent wiki_create_page now lands on the profile fallbackType
  instead of an untyped page.

Frontend:
- Load + parse the KB pageType profile into the wiki store (order, labels,
  fallbackType) on KB select / refresh.
- New useWikiPageType composable: profile-driven label (3-tier fallback) and
  colour (built-in fixed + deterministic hash palette for custom types).
- Sidebar grouping order, graph colouring, node panel, graph filter and the
  page header badge now follow the profile; transformation editor gains a
  target-type dropdown sourced from the profile when output target is a page.

Refs #292
This commit is contained in:
倪程伟 2026-06-08 16:56:10 +08:00 committed by matevip
parent da5aaba0f7
commit 28f2ba973d
18 changed files with 281 additions and 48 deletions

View File

@ -84,6 +84,15 @@ public class WikiTransformationEntity {
*/
private String outputSchema;
/**
* Optional target pageType for output that lands as a wiki page
* ({@code outputTarget == 'page'}). Normalised against the KB's pageType
* profile at save time; {@code null}/blank falls back to the profile's
* {@code fallbackType}, so transformation output is always a first-class
* member of the KB classification rather than a hard-coded type.
*/
private String targetPageType;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -57,6 +57,12 @@ public class WikiTransformationAggregator {
@Autowired(required = false)
private WikiEmbeddingService embeddingService;
/** Optional. When wired, the aggregate page is classified against the KB's
* pageType profile (template target type, else the profile fallback)
* instead of a hard-coded type that sits outside every profile. */
@Autowired(required = false)
private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper =
new com.fasterxml.jackson.databind.ObjectMapper();
@ -152,12 +158,16 @@ public class WikiTransformationAggregator {
+ (triggeredBy == null ? "manual" : triggeredBy);
String sourceRawIdsJson = toJsonArray(new ArrayList<>(sourceRawIds));
String pageType = pageTypeProfileService == null
? "synthesis"
: pageTypeProfileService.normalizePageType(kbId, template.getTargetPageType());
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
WikiPageEntity persisted;
boolean created;
if (existing == null) {
persisted = pageService.createPage(kbId, slug, title, mergedOutput, summary,
sourceRawIdsJson, "synthesis");
sourceRawIdsJson, pageType);
created = true;
} else {
persisted = pageService.updatePageByAi(kbId, slug, mergedOutput, summary,

View File

@ -58,6 +58,12 @@ public class WikiTransformationExecutor {
@Autowired(required = false)
private WikiPageService pageService;
/** Optional. When wired, a run saved as a page is classified against the
* KB's pageType profile (template target type, else the profile fallback)
* instead of a hard-coded type that sits outside every profile. */
@Autowired(required = false)
private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService;
/** Optional. When wired, every persisted synthesis page is embedded so
* the semantic retriever can surface it on terms that exist only in the
* transformation output (not in any source raw's chunks). */
@ -628,12 +634,13 @@ public class WikiTransformationExecutor {
String title = template.getTitle() + " · " + safeTitle(raw);
String summary = deriveSummary(output);
String sourceRawIdsJson = toJsonArray(raw.getId());
String pageType = resolvePageType(kbId, template);
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
WikiPageEntity persisted;
if (existing == null) {
persisted = pageService.createPage(kbId, slug, title, output, summary,
sourceRawIdsJson, "synthesis");
sourceRawIdsJson, pageType);
log.info("[WikiTransformation] saved run={} as new page slug={} pageId={}",
run.getId(), slug, persisted.getId());
} else {
@ -686,6 +693,19 @@ public class WikiTransformationExecutor {
"\\.(pdf|docx?|pptx?|xlsx?|csv|tsv|txt|md|markdown|rtf|odt|epub|html?|json|xml|yaml|yml|jpe?g|png|gif|bmp|tiff?|webp|svg|mp3|wav|mp4|mov|webm)$",
java.util.regex.Pattern.CASE_INSENSITIVE);
/**
* Classify the saved page against the KB's pageType profile: use the
* template's declared target type, normalised (an unknown / blank type is
* downgraded to the profile's fallbackType). Falls back to the legacy
* {@code "synthesis"} only when the profile service is not wired.
*/
private String resolvePageType(Long kbId, WikiTransformationEntity template) {
if (pageTypeProfileService == null) {
return "synthesis";
}
return pageTypeProfileService.normalizePageType(kbId, template.getTargetPageType());
}
private static String buildSlug(WikiTransformationEntity template, WikiRawMaterialEntity raw) {
String trimmedTitle = stripFileExtension(raw.getTitle());
String rawPart = WikiPageService.toSlug(trimmedTitle);

View File

@ -112,6 +112,7 @@ public class WikiTransformationService {
entity.setOutputTarget(normalizeOutputTarget(input.getOutputTarget()));
entity.setOutputFormat(normalizeOutputFormat(input.getOutputFormat()));
entity.setOutputSchema(sanitizeOutputSchema(input.getOutputSchema()));
entity.setTargetPageType(normalizeTargetPageType(input.getTargetPageType()));
transformationMapper.insert(entity);
log.info("[WikiTransformation] created id={} name={} kbId={}",
entity.getId(), entity.getName(), entity.getKbId());
@ -144,6 +145,10 @@ public class WikiTransformationService {
// Empty string clears the schema; non-blank gets stored after a parse check.
entity.setOutputSchema(sanitizeOutputSchema(patch.getOutputSchema()));
}
if (patch.getTargetPageType() != null) {
// Empty string clears (back to profile fallback); non-blank is stored lowercase.
entity.setTargetPageType(normalizeTargetPageType(patch.getTargetPageType()));
}
transformationMapper.updateById(entity);
return entity;
}
@ -158,6 +163,19 @@ public class WikiTransformationService {
};
}
/**
* Normalise the optional target pageType. Blank / null means "auto"
* stored as {@code null} so the executor falls back to the profile's
* {@code fallbackType} at save time. Membership against the KB profile is
* NOT validated here: that is deferred to {@code normalizePageType} at
* page-save time, so editing a profile never breaks an existing template.
*/
private static String normalizeTargetPageType(String raw) {
if (raw == null) return null;
String trimmed = raw.trim();
return trimmed.isEmpty() ? null : trimmed.toLowerCase();
}
/** Whitelist incoming outputFormat; unknown / null = "markdown". */
private static String normalizeOutputFormat(String raw) {
if (raw == null) return "markdown";

View File

@ -91,6 +91,12 @@ public class WikiTool {
@Autowired(required = false)
private ApprovalWorkflowService approvalWorkflowService;
/** Optional. Classifies an agent-authored page against the KB's pageType
* profile fallback so it lands inside the KB classification rather than
* as an untyped page. Absent in lightweight contexts page stays untyped. */
@Autowired(required = false)
private vip.mate.wiki.profile.WikiPageTypeProfileService pageTypeProfileService;
/**
* Per-agent pageType permission gate. Mandatory: this is a security control,
* so it is a required constructor dependency rather than an optional bean
@ -489,8 +495,13 @@ public class WikiTool {
}
String summary = content.length() > 200 ? content.substring(0, 200) + "..." : content;
WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null);
log.info("[WikiTool] Created page: {} (slug={}, kbId={})", title, slug, kbId);
// Classify into the KB profile's fallbackType so an agent-authored page
// joins the KB classification instead of being stored untyped.
String pageType = pageTypeProfileService == null
? null
: pageTypeProfileService.normalizePageType(kbId, null);
WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null, pageType);
log.info("[WikiTool] Created page: {} (slug={}, kbId={}, type={})", title, slug, kbId, pageType);
return JSONUtil.createObj()
.set("ok", true)

View File

@ -0,0 +1,9 @@
-- Optional target pageType for a transformation whose output_target='page'.
-- When set, a run persisted as a wiki page is classified with this pageType
-- (normalised against the KB's pageType profile at save time). When NULL the
-- save falls back to the profile's fallbackType, so transformation output is
-- always a first-class member of the KB's classification rather than a
-- hard-coded "synthesis" type that sits outside every profile.
ALTER TABLE mate_wiki_transformation
ADD COLUMN IF NOT EXISTS target_page_type VARCHAR(64) DEFAULT NULL;

View File

@ -0,0 +1,13 @@
-- Optional target pageType for a transformation whose output_target='page'.
-- See the h2 sibling migration for the prose explanation. MySQL lacks
-- `ADD COLUMN IF NOT EXISTS`, so the column is guarded by an
-- INFORMATION_SCHEMA check + prepared statement.
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_wiki_transformation'
AND COLUMN_NAME = 'target_page_type');
SET @s := IF(@c = 0,
'ALTER TABLE mate_wiki_transformation ADD COLUMN target_page_type VARCHAR(64) DEFAULT NULL',
'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -879,6 +879,7 @@ export const wikiApi = {
outputTarget?: 'none' | 'page'
outputFormat?: 'markdown' | 'json'
outputSchema?: string | null
targetPageType?: string | null
}) =>
http.post('/wiki/transformations', data),
updateTransformation: (id: number, data: {
@ -891,6 +892,7 @@ export const wikiApi = {
outputTarget?: 'none' | 'page'
outputFormat?: 'markdown' | 'json'
outputSchema?: string | null
targetPageType?: string | null
}) =>
http.put(`/wiki/transformations/${id}`, data),
deleteTransformation: (id: number) =>

View File

@ -0,0 +1,69 @@
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
/**
* Shared pageType presentation helpers, driven by the active KB's pageType
* profile (loaded into the wiki store). Centralises label resolution and
* colouring so the sidebar, graph view, node panel and toolbar all render a
* KB's custom classification consistently instead of each hard-coding the
* built-in ten types.
*/
// Fixed colours for the built-in ten types so existing KBs look unchanged.
const BUILTIN_COLORS: Record<string, string> = {
concept: '#D96E46',
person: '#5B8DEF',
place: '#4CAF82',
event: '#F59E0B',
technology: '#8B5CF6',
organization: '#EC4899',
product: '#14B8A6',
term: '#6B7280',
process: '#F97316',
other: '#9CA3AF',
}
// Palette for custom / synthesis types not in the built-in map. A type name is
// hashed to a stable index so the same type always gets the same colour within
// and across sessions, without needing a colour field on the profile schema.
const HASH_PALETTE = [
'#0EA5E9', '#A855F7', '#22C55E', '#EAB308', '#EF4444',
'#06B6D4', '#D946EF', '#84CC16', '#F43F5E', '#3B82F6',
'#10B981', '#FB923C',
]
function hashIndex(s: string, mod: number): number {
let h = 0
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0
}
return Math.abs(h) % mod
}
export function useWikiPageType() {
const store = useWikiStore()
const { t, te } = useI18n()
/**
* Display label for a pageType, three-tier fallback:
* profile label i18n `wiki.pageTypes.{type}` capitalised raw key.
*/
function formatPageTypeLabel(type: string | null | undefined): string {
const key = (type || '').trim().toLowerCase()
if (!key) return ''
const fromProfile = store.pageTypeProfile?.labels?.[key]
if (fromProfile) return fromProfile
const i18nKey = `wiki.pageTypes.${key}`
if (te(i18nKey)) return t(i18nKey)
return key.charAt(0).toUpperCase() + key.slice(1)
}
/** Stable colour for a pageType: built-in fixed colour, else hashed palette. */
function typeColor(type: string | null | undefined): string {
const key = (type || 'other').toLowerCase()
if (BUILTIN_COLORS[key]) return BUILTIN_COLORS[key]
return HASH_PALETTE[hashIndex(key, HASH_PALETTE.length)]
}
return { formatPageTypeLabel, typeColor }
}

View File

@ -2115,6 +2115,9 @@ export default {
outputSchemaHelp: 'Schema text is injected into the prompt and checked for required fields after parsing. Leave blank to skip validation.',
outputSchemaPlaceholder: '{\n "type": "object",\n "required": ["title", "items"],\n "properties": {\n "title": { "type": "string" },\n "items": { "type": "array" }\n }\n}',
outputSchemaBadge: 'Schema',
targetPageType: 'Target type',
targetPageTypeAuto: '(Default / auto)',
targetPageTypeHelp: "The page type used when this output lands as a page, drawn from this knowledge base's pageType profile. Leave blank to use the profile's fallback type.",
saveAsPageBtn: 'Save as page',
saving: 'Saving…',
savedAsPage: 'Saved as:',

View File

@ -2127,6 +2127,9 @@ export default {
outputSchemaHelp: '写在这里的 JSON Schema 会注入到 prompt并在解析后做必填字段校验。留空表示不校验。',
outputSchemaPlaceholder: '{\n "type": "object",\n "required": ["title", "items"],\n "properties": {\n "title": { "type": "string" },\n "items": { "type": "array" }\n }\n}',
outputSchemaBadge: 'Schema',
targetPageType: '目标分类',
targetPageTypeAuto: '(默认 / 自动)',
targetPageTypeHelp: '落页时使用的页面分类,来自当前知识库的分类配置;留空则使用配置中的兜底分类。',
saveAsPageBtn: '保存为页面',
saving: '保存中…',
savedAsPage: '已保存:',

View File

@ -76,6 +76,20 @@ export interface WikiPageRef {
archived: boolean
}
/**
* Parsed view of a KB's pageType profile, derived from the GET
* page-type-profile endpoint's `config` JSON. `order` preserves the profile's
* declared pageType ordering (the JSON object key order); `labels` maps each
* pageType to its display label. Consumed by the sidebar grouping, graph
* colouring and the transformation editor's target-type dropdown so all of
* them follow the KB's own classification instead of a hard-coded list.
*/
export interface WikiPageTypeProfile {
fallbackType: string
order: string[]
labels: Record<string, string>
}
/** Per-page row in a broken-links report. */
export interface WikiBrokenLinkPage {
// Snowflake — stay as string end-to-end.
@ -121,6 +135,11 @@ export const useWikiStore = defineStore('wiki', () => {
const selectedRawId = ref<number | null>(null)
const totalPageCount = ref(0)
// Active KB's pageType profile (parsed). Loaded alongside the KB so sidebar
// grouping, graph colouring and the transformation editor can render the
// KB's own classification. Null until a KB is selected / its profile loads.
const pageTypeProfile = ref<WikiPageTypeProfile | null>(null)
// Wikilink resolution index — kept separate from `pages` because (a) it must
// survive the raw-material filter, and (b) the viewer's postprocess needs an
// O(1) slug/title lookup over the full KB. `archivedPageRefs` is only loaded
@ -169,9 +188,40 @@ export const useWikiStore = defineStore('wiki', () => {
fetchPages(id),
fetchPageRefs(id),
loadBrokenLinksReport(id),
loadPageTypeProfile(id),
])
}
/**
* Load + parse the KB's pageType profile into {@link pageTypeProfile}. The
* endpoint returns `config` as a JSON string (built-in default when the KB
* has no custom profile); we extract the declared type order, labels and
* fallbackType. Failures degrade to a null profile consumers then fall
* back to i18n labels / hash colouring rather than breaking the view.
*/
async function loadPageTypeProfile(kbId: number) {
try {
const res: any = await wikiApi.getPageTypeProfile(kbId)
const payload = res.data ?? res
const cfg = JSON.parse(payload.config || '{}')
const types = cfg.pageTypes && typeof cfg.pageTypes === 'object' ? cfg.pageTypes : {}
const order = Object.keys(types)
const labels: Record<string, string> = {}
for (const key of order) {
const def = types[key]
labels[key] = (def && typeof def.label === 'string' && def.label) || key
}
pageTypeProfile.value = {
fallbackType: typeof cfg.fallbackType === 'string' ? cfg.fallbackType : 'concept',
order,
labels,
}
} catch (e) {
console.error('[Wiki] Failed to load pageType profile', e)
pageTypeProfile.value = null
}
}
async function createKB(data: { name: string; description?: string; agentId?: number }) {
const res: any = await wikiApi.createKB(data)
const kb = res.data || res
@ -201,6 +251,7 @@ export const useWikiStore = defineStore('wiki', () => {
if (brokenLinksPollTimer) { clearInterval(brokenLinksPollTimer); brokenLinksPollTimer = null }
brokenLinksLoading.value = false
selectedRawId.value = null
pageTypeProfile.value = null
}
async function fetchRawMaterials(kbId: number) {
@ -326,6 +377,7 @@ export const useWikiStore = defineStore('wiki', () => {
// Keep refs in lockstep with the rest of the KB state so a freshly
// created page is immediately resolvable by the viewer.
fetchPageRefs(kbId),
loadPageTypeProfile(kbId),
])
const nextKB = (kbRes as any).data || kbRes
currentKB.value = nextKB
@ -394,6 +446,7 @@ export const useWikiStore = defineStore('wiki', () => {
loading,
selectedRawId,
totalPageCount,
pageTypeProfile,
pageRefs,
archivedPageRefs,
brokenLinksReport,
@ -410,6 +463,7 @@ export const useWikiStore = defineStore('wiki', () => {
fetchArchivedPageRefs,
loadBrokenLinksReport,
startBrokenLinksScan,
loadPageTypeProfile,
refreshCurrentKB,
filterPagesByRaw,
clearRawFilter,

View File

@ -3,7 +3,7 @@
<div class="page-header-left">
<!-- Page type badge -->
<span v-if="page.pageType" class="page-type-badge" :class="page.pageType">
{{ t(`wiki.page.type.${page.pageType}`) || page.pageType }}
{{ formatPageTypeLabel(page.pageType) }}
</span>
<h2 class="page-title">{{ page.title }}</h2>
@ -42,8 +42,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Link } from '@element-plus/icons-vue'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { useWikiPageType } from '@/composables/useWikiPageType'
const { t } = useI18n()
const { formatPageTypeLabel } = useWikiPageType()
const workspace = useWorkspaceStore()
// Enriching a page (adding cross-links) is a write action viewers only read.

View File

@ -262,6 +262,17 @@
</label>
</fieldset>
<label v-if="form.outputTarget === 'page'" class="field">
<span class="field-label">{{ t('wiki.transformations.targetPageType') }}</span>
<select v-model="form.targetPageType" class="field-input">
<option value="">{{ t('wiki.transformations.targetPageTypeAuto') }}</option>
<option v-for="opt in pageTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span class="field-hint">{{ t('wiki.transformations.targetPageTypeHelp') }}</span>
</label>
<fieldset class="field field--group">
<legend class="field-label">{{ t('wiki.transformations.outputFormatLabel') }}</legend>
<label class="radio-row">
@ -353,6 +364,7 @@ interface WikiTransformation {
outputTarget: 'none' | 'page' | null
outputFormat: 'markdown' | 'json' | null
outputSchema: string | null
targetPageType: string | null
}
interface WikiTransformationRun {
@ -411,6 +423,7 @@ const form = reactive<{
outputTarget: 'none' | 'page'
outputFormat: 'markdown' | 'json'
outputSchema: string
targetPageType: string
modelId: number | null
}>({
name: '',
@ -422,9 +435,20 @@ const form = reactive<{
outputTarget: 'none',
outputFormat: 'markdown',
outputSchema: '',
targetPageType: '',
modelId: null,
})
// Target-type dropdown options come from the active KB's pageType profile.
const pageTypeOptions = computed(() => {
const profile = store.pageTypeProfile
if (!profile) return [] as { value: string; label: string }[]
return profile.order.map((type) => ({
value: type,
label: profile.labels[type] || type,
}))
})
const completedRaws = computed<WikiRawMaterial[]>(() =>
store.rawMaterials.filter(
(r) => r.processingStatus === 'completed' || r.processingStatus === 'partial'
@ -517,6 +541,7 @@ function openCreate() {
form.outputTarget = 'none'
form.outputFormat = 'markdown'
form.outputSchema = ''
form.targetPageType = ''
form.modelId = null
editorOpen.value = true
ensureModelsLoaded()
@ -533,6 +558,7 @@ function openEdit(tpl: WikiTransformation) {
form.outputTarget = tpl.outputTarget === 'page' ? 'page' : 'none'
form.outputFormat = tpl.outputFormat === 'json' ? 'json' : 'markdown'
form.outputSchema = tpl.outputSchema || ''
form.targetPageType = tpl.targetPageType || ''
form.modelId = tpl.modelId ?? null
editorOpen.value = true
ensureModelsLoaded()
@ -554,6 +580,9 @@ async function onSave() {
// Schema is only persisted when format=json; otherwise we always send
// an empty string so the backend can clear a previously-stored value.
const schemaPayload = form.outputFormat === 'json' ? form.outputSchema.trim() : ''
// Target pageType only applies when output lands as a page; otherwise send
// an empty string so the backend clears any previously-stored value.
const targetPageTypePayload = form.outputTarget === 'page' ? form.targetPageType : ''
if (editing.value) {
// Update path: backend treats `-1` as "clear modelId"; null is skipped.
const updateModelId = form.modelId == null ? -1 : form.modelId
@ -566,6 +595,7 @@ async function onSave() {
outputTarget: form.outputTarget,
outputFormat: form.outputFormat,
outputSchema: schemaPayload,
targetPageType: targetPageTypePayload,
modelId: updateModelId,
})
} else {
@ -580,6 +610,7 @@ async function onSave() {
outputTarget: form.outputTarget,
outputFormat: form.outputFormat,
outputSchema: schemaPayload || null,
targetPageType: targetPageTypePayload || null,
modelId: form.modelId,
})
}

View File

@ -2,7 +2,7 @@
<div class="node-panel">
<div class="node-panel-header">
<span class="node-type-badge" :style="{ background: typeColor(page.pageType) }">
{{ t(`wiki.pageTypes.${page.pageType || 'other'}`, page.pageType || 'other') }}
{{ formatPageTypeLabel(page.pageType || 'other') }}
</span>
<button class="node-panel-close" @click="emit('close')"></button>
</div>
@ -29,8 +29,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { WikiPage } from '@/stores/useWikiStore'
import { useWikiPageType } from '@/composables/useWikiPageType'
const { t } = useI18n()
const { typeColor, formatPageTypeLabel } = useWikiPageType()
defineProps<{
page: WikiPage
@ -42,22 +44,6 @@ const emit = defineEmits<{
(e: 'open-page', slug: string): void
}>()
const TYPE_COLORS: Record<string, string> = {
concept: '#D96E46',
person: '#5B8DEF',
place: '#4CAF82',
event: '#F59E0B',
technology: '#8B5CF6',
organization: '#EC4899',
product: '#14B8A6',
term: '#6B7280',
process: '#F97316',
other: '#9CA3AF',
}
function typeColor(type: string | null | undefined): string {
return TYPE_COLORS[(type || 'other').toLowerCase()] || TYPE_COLORS.other
}
</script>
<style scoped>

View File

@ -29,7 +29,7 @@
<select :value="typeFilter" class="type-select" @change="emit('update:typeFilter', ($event.target as HTMLSelectElement).value)">
<option value="">{{ t('wiki.graph.allTypes') }}</option>
<option v-for="type in availableTypes" :key="type" :value="type">
{{ t(`wiki.pageTypes.${type}`, type) }}
{{ formatPageTypeLabel(type) }}
</option>
</select>
<button class="btn-icon-sm" :title="t('wiki.graph.resetView')" @click="emit('reset')">
@ -60,8 +60,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useWikiPageType } from '@/composables/useWikiPageType'
const { t } = useI18n()
const { formatPageTypeLabel } = useWikiPageType()
defineProps<{
nodeCount: number

View File

@ -44,12 +44,14 @@ import { GraphChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { WikiPage } from '@/stores/useWikiStore'
import { useWikiPageType } from '@/composables/useWikiPageType'
import WikiGraphToolbar from './WikiGraphToolbar.vue'
import WikiGraphNodePanel from './WikiGraphNodePanel.vue'
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
const { t } = useI18n()
const { typeColor, formatPageTypeLabel } = useWikiPageType()
const props = defineProps<{ pages: WikiPage[] }>()
const emit = defineEmits<{ (e: 'open-page', slug: string): void }>()
@ -62,23 +64,6 @@ const showOrphans = ref(true)
const typeFilter = ref('')
const selectedNode = ref<WikiPage | null>(null)
// Type color map
const TYPE_COLORS: Record<string, string> = {
concept: '#D96E46',
person: '#5B8DEF',
place: '#4CAF82',
event: '#F59E0B',
technology: '#8B5CF6',
organization: '#EC4899',
product: '#14B8A6',
term: '#6B7280',
process: '#F97316',
other: '#9CA3AF',
}
function typeColor(type: string | null | undefined): string {
return TYPE_COLORS[(type || 'other').toLowerCase()] || TYPE_COLORS.other
}
// Parse outgoing links JSON string slug[]
function parseLinks(outgoingLinks: string | null | undefined): string[] {
@ -245,7 +230,7 @@ function buildOption() {
// Look up from slugToPage instead of relying on _page in ECharts data
const page = slugToPage.value.get(params.data.id)
if (!page) return ''
const typeLabel = t(`wiki.pageTypes.${page.pageType || 'other'}`, page.pageType || 'other')
const typeLabel = formatPageTypeLabel(page.pageType || 'other')
const summary = (page.summary || '').substring(0, 80)
const ellipsis = (page.summary || '').length > 80 ? '…' : ''
return [

View File

@ -186,16 +186,21 @@
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
import { useWikiPageType } from '@/composables/useWikiPageType'
import { wikiApi } from '@/api/index'
const emit = defineEmits<{ (e: 'open-page', slug: string): void }>()
const { t } = useI18n()
const store = useWikiStore()
const { formatPageTypeLabel } = useWikiPageType()
const pageListEl = ref<HTMLElement | null>(null)
const PAGE_STEP = 20
const TYPE_ORDER = ['concept', 'technology', 'process', 'person', 'organization', 'product', 'place', 'event', 'term', 'other']
// Group ordering follows the KB profile's declared pageType order; types not in
// the profile (synthesis/system or stale types from a prior profile) sort after.
const typeOrder = computed(() => store.pageTypeProfile?.order ?? [])
const pageSearch = ref('')
const searchPageLimit = ref(PAGE_STEP)
@ -226,11 +231,14 @@ const groupedPages = computed(() => {
if (!map.has(type)) map.set(type, [])
map.get(type)!.push(page)
}
const order = typeOrder.value
return [...map.entries()]
.sort(([a], [b]) => {
const ia = TYPE_ORDER.indexOf(a) >= 0 ? TYPE_ORDER.indexOf(a) : 99
const ib = TYPE_ORDER.indexOf(b) >= 0 ? TYPE_ORDER.indexOf(b) : 99
return ia - ib
const ia = order.indexOf(a) >= 0 ? order.indexOf(a) : 99
const ib = order.indexOf(b) >= 0 ? order.indexOf(b) : 99
if (ia !== ib) return ia - ib
// Stable, deterministic tail ordering for types outside the profile.
return a.localeCompare(b)
})
.map(([type, pages]) => ({ type, pages }))
})
@ -267,9 +275,7 @@ function paginatedGroupPages(group: { type: string; pages: any[] }) {
function formatGroupLabel(type: string): string {
if (!type) return t('wiki.pageTypes.other')
const key = `wiki.pageTypes.${type.toLowerCase()}`
const translated = t(key)
return translated === key ? (type.charAt(0).toUpperCase() + type.slice(1)) : translated
return formatPageTypeLabel(type)
}
function toggleSelect(slug: string) {