mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
da5aaba0f7
commit
28f2ba973d
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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) =>
|
||||
|
||||
69
mateclaw-ui/src/composables/useWikiPageType.ts
Normal file
69
mateclaw-ui/src/composables/useWikiPageType.ts
Normal 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 }
|
||||
}
|
||||
@ -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:',
|
||||
|
||||
@ -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: '已保存:',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user