diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java index 7acc5ec5..75a35914 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiTransformationEntity.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java index 5e21a5f4..629b2c8f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationAggregator.java @@ -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, diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index c6961dd2..38f1e31c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -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); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java index 90ed1606..729d7e09 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationService.java @@ -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"; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index cff6f4ee..53ae4cf9 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -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) diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V142__wiki_transformation_target_page_type.sql b/mateclaw-server/src/main/resources/db/migration/h2/V142__wiki_transformation_target_page_type.sql new file mode 100644 index 00000000..f4d2cf8c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V142__wiki_transformation_target_page_type.sql @@ -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; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V142__wiki_transformation_target_page_type.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V142__wiki_transformation_target_page_type.sql new file mode 100644 index 00000000..e8a310b9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V142__wiki_transformation_target_page_type.sql @@ -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; diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 9d97c59d..222028f7 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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) => diff --git a/mateclaw-ui/src/composables/useWikiPageType.ts b/mateclaw-ui/src/composables/useWikiPageType.ts new file mode 100644 index 00000000..cf146f34 --- /dev/null +++ b/mateclaw-ui/src/composables/useWikiPageType.ts @@ -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 = { + 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 } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 01fe9161..820ce98d 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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:', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 9494f840..fe610224 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '已保存:', diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts index 08e7f84a..93b9b888 100644 --- a/mateclaw-ui/src/stores/useWikiStore.ts +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -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 +} + /** 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(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(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 = {} + 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, diff --git a/mateclaw-ui/src/views/Wiki/components/PageHeader.vue b/mateclaw-ui/src/views/Wiki/components/PageHeader.vue index 5d89da2b..dea6330c 100644 --- a/mateclaw-ui/src/views/Wiki/components/PageHeader.vue +++ b/mateclaw-ui/src/views/Wiki/components/PageHeader.vue @@ -3,7 +3,7 @@
- {{ t(`wiki.page.type.${page.pageType}`) || page.pageType }} + {{ formatPageTypeLabel(page.pageType) }}

{{ page.title }}

@@ -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. diff --git a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue index 5a03725f..69eed1fe 100644 --- a/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/TransformationsPanel.vue @@ -262,6 +262,17 @@ + +
{{ t('wiki.transformations.outputFormatLabel') }}